WebSensor 0.2 — signal intelligence upgrade
Registry: 7 depth fragments (40–46: AI frontier, cloud infrastructure, cybersecurity, finance & markets, governments, science & health, transport/telecom/sports/news) → 3 167 organizations / 6 320 validated sensors (was 2 681 / 4 052); seeds carry country, language and first_party; sync infers country/language by TLD and a 0–3 scheduling priority. Core: semantic diff (noise classes vs pricing/policy/product/personnel, field-level before → after with % deltas), WebSensor Signal Score with explainable reasons, impact / velocity / anomaly scores, breaking state, entity rank, ~40 new event classes (zero_day, active_exploitation, supply_chain_attack, guidance, buyback, sanction, legislation, outbreak, grounding, service_shutdown…), search syntax (entity: type: after:7d silent: importance:>70 country:), shared registry schema. Engine: noise never becomes an event, routine firehose batches damped, fingerprint idempotency, ambiguous-alias rule for entity resolution (+ relink-entities CLI), silent-change bar (first-party · eligible type · importance ≥ 45), clusters with propagation timeline, first-party/external counts, velocity, lead time and breaking/developing/confirmed states, server-side alerts with HMAC-signed webhooks, raw-body retention with reference counting, priority-aware scheduler with per-domain circuit breaker and Redis heartbeat, UTF-16 BOM transcoding. API: new filters (signal_min, group, first_party, confirmed, country, language, change_class, q syntax, order=signal), /breaking /pulse /radar /clusters/:slug /entities/rank /countries /categories/:channel /sensors/:id/snapshots, owner routes (bookmarks, saved views, notifications, custom URL monitors with SSRF checks), admin routes behind WS_ADMIN_TOKEN (ops, sensor/source actions, connector test, YAML/JSON bulk import), TTL cache, WebSocket protocol 2 with stream ids and replay. Web: terminal-style design system (badges, density modes, skeletons, sparklines, heatmaps), live system strip, ⌘K command palette, intelligence drawer, live feed v2 (URL-synced filters, pause, "↑ N new events", replay), pages /live /pulse /radar /entity/[id] /cluster/[slug] /country /bookmarks /monitors /ops, redesigned breaking / silent / explore / event / source / sensor / search / alerts / watchlists / health / api docs, OG image per event, sitemap, loading skeletons. Migrations 0005 (additive schema) and 0006 (backfills). Docs updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
102 changed files +16,500 −1,428
modified
.env.example
+15 −1
@@ -26,5 +26,19 @@ WS_LLM_MIN_IMPORTANCE=35 | ||
| 26 | 26 | |
| 27 | 27 | # Engine tuning |
| 28 | 28 | WS_FETCH_CONCURRENCY=16 |
| 29 | −WS_USER_AGENT=WebSensorBot/0.1 (+https://www.websensor.io/bot) | |
| 29 | +WS_USER_AGENT="WebSensorBot/0.2 (+https://www.websensor.io/bot; contact@websensor.io)" | |
| 30 | 30 | WS_SOURCES_FILE=./config/sources.yaml |
| 31 | +WS_PER_HOST_CONCURRENCY=2 | |
| 32 | +WS_MEANINGFUL_SIGNAL=0.32 | |
| 33 | +# Silent-change bar: only silent-eligible types with importance >= this are flagged (spec §23) | |
| 34 | +WS_SILENT_MIN_IMPORTANCE=45 | |
| 35 | +# Storage lifecycle (spec §58): raw bodies of unchanged snapshots are dropped after N days (canonical + hashes kept); | |
| 36 | +# snapshots referenced by an event are never pruned. WS_RETENTION=0 disables the job. | |
| 37 | +WS_RETENTION=1 | |
| 38 | +WS_RETENTION_RAW_DAYS=21 | |
| 39 | +WS_RETENTION_CHANGED_RAW_DAYS=60 | |
| 40 | + | |
| 41 | +# API — internal operations (/api/v1/admin/*, /ops). Unset = admin API disabled. | |
| 42 | +WS_ADMIN_TOKEN= | |
| 43 | +# Custom URL monitors per anonymous owner (spec §45) | |
| 44 | +WS_MONITORS_PER_OWNER=5 | |
modified
CLAUDE.md
+93 −80
@@ -1,97 +1,110 @@ | ||
| 1 | 1 | # WebSensor — repository guide |
| 2 | 2 | |
| 3 | 3 | WebSensor (www.websensor.io) is a real-time web intelligence platform: a global sensor network for the changing |
| 4 | −Web. It monitors official public sources, detects meaningful changes, classifies them, links entities, scores | |
| 5 | −importance, preserves evidence and publishes events through a WebSocket feed. The product brief that drove the | |
| 6 | −design is `docs/PRODUCT-BRIEF.md`; this file is the working guide for the code. | |
| 4 | +Web. It monitors official public sources, detects meaningful changes, classifies them semantically, links | |
| 5 | +entities, scores importance / confidence / novelty / impact / velocity / anomaly into a WebSensor Signal Score, | |
| 6 | +preserves immutable evidence, clusters signals into events with propagation timelines, and publishes them through | |
| 7 | +a replayable WebSocket feed. Product brief: `docs/PRODUCT-BRIEF.md`; architecture: `docs/ARCHITECTURE.md`. This file | |
| 8 | +is the working guide for the code (v0.2, 2026-09-11). | |
| 7 | 9 | |
| 8 | 10 | ## Layout (pnpm workspace, TypeScript ESM, Node ≥ 22.15) |
| 9 | −- `packages/core` — taxonomy (event types + intrinsic severity, tiers, categories), ids, SSRF policy | |
| 10 | − (`assertUrlAllowed`, `safeLookup`), hashing (sha256, simhash, shingles/Jaccard), canonical extraction | |
| 11 | − (`canonicalizeHtml`: strips scripts/nav/footer/cookie chrome, timestamps, tokens, counters), diff engines | |
| 12 | − (text / json / keyed list), stage-1 heuristics (`evaluateChange`, `describeChange`), scoring | |
| 13 | − (importance components, confidence, novelty helpers, trending, activity anomaly), adaptive schedule. | |
| 14 | − `@websensor/core/client` is the browser-safe subset (no `node:` imports) — client components must import it. | |
| 15 | −- `packages/db` — plain SQL migrations (`migrations/*.sql`, applied by `migrate()` with an advisory lock) + | |
| 16 | − Drizzle schema for typed access. **`textArray()`** must be used for `= any(...)` / `&&` with JS arrays | |
| 17 | − (Drizzle spreads arrays into parameter lists). | |
| 18 | −- `packages/store` — content-addressed blob store (`sha256/ab/cd/<hash>.zst`, zstd via `node:zlib`). Raw | |
| 19 | − bodies, canonical representations and full diffs live there, never in Postgres. Interface ready for S3/MinIO. | |
| 20 | −- `packages/connectors` — connector SDK (`WebSensorConnector.fetch/normalize`), safe fetcher (conditional GET, | |
| 21 | − manual redirects validated per hop, HTTP/2→1.1 pin on NGHTTP2 errors, browser-UA second attempt on | |
| 22 | − 403/resets, size + time limits, `keepAllHeaders`), 16 connector families: `http` (HTML/JSON/text/HEAD), `rss` | |
| 23 | − (RSS/Atom/RDF/JSON Feed), `sitemap` (index + news), `statuspage` (Atlassian v2 summary), `statusjson` (Instatus / | |
| 24 | − incident.io / Status.io), `github` (releases/tags/commits Atom, advisories REST), `jsonlist` (keyed records from | |
| 25 | − any JSON API; `{now-2h}` placeholders), `package` (npm/PyPI/crates/RubyGems/NuGet/Packagist/Hex/Go proxy/Homebrew/ | |
| 26 | − Docker Hub version streams), `edgar` (SEC submissions → filings, 8-K items decoded), `openapi` (API contract | |
| 27 | − fingerprints), `csv` (open-data rows, `tail`), `pdf` (pdf.js text via unpdf), `dns` (A/AAAA/NS/MX/TXT/CAA/SOA/DMARC), | |
| 28 | − `tls` (certificate identity, chain, ALPN, expiry), `headers` (security/infra response headers), `rdap` (registrar, | |
| 29 | − EPP status, expiry via IANA bootstrap), `discovery` (robots sitemaps, `<link rel=alternate>`, well-known feed | |
| 30 | − paths, linked status pages — every candidate is fetched and parsed), `scrapfly` (fallback, budgeted, only when | |
| 31 | − `fallback.scrapfly` is set on the source). `dns://` and `tls://` sensor URLs are accepted (host only). | |
| 32 | −- `apps/engine` — scheduler (`FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency), pipeline | |
| 33 | − (fetch → normalize → snapshot → diff → heuristics → change → event: entities, novelty, LLM interpretation, | |
| 34 | − clustering, importance/confidence, silent-change detection, publish to Redis), registry sync from | |
| 35 | − `config/sources.yaml` + `config/sources.d/`, discovery, connector health rollups, Prometheus metrics on :8262. | |
| 36 | − CLI: `src/cli.ts` (`sync`, `discover`, `probe <domain>`, `run-once <sensor>`, `run-due [n]`); `src/validate.ts` | |
| 37 | − (registry validator, see below). | |
| 38 | −- `apps/api` — Fastify gateway (:8260): REST `/api/v1/*`, WebSocket `/api/v1/live` (Redis pub/sub fan-out, | |
| 39 | − channels `events:*`, `entity:*`, `source:*`, `watchlist:*`), `/api/v1/feed.rss`, `/api/health` `/api/ready` `/api/metrics`, apex→www | |
| 40 | − redirect, and a reverse proxy to the Next.js app for everything else. | |
| 41 | −- `apps/web` — Next.js 16 frontend (:8261, loopback). Pages: live, breaking, explore, sources, entities, | |
| 42 | − timelines, silent changes, watchlists, alerts, event detail with diff viewer, health, API docs. | |
| 43 | −- `config/sources.yaml` — the founding 271-organization registry (incl. governments and news media; news sources | |
| 44 | − run with `llm: false`, heuristics only) + `config/sources.d/*.yaml` fragments, one per **site class** (open source, | |
| 45 | − central banks & finance, energy/climate/weather, telecom & internet infrastructure, retail/consumer/travel, | |
| 46 | − gaming & entertainment, universities & research, international orgs/NGOs/standards, sports, crypto, | |
| 47 | − transport/aviation/space, consumer safety/food/agri, housing/labour/open data, enterprise SaaS, health systems & | |
| 48 | − medtech, politics/elections/courts, EDGAR filings, package registries, web posture, OpenAPI, status JSON, | |
| 49 | − documents & data). Fragments are merged by `apps/engine/src/seeds.ts` in file-name order; an entry with | |
| 50 | − `extend: true` adds sensors/products/aliases to a source declared earlier. Authoring guide + connector | |
| 51 | − cheat-sheet: `docs/registry/AUTHORING.md`; `docs/connectors/*.md` document each connector family. | |
| 52 | −- Registry validator (no DB): `node node_modules/tsx/dist/cli.mjs apps/engine/src/validate.ts [fragment.yaml] [--all] | |
| 53 | − [--source id] [--connector key] [--json report.json]` runs every curated sensor through its connector and prints | |
| 54 | − OK/WARN/FAIL; `--probe <domain>` is a discovery dry-run. `scripts/prune-fragment.py` removes FAIL/WARN sensors from | |
| 55 | − a fragment using the JSON report; `scripts/gen-web-posture.py` regenerates the web-posture fragment. | |
| 11 | +- `packages/core` — taxonomy (event types incl. cyber/finance/government/health/transport classes, event groups, | |
| 12 | + silent-eligible types, change classes, cluster states, countries), ids, SSRF policy (`assertUrlAllowed`, | |
| 13 | + `safeLookup`), hashing, canonical extraction, diff engines (text / json / keyed list), stage-1 heuristics | |
| 14 | + (`evaluateChange`, `describeChange`, `humanizeUrl`), **semantic diff** (`semantic.ts`: `classifyChange` → | |
| 15 | + cosmetic / navigation / timestamp / advertisement / boilerplate vs meaningful / pricing / policy / product / | |
| 16 | + personnel; `extractFieldChanges` → field-level before → after pairs with % deltas), scoring (`scoring.ts`: | |
| 17 | + importance components, confidence, impact, velocity, **WebSensor Signal Score** with explainable reasons, | |
| 18 | + `breakingState`, entity rank, daily anomaly), adaptive schedule, **search syntax** (`search.ts`: `entity:` `type:` | |
| 19 | + `after:7d` `silent:true` `importance:>70` `country:CA` …), registry seed schema (`registry-schema.ts`, shared by | |
| 20 | + engine, validator and the admin import endpoint). `@websensor/core/client` is the browser-safe subset. | |
| 21 | +- `packages/db` — plain SQL migrations (`migrations/*.sql`, applied by `migrate()` with an advisory lock) + Drizzle | |
| 22 | + schema. `0005_intelligence.sql` added provenance (first_party / country / language / kind / owner_token on | |
| 23 | + sources), sensor lifecycle (`status`, `priority`, `validated_at`), semantic class + field changes on changes, | |
| 24 | + signal/velocity/impact/anomaly scores, fingerprints, score reasons on events, cluster propagation columns, | |
| 25 | + `entity_daily`, `source_daily`, `bookmarks`, `saved_views`, alert channel config, notification delivery. | |
| 26 | + `0006_backfill_states.sql` backfills them. **`textArray()`** must be used for `= any(...)` / `&&` with JS arrays. | |
| 27 | +- `packages/store` — content-addressed blob store (`sha256/ab/cd/<hash>.zst`). Raw bodies, canonical forms and full | |
| 28 | + diffs live there, never in Postgres. | |
| 29 | +- `packages/connectors` — connector SDK (`fetch/normalize`), safe fetcher (conditional GET, per-hop SSRF checks, | |
| 30 | + HTTP/2→1.1 pin, browser-UA retry, UTF-16 BOM transcoding, `postJson` for signed webhooks), 16 connector families | |
| 31 | + (`http rss sitemap statuspage statusjson github jsonlist package edgar openapi csv pdf dns tls headers rdap`) + | |
| 32 | + `discovery` + `scrapfly` fallback. `jsonlist.urlTemplate` supports `{key}` (path separators kept) and | |
| 33 | + `{field.path}`. | |
| 34 | +- `apps/engine` — scheduler (priority-aware `FOR UPDATE SKIP LOCKED` claims, global + per-host concurrency, | |
| 35 | + **domain circuit breaker**, heartbeat to Redis `ws:engine:status`), pipeline (fetch → normalize → snapshot → diff → | |
| 36 | + heuristics + semantic class → change → event: fingerprint idempotency, entity resolution with ambiguous-alias | |
| 37 | + rules, novelty, optional Claude interpretation, impact / anomaly / signal scores, silent-change bar, clustering | |
| 38 | + with propagation timeline + lead time + breaking state, entity/source daily counters, alert evaluation + webhook | |
| 39 | + delivery, Redis stream + pub/sub), registry sync (`config/sources.yaml` + `config/sources.d/`), discovery, | |
| 40 | + connector health rollups, **retention** (`retention.ts`), Prometheus metrics :8262. | |
| 41 | + CLI: `src/cli.ts` (`sync`, `discover`, `probe`, `run-once`, `run-due`, `relink-entities [days]`, `prune-blobs`, | |
| 42 | + `refresh-clusters`, `llm-test`); `src/validate.ts` (registry validator). | |
| 43 | +- `apps/api` — Fastify gateway (:8260): REST `/api/v1/*` (`routes.ts` public, `routes-user.ts` owner-scoped: | |
| 44 | + watchlists / alerts (+webhook) / notifications / bookmarks / saved views / custom monitors, `routes-admin.ts` | |
| 45 | + behind `WS_ADMIN_TOKEN`: ops, sensor & source actions, connector test, bulk import), intelligence read-models | |
| 46 | + (`intel.ts`: breaking desk, pulse, radar, entity insights, rankings, cluster detail, country & category desks), | |
| 47 | + TTL cache (`cache.ts`), WebSocket `/api/v1/live` protocol 2 (`live.ts`: `sid` on every frame, `{"since"}` replay | |
| 48 | + from the durable stream, channels `events:* group:* country:* state:* type:* entity:* source:* watchlist:*`), | |
| 49 | + `/api/v1/feed.rss`, `/api/health|ready|metrics`, apex→www redirect, reverse proxy to Next. | |
| 50 | +- `apps/web` — Next.js 16 (:8261 loopback). Design system in `components/ui.tsx` (Panel, Badge, Score, Tabs, | |
| 51 | + Sparkline, Heatmap, Skeleton…), `prefs.tsx` (density compact/normal/comfortable, pause), `event-drawer.tsx` | |
| 52 | + (intelligence panel), `command-palette.tsx` (⌘K), `live-strip.tsx`, `live-feed.tsx` v2 (URL-synced filters, | |
| 53 | + pause, "↑ N new events", replay), `field-changes.tsx`. Pages: `/` `/live` `/breaking` `/silent` `/pulse` `/radar` | |
| 54 | + `/explore` `/entity/[id]` (`/company` redirects) `/cluster/[slug]` `/source/[id]` `/sensor/[id]` `/category/[c]` | |
| 55 | + `/country/[slug]` `/event/[slug]` (+ OG image) `/bookmarks` `/watchlists` `/alerts` `/monitors` `/ops` `/health` | |
| 56 | + `/api`. | |
| 57 | +- `config/sources.yaml` (founding registry) + `config/sources.d/*.yaml` fragments merged in file-name order | |
| 58 | + (`extend: true` adds to an earlier source). Fragments 10–35 = site classes; **40–46 (2026-09-11)** = depth: AI | |
| 59 | + frontier, cloud infrastructure, cybersecurity, finance & markets, governments, science & health, transport / | |
| 60 | + telecom / sports / news. Seeds may carry `country:` (ISO-2, `EU`, `INT`), `language:` and `first_party: false` | |
| 61 | + (media). Guide: `docs/registry/AUTHORING.md`. Validator: `node node_modules/tsx/dist/cli.mjs | |
| 62 | + apps/engine/src/validate.ts <fragment> [--all] [--json report.json]` — nothing enters the registry without OK. | |
| 56 | 63 | |
| 57 | 64 | ## Rules |
| 58 | −- Every URL the engine touches — seeds, discovered candidates, redirects, Scrapfly targets — goes through | |
| 59 | − `assertUrlAllowed()`. Private ranges, metadata endpoints, `.maclustr.io`/`.ts.net` and single-label hosts are | |
| 60 | − blocked; the dispatcher's DNS lookup only returns approved addresses. | |
| 61 | −- Raw evidence is immutable: snapshots and diffs are never rewritten. Re-interpretation creates a new row in | |
| 62 | − `interpretations` (versioned); `events.interpretation` holds the latest. | |
| 63 | −- LLM cost control: heuristics first; Claude is called only for candidates above `WS_LLM_MIN_IMPORTANCE`, with a | |
| 64 | − daily call budget; the deep model only above `WS_LLM_DEEP_MIN_IMPORTANCE`. Output is strict JSON | |
| 65 | − (`output_config.format`), never free text. No key → heuristics only, the system still works. | |
| 66 | −- Feeds: items are "new" only if their key was never seen (sensor `state.seenKeys`) and they are not older than | |
| 67 | − 14 days; items scrolling out of the window are never "removed". Sitemaps/statuspages: a >50 % shrink is | |
| 68 | − treated as a partial response, not mass deletion. A 404 becomes `page_removed` only after | |
| 69 | − `WS_DELETE_CONFIRMATIONS` checks separated by `WS_DELETE_SEPARATION_MIN`. | |
| 70 | −- Never label inference as fact: events carry `evidence_label` (OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED) | |
| 71 | − and the interpretation keeps `observed` and `inferred` apart. Silent changes are flagged, never asserted as | |
| 72 | − "unannounced" without checking recent announcement-type events of the same source. | |
| 73 | −- Every sensor in the registry has been fetched and parsed successfully by the validator before being committed; | |
| 74 | − a fragment that fails validation is not merged. Never add sensors to the registry without running it. | |
| 75 | −- SSRF policy: NAT64 addresses (`64:ff9b::/96`) are judged by the embedded IPv4 address (IPv6-only networks with | |
| 76 | − 464XLAT resolve every IPv4-only host that way), not blocked wholesale. | |
| 77 | −- Do not add sensors that are blocked with 403 by design (Akamai/Cloudflare bot management) unless the source | |
| 78 | − has `fallback.scrapfly: true` and a tier ≥ C; Scrapfly is a budgeted fallback, not the foundation. | |
| 79 | −- All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`…). | |
| 65 | +- Every URL the engine touches — seeds, discovered candidates, redirects, Scrapfly targets, webhooks, custom | |
| 66 | + monitors — goes through `assertUrlAllowed()`. Private ranges, metadata endpoints, `.maclustr.io`/`.ts.net`, | |
| 67 | + single-label hosts and NAT64-embedded private IPv4 are blocked. | |
| 68 | +- Raw evidence is immutable: snapshots and diffs are never rewritten. Retention may drop the RAW body of | |
| 69 | + snapshots that are not referenced by any event (canonical form and hashes stay); event snapshots are kept forever. | |
| 70 | + Re-interpretation creates a new `interpretations` row. | |
| 71 | +- Noise never becomes an event: changes classified cosmetic / navigation / timestamp / advertisement / | |
| 72 | + boilerplate are stored as changes only. Routine batches from firehose feeds are damped. Ingestion is idempotent | |
| 73 | + (`events.fingerprint` = sensor + before/after canonical hashes). | |
| 74 | +- Silent change = first-party source · silent-eligible type (pricing, terms, policy, API, availability, docs, | |
| 75 | + shutdown, feature removed, leadership, page removed, crawler policy…) · no matching announcement within 12 h · | |
| 76 | + importance ≥ `WS_SILENT_MIN_IMPORTANCE` · not a noise class. Never asserted as "unannounced" otherwise. | |
| 77 | +- Never label inference as fact: `evidence_label` OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED; AI text is | |
| 78 | + labelled analysis; `score_reasons` explain every signal score. First-party evidence outweighs media reports | |
| 79 | + (`sources.first_party`, media seeds carry `first_party: false`). | |
| 80 | +- Entity aliases that are ordinary words (`first`, `has`, `who`, `make`…) only match as exact upper-case acronyms | |
| 81 | + in text (`AMBIGUOUS_ALIASES` in `apps/engine/src/entities.ts`); run `cli.ts relink-entities 7` after changing | |
| 82 | + alias rules. | |
| 83 | +- LLM cost control: heuristics first; Claude only above `WS_LLM_MIN_IMPORTANCE`, daily budget, strict JSON output; | |
| 84 | + `llm: false` on firehoses (news, arXiv, NVD, package streams). No key → the system still works. | |
| 85 | +- Feeds: items are "new" only if never seen and < 14 days old; >50 % list shrink = partial response; 404 becomes | |
| 86 | + `page_removed` after `WS_DELETE_CONFIRMATIONS` separated checks. | |
| 87 | +- Admin API is off unless `WS_ADMIN_TOKEN` is set; custom monitors are limited per owner and never appear in public | |
| 88 | + feeds (`sources.kind = 'custom'`). | |
| 89 | +- All timestamps UTC. Ids are prefixed (`src_`, `sen_`, `snap_`, `chg_`, `evt_`, `clu_`, `ent_`, `wl_`, `alr_`…). | |
| 80 | 90 | |
| 81 | 91 | ## Dev |
| 82 | 92 | ``` |
| 83 | 93 | createdb websensor && cp .env.example .env |
| 84 | 94 | pnpm install |
| 85 | −pnpm db:migrate # or let the engine migrate on start | |
| 86 | −npx tsx apps/engine/src/cli.ts sync # registry → DB | |
| 95 | +pnpm db:migrate # or let the engine migrate on start | |
| 96 | +npx tsx apps/engine/src/cli.ts sync # registry → DB | |
| 87 | 97 | npx tsx apps/engine/src/cli.ts run-due 50 |
| 88 | −pnpm dev:api · pnpm dev:engine · pnpm dev:web | |
| 98 | +pnpm dev:api · pnpm dev:engine · pnpm dev:web # open the site through the gateway (:8260) so /api is same-origin | |
| 89 | 99 | pnpm test · pnpm typecheck |
| 90 | 100 | ``` |
| 91 | −Run CLI/engine from the repo root (config paths are relative to cwd). | |
| 101 | +Run CLI/engine from the repo root. A copy of production data for UI work: | |
| 102 | +`ssh M4M64b 'pg_dump websensor --data-only -t events -t …' | psql websensor` then `cli.ts sync`. | |
| 92 | 103 | |
| 93 | 104 | ## Deploy (MacLustr) |
| 94 | −`mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `deploy/websensor.mld.json` (secrets only | |
| 95 | −on M1M32). Processes: `websensor-api` (:8260, ngrok www.websensor.io), `websensor-web` (:8261 loopback), | |
| 96 | −`websensor-engine` (metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`. | |
| 97 | −See `deploy/README.md`. | |
| 105 | +`mld stage . websensor && mld deploy websensor --node M4M64b`. Manifest `M1M32:~/dispatch/apps/websensor.json` | |
| 106 | +(secrets incl. `WS_ADMIN_TOKEN`; gitignored copy `deploy/websensor.mld.json`). Processes: `websensor-api` (:8260, | |
| 107 | +public via MacLustr Tunnel `www.websensor.io` on BHS64), `websensor-web` (:8261 loopback), `websensor-engine` | |
| 108 | +(metrics :8262). Postgres 17 `websensor` + Redis local. Blob store `~/websensor-data/blobs`. After a deploy that | |
| 109 | +changes alias or scoring rules: `cli.ts relink-entities 7` and `cli.ts refresh-clusters` on the node. See | |
| 110 | +`deploy/README.md`. | |
modified
apps/api/package.json
+2 −0
@@ -14,6 +14,7 @@ | ||
| 14 | 14 | "@fastify/rate-limit": "^10.3.0", |
| 15 | 15 | "@fastify/reply-from": "^12.6.5", |
| 16 | 16 | "@fastify/websocket": "^11.2.0", |
| 17 | + "@websensor/connectors": "workspace:*", | |
| 17 | 18 | "@websensor/core": "workspace:*", |
| 18 | 19 | "@websensor/db": "workspace:*", |
| 19 | 20 | "@websensor/store": "workspace:*", |
@@ -24,6 +25,7 @@ | ||
| 24 | 25 | "prom-client": "^15.1.0", |
| 25 | 26 | "tsx": "^4.20.0", |
| 26 | 27 | "ws": "^8.21.3", |
| 28 | + "yaml": "^2.8.0", | |
| 27 | 29 | "zod": "^4.0.0" |
| 28 | 30 | }, |
| 29 | 31 | "devDependencies": { |
added
apps/api/src/cache.ts
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +/** | |
| 2 | + * Tiny in-process TTL cache with request coalescing (spec §59). Used for aggregates only | |
| 3 | + * (stats, trending, pulse, radar, rankings) — never for the live feed itself. | |
| 4 | + */ | |
| 5 | +const store = new Map<string, { at: number; ttl: number; value: unknown; pending?: Promise<unknown> }>(); | |
| 6 | + | |
| 7 | +export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> { | |
| 8 | + const now = Date.now(); | |
| 9 | + const hit = store.get(key); | |
| 10 | + if (hit && now - hit.at < hit.ttl) return hit.value as T; | |
| 11 | + if (hit?.pending) return hit.pending as Promise<T>; | |
| 12 | + const pending = fn() | |
| 13 | + .then((value) => { | |
| 14 | + store.set(key, { at: Date.now(), ttl: ttlMs, value }); | |
| 15 | + return value; | |
| 16 | + }) | |
| 17 | + .catch((e) => { | |
| 18 | + // serve stale on failure when we have anything at all | |
| 19 | + if (hit) { | |
| 20 | + store.set(key, { ...hit, pending: undefined }); | |
| 21 | + return hit.value as T; | |
| 22 | + } | |
| 23 | + store.delete(key); | |
| 24 | + throw e; | |
| 25 | + }); | |
| 26 | + store.set(key, { at: hit?.at ?? 0, ttl: hit?.ttl ?? ttlMs, value: hit?.value, pending }); | |
| 27 | + return pending; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function cacheStats(): { entries: number; keys: string[] } { | |
| 31 | + return { entries: store.size, keys: [...store.keys()].slice(0, 50) }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function invalidate(prefix: string): void { | |
| 35 | + for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k); | |
| 36 | +} | |
modified
apps/api/src/config.ts
+5 −1
@@ -10,5 +10,9 @@ export const config = { | ||
| 10 | 10 | publicBaseUrl: env.PUBLIC_BASE_URL ?? "http://localhost:8260", |
| 11 | 11 | canonicalHost: env.CANONICAL_HOST ?? "www.websensor.io", |
| 12 | 12 | redirectApexToWww: (env.WS_REDIRECT_APEX ?? "1") !== "0", |
| 13 | − version: "0.1.0", | |
| 13 | + /** Admin endpoints (`/api/v1/admin/*`) are enabled only when this token is set; sent as `X-WebSensor-Admin`. */ | |
| 14 | + adminToken: env.WS_ADMIN_TOKEN ?? "", | |
| 15 | + /** Custom URL monitors per anonymous owner (spec §45). */ | |
| 16 | + monitorsPerOwner: Number(env.WS_MONITORS_PER_OWNER ?? 5), | |
| 17 | + version: "0.2.0", | |
| 14 | 18 | }; |
added
apps/api/src/intel.ts
+238 −0
@@ -0,0 +1,238 @@ | ||
| 1 | +import { COUNTRIES, dailyAnomaly, entityRankScore, EVENT_GROUPS, FEED_CHANNELS } from "@websensor/core"; | |
| 2 | +import { db, sql, textArray } from "@websensor/db"; | |
| 3 | +import { cached } from "./cache"; | |
| 4 | +import { EVENT_SELECT, listEvents } from "./queries"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Intelligence read-models (spec §34–39, §101–105): breaking desk, pulse, radar, entity insights, | |
| 8 | + * rankings, cluster propagation, country and category desks. Aggregates are cached briefly. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +const CLUSTER_SELECT = sql`c.id, c.slug, c.title, c.summary, c.primary_event_id, c.entity_ids, c.categories, c.event_count, c.max_importance, c.first_at, c.last_at, c.source_count, c.first_party_count, c.external_count, c.velocity, c.state, c.lead_time_ms, c.first_party_at, c.first_external_at`; | |
| 12 | + | |
| 13 | +async function clusterPrimaryEvents(where: ReturnType<typeof sql>, order: ReturnType<typeof sql>, limit: number): Promise<Record<string, unknown>[]> { | |
| 14 | + const rows = await db.execute<Record<string, unknown>>(sql` | |
| 15 | + select ${CLUSTER_SELECT}, | |
| 16 | + (select row_to_json(x) from (select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = c.primary_event_id) x) as event, | |
| 17 | + (select json_agg(json_build_object('id', s2.id, 'name', s2.name, 'domain', s2.domain, 'first_party', s2.first_party) order by s2.name) from (select distinct s3.id, s3.name, s3.domain, s3.first_party from events e3 join sources s3 on s3.id = e3.source_id where e3.cluster_id = c.id limit 12) s2) as sources | |
| 18 | + from event_clusters c where ${where} order by ${order} limit ${limit}`); | |
| 19 | + return rows.rows; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export async function breakingDesk(): Promise<Record<string, unknown>> { | |
| 23 | + return cached("breaking", 8_000, async () => { | |
| 24 | + const [breaking, developing, confirmed, watching] = await Promise.all([ | |
| 25 | + clusterPrimaryEvents(sql`c.state = 'breaking' and c.last_at >= now() - interval '24 hours'`, sql`c.max_importance desc, c.velocity desc, c.last_at desc`, 20), | |
| 26 | + clusterPrimaryEvents(sql`c.state = 'developing' and c.last_at >= now() - interval '24 hours'`, sql`c.velocity desc, c.max_importance desc, c.last_at desc`, 20), | |
| 27 | + clusterPrimaryEvents(sql`c.state = 'confirmed' and c.last_at >= now() - interval '48 hours'`, sql`c.last_at desc`, 20), | |
| 28 | + listEvents({ limit: 25, order: "signal", signal_min: 60, after: new Date(Date.now() - 12 * 3600e3).toISOString() }).then((r) => r.items.filter((e) => !["breaking", "developing", "confirmed"].includes(String((e.cluster as { state?: string } | null)?.state ?? "")))), | |
| 29 | + ]); | |
| 30 | + return { breaking_now: breaking, developing, recently_confirmed: confirmed, watching, generated_at: new Date().toISOString() }; | |
| 31 | + }); | |
| 32 | +} | |
| 33 | + | |
| 34 | +export async function pulse(): Promise<Record<string, unknown>> { | |
| 35 | + return cached("pulse", 10_000, async () => { | |
| 36 | + const desks = ["ai", "cyber", "finance", "government", "infrastructure", "health", "science", "products"]; | |
| 37 | + const [activity, byDesk, rising, anomalies, silent, incidents, breaking, groups, totals] = await Promise.all([ | |
| 38 | + db.execute<Record<string, unknown>>(sql` | |
| 39 | + with b as (select generate_series(date_trunc('hour', now() at time zone 'UTC') - interval '6 hours', date_trunc('minute', now() at time zone 'UTC'), interval '15 minutes') as bucket), | |
| 40 | + ev as (select to_timestamp(floor(extract(epoch from detected_at) / 900) * 900) at time zone 'UTC' as bk, count(*) as n from events where detected_at >= now() - interval '7 hours' group by 1), | |
| 41 | + ch as (select to_timestamp(floor(extract(epoch from detected_at) / 900) * 900) at time zone 'UTC' as bk, count(*) as n from changes where detected_at >= now() - interval '7 hours' group by 1) | |
| 42 | + select to_char(b.bucket, 'YYYY-MM-DD"T"HH24:MI:00"Z"') as t, coalesce(ev.n, 0)::int as events, coalesce(ch.n, 0)::int as changes | |
| 43 | + from b left join ev on ev.bk = b.bucket left join ch on ch.bk = b.bucket order by b.bucket`).then((r) => r.rows).catch(() => []), | |
| 44 | + Promise.all(desks.map(async (d) => ({ desk: d, items: (await listEvents({ limit: 5, order: "signal", category: d, after: new Date(Date.now() - 12 * 3600e3).toISOString() })).items }))), | |
| 45 | + db.execute<Record<string, unknown>>(sql` | |
| 46 | + with cur as (select ee.entity_id, count(*) n from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - interval '3 hours' group by 1), | |
| 47 | + prev as (select ee.entity_id, count(*) n from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - interval '27 hours' and e.detected_at < now() - interval '3 hours' group by 1) | |
| 48 | + select en.id, en.name, en.type, cur.n::int as events_3h, coalesce(prev.n,0)::int as events_prev_24h, round((cur.n::float / greatest(0.125, coalesce(prev.n,0)/8.0))::numeric, 1)::float as acceleration | |
| 49 | + from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id where cur.n >= 2 order by acceleration desc, cur.n desc limit 10`).then((r) => r.rows), | |
| 50 | + db.execute<Record<string, unknown>>(sql` | |
| 51 | + with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id), | |
| 52 | + base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id) | |
| 53 | + select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day, | |
| 54 | + round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score, | |
| 55 | + (case when coalesce(base.per_hour,0) > 0 then round(((cur.n/2.0/base.per_hour - 1) * 100)::numeric) else null end)::int as pct_vs_baseline | |
| 56 | + from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id where so.kind = 'registry' order by activity_score desc limit 8`).then((r) => r.rows), | |
| 57 | + listEvents({ limit: 8, silent_change: true, order: "signal", after: new Date(Date.now() - 24 * 3600e3).toISOString() }).then((r) => r.items), | |
| 58 | + listEvents({ limit: 8, group: "reliability", order: "recent", after: new Date(Date.now() - 6 * 3600e3).toISOString() }).then((r) => r.items), | |
| 59 | + clusterPrimaryEvents(sql`c.state in ('breaking','developing') and c.last_at >= now() - interval '24 hours'`, sql`(c.state = 'breaking') desc, c.max_importance desc, c.velocity desc`, 8), | |
| 60 | + db.execute<Record<string, unknown>>(sql`select e.event_type, count(*)::int as n from events e where e.detected_at >= now() - interval '24 hours' group by 1`).then((r) => { | |
| 61 | + const byGroup: Record<string, number> = {}; | |
| 62 | + for (const row of r.rows) { | |
| 63 | + const g = Object.entries(EVENT_GROUPS).find(([, spec]) => spec.types.includes(String(row.event_type)))?.[0] ?? "web"; | |
| 64 | + byGroup[g] = (byGroup[g] ?? 0) + Number(row.n); | |
| 65 | + } | |
| 66 | + return byGroup; | |
| 67 | + }), | |
| 68 | + db.execute<Record<string, unknown>>(sql`select (select count(*) from events where detected_at >= now() - interval '1 hour')::int as events_1h, (select count(*) from changes where detected_at >= now() - interval '1 hour')::int as changes_1h, (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes')::int as checks_5m, (select count(distinct source_id) from events where detected_at >= now() - interval '24 hours')::int as active_sources_24h, (select count(distinct country) from events where detected_at >= now() - interval '24 hours' and country is not null)::int as active_countries_24h`).then((r) => r.rows[0]), | |
| 69 | + ]); | |
| 70 | + return { activity, desks: byDesk, rising_entities: rising, anomalies, silent_changes: silent, infrastructure: incidents, breaking, by_group_24h: groups, totals, generated_at: new Date().toISOString() }; | |
| 71 | + }); | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** Weak signals (spec §101): things that are NOT breaking yet but could become important. Clearly labelled as indicators. */ | |
| 75 | +export async function radar(): Promise<Record<string, unknown>> { | |
| 76 | + return cached("radar", 30_000, async () => { | |
| 77 | + const [quietBursts, silentClusters, docBursts, repoBursts, statusChanges, developing, newSensorsFiring] = await Promise.all([ | |
| 78 | + // Sources far above their baseline but without any high-importance event | |
| 79 | + db.execute<Record<string, unknown>>(sql` | |
| 80 | + with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '3 hours' group by s.source_id), | |
| 81 | + base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id), | |
| 82 | + hot as (select distinct source_id from events where detected_at >= now() - interval '6 hours' and coalesce(signal_score, importance) >= 75) | |
| 83 | + select so.id, so.name, so.domain, so.categories, cur.n::int as changes_3h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day, | |
| 84 | + round((cur.n/3.0 / greatest(0.02, coalesce(base.per_hour, 0)))::numeric, 1)::float as ratio | |
| 85 | + from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id | |
| 86 | + where so.kind = 'registry' and cur.n >= 4 and cur.source_id not in (select source_id from hot) and (coalesce(base.per_hour,0) = 0 or cur.n/3.0 / base.per_hour >= 4) | |
| 87 | + order by ratio desc limit 12`).then((r) => r.rows), | |
| 88 | + // Entities with several silent changes in 24 h | |
| 89 | + db.execute<Record<string, unknown>>(sql` | |
| 90 | + select en.id, en.name, en.type, count(*)::int as silent_24h, array_agg(distinct e.event_type) as types, max(e.detected_at) as last_at | |
| 91 | + from events e join event_entities ee on ee.event_id = e.id join entities en on en.id = ee.entity_id | |
| 92 | + where e.silent_change and e.detected_at >= now() - interval '24 hours' group by en.id, en.name, en.type having count(*) >= 2 order by silent_24h desc, last_at desc limit 12`).then((r) => r.rows), | |
| 93 | + // Documentation / API modification bursts | |
| 94 | + db.execute<Record<string, unknown>>(sql` | |
| 95 | + select s.id, s.name, s.domain, count(*)::int as doc_changes_6h, array_agg(distinct e.event_type) as types, max(e.detected_at) as last_at | |
| 96 | + from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '6 hours' and e.event_type in ('documentation_change','API_change','api_change','availability_change') | |
| 97 | + group by s.id, s.name, s.domain having count(*) >= 3 order by doc_changes_6h desc limit 10`).then((r) => r.rows), | |
| 98 | + // Repository activity bursts (commits/tags/releases) | |
| 99 | + db.execute<Record<string, unknown>>(sql` | |
| 100 | + select s.id, s.name, s.domain, count(*)::int as repo_events_6h, max(e.detected_at) as last_at | |
| 101 | + from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.detected_at >= now() - interval '6 hours' and sen.connector = 'github' | |
| 102 | + group by s.id, s.name, s.domain having count(*) >= 3 order by repo_events_6h desc limit 10`).then((r) => r.rows), | |
| 103 | + // Fresh status-page changes below the breaking bar | |
| 104 | + listEvents({ limit: 10, group: "reliability", after: new Date(Date.now() - 3 * 3600e3).toISOString() }).then((r) => r.items.filter((e) => Number(e.importance) < 80)), | |
| 105 | + clusterPrimaryEvents(sql`c.state = 'developing' and c.last_at >= now() - interval '12 hours'`, sql`c.velocity desc, c.last_at desc`, 8), | |
| 106 | + // Sensors that produced their first-ever event in the last 24 h (new coverage lighting up) | |
| 107 | + db.execute<Record<string, unknown>>(sql` | |
| 108 | + select sen.id, sen.name, sen.source_id, s.name as source_name, min(e.detected_at) as first_event_at, count(*)::int as events | |
| 109 | + from events e join sensors sen on sen.id = e.sensor_id join sources s on s.id = sen.source_id | |
| 110 | + where e.detected_at >= now() - interval '24 hours' and not exists (select 1 from events e2 where e2.sensor_id = sen.id and e2.detected_at < now() - interval '24 hours') | |
| 111 | + group by sen.id, sen.name, sen.source_id, s.name order by events desc limit 10`).then((r) => r.rows), | |
| 112 | + ]); | |
| 113 | + return { unusual_source_activity: quietBursts, silent_clusters: silentClusters, documentation_bursts: docBursts, repository_bursts: repoBursts, status_changes: statusChanges, developing, new_coverage: newSensorsFiring, generated_at: new Date().toISOString(), disclaimer: "Indicators, not facts: each item is a pattern in raw observations that has not (yet) produced a high-importance event." }; | |
| 114 | + }); | |
| 115 | +} | |
| 116 | + | |
| 117 | +/** Entity insights (spec §26, §38, §102): heatmap, baseline, anomaly, velocity, rank, most active sensors. */ | |
| 118 | +export async function entityInsights(entityId: string): Promise<Record<string, unknown>> { | |
| 119 | + const [heat, today, last24, prev24, sensorsTop, silent24, breaking24, sources24, rank] = await Promise.all([ | |
| 120 | + db.execute<Record<string, unknown>>(sql`select day::text as day, events, silent, breaking, max_importance from entity_daily where entity_id = ${entityId} and day >= (now() at time zone 'UTC')::date - 34 order by day`).then((r) => r.rows), | |
| 121 | + db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= date_trunc('day', now() at time zone 'UTC')`).then((r) => Number(r.rows[0]?.n ?? 0)), | |
| 122 | + db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)), | |
| 123 | + db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '48 hours' and e.detected_at < now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)), | |
| 124 | + db.execute<Record<string, unknown>>(sql`select sen.id, sen.name, sen.type, sen.connector, sen.source_id, count(*)::int as events_7d, max(e.detected_at) as last_event_at from events e join event_entities ee on ee.event_id = e.id join sensors sen on sen.id = e.sensor_id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '7 days' group by sen.id, sen.name, sen.type, sen.connector, sen.source_id order by events_7d desc limit 8`).then((r) => r.rows), | |
| 125 | + db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.silent_change and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)), | |
| 126 | + db.execute<{ n: string }>(sql`select count(*)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and coalesce(e.signal_score, e.importance) >= 80 and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)), | |
| 127 | + db.execute<{ n: string }>(sql`select count(distinct e.source_id)::text as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${entityId} and e.detected_at >= now() - interval '24 hours'`).then((r) => Number(r.rows[0]?.n ?? 0)), | |
| 128 | + entityRank(entityId), | |
| 129 | + ]); | |
| 130 | + const baselineDays = heat.filter((d) => String(d.day) < new Date().toISOString().slice(0, 10)); | |
| 131 | + const baselinePerDay = baselineDays.length ? baselineDays.reduce((n, d) => n + Number(d.events), 0) / Math.max(baselineDays.length, 7) : 0; | |
| 132 | + // Rolling 24 h vs the 30-day daily baseline (a UTC "today" window is misleading in the first hours of the day). | |
| 133 | + const anomaly = dailyAnomaly(last24, baselinePerDay, 24); | |
| 134 | + const velocity = last24 && prev24 ? Math.round((last24 / prev24) * 100) / 100 : last24 ? 2 : 0; | |
| 135 | + // Fill the 35-day heatmap with zero days | |
| 136 | + const map = new Map(heat.map((d) => [String(d.day), d])); | |
| 137 | + const days: Record<string, unknown>[] = []; | |
| 138 | + for (let i = 34; i >= 0; i--) { | |
| 139 | + const d = new Date(Date.now() - i * 86400e3).toISOString().slice(0, 10); | |
| 140 | + days.push(map.get(d) ?? { day: d, events: 0, silent: 0, breaking: 0, max_importance: 0 }); | |
| 141 | + } | |
| 142 | + return { heatmap: days, baseline_per_day: Math.round(baselinePerDay * 10) / 10, today, events_24h: last24, events_prev_24h: prev24, velocity_ratio: velocity, anomaly, silent_24h: silent24, breaking_24h: breaking24, sources_24h: sources24, most_active_sensors: sensorsTop, rank }; | |
| 143 | +} | |
| 144 | + | |
| 145 | +/** WebSensor entity ranking (spec §105): computed over entities active in the last 7 days, cached 2 minutes. */ | |
| 146 | +export async function entityRankings(limit = 100): Promise<Record<string, unknown>[]> { | |
| 147 | + return cached(`rank:${limit}`, 120_000, async () => { | |
| 148 | + const rows = await db.execute<Record<string, unknown>>(sql` | |
| 149 | + with agg as ( | |
| 150 | + select ee.entity_id, | |
| 151 | + count(*) filter (where e.detected_at >= now() - interval '24 hours') as e24, | |
| 152 | + count(*) as e7, | |
| 153 | + avg(coalesce(e.signal_score, e.importance)) as avg_signal, | |
| 154 | + avg(case when e.evidence_label = 'CONFIRMED' then 1 else 0 end) as confirmed_ratio, | |
| 155 | + count(distinct e.source_id) as sources, | |
| 156 | + count(*) filter (where e.silent_change and e.detected_at >= now() - interval '24 hours') as silent24, | |
| 157 | + count(*) filter (where coalesce(e.signal_score, e.importance) >= 80 and e.detected_at >= now() - interval '24 hours') as breaking24, | |
| 158 | + max(e.detected_at) as last_at | |
| 159 | + from events e join event_entities ee on ee.event_id = e.id join sources s on s.id = e.source_id where s.kind = 'registry' and e.detected_at >= now() - interval '7 days' group by ee.entity_id), | |
| 160 | + base as (select entity_id, avg(events)::float as per_day from entity_daily where day >= (now() at time zone 'UTC')::date - 30 and day < (now() at time zone 'UTC')::date group by entity_id) | |
| 161 | + select en.id, en.name, en.type, en.domain, en.importance, agg.e24::int as events_24h, agg.e7::int as events_7d, round(agg.avg_signal::numeric,1)::float as avg_signal, round(agg.confirmed_ratio::numeric,2)::float as confirmed_ratio, agg.sources::int as sources, agg.silent24::int as silent_24h, agg.breaking24::int as breaking_24h, agg.last_at, coalesce(base.per_day,0)::float as baseline_per_day | |
| 162 | + from agg join entities en on en.id = agg.entity_id left join base on base.entity_id = agg.entity_id where en.type <> 'person'`); | |
| 163 | + const scored = rows.rows.map((r) => ({ ...r, rank_score: entityRankScore({ importance: Number(r.importance), events24h: Number(r.events_24h), events7d: Number(r.events_7d), avgSignal: Number(r.avg_signal), confirmedRatio: Number(r.confirmed_ratio), uniqueSources: Number(r.sources), baselinePerDay: Number(r.baseline_per_day) }) })); | |
| 164 | + scored.sort((a, b) => b.rank_score - a.rank_score); | |
| 165 | + return scored.slice(0, limit).map((r, i) => ({ ...r, rank: i + 1 })); | |
| 166 | + }); | |
| 167 | +} | |
| 168 | + | |
| 169 | +export async function entityRank(entityId: string): Promise<{ rank: number | null; total: number; score: number | null }> { | |
| 170 | + const all = await entityRankings(2000); | |
| 171 | + const i = all.findIndex((r) => r.id === entityId); | |
| 172 | + return { rank: i >= 0 ? i + 1 : null, total: all.length, score: i >= 0 ? Number(all[i]!.rank_score) : null }; | |
| 173 | +} | |
| 174 | + | |
| 175 | +export async function clusterDetail(idOrSlug: string): Promise<Record<string, unknown> | null> { | |
| 176 | + const c = (await db.execute<Record<string, unknown>>(sql`select ${CLUSTER_SELECT}, c.timeline from event_clusters c where c.id = ${idOrSlug} or c.slug = ${idOrSlug} limit 1`)).rows[0]; | |
| 177 | + if (!c) return null; | |
| 178 | + const events = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.cluster_id = ${String(c.id)} order by e.detected_at asc limit 200`); | |
| 179 | + const ents = (c.entity_ids as string[]).length ? await db.execute<Record<string, unknown>>(sql`select id, name, type, importance from entities where id = any(${textArray(c.entity_ids as string[])}) order by importance desc`) : { rows: [] }; | |
| 180 | + // Propagation timeline (spec §35): offsets from the first signal. | |
| 181 | + const first = new Date(String(c.first_at)).getTime(); | |
| 182 | + const propagation = events.rows.map((e) => ({ id: e.id, slug: e.slug, at: e.detected_at, offset_ms: new Date(String(e.detected_at)).getTime() - first, source: e.source, sensor: e.sensor, first_party: e.first_party, event_type: e.event_type, importance: e.importance, title: e.title })); | |
| 183 | + const firstExternal = events.rows.find((e) => e.first_party === false); | |
| 184 | + const firstParty = events.rows.find((e) => e.first_party !== false); | |
| 185 | + const leadTime = firstParty && firstExternal ? new Date(String(firstExternal.detected_at)).getTime() - new Date(String(firstParty.detected_at)).getTime() : null; | |
| 186 | + return { cluster: c, events: events.rows, entities: ents.rows, propagation, lead_time_ms: leadTime ?? c.lead_time_ms ?? null, first_party_signals: events.rows.filter((e) => e.first_party !== false).length, external_signals: events.rows.filter((e) => e.first_party === false).length }; | |
| 187 | +} | |
| 188 | + | |
| 189 | +export async function countryList(): Promise<Record<string, unknown>[]> { | |
| 190 | + return cached("countries", 60_000, async () => { | |
| 191 | + const rows = await db.execute<Record<string, unknown>>(sql` | |
| 192 | + select s.country, count(distinct s.id)::int as sources, (select count(*) from events e where e.country = s.country and e.detected_at >= now() - interval '24 hours')::int as events_24h, | |
| 193 | + (select count(*) from events e where e.country = s.country and e.detected_at >= now() - interval '24 hours' and coalesce(e.signal_score, e.importance) >= 80)::int as breaking_24h | |
| 194 | + from sources s where s.country is not null and s.enabled and s.kind = 'registry' group by s.country order by events_24h desc, sources desc`); | |
| 195 | + return rows.rows.map((r) => ({ ...r, name: COUNTRIES[String(r.country)]?.name ?? String(r.country), slug: COUNTRIES[String(r.country)]?.slug ?? String(r.country).toLowerCase(), flag: COUNTRIES[String(r.country)]?.flag ?? "" })); | |
| 196 | + }); | |
| 197 | +} | |
| 198 | + | |
| 199 | +export async function countryDesk(code: string): Promise<Record<string, unknown>> { | |
| 200 | + const c = code.toUpperCase(); | |
| 201 | + const cats = ["government", "finance", "infrastructure", "health", "news", "cyber", "ai", "science"]; | |
| 202 | + const [breaking, byCategory, sources, byType, silent, recent] = await Promise.all([ | |
| 203 | + listEvents({ limit: 10, country: c, order: "signal", after: new Date(Date.now() - 48 * 3600e3).toISOString() }).then((r) => r.items), | |
| 204 | + Promise.all(cats.map(async (cat) => ({ category: cat, items: (await listEvents({ limit: 6, country: c, category: cat })).items }))).then((x) => x.filter((d) => d.items.length)), | |
| 205 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier, s.categories, s.first_party, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h from sources s where s.country = ${c} and s.enabled and s.kind = 'registry' order by events_24h desc, s.tier, s.name limit 200`).then((r) => r.rows), | |
| 206 | + db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where country = ${c} and detected_at >= now() - interval '7 days' group by 1 order by 2 desc limit 20`).then((r) => r.rows), | |
| 207 | + listEvents({ limit: 8, country: c, silent_change: true }).then((r) => r.items), | |
| 208 | + listEvents({ limit: 40, country: c }), | |
| 209 | + ]); | |
| 210 | + return { country: { code: c, name: COUNTRIES[c]?.name ?? c, flag: COUNTRIES[c]?.flag ?? "" }, breaking, by_category: byCategory, sources, by_type: byType, silent, recent: recent.items, nextCursor: recent.nextCursor }; | |
| 211 | +} | |
| 212 | + | |
| 213 | +export async function categoryDesk(channel: string): Promise<Record<string, unknown>> { | |
| 214 | + const cats = FEED_CHANNELS[channel] ?? [channel]; | |
| 215 | + const [breaking, silent, activeSources, byType, trendingEntities, recent, series] = await Promise.all([ | |
| 216 | + listEvents({ limit: 8, category: channel, order: "signal", after: new Date(Date.now() - 24 * 3600e3).toISOString() }).then((r) => r.items), | |
| 217 | + listEvents({ limit: 8, category: channel, silent_change: true }).then((r) => r.items), | |
| 218 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier, s.first_party, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where ${channel} = any(e.categories) and e.detected_at >= now() - interval '24 hours' and s.kind = 'registry' group by s.id, s.name, s.domain, s.tier, s.first_party order by events_24h desc limit 12`).then((r) => r.rows), | |
| 219 | + db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where ${channel} = any(categories) and detected_at >= now() - interval '7 days' group by 1 order by 2 desc limit 16`).then((r) => r.rows), | |
| 220 | + db.execute<Record<string, unknown>>(sql`select en.id, en.name, en.type, count(*)::int as events_24h, count(distinct e.source_id)::int as sources from events e join event_entities ee on ee.event_id = e.id join entities en on en.id = ee.entity_id where ${channel} = any(e.categories) and e.detected_at >= now() - interval '24 hours' group by en.id, en.name, en.type order by events_24h desc limit 10`).then((r) => r.rows), | |
| 221 | + listEvents({ limit: 60, category: channel }), | |
| 222 | + db.execute<Record<string, unknown>>(sql`select to_char(date_trunc('hour', detected_at at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as t, count(*)::int as n from events where ${channel} = any(categories) and detected_at >= now() - interval '48 hours' group by 1 order by 1`).then((r) => r.rows), | |
| 223 | + ]); | |
| 224 | + return { channel, categories: cats, breaking, silent, active_sources: activeSources, by_type: byType, trending_entities: trendingEntities, recent: recent.items, nextCursor: recent.nextCursor, series }; | |
| 225 | +} | |
| 226 | + | |
| 227 | +/** Sensors table for a source, with polling metadata the source page shows (spec §27). */ | |
| 228 | +export async function sourceSensors(sourceId: string): Promise<Record<string, unknown>[]> { | |
| 229 | + const r = await db.execute<Record<string, unknown>>(sql` | |
| 230 | + select id, name, url, type, connector, tier, health, status, priority, enabled, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, consecutive_errors, total_runs, total_not_modified, raw_changes, meaningful_changes, avg_latency_ms, base_interval_seconds, validated_at, | |
| 231 | + etag is not null as has_etag, last_modified is not null as has_last_modified, etag, last_modified, | |
| 232 | + (select count(*) from sensor_runs r where r.sensor_id = sensors.id and r.started_at >= now() - interval '24 hours')::int as checks_24h, | |
| 233 | + (select count(*) from changes c where c.sensor_id = sensors.id and c.detected_at >= now() - interval '24 hours')::int as changes_24h, | |
| 234 | + (select count(*) from snapshots sn where sn.sensor_id = sensors.id)::int as snapshot_count, | |
| 235 | + case when last_check_at is not null and next_check_at is not null then extract(epoch from (next_check_at - last_check_at))::int else null end as current_interval_seconds | |
| 236 | + from sensors where source_id = ${sourceId} order by priority, tier, name`); | |
| 237 | + return r.rows; | |
| 238 | +} | |
modified
apps/api/src/live.ts
+90 −19
@@ -1,34 +1,63 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 2 | import type { WebSocket } from "ws"; |
| 3 | 3 | import Redis from "ioredis"; |
| 4 | −import { FEED_CHANNELS } from "@websensor/core"; | |
| 4 | +import { eventGroupOf, FEED_CHANNELS } from "@websensor/core"; | |
| 5 | 5 | import { db, sql } from "@websensor/db"; |
| 6 | 6 | import { config } from "./config"; |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | 9 | * WebSocket gateway `/api/v1/live`. One Redis subscriber fans out to every client; clients |
| 10 | − * pick channels: events:global · events:breaking · events:<ai|cyber|finance|health|government| | |
| 11 | − * science|products|infrastructure> · entity:<id> · source:<id> · watchlist:<id>. | |
| 12 | − * Protocol (JSON): client → {"subscribe":[…]} | {"unsubscribe":[…]} | {"ping":1} | |
| 13 | − * server → {"type":"hello"} | {"type":"event", "channels":[…], "event":{…}} | {"type":"pong"} | |
| 10 | + * pick channels: events:global · events:breaking · events:silent · events:first-party · | |
| 11 | + * events:<ai|cyber|finance|health|government|science|products|infrastructure|news> · group:<security|…> · | |
| 12 | + * country:<CA> · type:<event_type> · entity:<id> · source:<id> · watchlist:<id>. | |
| 13 | + * Protocol (JSON): client → {"subscribe":[…]} | {"unsubscribe":[…]} | {"ping":1} | {"since":"<sid>"} | |
| 14 | + * server → {"type":"hello"} | {"type":"event", "sid":"…", "channels":[…], "event":{…}} | {"type":"replay_done"} | {"type":"pong"} | {"type":"heartbeat"} | |
| 15 | + * Every event frame carries the Redis stream id `sid`; after a reconnection the client sends | |
| 16 | + * {"since": lastSid} and missed events (up to 500) are replayed from the durable stream (spec §60). | |
| 14 | 17 | */ |
| 15 | 18 | interface Client { |
| 16 | 19 | ws: WebSocket; |
| 17 | 20 | channels: Set<string>; |
| 18 | − watchlists: Map<string, { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }>; | |
| 21 | + watchlists: Map<string, WatchRules>; | |
| 22 | +} | |
| 23 | +interface WatchRules { | |
| 24 | + entities: Set<string>; | |
| 25 | + sources: Set<string>; | |
| 26 | + keywords: string[]; | |
| 27 | + categories: Set<string>; | |
| 28 | + types: Set<string>; | |
| 29 | + countries: Set<string>; | |
| 30 | + urls: string[]; | |
| 19 | 31 | } |
| 20 | 32 | |
| 21 | 33 | const clients = new Set<Client>(); |
| 22 | 34 | let sub: Redis | null = null; |
| 35 | +let cmd: Redis | null = null; | |
| 23 | 36 | let published = 0; |
| 24 | 37 | |
| 25 | 38 | export function liveStats(): { clients: number; published: number } { |
| 26 | 39 | return { clients: clients.size, published }; |
| 27 | 40 | } |
| 28 | 41 | |
| 42 | +/** Engine heartbeat written by the scheduler (`ws:engine:status`). */ | |
| 43 | +export async function engineStatus(): Promise<Record<string, unknown> | null> { | |
| 44 | + try { | |
| 45 | + const raw = await getCmd().get("ws:engine:status"); | |
| 46 | + return raw ? (JSON.parse(raw) as Record<string, unknown>) : null; | |
| 47 | + } catch { | |
| 48 | + return null; | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +function getCmd(): Redis { | |
| 53 | + if (!cmd) cmd = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, lazyConnect: false }); | |
| 54 | + return cmd; | |
| 55 | +} | |
| 56 | + | |
| 29 | 57 | export async function registerLive(app: FastifyInstance): Promise<void> { |
| 30 | 58 | sub = new Redis(config.redisUrl, { maxRetriesPerRequest: 3 }); |
| 31 | 59 | sub.on("error", (e) => app.log.warn({ err: e.message }, "redis sub error")); |
| 60 | + getCmd().on("error", (e) => app.log.warn({ err: e.message }, "redis cmd error")); | |
| 32 | 61 | await sub.subscribe("ws:live"); |
| 33 | 62 | sub.on("message", (_ch, msg) => { |
| 34 | 63 | let ev: Record<string, unknown>; |
@@ -38,21 +67,15 @@ export async function registerLive(app: FastifyInstance): Promise<void> { | ||
| 38 | 67 | return; |
| 39 | 68 | } |
| 40 | 69 | published++; |
| 41 | − const chans = channelsFor(ev); | |
| 42 | − for (const c of clients) { | |
| 43 | − const hit = [...chans].filter((ch) => c.channels.has(ch)); | |
| 44 | − for (const [wid, w] of c.watchlists) if (matchesWatchlist(ev, w)) hit.push(`watchlist:${wid}`); | |
| 45 | − if (!hit.length) continue; | |
| 46 | − if (c.ws.readyState === c.ws.OPEN) c.ws.send(JSON.stringify({ type: "event", channels: hit, event: ev })); | |
| 47 | − } | |
| 70 | + deliver(ev, String(ev.sid ?? "")); | |
| 48 | 71 | }); |
| 49 | 72 | |
| 50 | 73 | app.get("/api/v1/live", { websocket: true }, (socket) => { |
| 51 | 74 | const client: Client = { ws: socket, channels: new Set(["events:global"]), watchlists: new Map() }; |
| 52 | 75 | clients.add(client); |
| 53 | − socket.send(JSON.stringify({ type: "hello", channels: [...client.channels], serverTime: new Date().toISOString() })); | |
| 76 | + socket.send(JSON.stringify({ type: "hello", channels: [...client.channels], serverTime: new Date().toISOString(), protocol: 2 })); | |
| 54 | 77 | socket.on("message", async (raw: Buffer | string) => { |
| 55 | − let msg: { subscribe?: string[]; unsubscribe?: string[]; ping?: number }; | |
| 78 | + let msg: { subscribe?: string[]; unsubscribe?: string[]; ping?: number; since?: string }; | |
| 56 | 79 | try { |
| 57 | 80 | msg = JSON.parse(raw.toString()) as typeof msg; |
| 58 | 81 | } catch { |
@@ -68,7 +91,8 @@ export async function registerLive(app: FastifyInstance): Promise<void> { | ||
| 68 | 91 | client.channels.delete(ch); |
| 69 | 92 | if (ch.startsWith("watchlist:")) client.watchlists.delete(ch.slice(10)); |
| 70 | 93 | } |
| 71 | − socket.send(JSON.stringify({ type: "subscribed", channels: [...client.channels, ...[...client.watchlists.keys()].map((w) => `watchlist:${w}`)] })); | |
| 94 | + if (msg.subscribe || msg.unsubscribe) socket.send(JSON.stringify({ type: "subscribed", channels: [...client.channels, ...[...client.watchlists.keys()].map((w) => `watchlist:${w}`)] })); | |
| 95 | + if (typeof msg.since === "string" && /^\d{10,16}-\d{1,6}$/.test(msg.since)) await replay(client, msg.since); | |
| 72 | 96 | }); |
| 73 | 97 | const hb = setInterval(() => { |
| 74 | 98 | if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type: "heartbeat", t: Date.now() })); |
@@ -84,37 +108,83 @@ export async function registerLive(app: FastifyInstance): Promise<void> { | ||
| 84 | 108 | }); |
| 85 | 109 | } |
| 86 | 110 | |
| 111 | +function deliver(ev: Record<string, unknown>, sid: string, only?: Client): void { | |
| 112 | + const chans = channelsFor(ev); | |
| 113 | + for (const c of only ? [only] : clients) { | |
| 114 | + const hit = [...chans].filter((ch) => c.channels.has(ch)); | |
| 115 | + for (const [wid, w] of c.watchlists) if (matchesWatchlist(ev, w)) hit.push(`watchlist:${wid}`); | |
| 116 | + if (!hit.length) continue; | |
| 117 | + if (c.ws.readyState === c.ws.OPEN) c.ws.send(JSON.stringify({ type: "event", sid, channels: hit, event: ev })); | |
| 118 | + } | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** Replay missed events from the durable stream (exclusive of `since`). */ | |
| 122 | +async function replay(client: Client, since: string): Promise<void> { | |
| 123 | + try { | |
| 124 | + const [ms, seq] = since.split("-"); | |
| 125 | + const start = `${ms}-${Number(seq) + 1}`; | |
| 126 | + const rows = (await getCmd().xrange("ws:events", start, "+", "COUNT", 500)) as [string, string[]][]; | |
| 127 | + let n = 0; | |
| 128 | + for (const [sid, fields] of rows) { | |
| 129 | + const i = fields.indexOf("event"); | |
| 130 | + if (i < 0) continue; | |
| 131 | + try { | |
| 132 | + const ev = JSON.parse(fields[i + 1]!) as Record<string, unknown>; | |
| 133 | + deliver({ ...ev, sid, replayed: true }, sid, client); | |
| 134 | + n++; | |
| 135 | + } catch { | |
| 136 | + // skip malformed | |
| 137 | + } | |
| 138 | + } | |
| 139 | + if (client.ws.readyState === client.ws.OPEN) client.ws.send(JSON.stringify({ type: "replay_done", since, count: n, truncated: rows.length >= 500 })); | |
| 140 | + } catch { | |
| 141 | + if (client.ws.readyState === client.ws.OPEN) client.ws.send(JSON.stringify({ type: "replay_done", since, count: 0, error: "replay_unavailable" })); | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 87 | 145 | export function channelsFor(ev: Record<string, unknown>): Set<string> { |
| 88 | 146 | const out = new Set<string>(["events:global"]); |
| 89 | 147 | const importance = Number(ev.importance ?? 0); |
| 90 | − if (importance >= 80) out.add("events:breaking"); | |
| 148 | + const signal = Number(ev.signal ?? importance); | |
| 149 | + if (signal >= 80 || importance >= 80) out.add("events:breaking"); | |
| 91 | 150 | if (ev.silent) out.add("events:silent"); |
| 151 | + if (ev.firstParty !== false) out.add("events:first-party"); | |
| 92 | 152 | const cats = (ev.categories as string[] | undefined) ?? []; |
| 93 | 153 | for (const [ch, list] of Object.entries(FEED_CHANNELS)) if (list.some((c) => cats.includes(c)) || cats.includes(ch)) out.add(`events:${ch}`); |
| 94 | 154 | const src = ev.source as { id?: string } | undefined; |
| 95 | 155 | if (src?.id) out.add(`source:${src.id}`); |
| 96 | 156 | for (const e of (ev.entities as { id: string }[] | undefined) ?? []) out.add(`entity:${e.id}`); |
| 97 | 157 | out.add(`type:${String(ev.type)}`); |
| 158 | + out.add(`group:${String(ev.group ?? eventGroupOf(String(ev.type)))}`); | |
| 159 | + if (ev.country) out.add(`country:${String(ev.country).toUpperCase()}`); | |
| 160 | + if (ev.clusterState === "breaking" || ev.clusterState === "developing") out.add(`state:${String(ev.clusterState)}`); | |
| 98 | 161 | return out; |
| 99 | 162 | } |
| 100 | 163 | |
| 101 | 164 | async function loadWatchlist(client: Client, id: string): Promise<void> { |
| 102 | 165 | const rows = await db.execute<{ kind: string; value: string }>(sql`select kind, value from watchlist_items where watchlist_id = ${id}`); |
| 103 | − const w = { entities: new Set<string>(), sources: new Set<string>(), keywords: [] as string[], categories: new Set<string>() }; | |
| 166 | + const w: WatchRules = { entities: new Set(), sources: new Set(), keywords: [], categories: new Set(), types: new Set(), countries: new Set(), urls: [] }; | |
| 104 | 167 | for (const r of rows.rows) { |
| 105 | 168 | if (r.kind === "entity") w.entities.add(r.value); |
| 106 | 169 | else if (r.kind === "source") w.sources.add(r.value); |
| 107 | 170 | else if (r.kind === "keyword") w.keywords.push(r.value.toLowerCase()); |
| 108 | 171 | else if (r.kind === "category") w.categories.add(r.value); |
| 172 | + else if (r.kind === "event_type") w.types.add(r.value); | |
| 173 | + else if (r.kind === "country") w.countries.add(r.value.toUpperCase()); | |
| 174 | + else if (r.kind === "url") w.urls.push(r.value.replace(/\/$/, "")); | |
| 109 | 175 | } |
| 110 | 176 | client.watchlists.set(id, w); |
| 111 | 177 | } |
| 112 | 178 | |
| 113 | −function matchesWatchlist(ev: Record<string, unknown>, w: { entities: Set<string>; sources: Set<string>; keywords: string[]; categories: Set<string> }): boolean { | |
| 179 | +function matchesWatchlist(ev: Record<string, unknown>, w: WatchRules): boolean { | |
| 114 | 180 | const src = ev.source as { id?: string } | undefined; |
| 115 | 181 | if (src?.id && w.sources.has(src.id)) return true; |
| 116 | 182 | for (const e of (ev.entities as { id: string }[] | undefined) ?? []) if (w.entities.has(e.id)) return true; |
| 117 | 183 | for (const c of (ev.categories as string[] | undefined) ?? []) if (w.categories.has(c)) return true; |
| 184 | + if (w.types.has(String(ev.type))) return true; | |
| 185 | + if (ev.country && w.countries.has(String(ev.country).toUpperCase())) return true; | |
| 186 | + const url = String(ev.url ?? ""); | |
| 187 | + if (w.urls.some((u) => url === u || url.startsWith(u + "/"))) return true; | |
| 118 | 188 | if (w.keywords.length) { |
| 119 | 189 | const hay = `${String(ev.title)} ${String(ev.summary)}`.toLowerCase(); |
| 120 | 190 | if (w.keywords.some((k) => hay.includes(k))) return true; |
@@ -126,4 +196,5 @@ export async function closeLive(): Promise<void> { | ||
| 126 | 196 | for (const c of clients) c.ws.close(1001, "server shutdown"); |
| 127 | 197 | clients.clear(); |
| 128 | 198 | if (sub) await sub.quit().catch(() => undefined); |
| 199 | + if (cmd) await cmd.quit().catch(() => undefined); | |
| 129 | 200 | } |
modified
apps/api/src/queries.ts
+124 −23
@@ -1,3 +1,4 @@ | ||
| 1 | +import { EVENT_GROUPS, parseSearch } from "@websensor/core"; | |
| 1 | 2 | import { db, sql, textArray } from "@websensor/db"; |
| 2 | 3 | |
| 3 | 4 | /** Read-model helpers shared by REST routes. All return plain JSON-ready objects. */ |
@@ -13,27 +14,48 @@ export interface EventFilters { | ||
| 13 | 14 | cluster?: string; |
| 14 | 15 | importance_min?: number; |
| 15 | 16 | confidence_min?: number; |
| 17 | + signal_min?: number; | |
| 16 | 18 | event_type?: string; |
| 19 | + group?: string; | |
| 17 | 20 | silent_change?: boolean; |
| 21 | + first_party?: boolean; | |
| 22 | + confirmed?: boolean; | |
| 23 | + country?: string; | |
| 24 | + language?: string; | |
| 25 | + change_class?: string; | |
| 18 | 26 | q?: string; |
| 19 | 27 | limit: number; |
| 20 | 28 | cursor?: string; |
| 21 | − order?: "recent" | "importance"; | |
| 29 | + order?: "recent" | "importance" | "signal"; | |
| 30 | + /** include events from custom (owner) sources — only set by owner-scoped routes */ | |
| 31 | + includeCustom?: boolean; | |
| 22 | 32 | } |
| 23 | 33 | |
| 24 | 34 | export const EVENT_SELECT = sql` |
| 25 | 35 | e.id, e.slug, e.event_type, e.title, e.summary, e.why_it_matters, e.importance, e.confidence, e.novelty, e.categories, e.keywords, |
| 26 | − e.silent_change, e.evidence_label, e.url, e.detected_at, e.published_at, e.observed_from, e.processed_at, e.published_to_feed_at, | |
| 36 | + e.silent_change, e.evidence_label, e.url, e.canonical_url, e.detected_at, e.published_at, e.observed_from, e.processed_at, e.published_to_feed_at, | |
| 27 | 37 | e.detection_latency_ms, e.processing_latency_ms, e.cluster_id, e.sensor_id, e.source_id, e.change_id, e.old_snapshot_id, e.new_snapshot_id, |
| 28 | 38 | e.importance_components, e.processing_version, |
| 29 | − json_build_object('id', s.id, 'name', s.name, 'domain', s.domain, 'tier', s.tier, 'categories', s.categories) as source, | |
| 39 | + e.signal_score, e.velocity_score, e.impact_score, e.anomaly_score, e.change_class, e.first_party, e.country, e.language, e.field_changes, e.score_reasons, | |
| 40 | + json_build_object('id', s.id, 'name', s.name, 'domain', s.domain, 'tier', s.tier, 'categories', s.categories, 'first_party', s.first_party, 'country', s.country) as source, | |
| 30 | 41 | json_build_object('id', sen.id, 'name', sen.name, 'type', sen.type, 'connector', sen.connector, 'tier', sen.tier) as sensor, |
| 31 | 42 | coalesce((select json_agg(json_build_object('id', en.id, 'name', en.name, 'type', en.type, 'role', ee.role) order by ee.role, en.name) |
| 32 | 43 | from event_entities ee join entities en on en.id = ee.entity_id where ee.event_id = e.id), '[]'::json) as entities, |
| 33 | − (select event_count from event_clusters c where c.id = e.cluster_id) as cluster_size`; | |
| 44 | + (select event_count from event_clusters c where c.id = e.cluster_id) as cluster_size, | |
| 45 | + (select json_build_object('id', c.id, 'slug', c.slug, 'state', c.state, 'event_count', c.event_count, 'source_count', c.source_count, 'first_party_count', c.first_party_count, 'external_count', c.external_count, 'velocity', c.velocity, 'lead_time_ms', c.lead_time_ms) from event_clusters c where c.id = e.cluster_id) as cluster`; | |
| 34 | 46 | |
| 35 | −export async function listEvents(f: EventFilters): Promise<{ items: Record<string, unknown>[]; nextCursor: string | null }> { | |
| 47 | +/** Build WHERE conditions from filters (shared by list, RSS, watchlist and desk queries). */ | |
| 48 | +export function eventConditions(f: EventFilters): ReturnType<typeof sql>[] { | |
| 36 | 49 | const conds = [sql`true`]; |
| 50 | + let free = f.q?.trim() ?? ""; | |
| 51 | + // Advanced syntax inside q (entity:… type:… after:…) is merged into the filters. | |
| 52 | + if (free) { | |
| 53 | + const p = parseSearch(free); | |
| 54 | + free = p.text; | |
| 55 | + const pf = p.filters; | |
| 56 | + f = { ...f, entity: f.entity ?? pf.entity, source: f.source ?? pf.source, domain: f.domain ?? pf.domain, event_type: f.event_type ?? pf.event_type, group: f.group ?? pf.group, category: f.category ?? pf.category, country: f.country ?? pf.country, language: f.language ?? pf.language, after: f.after ?? pf.after, before: f.before ?? pf.before, silent_change: f.silent_change ?? pf.silent_change, first_party: f.first_party ?? pf.first_party, confirmed: f.confirmed ?? pf.confirmed, importance_min: f.importance_min ?? pf.importance_min, confidence_min: f.confidence_min ?? pf.confidence_min, signal_min: f.signal_min ?? pf.signal_min, change_class: f.change_class ?? pf.change_class, cluster: f.cluster ?? pf.cluster, sensor: f.sensor ?? pf.sensor }; | |
| 57 | + } | |
| 58 | + if (!f.includeCustom) conds.push(sql`s.kind = 'registry'`); | |
| 37 | 59 | if (f.after) conds.push(sql`e.detected_at > ${new Date(f.after)}`); |
| 38 | 60 | if (f.before) conds.push(sql`e.detected_at < ${new Date(f.before)}`); |
| 39 | 61 | if (f.category) conds.push(sql`${f.category} = any(e.categories)`); |
@@ -44,22 +66,41 @@ export async function listEvents(f: EventFilters): Promise<{ items: Record<strin | ||
| 44 | 66 | if (f.entity) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = ${f.entity})`); |
| 45 | 67 | if (f.importance_min !== undefined) conds.push(sql`e.importance >= ${f.importance_min}`); |
| 46 | 68 | if (f.confidence_min !== undefined) conds.push(sql`e.confidence >= ${f.confidence_min}`); |
| 47 | − if (f.event_type) conds.push(sql`e.event_type = any(${textArray(f.event_type.split(","))})`); | |
| 69 | + if (f.signal_min !== undefined) conds.push(sql`coalesce(e.signal_score, e.importance) >= ${f.signal_min}`); | |
| 70 | + if (f.event_type) conds.push(sql`e.event_type = any(${textArray(f.event_type.split(",").map((t) => t.trim()).filter(Boolean))})`); | |
| 71 | + if (f.group && EVENT_GROUPS[f.group]) conds.push(sql`e.event_type = any(${textArray(EVENT_GROUPS[f.group]!.types)})`); | |
| 48 | 72 | if (f.silent_change !== undefined) conds.push(sql`e.silent_change = ${f.silent_change}`); |
| 49 | − if (f.q) conds.push(sql`e.search @@ websearch_to_tsquery('english', ${f.q})`); | |
| 73 | + if (f.first_party !== undefined) conds.push(sql`e.first_party = ${f.first_party}`); | |
| 74 | + if (f.confirmed) conds.push(sql`e.evidence_label = 'CONFIRMED'`); | |
| 75 | + if (f.country) conds.push(sql`e.country = ${f.country.toUpperCase()}`); | |
| 76 | + if (f.language) conds.push(sql`e.language = ${f.language.toLowerCase()}`); | |
| 77 | + if (f.change_class) conds.push(sql`e.change_class = ${f.change_class}`); | |
| 78 | + if (free) conds.push(sql`(e.search @@ websearch_to_tsquery('english', ${free}) or e.title ilike ${"%" + free + "%"})`); | |
| 79 | + return conds; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export async function listEvents(f: EventFilters): Promise<{ items: Record<string, unknown>[]; nextCursor: string | null }> { | |
| 83 | + const conds = eventConditions(f); | |
| 50 | 84 | if (f.cursor) { |
| 51 | 85 | const [ts, id] = decodeCursor(f.cursor); |
| 52 | 86 | if (f.order === "importance") conds.push(sql`(e.importance, e.id) < (${Number(ts)}, ${id})`); |
| 87 | + else if (f.order === "signal") conds.push(sql`(coalesce(e.signal_score, e.importance), e.id) < (${Number(ts)}, ${id})`); | |
| 53 | 88 | else conds.push(sql`(e.detected_at, e.id) < (${new Date(Number(ts))}, ${id})`); |
| 54 | 89 | } |
| 55 | − const order = f.order === "importance" ? sql`e.importance desc, e.id desc` : sql`e.detected_at desc, e.id desc`; | |
| 90 | + const order = f.order === "importance" ? sql`e.importance desc, e.id desc` : f.order === "signal" ? sql`coalesce(e.signal_score, e.importance) desc, e.id desc` : sql`e.detected_at desc, e.id desc`; | |
| 56 | 91 | const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` and `)} order by ${order} limit ${f.limit + 1}`); |
| 57 | 92 | const items = rows.rows.slice(0, f.limit); |
| 58 | 93 | const last = items[items.length - 1]; |
| 59 | − const nextCursor = rows.rows.length > f.limit && last ? encodeCursor(f.order === "importance" ? String(last.importance) : String(new Date(last.detected_at as string).getTime()), String(last.id)) : null; | |
| 94 | + const nextCursor = rows.rows.length > f.limit && last ? encodeCursor(f.order === "importance" ? String(last.importance) : f.order === "signal" ? String(last.signal_score ?? last.importance) : String(new Date(last.detected_at as string).getTime()), String(last.id)) : null; | |
| 60 | 95 | return { items, nextCursor }; |
| 61 | 96 | } |
| 62 | 97 | |
| 98 | +export async function countEvents(f: EventFilters): Promise<number> { | |
| 99 | + const conds = eventConditions(f); | |
| 100 | + const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from events e join sources s on s.id = e.source_id where ${sql.join(conds, sql` and `)}`); | |
| 101 | + return Number(r.rows[0]?.n ?? 0); | |
| 102 | +} | |
| 103 | + | |
| 63 | 104 | export async function getEvent(idOrSlug: string): Promise<Record<string, unknown> | null> { |
| 64 | 105 | const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT}, e.interpretation from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = ${idOrSlug} or e.slug = ${idOrSlug} limit 1`); |
| 65 | 106 | return rows.rows[0] ?? null; |
@@ -68,49 +109,78 @@ export async function getEvent(idOrSlug: string): Promise<Record<string, unknown | ||
| 68 | 109 | export async function relatedEvents(ev: Record<string, unknown>, limit = 8): Promise<Record<string, unknown>[]> { |
| 69 | 110 | const rows = await db.execute<Record<string, unknown>>(sql` |
| 70 | 111 | select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id |
| 71 | − where e.id <> ${String(ev.id)} and (e.cluster_id = ${String(ev.cluster_id ?? "")} or e.source_id = ${String(ev.source_id)} or exists (select 1 from event_entities a join event_entities b on a.entity_id = b.entity_id where a.event_id = e.id and b.event_id = ${String(ev.id)})) | |
| 112 | + where e.id <> ${String(ev.id)} and s.kind = 'registry' and (e.cluster_id = ${String(ev.cluster_id ?? "")} or e.source_id = ${String(ev.source_id)} or exists (select 1 from event_entities a join event_entities b on a.entity_id = b.entity_id where a.event_id = e.id and b.event_id = ${String(ev.id)})) | |
| 72 | 113 | order by (e.cluster_id = ${String(ev.cluster_id ?? "")}) desc, e.detected_at desc limit ${limit}`); |
| 73 | 114 | return rows.rows; |
| 74 | 115 | } |
| 75 | 116 | |
| 117 | +/** Previous meaningful events on the same sensor (historical context for "what changed" — spec §79–80). */ | |
| 118 | +export async function sensorHistory(sensorId: string, beforeEventId: string, limit = 6): Promise<Record<string, unknown>[]> { | |
| 119 | + const rows = await db.execute<Record<string, unknown>>(sql` | |
| 120 | + select e.id, e.slug, e.title, e.event_type, e.importance, e.silent_change, e.detected_at, e.field_changes from events e | |
| 121 | + where e.sensor_id = ${sensorId} and e.id <> ${beforeEventId} order by e.detected_at desc limit ${limit}`); | |
| 122 | + return rows.rows; | |
| 123 | +} | |
| 124 | + | |
| 76 | 125 | export async function stats(): Promise<Record<string, unknown>> { |
| 77 | 126 | const [r] = ( |
| 78 | 127 | await db.execute<Record<string, unknown>>(sql` |
| 79 | 128 | select |
| 80 | − (select count(*) from sources where enabled) as sources, | |
| 129 | + (select count(*) from sources where enabled and kind = 'registry') as sources, | |
| 81 | 130 | (select count(*) from sensors where enabled) as sensors, |
| 82 | 131 | (select count(*) from entities) as entities, |
| 83 | 132 | (select count(*) from snapshots) as snapshots, |
| 84 | 133 | (select count(*) from events) as events_total, |
| 85 | 134 | (select count(*) from events where detected_at >= now() - interval '24 hours') as events_24h, |
| 135 | + (select count(*) from events where detected_at >= now() - interval '1 hour') as events_1h, | |
| 86 | 136 | (select count(*) from events where detected_at >= now() - interval '24 hours' and silent_change) as silent_24h, |
| 87 | − (select count(*) from events where detected_at >= now() - interval '24 hours' and importance >= 80) as breaking_24h, | |
| 137 | + (select count(*) from events where detected_at >= now() - interval '24 hours' and coalesce(signal_score, importance) >= 80) as breaking_24h, | |
| 138 | + (select count(*) from event_clusters where state = 'breaking') as breaking_now, | |
| 139 | + (select count(*) from event_clusters where state = 'developing') as developing_now, | |
| 88 | 140 | (select count(*) from changes where detected_at >= now() - interval '24 hours') as changes_24h, |
| 89 | − (select coalesce(sum(checks),0) from metrics_daily where day = current_date) as checks_today, | |
| 90 | − (select coalesce(sum(not_modified),0) from metrics_daily where day = current_date) as not_modified_today, | |
| 91 | − (select coalesce(sum(bytes),0) from metrics_daily where day = current_date) as bytes_today, | |
| 141 | + (select coalesce(sum(checks),0) from metrics_daily where day = (now() at time zone 'UTC')::date) as checks_today, | |
| 142 | + (select coalesce(sum(not_modified),0) from metrics_daily where day = (now() at time zone 'UTC')::date) as not_modified_today, | |
| 143 | + (select coalesce(sum(bytes),0) from metrics_daily where day = (now() at time zone 'UTC')::date) as bytes_today, | |
| 92 | 144 | (select count(*) from sensor_runs where started_at >= now() - interval '1 hour') as checks_last_hour, |
| 145 | + (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes') as checks_last_5m, | |
| 146 | + (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes' and outcome = 'not_modified') as not_modified_last_5m, | |
| 93 | 147 | (select count(*) from sensors where health = 'UP' and enabled) as sensors_up, |
| 94 | 148 | (select count(*) from sensors where health in ('DEGRADED','ERROR','RATE_LIMITED') and enabled) as sensors_degraded, |
| 149 | + (select count(*) from sources where enabled and first_party and kind = 'registry') as sources_first_party, | |
| 150 | + (select count(distinct country) from sources where country is not null) as countries, | |
| 95 | 151 | (select max(started_at) from sensor_runs) as last_check_at, |
| 96 | 152 | (select max(detected_at) from events) as last_event_at, |
| 97 | 153 | (select percentile_cont(0.5) within group (order by processing_latency_ms) from events where detected_at >= now() - interval '24 hours') as p50_processing_ms, |
| 98 | 154 | (select percentile_cont(0.5) within group (order by detection_latency_ms) from events where detected_at >= now() - interval '24 hours' and detection_latency_ms is not null and detection_latency_ms < 86400000) as p50_detection_ms`) |
| 99 | 155 | ).rows; |
| 100 | − return Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [k, typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v])); | |
| 156 | + const out = Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [k, typeof v === "string" && /^\d+(\.\d+)?$/.test(v) ? Number(v) : v])); | |
| 157 | + out.checks_per_min = Math.round(Number(out.checks_last_5m ?? 0) / 5); | |
| 158 | + out.events_per_min = Math.round((Number(out.events_1h ?? 0) / 60) * 10) / 10; | |
| 159 | + out.not_modified_ratio_5m = Number(out.checks_last_5m) ? Math.round((Number(out.not_modified_last_5m) / Number(out.checks_last_5m)) * 100) / 100 : null; | |
| 160 | + return out; | |
| 101 | 161 | } |
| 102 | 162 | |
| 103 | 163 | export async function trending(hours = 24, limit = 12): Promise<Record<string, unknown>[]> { |
| 104 | 164 | const rows = await db.execute<Record<string, unknown>>(sql` |
| 105 | 165 | with cur as ( |
| 106 | − select ee.entity_id, count(*) as n, sum(e.importance) as imp, count(distinct e.source_id) as sources, sum(case when e.silent_change then 1 else 0 end) as silent, max(e.importance) as max_imp | |
| 107 | − from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - make_interval(hours => ${hours}) group by ee.entity_id), | |
| 166 | + select ee.entity_id, count(*) as n, sum(e.importance) as imp, count(distinct e.source_id) as sources, sum(case when e.silent_change then 1 else 0 end) as silent, max(e.importance) as max_imp, | |
| 167 | + sum(case when e.first_party then 1 else 0 end) as first_party, sum(case when e.evidence_label = 'CONFIRMED' then 1 else 0 end) as confirmed, avg(coalesce(e.signal_score, e.importance)) as avg_signal, | |
| 168 | + count(*) filter (where e.detected_at >= now() - make_interval(hours => ${hours}) / 4) as recent_quarter | |
| 169 | + from events e join event_entities ee on ee.event_id = e.id join sources s on s.id = e.source_id where s.kind = 'registry' and e.detected_at >= now() - make_interval(hours => ${hours}) group by ee.entity_id), | |
| 108 | 170 | prev as ( |
| 109 | 171 | select ee.entity_id, count(*) as n from events e join event_entities ee on ee.event_id = e.id |
| 110 | − where e.detected_at >= now() - make_interval(hours => ${hours * 2}) and e.detected_at < now() - make_interval(hours => ${hours}) group by ee.entity_id) | |
| 111 | − select en.id, en.name, en.type, en.domain, cur.n::int as events, cur.imp::float as importance_sum, cur.sources::int as sources, cur.silent::int as silent, cur.max_imp::float as max_importance, coalesce(prev.n,0)::int as prev_events, | |
| 112 | − round((18*(ln(1+cur.n)/ln(2)) + 0.35*(cur.imp/greatest(1,cur.n)) + 10*(ln(1+cur.sources)/ln(2)) + 12*least(2, case when coalesce(prev.n,0)=0 then 2 else cur.n::float/prev.n end) + 5*least(3,cur.silent))::numeric, 1)::float as score | |
| 113 | − from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id | |
| 172 | + where e.detected_at >= now() - make_interval(hours => ${hours * 2}) and e.detected_at < now() - make_interval(hours => ${hours}) group by ee.entity_id), | |
| 173 | + base as ( | |
| 174 | + select entity_id, avg(events)::float as per_day from entity_daily where day >= (now() at time zone 'UTC')::date - 30 and day < (now() at time zone 'UTC')::date group by entity_id) | |
| 175 | + select en.id, en.name, en.type, en.domain, en.importance as entity_importance, cur.n::int as events, cur.imp::float as importance_sum, cur.sources::int as sources, cur.silent::int as silent, cur.max_imp::float as max_importance, | |
| 176 | + cur.first_party::int as first_party, cur.confirmed::int as confirmed, round(cur.avg_signal::numeric, 1)::float as avg_signal, coalesce(prev.n,0)::int as prev_events, coalesce(base.per_day, 0)::float as baseline_per_day, | |
| 177 | + cur.recent_quarter::int as recent_quarter, | |
| 178 | + case when cur.recent_quarter::float / greatest(1, cur.n) > 0.5 then 'up' when cur.recent_quarter = 0 and cur.n >= 3 then 'down' else 'flat' end as direction, | |
| 179 | + round((100 * (1 - exp(-( | |
| 180 | + 18*(ln(1+cur.n)/ln(2)) + 0.35*(cur.imp/greatest(1,cur.n)) + 10*(ln(1+cur.sources)/ln(2)) + 12*least(2, case when coalesce(prev.n,0)=0 then 2 else cur.n::float/prev.n end) + 5*least(3,cur.silent) | |
| 181 | + + 6*least(3, cur.first_party) + 4*least(3, cur.confirmed) + (case when coalesce(base.per_day,0) > 0 then 8*least(3, cur.n / (base.per_day * ${hours} / 24.0)) else 0 end) | |
| 182 | + ) / 110.0)))::numeric, 1)::float as score | |
| 183 | + from cur join entities en on en.id = cur.entity_id left join prev on prev.entity_id = cur.entity_id left join base on base.entity_id = cur.entity_id | |
| 114 | 184 | order by score desc limit ${limit}`); |
| 115 | 185 | return rows.rows; |
| 116 | 186 | } |
@@ -122,7 +192,9 @@ export async function sourceActivity(sourceId: string): Promise<Record<string, u | ||
| 122 | 192 | (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '2 hours')::int as changes_2h, |
| 123 | 193 | (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '14 days')::int as changes_14d, |
| 124 | 194 | (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours')::int as events_24h, |
| 125 | − (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '14 days')::int as events_14d`) | |
| 195 | + (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '14 days')::int as events_14d, | |
| 196 | + (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours' and silent_change)::int as silent_24h, | |
| 197 | + (select count(*) from events where source_id = ${sourceId} and detected_at >= now() - interval '24 hours' and coalesce(signal_score, importance) >= 80)::int as breaking_24h`) | |
| 126 | 198 | ).rows; |
| 127 | 199 | const c2h = Number(r?.changes_2h ?? 0); |
| 128 | 200 | const baselinePerHour = Number(r?.changes_14d ?? 0) / (14 * 24); |
@@ -136,6 +208,35 @@ export async function sourceActivity(sourceId: string): Promise<Record<string, u | ||
| 136 | 208 | return { ...r, baseline_changes_per_day: Math.round(baselinePerHour * 24 * 10) / 10, activity_score: Math.round(anomaly * 10) / 10 }; |
| 137 | 209 | } |
| 138 | 210 | |
| 211 | +/** Source quality score (spec §48): success rate, latency, structured share, usefulness (events/raw), consistency. Distinct from importance. */ | |
| 212 | +export async function sourceQuality(sourceId: string): Promise<Record<string, unknown>> { | |
| 213 | + const [r] = ( | |
| 214 | + await db.execute<Record<string, unknown>>(sql` | |
| 215 | + select | |
| 216 | + (select count(*) from sensors where source_id = ${sourceId} and enabled)::int as sensors, | |
| 217 | + (select count(*) from sensors where source_id = ${sourceId} and enabled and health = 'UP')::int as sensors_up, | |
| 218 | + (select avg(avg_latency_ms) from sensors where source_id = ${sourceId} and enabled)::int as avg_latency_ms, | |
| 219 | + (select coalesce(sum(raw_changes),0) from sensors where source_id = ${sourceId})::int as raw_changes, | |
| 220 | + (select coalesce(sum(meaningful_changes),0) from sensors where source_id = ${sourceId})::int as meaningful_changes, | |
| 221 | + (select coalesce(sum(total_runs),0) from sensors where source_id = ${sourceId})::int as total_runs, | |
| 222 | + (select coalesce(sum(total_not_modified),0) from sensors where source_id = ${sourceId})::int as total_not_modified, | |
| 223 | + (select count(*) from sensors where source_id = ${sourceId} and enabled and connector <> 'http')::int as structured_sensors, | |
| 224 | + (select coalesce(sum(checks),0) from source_daily where source_id = ${sourceId} and day >= (now() at time zone 'UTC')::date - 7)::int as checks_7d, | |
| 225 | + (select coalesce(sum(errors),0) from source_daily where source_id = ${sourceId} and day >= (now() at time zone 'UTC')::date - 7)::int as errors_7d, | |
| 226 | + (select avg(confidence) from events where source_id = ${sourceId} and detected_at >= now() - interval '30 days')::float as avg_confidence`) | |
| 227 | + ).rows; | |
| 228 | + const sensors = Number(r?.sensors ?? 0); | |
| 229 | + const success = Number(r?.checks_7d) ? 1 - Number(r?.errors_7d) / Number(r?.checks_7d) : sensors ? Number(r?.sensors_up) / sensors : 1; | |
| 230 | + const latency = Number(r?.avg_latency_ms ?? 800); | |
| 231 | + const latencyScore = latency <= 400 ? 1 : latency <= 1500 ? 0.8 : latency <= 4000 ? 0.6 : 0.4; | |
| 232 | + const structured = sensors ? Number(r?.structured_sensors) / sensors : 0; | |
| 233 | + const raw = Number(r?.raw_changes ?? 0); | |
| 234 | + const usefulness = raw >= 5 ? Math.min(1, (Number(r?.meaningful_changes) / raw) * 2) : 0.6; | |
| 235 | + const conf = Number(r?.avg_confidence ?? 70) / 100; | |
| 236 | + const score = Math.round(100 * (0.35 * success + 0.15 * latencyScore + 0.15 * structured + 0.2 * usefulness + 0.15 * conf)); | |
| 237 | + return { ...r, success_rate: Math.round(success * 1000) / 1000, structured_share: Math.round(structured * 100) / 100, usefulness: Math.round(usefulness * 100) / 100, quality_score: score }; | |
| 238 | +} | |
| 239 | + | |
| 139 | 240 | export function encodeCursor(a: string, b: string): string { |
| 140 | 241 | return Buffer.from(`${a}|${b}`).toString("base64url"); |
| 141 | 242 | } |
added
apps/api/src/routes-admin.ts
+228 −0
@@ -0,0 +1,228 @@ | ||
| 1 | +import { readdir, stat } from "node:fs/promises"; | |
| 2 | +import { join, resolve } from "node:path"; | |
| 3 | +import type { FastifyInstance } from "fastify"; | |
| 4 | +import YAML from "yaml"; | |
| 5 | +import { z } from "zod"; | |
| 6 | +import { extendSchema, importDocumentSchema, sourceSchema, type SensorEndpoint, type Tier } from "@websensor/core"; | |
| 7 | +import { getConnector, listConnectors, NormalizeError } from "@websensor/connectors"; | |
| 8 | +import { db, sql } from "@websensor/db"; | |
| 9 | +import { cacheStats, invalidate } from "./cache"; | |
| 10 | +import { config } from "./config"; | |
| 11 | +import { engineStatus, liveStats } from "./live"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Internal operations (spec §70–72, §85). Enabled only when WS_ADMIN_TOKEN is set; every request | |
| 15 | + * must carry `X-WebSensor-Admin: <token>`. Never linked from public pages; `/ops` in the web app | |
| 16 | + * asks for the token and stores it in sessionStorage. | |
| 17 | + */ | |
| 18 | +export async function registerAdminRoutes(app: FastifyInstance): Promise<void> { | |
| 19 | + app.addHook("onRequest", async (req, reply) => { | |
| 20 | + if (!req.url.startsWith("/api/v1/admin")) return; | |
| 21 | + if (!config.adminToken) return reply.status(404).send({ error: "not_found" }); | |
| 22 | + const t = req.headers["x-websensor-admin"]; | |
| 23 | + if (typeof t !== "string" || t !== config.adminToken) return reply.status(401).send({ error: "admin_token_required" }); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + app.get("/api/v1/admin/ops", async () => { | |
| 27 | + const t0 = Date.now(); | |
| 28 | + await db.execute(sql`select 1`); | |
| 29 | + const dbLatency = Date.now() - t0; | |
| 30 | + const [queue, failing, slow, throughput, storage, llm, jobs, es, byStatus, recentErrors] = await Promise.all([ | |
| 31 | + db.execute<Record<string, unknown>>(sql`select count(*) filter (where next_check_at <= now())::int as due, count(*) filter (where next_check_at <= now() - interval '10 minutes')::int as overdue_10m, count(*) filter (where priority = 0)::int as p0, count(*) filter (where priority = 0 and next_check_at <= now())::int as p0_due, min(next_check_at) as oldest_due from sensors where enabled`).then((r) => r.rows[0]), | |
| 32 | + db.execute<Record<string, unknown>>(sql`select split_part(split_part(s.url, '/', 3), ':', 1) as host, count(*)::int as failures, count(distinct s.id)::int as sensors, max(r.error) as last_error, max(r.started_at) as last_at from sensor_runs r join sensors s on s.id = r.sensor_id where r.started_at >= now() - interval '6 hours' and r.outcome in ('error','parse_error','rate_limited') group by 1 order by failures desc limit 20`).then((r) => r.rows), | |
| 33 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.connector, s.avg_latency_ms, s.total_runs from sensors s where s.enabled and s.avg_latency_ms is not null order by s.avg_latency_ms desc limit 20`).then((r) => r.rows), | |
| 34 | + db.execute<Record<string, unknown>>(sql` | |
| 35 | + select to_char(date_trunc('hour', started_at at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as t, count(*)::int as checks, count(*) filter (where outcome = 'not_modified')::int as not_modified, count(*) filter (where outcome in ('error','parse_error','rate_limited'))::int as errors, count(*) filter (where outcome = 'event')::int as events, avg(duration_ms)::int as avg_ms | |
| 36 | + from sensor_runs where started_at >= now() - interval '24 hours' group by 1 order by 1`).then((r) => r.rows), | |
| 37 | + Promise.all([ | |
| 38 | + db.execute<Record<string, unknown>>(sql`select pg_size_pretty(pg_database_size(current_database())) as db_size, pg_database_size(current_database())::bigint as db_bytes, (select count(*) from snapshots)::int as snapshots, (select count(*) from snapshots where storage_key is not null)::int as snapshots_with_raw, (select coalesce(sum(content_length),0) from snapshots where storage_key is not null)::bigint as raw_bytes_uncompressed, (select count(*) from changes)::int as changes, (select count(*) from events)::int as events, (select count(*) from sensor_runs)::int as runs`).then((r) => r.rows[0]), | |
| 39 | + blobDirSize(), | |
| 40 | + ]).then(([d, blobs]) => ({ ...d, blobs })), | |
| 41 | + db.execute<Record<string, unknown>>(sql`select model, count(*)::int as calls, sum(input_tokens)::bigint as input_tokens, sum(output_tokens)::bigint as output_tokens, count(*) filter (where not ok)::int as failures from llm_usage where at >= now() - interval '24 hours' group by model`).then((r) => r.rows), | |
| 42 | + db.execute<Record<string, unknown>>(sql`select outcome, count(*)::int as n from sensor_runs where started_at >= now() - interval '1 hour' group by outcome order by n desc`).then((r) => r.rows), | |
| 43 | + engineStatus(), | |
| 44 | + db.execute<Record<string, unknown>>(sql`select status, health, count(*)::int as n from sensors group by status, health order by n desc`).then((r) => r.rows), | |
| 45 | + db.execute<Record<string, unknown>>(sql`select r.started_at, s.id as sensor_id, s.source_id, s.connector, r.http_status, r.outcome, r.error from sensor_runs r join sensors s on s.id = r.sensor_id where r.outcome in ('error','parse_error') and r.started_at >= now() - interval '1 hour' order by r.started_at desc limit 30`).then((r) => r.rows), | |
| 46 | + ]); | |
| 47 | + return { db: { latency_ms: dbLatency }, queue, engine: es, workers: es ? { inflight: es.inflight, concurrency: es.concurrency, busy_hosts: es.busyHosts, circuit_open: es.circuitOpen } : null, outcomes_1h: jobs, top_failing_domains: failing, slowest_sensors: slow, throughput_24h: throughput, storage, llm_24h: llm, sensors_by_status: byStatus, recent_errors: recentErrors, cache: cacheStats(), live: liveStats(), connectors: listConnectors().map((c) => c.metadata()), generated_at: new Date().toISOString() }; | |
| 48 | + }); | |
| 49 | + | |
| 50 | + // ---- Sensor actions ------------------------------------------------------------------------------ | |
| 51 | + app.post<{ Params: { id: string } }>("/api/v1/admin/sensors/:id/run-now", async (req, reply) => { | |
| 52 | + const r = await db.execute(sql`update sensors set next_check_at = now() - interval '1 second', enabled = true where id = ${req.params.id}`); | |
| 53 | + if (!r.rowCount) return reply.status(404).send({ error: "not_found" }); | |
| 54 | + return { ok: true, scheduled: "now" }; | |
| 55 | + }); | |
| 56 | + app.post<{ Params: { id: string } }>("/api/v1/admin/sensors/:id/enable", async (req, reply) => { | |
| 57 | + const r = await db.execute(sql`update sensors set enabled = true, status = 'ACTIVE', consecutive_errors = 0, next_check_at = now(), updated_at = now() where id = ${req.params.id}`); | |
| 58 | + if (!r.rowCount) return reply.status(404).send({ error: "not_found" }); | |
| 59 | + return { ok: true }; | |
| 60 | + }); | |
| 61 | + app.post<{ Params: { id: string } }>("/api/v1/admin/sensors/:id/disable", async (req, reply) => { | |
| 62 | + const r = await db.execute(sql`update sensors set enabled = false, status = 'DISABLED', updated_at = now() where id = ${req.params.id}`); | |
| 63 | + if (!r.rowCount) return reply.status(404).send({ error: "not_found" }); | |
| 64 | + return { ok: true }; | |
| 65 | + }); | |
| 66 | + app.patch<{ Params: { id: string } }>("/api/v1/admin/sensors/:id", async (req, reply) => { | |
| 67 | + const body = z.object({ tier: z.enum(["S", "A", "B", "C", "D"]).optional(), priority: z.number().int().min(0).max(3).optional(), base_interval_seconds: z.number().int().min(15).max(7 * 86400).nullable().optional(), config: z.record(z.string(), z.unknown()).optional(), name: z.string().min(1).max(120).optional() }).parse(req.body ?? {}); | |
| 68 | + const sets: ReturnType<typeof sql>[] = [sql`updated_at = now()`]; | |
| 69 | + if (body.tier) sets.push(sql`tier = ${body.tier}`); | |
| 70 | + if (body.priority !== undefined) sets.push(sql`priority = ${body.priority}`); | |
| 71 | + if (body.base_interval_seconds !== undefined) sets.push(sql`base_interval_seconds = ${body.base_interval_seconds}`); | |
| 72 | + if (body.config) sets.push(sql`config = config || ${JSON.stringify(body.config)}::jsonb`); | |
| 73 | + if (body.name) sets.push(sql`name = ${body.name}`); | |
| 74 | + const r = await db.execute(sql`update sensors set ${sql.join(sets, sql`, `)} where id = ${req.params.id}`); | |
| 75 | + if (!r.rowCount) return reply.status(404).send({ error: "not_found" }); | |
| 76 | + return { ok: true }; | |
| 77 | + }); | |
| 78 | + /** Dry-run a connector against a URL (or an existing sensor) without persisting anything: inspect response + normalized output. */ | |
| 79 | + app.post("/api/v1/admin/sensors/test", async (req, reply) => { | |
| 80 | + const body = z.object({ sensor_id: z.string().optional(), url: z.string().url().optional(), connector: z.string().default("http"), type: z.string().default("HTML"), config: z.record(z.string(), z.unknown()).default({}) }).parse(req.body ?? {}); | |
| 81 | + let endpoint: SensorEndpoint; | |
| 82 | + if (body.sensor_id) { | |
| 83 | + const s = (await db.execute<Record<string, unknown>>(sql`select * from sensors where id = ${body.sensor_id}`)).rows[0]; | |
| 84 | + if (!s) return reply.status(404).send({ error: "not_found" }); | |
| 85 | + endpoint = { id: String(s.id), sourceId: String(s.source_id), name: String(s.name), url: String(s.url), type: String(s.type) as SensorEndpoint["type"], tier: String(s.tier) as Tier, connector: String(s.connector), config: (s.config as Record<string, unknown>) ?? {}, etag: null, lastModified: null, state: null }; | |
| 86 | + } else if (body.url) { | |
| 87 | + endpoint = { id: "admin_test", sourceId: "admin", name: "test", url: body.url, type: body.type as SensorEndpoint["type"], tier: "C", connector: body.connector, config: body.config, etag: null, lastModified: null, state: null }; | |
| 88 | + } else return reply.status(400).send({ error: "sensor_id_or_url_required" }); | |
| 89 | + let connector; | |
| 90 | + try { | |
| 91 | + connector = getConnector(endpoint.connector); | |
| 92 | + } catch (e) { | |
| 93 | + return reply.status(400).send({ error: "unknown_connector", detail: (e as Error).message }); | |
| 94 | + } | |
| 95 | + const t0 = Date.now(); | |
| 96 | + const obs = await connector.fetch(endpoint); | |
| 97 | + const fetchMs = Date.now() - t0; | |
| 98 | + if (obs.error) return { ok: false, stage: "fetch", error: obs.error, meta: obs.meta, fetch_ms: fetchMs }; | |
| 99 | + if (obs.meta.status >= 400) return { ok: false, stage: "fetch", http_status: obs.meta.status, meta: obs.meta, fetch_ms: fetchMs }; | |
| 100 | + try { | |
| 101 | + const norm = await connector.normalize(endpoint, obs); | |
| 102 | + return { ok: true, fetch_ms: fetchMs, meta: obs.meta, normalized: { mode: norm.mode, title: norm.title ?? null, extractionConfidence: norm.extractionConfidence, items: norm.items?.length ?? null, sample_items: norm.items?.slice(0, 5) ?? null, text_preview: norm.text?.slice(0, 1500) ?? null, json_preview: norm.json !== undefined ? JSON.stringify(norm.json).slice(0, 1500) : null, canonicalHash: norm.canonicalHash, publishedAt: norm.publishedAt ?? null } }; | |
| 103 | + } catch (e) { | |
| 104 | + return { ok: false, stage: "normalize", error: e instanceof NormalizeError ? { code: e.code, message: e.message } : { code: "error", message: (e as Error).message }, meta: obs.meta, fetch_ms: fetchMs, body_preview: obs.body?.toString("utf8").slice(0, 800) ?? null }; | |
| 105 | + } | |
| 106 | + }); | |
| 107 | + | |
| 108 | + // ---- Source actions ------------------------------------------------------------------------------ | |
| 109 | + app.post<{ Params: { id: string } }>("/api/v1/admin/sources/:id/enable", async (req) => { | |
| 110 | + await db.execute(sql`update sources set enabled = true, updated_at = now() where id = ${req.params.id}`); | |
| 111 | + invalidate(""); | |
| 112 | + return { ok: true }; | |
| 113 | + }); | |
| 114 | + app.post<{ Params: { id: string } }>("/api/v1/admin/sources/:id/disable", async (req) => { | |
| 115 | + await db.execute(sql`update sources set enabled = false, updated_at = now() where id = ${req.params.id}`); | |
| 116 | + invalidate(""); | |
| 117 | + return { ok: true }; | |
| 118 | + }); | |
| 119 | + app.post<{ Params: { id: string } }>("/api/v1/admin/sources/:id/run-now", async (req) => { | |
| 120 | + const r = await db.execute(sql`update sensors set next_check_at = now() - interval '1 second' where source_id = ${req.params.id} and enabled`); | |
| 121 | + return { ok: true, sensors: r.rowCount ?? 0 }; | |
| 122 | + }); | |
| 123 | + app.post("/api/v1/admin/sensors/bulk", async (req) => { | |
| 124 | + const body = z.object({ ids: z.array(z.string()).min(1).max(2000), action: z.enum(["enable", "disable", "run-now"]) }).parse(req.body ?? {}); | |
| 125 | + const arr = sql.raw("array[" + body.ids.map((i) => "'" + i.replace(/'/g, "''") + "'").join(",") + "]::text[]"); | |
| 126 | + let r; | |
| 127 | + if (body.action === "enable") r = await db.execute(sql`update sensors set enabled = true, status = 'ACTIVE', consecutive_errors = 0, next_check_at = now() where id = any(${arr})`); | |
| 128 | + else if (body.action === "disable") r = await db.execute(sql`update sensors set enabled = false, status = 'DISABLED' where id = any(${arr})`); | |
| 129 | + else r = await db.execute(sql`update sensors set next_check_at = now() - interval '1 second' where id = any(${arr}) and enabled`); | |
| 130 | + return { ok: true, affected: r.rowCount ?? 0 }; | |
| 131 | + }); | |
| 132 | + | |
| 133 | + /** | |
| 134 | + * Bulk import (spec §71): JSON body `{ sources: [...] }` or raw YAML (content-type text/yaml). Validates with the | |
| 135 | + * registry schema first; `dry_run=1` returns the validation report without writing. Imported sources are stored | |
| 136 | + * with `kind = 'registry'` and `config.seed = false` so the file-based sync never disables them. | |
| 137 | + */ | |
| 138 | + app.post<{ Querystring: { dry_run?: string } }>("/api/v1/admin/sources/import", async (req, reply) => { | |
| 139 | + let doc: unknown = req.body; | |
| 140 | + if (typeof req.body === "string") { | |
| 141 | + try { | |
| 142 | + doc = YAML.parse(req.body); | |
| 143 | + } catch (e) { | |
| 144 | + return reply.status(400).send({ error: "yaml_parse_error", detail: (e as Error).message }); | |
| 145 | + } | |
| 146 | + } | |
| 147 | + const parsed = importDocumentSchema.safeParse(doc); | |
| 148 | + if (!parsed.success) return reply.status(400).send({ error: "invalid_document", issues: parsed.error.issues }); | |
| 149 | + const report: { id: string | undefined; ok: boolean; extend: boolean; issues?: string[]; sensors?: number }[] = []; | |
| 150 | + const existing = new Set((await db.execute<{ id: string }>(sql`select id from sources`)).rows.map((r) => r.id)); | |
| 151 | + const valid: { extend: boolean; data: z.infer<typeof sourceSchema> | z.infer<typeof extendSchema> }[] = []; | |
| 152 | + for (const s of parsed.data.sources) { | |
| 153 | + const isExtend = (s as { extend?: boolean })?.extend === true; | |
| 154 | + const res = isExtend ? extendSchema.safeParse(s) : sourceSchema.safeParse(s); | |
| 155 | + const id = (s as { id?: string })?.id; | |
| 156 | + if (!res.success) { | |
| 157 | + report.push({ id, ok: false, extend: isExtend, issues: res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }); | |
| 158 | + continue; | |
| 159 | + } | |
| 160 | + if (isExtend && !existing.has(res.data.id)) { | |
| 161 | + report.push({ id, ok: false, extend: true, issues: ["extend: true but source does not exist"] }); | |
| 162 | + continue; | |
| 163 | + } | |
| 164 | + if (!isExtend && existing.has(res.data.id)) { | |
| 165 | + report.push({ id, ok: false, extend: false, issues: ["source id already exists (use extend: true)"] }); | |
| 166 | + continue; | |
| 167 | + } | |
| 168 | + valid.push({ extend: isExtend, data: res.data }); | |
| 169 | + report.push({ id, ok: true, extend: isExtend, sensors: res.data.sensors.length }); | |
| 170 | + } | |
| 171 | + if (req.query.dry_run === "1") return { dry_run: true, valid: valid.length, invalid: report.filter((r) => !r.ok).length, report }; | |
| 172 | + let sensors = 0; | |
| 173 | + for (const v of valid) { | |
| 174 | + if (!v.extend) { | |
| 175 | + const s = v.data as z.infer<typeof sourceSchema>; | |
| 176 | + await db.execute(sql`insert into sources (id, name, domain, homepage, description, categories, tier, importance_weight, discover, fallback, notes, enabled, llm_enabled, first_party, country, language, kind) | |
| 177 | + values (${s.id}, ${s.name}, ${s.domain}, ${s.homepage ?? `https://${s.domain}`}, ${s.description ?? null}, ${sql.raw("'{" + s.categories.map((c) => '"' + c.replace(/"/g, "") + '"').join(",") + "}'::text[]")}, ${s.tier}, ${s.weight}, ${JSON.stringify(s.discover)}::jsonb, ${JSON.stringify(s.fallback)}::jsonb, ${s.notes ?? null}, ${s.enabled}, ${s.llm}, ${s.first_party ?? true}, ${s.country ?? null}, ${s.language ?? null}, 'registry')`); | |
| 178 | + const entId = `org_${s.id}`; | |
| 179 | + await db.execute(sql`insert into entities (id, name, type, domain, homepage, description, importance, categories) values (${entId}, ${s.name}, ${s.entity_type}, ${s.domain}, ${s.homepage ?? `https://${s.domain}`}, ${s.description ?? null}, 60, ${sql.raw("'{" + s.categories.map((c) => '"' + c.replace(/"/g, "") + '"').join(",") + "}'::text[]")}) on conflict (id) do nothing`); | |
| 180 | + await db.execute(sql`insert into source_entities (source_id, entity_id) values (${s.id}, ${entId}) on conflict do nothing`); | |
| 181 | + for (const a of new Set([s.name, s.domain, ...s.aliases])) await db.execute(sql`insert into entity_aliases (alias, entity_id) values (${a.toLowerCase()}, ${entId}) on conflict do nothing`); | |
| 182 | + } | |
| 183 | + const base = v.data; | |
| 184 | + for (const sen of base.sensors) { | |
| 185 | + const id = sen.id ?? `${base.id}_${sen.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`; | |
| 186 | + await db.execute(sql`insert into sensors (id, source_id, name, url, type, connector, tier, importance_weight, config, base_interval_seconds, status, priority) | |
| 187 | + values (${id}, ${base.id}, ${sen.name}, ${sen.url}, ${sen.type}, ${sen.connector}, ${sen.tier ?? (v.extend ? "B" : (v.data as z.infer<typeof sourceSchema>).tier)}, ${sen.weight ?? 1}, ${JSON.stringify({ ...sen.config, seed: false, imported_at: new Date().toISOString() })}::jsonb, ${sen.interval ?? null}, 'PENDING', 2) | |
| 188 | + on conflict (id) do update set name = excluded.name, url = excluded.url, type = excluded.type, connector = excluded.connector, config = excluded.config, enabled = true, updated_at = now()`); | |
| 189 | + sensors++; | |
| 190 | + } | |
| 191 | + } | |
| 192 | + invalidate(""); | |
| 193 | + return { dry_run: false, imported_sources: valid.filter((v) => !v.extend).length, extended_sources: valid.filter((v) => v.extend).length, sensors, invalid: report.filter((r) => !r.ok).length, report }; | |
| 194 | + }); | |
| 195 | + | |
| 196 | + app.get("/api/v1/admin/failures", async () => { | |
| 197 | + const rows = await db.execute<Record<string, unknown>>(sql`select r.started_at, r.outcome, r.http_status, r.error, r.duration_ms, s.id as sensor_id, s.name, s.source_id, s.url, s.connector from sensor_runs r join sensors s on s.id = r.sensor_id where r.outcome in ('error','parse_error','rate_limited') and r.started_at >= now() - interval '24 hours' order by r.started_at desc limit 200`); | |
| 198 | + return { items: rows.rows }; | |
| 199 | + }); | |
| 200 | +} | |
| 201 | + | |
| 202 | +async function blobDirSize(): Promise<{ files: number; bytes: number } | null> { | |
| 203 | + const root = resolve(process.env.BLOB_STORE_DIR ?? "./data/blobs"); | |
| 204 | + let files = 0; | |
| 205 | + let bytes = 0; | |
| 206 | + const started = Date.now(); | |
| 207 | + const walk = async (dir: string): Promise<void> => { | |
| 208 | + if (Date.now() - started > 4000) return; // bounded | |
| 209 | + let entries: string[]; | |
| 210 | + try { | |
| 211 | + entries = await readdir(dir); | |
| 212 | + } catch { | |
| 213 | + return; | |
| 214 | + } | |
| 215 | + for (const e of entries) { | |
| 216 | + const p = join(dir, e); | |
| 217 | + const st = await stat(p).catch(() => null); | |
| 218 | + if (!st) continue; | |
| 219 | + if (st.isDirectory()) await walk(p); | |
| 220 | + else { | |
| 221 | + files++; | |
| 222 | + bytes += st.size; | |
| 223 | + } | |
| 224 | + } | |
| 225 | + }; | |
| 226 | + await walk(root); | |
| 227 | + return { files, bytes }; | |
| 228 | +} | |
added
apps/api/src/routes-user.ts
+299 −0
@@ -0,0 +1,299 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import type { FastifyInstance } from "fastify"; | |
| 3 | +import { z } from "zod"; | |
| 4 | +import { assertUrlAllowed, newId, UrlPolicyError } from "@websensor/core"; | |
| 5 | +import { getConnector } from "@websensor/connectors"; | |
| 6 | +import { db, sql, textArray } from "@websensor/db"; | |
| 7 | +import { config } from "./config"; | |
| 8 | +import { EVENT_SELECT, eventConditions, listEvents } from "./queries"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Owner-scoped routes (anonymous owner token, phase 1): watchlists, alert rules (+ webhook | |
| 12 | + * channel), notifications, bookmarks, saved views and custom URL monitors (spec §42–45, §81, §100). | |
| 13 | + */ | |
| 14 | +export function ownerToken(headers: Record<string, unknown>): string | null { | |
| 15 | + const t = headers["x-websensor-owner"]; | |
| 16 | + return typeof t === "string" && /^[A-Za-z0-9_-]{16,80}$/.test(t) ? t : null; | |
| 17 | +} | |
| 18 | + | |
| 19 | +const WATCH_KINDS = ["entity", "source", "keyword", "category", "url", "event_type", "country", "group"] as const; | |
| 20 | + | |
| 21 | +export async function registerUserRoutes(app: FastifyInstance): Promise<void> { | |
| 22 | + const requireOwner = (req: { headers: Record<string, unknown> }, reply: { status: (n: number) => { send: (b: unknown) => unknown } }): string | null => { | |
| 23 | + const owner = ownerToken(req.headers); | |
| 24 | + if (!owner) reply.status(401).send({ error: "owner_token_required", detail: "Send an X-WebSensor-Owner header (16–80 URL-safe characters)." }); | |
| 25 | + return owner; | |
| 26 | + }; | |
| 27 | + | |
| 28 | + // ---- Watchlists ----------------------------------------------------------------------------- | |
| 29 | + app.get("/api/v1/watchlists", async (req, reply) => { | |
| 30 | + const owner = requireOwner(req, reply); | |
| 31 | + if (!owner) return; | |
| 32 | + const rows = await db.execute<Record<string, unknown>>(sql`select w.id, w.name, w.created_at, coalesce((select json_agg(json_build_object('kind', i.kind, 'value', i.value, 'added_at', i.added_at) order by i.added_at) from watchlist_items i where i.watchlist_id = w.id), '[]'::json) as items from watchlists w where owner_token = ${owner} order by created_at`); | |
| 33 | + return { items: rows.rows }; | |
| 34 | + }); | |
| 35 | + const wlBody = z.object({ name: z.string().min(1).max(80).default("My watchlist"), items: z.array(z.object({ kind: z.enum(WATCH_KINDS), value: z.string().min(1).max(300) })).max(300).default([]) }); | |
| 36 | + app.post("/api/v1/watchlists", async (req, reply) => { | |
| 37 | + const owner = requireOwner(req, reply); | |
| 38 | + if (!owner) return; | |
| 39 | + const body = wlBody.parse(req.body ?? {}); | |
| 40 | + const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from watchlists where owner_token = ${owner}`)).rows[0]?.n; | |
| 41 | + if (Number(count) >= 20) return reply.status(429).send({ error: "too_many_watchlists" }); | |
| 42 | + const id = newId("wl"); | |
| 43 | + await db.execute(sql`insert into watchlists (id, owner_token, name) values (${id}, ${owner}, ${body.name})`); | |
| 44 | + for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${id}, ${it.kind}, ${it.value}) on conflict do nothing`); | |
| 45 | + return { id, name: body.name, items: body.items }; | |
| 46 | + }); | |
| 47 | + app.put<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => { | |
| 48 | + const owner = requireOwner(req, reply); | |
| 49 | + if (!owner) return; | |
| 50 | + const w = (await db.execute<{ id: string }>(sql`select id from watchlists where id = ${req.params.id} and owner_token = ${owner}`)).rows[0]; | |
| 51 | + if (!w) return reply.status(404).send({ error: "not_found" }); | |
| 52 | + const body = wlBody.partial().parse(req.body ?? {}); | |
| 53 | + if (body.name) await db.execute(sql`update watchlists set name = ${body.name} where id = ${w.id}`); | |
| 54 | + if (body.items) { | |
| 55 | + await db.execute(sql`delete from watchlist_items where watchlist_id = ${w.id}`); | |
| 56 | + for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${w.id}, ${it.kind}, ${it.value}) on conflict do nothing`); | |
| 57 | + } | |
| 58 | + return { ok: true }; | |
| 59 | + }); | |
| 60 | + app.delete<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => { | |
| 61 | + const owner = requireOwner(req, reply); | |
| 62 | + if (!owner) return; | |
| 63 | + await db.execute(sql`delete from watchlists where id = ${req.params.id} and owner_token = ${owner}`); | |
| 64 | + return { ok: true }; | |
| 65 | + }); | |
| 66 | + app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/watchlists/:id/events", async (req, reply) => { | |
| 67 | + const owner = requireOwner(req, reply); | |
| 68 | + if (!owner) return; | |
| 69 | + const items = (await db.execute<{ kind: string; value: string }>(sql`select i.kind, i.value from watchlist_items i join watchlists w on w.id = i.watchlist_id where w.id = ${req.params.id} and w.owner_token = ${owner}`)).rows; | |
| 70 | + if (!items.length) return reply.send({ items: [] }); | |
| 71 | + const by = (k: string): string[] => items.filter((i) => i.kind === k).map((i) => i.value); | |
| 72 | + const conds = [] as ReturnType<typeof sql>[]; | |
| 73 | + const ents = by("entity"); | |
| 74 | + const srcs = by("source"); | |
| 75 | + const cats = by("category"); | |
| 76 | + const types = by("event_type"); | |
| 77 | + const countries = by("country"); | |
| 78 | + const urls = by("url"); | |
| 79 | + if (ents.length) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = any(${textArray(ents)}))`); | |
| 80 | + if (srcs.length) conds.push(sql`e.source_id = any(${textArray(srcs)})`); | |
| 81 | + if (cats.length) conds.push(sql`e.categories && ${textArray(cats)}`); | |
| 82 | + if (types.length) conds.push(sql`e.event_type = any(${textArray(types)})`); | |
| 83 | + if (countries.length) conds.push(sql`e.country = any(${textArray(countries.map((c) => c.toUpperCase()))})`); | |
| 84 | + for (const u of urls) conds.push(sql`(e.url = ${u} or e.url like ${u.replace(/\/$/, "") + "/%"})`); | |
| 85 | + for (const k of by("keyword")) conds.push(sql`(e.title ilike ${"%" + k + "%"} or e.summary ilike ${"%" + k + "%"})`); | |
| 86 | + if (!conds.length) return { items: [] }; | |
| 87 | + const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where (s.kind = 'registry' or s.owner_token = ${owner}) and (${sql.join(conds, sql` or `)}) order by e.detected_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`); | |
| 88 | + return { items: rows.rows }; | |
| 89 | + }); | |
| 90 | + | |
| 91 | + // ---- Alerts ----------------------------------------------------------------------------------- | |
| 92 | + const ruleSchema = z.object({ | |
| 93 | + importance_min: z.number().min(0).max(100).optional(), | |
| 94 | + signal_min: z.number().min(0).max(100).optional(), | |
| 95 | + event_types: z.array(z.string().max(60)).max(60).optional(), | |
| 96 | + groups: z.array(z.string().max(30)).max(12).optional(), | |
| 97 | + entities: z.array(z.string().max(120)).max(100).optional(), | |
| 98 | + sources: z.array(z.string().max(120)).max(100).optional(), | |
| 99 | + keywords: z.array(z.string().max(80)).max(50).optional(), | |
| 100 | + categories: z.array(z.string().max(40)).max(40).optional(), | |
| 101 | + countries: z.array(z.string().max(3)).max(60).optional(), | |
| 102 | + silent_only: z.boolean().optional(), | |
| 103 | + first_party_only: z.boolean().optional(), | |
| 104 | + confirmed_only: z.boolean().optional(), | |
| 105 | + }); | |
| 106 | + const alertBody = z.object({ name: z.string().min(1).max(80), rule: ruleSchema, channel: z.enum(["web", "webhook"]).default("web"), channel_config: z.object({ url: z.string().url().max(500).optional(), secret: z.string().max(200).optional() }).default({}) }); | |
| 107 | + app.get("/api/v1/alerts", async (req, reply) => { | |
| 108 | + const owner = requireOwner(req, reply); | |
| 109 | + if (!owner) return; | |
| 110 | + const rows = await db.execute<Record<string, unknown>>(sql`select a.id, a.name, a.rule, a.channel, a.channel_config - 'secret' as channel_config, a.enabled, a.created_at, a.last_fired_at, a.fired_count, (select count(*) from notifications n where n.alert_id = a.id and n.created_at >= now() - interval '24 hours')::int as fired_24h from alerts a where owner_token = ${owner} order by created_at`); | |
| 111 | + return { items: rows.rows, channels: { web: "active", webhook: "active", email: "planned", slack: "planned", discord: "planned", telegram: "planned", push: "planned" } }; | |
| 112 | + }); | |
| 113 | + app.post("/api/v1/alerts", async (req, reply) => { | |
| 114 | + const owner = requireOwner(req, reply); | |
| 115 | + if (!owner) return; | |
| 116 | + const body = alertBody.parse(req.body ?? {}); | |
| 117 | + if (body.channel === "webhook") { | |
| 118 | + if (!body.channel_config.url) return reply.status(400).send({ error: "webhook_url_required" }); | |
| 119 | + try { | |
| 120 | + const u = await assertUrlAllowed(body.channel_config.url); | |
| 121 | + if (u.url.protocol !== "https:") return reply.status(400).send({ error: "webhook_https_required" }); | |
| 122 | + } catch (e) { | |
| 123 | + return reply.status(400).send({ error: "webhook_url_rejected", detail: e instanceof UrlPolicyError ? e.message : String(e) }); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from alerts where owner_token = ${owner}`)).rows[0]?.n; | |
| 127 | + if (Number(count) >= 50) return reply.status(429).send({ error: "too_many_alerts" }); | |
| 128 | + const id = newId("alr"); | |
| 129 | + await db.execute(sql`insert into alerts (id, owner_token, name, rule, channel, channel_config) values (${id}, ${owner}, ${body.name}, ${JSON.stringify(body.rule)}::jsonb, ${body.channel}, ${JSON.stringify(body.channel_config)}::jsonb)`); | |
| 130 | + return { id, name: body.name, rule: body.rule, channel: body.channel }; | |
| 131 | + }); | |
| 132 | + app.patch<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => { | |
| 133 | + const owner = requireOwner(req, reply); | |
| 134 | + if (!owner) return; | |
| 135 | + const body = z.object({ enabled: z.boolean().optional(), name: z.string().min(1).max(80).optional() }).parse(req.body ?? {}); | |
| 136 | + if (body.enabled !== undefined) await db.execute(sql`update alerts set enabled = ${body.enabled} where id = ${req.params.id} and owner_token = ${owner}`); | |
| 137 | + if (body.name) await db.execute(sql`update alerts set name = ${body.name} where id = ${req.params.id} and owner_token = ${owner}`); | |
| 138 | + return { ok: true }; | |
| 139 | + }); | |
| 140 | + app.delete<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => { | |
| 141 | + const owner = requireOwner(req, reply); | |
| 142 | + if (!owner) return; | |
| 143 | + await db.execute(sql`delete from alerts where id = ${req.params.id} and owner_token = ${owner}`); | |
| 144 | + return { ok: true }; | |
| 145 | + }); | |
| 146 | + app.get<{ Querystring: { limit?: string; unread?: string } }>("/api/v1/notifications", async (req, reply) => { | |
| 147 | + const owner = requireOwner(req, reply); | |
| 148 | + if (!owner) return; | |
| 149 | + const rows = await db.execute<Record<string, unknown>>(sql` | |
| 150 | + select n.id, n.alert_id, a.name as alert_name, n.event_id, n.channel, n.status, n.created_at, n.read_at, n.delivered_at, n.error, | |
| 151 | + json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'signal_score', e.signal_score, 'event_type', e.event_type, 'silent_change', e.silent_change, 'detected_at', e.detected_at, 'source', json_build_object('id', s.id, 'name', s.name)) as event | |
| 152 | + from notifications n join alerts a on a.id = n.alert_id join events e on e.id = n.event_id join sources s on s.id = e.source_id | |
| 153 | + where a.owner_token = ${owner} ${req.query.unread === "1" ? sql`and n.read_at is null` : sql``} order by n.created_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`); | |
| 154 | + const unread = (await db.execute<{ n: string }>(sql`select count(*)::text as n from notifications n join alerts a on a.id = n.alert_id where a.owner_token = ${owner} and n.read_at is null`)).rows[0]?.n; | |
| 155 | + return { items: rows.rows, unread: Number(unread ?? 0) }; | |
| 156 | + }); | |
| 157 | + app.post("/api/v1/notifications/read", async (req, reply) => { | |
| 158 | + const owner = requireOwner(req, reply); | |
| 159 | + if (!owner) return; | |
| 160 | + const body = z.object({ ids: z.array(z.number().int()).max(500).optional() }).parse(req.body ?? {}); | |
| 161 | + if (body.ids?.length) await db.execute(sql`update notifications n set read_at = now() from alerts a where a.id = n.alert_id and a.owner_token = ${owner} and n.id = any(${sql.raw("array[" + body.ids.map((i) => Number(i)).join(",") + "]::bigint[]")})`); | |
| 162 | + else await db.execute(sql`update notifications n set read_at = now() from alerts a where a.id = n.alert_id and a.owner_token = ${owner} and n.read_at is null`); | |
| 163 | + return { ok: true }; | |
| 164 | + }); | |
| 165 | + | |
| 166 | + // ---- Bookmarks --------------------------------------------------------------------------------- | |
| 167 | + app.get<{ Querystring: { limit?: string } }>("/api/v1/bookmarks", async (req, reply) => { | |
| 168 | + const owner = requireOwner(req, reply); | |
| 169 | + if (!owner) return; | |
| 170 | + const rows = await db.execute<Record<string, unknown>>(sql`select b.created_at as bookmarked_at, b.note, x.* from bookmarks b join lateral (select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where e.id = b.event_id) x on true where b.owner_token = ${owner} order by b.created_at desc limit ${Math.min(500, Number(req.query.limit ?? 100))}`); | |
| 171 | + return { items: rows.rows }; | |
| 172 | + }); | |
| 173 | + app.post("/api/v1/bookmarks", async (req, reply) => { | |
| 174 | + const owner = requireOwner(req, reply); | |
| 175 | + if (!owner) return; | |
| 176 | + const body = z.object({ event_id: z.string().min(4).max(80), note: z.string().max(500).optional() }).parse(req.body ?? {}); | |
| 177 | + const ev = (await db.execute<{ id: string }>(sql`select id from events where id = ${body.event_id} or slug = ${body.event_id}`)).rows[0]; | |
| 178 | + if (!ev) return reply.status(404).send({ error: "event_not_found" }); | |
| 179 | + const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from bookmarks where owner_token = ${owner}`)).rows[0]?.n; | |
| 180 | + if (Number(count) >= 2000) return reply.status(429).send({ error: "too_many_bookmarks" }); | |
| 181 | + await db.execute(sql`insert into bookmarks (owner_token, event_id, note) values (${owner}, ${ev.id}, ${body.note ?? null}) on conflict (owner_token, event_id) do update set note = coalesce(excluded.note, bookmarks.note)`); | |
| 182 | + return { ok: true, event_id: ev.id }; | |
| 183 | + }); | |
| 184 | + app.delete<{ Params: { id: string } }>("/api/v1/bookmarks/:id", async (req, reply) => { | |
| 185 | + const owner = requireOwner(req, reply); | |
| 186 | + if (!owner) return; | |
| 187 | + await db.execute(sql`delete from bookmarks where owner_token = ${owner} and (event_id = ${req.params.id} or event_id = (select id from events where slug = ${req.params.id}))`); | |
| 188 | + return { ok: true }; | |
| 189 | + }); | |
| 190 | + app.get("/api/v1/bookmarks/ids", async (req, reply) => { | |
| 191 | + const owner = requireOwner(req, reply); | |
| 192 | + if (!owner) return; | |
| 193 | + const rows = await db.execute<{ event_id: string }>(sql`select event_id from bookmarks where owner_token = ${owner}`); | |
| 194 | + return { ids: rows.rows.map((r) => r.event_id) }; | |
| 195 | + }); | |
| 196 | + | |
| 197 | + // ---- Saved views ------------------------------------------------------------------------------- | |
| 198 | + app.get("/api/v1/views", async (req, reply) => { | |
| 199 | + const owner = requireOwner(req, reply); | |
| 200 | + if (!owner) return; | |
| 201 | + return { items: (await db.execute<Record<string, unknown>>(sql`select id, name, query, created_at from saved_views where owner_token = ${owner} order by created_at`)).rows }; | |
| 202 | + }); | |
| 203 | + app.post("/api/v1/views", async (req, reply) => { | |
| 204 | + const owner = requireOwner(req, reply); | |
| 205 | + if (!owner) return; | |
| 206 | + const body = z.object({ name: z.string().min(1).max(60), query: z.string().min(1).max(600) }).parse(req.body ?? {}); | |
| 207 | + const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from saved_views where owner_token = ${owner}`)).rows[0]?.n; | |
| 208 | + if (Number(count) >= 50) return reply.status(429).send({ error: "too_many_views" }); | |
| 209 | + const id = newId("view"); | |
| 210 | + await db.execute(sql`insert into saved_views (id, owner_token, name, query) values (${id}, ${owner}, ${body.name}, ${body.query})`); | |
| 211 | + return { id, ...body }; | |
| 212 | + }); | |
| 213 | + app.delete<{ Params: { id: string } }>("/api/v1/views/:id", async (req, reply) => { | |
| 214 | + const owner = requireOwner(req, reply); | |
| 215 | + if (!owner) return; | |
| 216 | + await db.execute(sql`delete from saved_views where id = ${req.params.id} and owner_token = ${owner}`); | |
| 217 | + return { ok: true }; | |
| 218 | + }); | |
| 219 | + | |
| 220 | + // ---- Custom URL monitors (spec §45, §87) ----------------------------------------------------------- | |
| 221 | + const monitorBody = z.object({ | |
| 222 | + url: z.string().url().max(2000), | |
| 223 | + name: z.string().min(1).max(80).optional(), | |
| 224 | + /** hourly | daily (maps to tier C / D) */ | |
| 225 | + frequency: z.enum(["hourly", "daily"]).default("hourly"), | |
| 226 | + /** low = only big changes, normal, high = every meaningful change */ | |
| 227 | + sensitivity: z.enum(["low", "normal", "high"]).default("normal"), | |
| 228 | + selector: z.string().max(200).optional(), | |
| 229 | + keywords: z.array(z.string().min(1).max(60)).max(20).optional(), | |
| 230 | + }); | |
| 231 | + const ownerSourceId = (owner: string): string => `custom-${createHash("sha256").update(owner).digest("hex").slice(0, 12)}`; | |
| 232 | + | |
| 233 | + app.get("/api/v1/monitors", async (req, reply) => { | |
| 234 | + const owner = requireOwner(req, reply); | |
| 235 | + if (!owner) return; | |
| 236 | + const sid = ownerSourceId(owner); | |
| 237 | + const rows = await db.execute<Record<string, unknown>>(sql`select id, name, url, tier, health, status, enabled, config, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, total_runs, raw_changes, meaningful_changes, created_at from sensors where source_id = ${sid} order by created_at`); | |
| 238 | + return { items: rows.rows, limit: config.monitorsPerOwner, source_id: sid }; | |
| 239 | + }); | |
| 240 | + app.post("/api/v1/monitors", async (req, reply) => { | |
| 241 | + const owner = requireOwner(req, reply); | |
| 242 | + if (!owner) return; | |
| 243 | + const body = monitorBody.parse(req.body ?? {}); | |
| 244 | + let host: string; | |
| 245 | + try { | |
| 246 | + const u = await assertUrlAllowed(body.url); | |
| 247 | + if (!["http:", "https:"].includes(u.url.protocol)) return reply.status(400).send({ error: "scheme_not_allowed" }); | |
| 248 | + host = u.url.hostname; | |
| 249 | + } catch (e) { | |
| 250 | + return reply.status(400).send({ error: "url_rejected", detail: e instanceof UrlPolicyError ? e.message : "URL not allowed" }); | |
| 251 | + } | |
| 252 | + const sid = ownerSourceId(owner); | |
| 253 | + const n = Number((await db.execute<{ n: string }>(sql`select count(*)::text as n from sensors where source_id = ${sid} and enabled`)).rows[0]?.n ?? 0); | |
| 254 | + if (n >= config.monitorsPerOwner) return reply.status(429).send({ error: "monitor_limit_reached", limit: config.monitorsPerOwner }); | |
| 255 | + // Sensor auto-test (spec §72): fetch + normalize before activation; baseline is taken by the engine. | |
| 256 | + const sensorId = `${sid}_${newId("m").slice(2)}`; | |
| 257 | + const cfg: Record<string, unknown> = { custom: true, sensitivity: body.sensitivity, ...(body.selector ? { selector: body.selector } : {}), ...(body.keywords?.length ? { keywords: body.keywords } : {}) }; | |
| 258 | + const tier = body.frequency === "hourly" ? "C" : "D"; | |
| 259 | + const connector = getConnector("http"); | |
| 260 | + const endpoint = { id: sensorId, sourceId: sid, name: body.name ?? host, url: body.url, type: "HTML" as const, tier: tier as "C" | "D", connector: "http", config: cfg, etag: null, lastModified: null, state: null }; | |
| 261 | + const obs = await connector.fetch(endpoint); | |
| 262 | + if (obs.error) return reply.status(422).send({ error: "fetch_failed", detail: `${obs.error.code}: ${obs.error.message}` }); | |
| 263 | + if (obs.meta.status >= 400) return reply.status(422).send({ error: "http_error", status: obs.meta.status }); | |
| 264 | + let norm; | |
| 265 | + try { | |
| 266 | + norm = await connector.normalize(endpoint, obs); | |
| 267 | + } catch (e) { | |
| 268 | + return reply.status(422).send({ error: "unparseable", detail: (e as Error).message }); | |
| 269 | + } | |
| 270 | + if ((norm.text ?? "").length < 40 && norm.mode === "text") return reply.status(422).send({ error: "thin_content", detail: "The page has almost no server-rendered text (client-side app?)." }); | |
| 271 | + await db.execute(sql`insert into sources (id, name, domain, homepage, description, categories, tier, importance_weight, enabled, llm_enabled, kind, owner_token, first_party, notes) | |
| 272 | + values (${sid}, ${"Custom monitors"}, ${"custom.websensor.io"}, ${null}, ${"Private URL monitors"}, ${"{custom}"}::text[], 'C', 0.5, true, false, 'custom', ${owner}, true, 'owner-scoped custom monitors') on conflict (id) do nothing`); | |
| 273 | + await db.execute(sql`insert into sensors (id, source_id, name, url, type, connector, tier, importance_weight, config, priority, status, validated_at, enabled, next_check_at) | |
| 274 | + values (${sensorId}, ${sid}, ${body.name ?? host}, ${body.url}, 'HTML', 'http', ${tier}, ${body.sensitivity === "high" ? 1.3 : body.sensitivity === "low" ? 0.7 : 1}, ${JSON.stringify(cfg)}::jsonb, 3, 'VALIDATED', now(), true, now())`); | |
| 275 | + return { id: sensorId, source_id: sid, url: body.url, tier, status: "VALIDATED", test: { http_status: obs.meta.status, content_type: obs.meta.contentType, bytes: obs.meta.contentLength, title: norm.title ?? null, mode: norm.mode, extraction_confidence: norm.extractionConfidence } }; | |
| 276 | + }); | |
| 277 | + app.delete<{ Params: { id: string } }>("/api/v1/monitors/:id", async (req, reply) => { | |
| 278 | + const owner = requireOwner(req, reply); | |
| 279 | + if (!owner) return; | |
| 280 | + const sid = ownerSourceId(owner); | |
| 281 | + await db.execute(sql`delete from sensors where id = ${req.params.id} and source_id = ${sid}`); | |
| 282 | + return { ok: true }; | |
| 283 | + }); | |
| 284 | + app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/monitors/:id/events", async (req, reply) => { | |
| 285 | + const owner = requireOwner(req, reply); | |
| 286 | + if (!owner) return; | |
| 287 | + const sid = ownerSourceId(owner); | |
| 288 | + const sensor = (await db.execute<{ id: string }>(sql`select id from sensors where id = ${req.params.id} and source_id = ${sid}`)).rows[0]; | |
| 289 | + if (!sensor) return reply.status(404).send({ error: "not_found" }); | |
| 290 | + const [events, changes] = await Promise.all([ | |
| 291 | + listEvents({ sensor: sensor.id, limit: Math.min(100, Number(req.query.limit ?? 30)), includeCustom: true }), | |
| 292 | + db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, change_class, field_changes, meaningful, event_id, old_snapshot_id, new_snapshot_id from changes where sensor_id = ${sensor.id} order by detected_at desc limit 30`).then((r) => r.rows), | |
| 293 | + ]); | |
| 294 | + return { events: events.items, changes }; | |
| 295 | + }); | |
| 296 | + | |
| 297 | + // keep eventConditions referenced for owner-scoped searches later | |
| 298 | + void eventConditions; | |
| 299 | +} | |
modified
apps/api/src/routes.ts
+226 −173
@@ -1,13 +1,20 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 2 | import { z } from "zod"; |
| 3 | −import { diffText, EVENT_TYPES, FEED_CHANNELS, newId } from "@websensor/core"; | |
| 3 | +import { COUNTRIES, countryBySlug, diffText, EVENT_GROUPS, EVENT_TYPES, FEED_CHANNELS, parseSearch } from "@websensor/core"; | |
| 4 | 4 | import { db, sql, textArray } from "@websensor/db"; |
| 5 | 5 | import { getBlobStore } from "@websensor/store"; |
| 6 | +import { cached } from "./cache"; | |
| 6 | 7 | import { config } from "./config"; |
| 7 | −import { liveStats } from "./live"; | |
| 8 | −import { getEvent, listEvents, relatedEvents, sourceActivity, stats, trending, EVENT_SELECT } from "./queries"; | |
| 8 | +import { breakingDesk, categoryDesk, clusterDetail, countryDesk, countryList, entityInsights, entityRankings, pulse, radar, sourceSensors } from "./intel"; | |
| 9 | +import { engineStatus, liveStats } from "./live"; | |
| 10 | +import { countEvents, getEvent, listEvents, relatedEvents, sensorHistory, sourceActivity, sourceQuality, stats, trending } from "./queries"; | |
| 9 | 11 | |
| 10 | −const eventsQuery = z.object({ | |
| 12 | +const bool = z | |
| 13 | + .enum(["true", "false", "1", "0"]) | |
| 14 | + .transform((v) => v === "true" || v === "1") | |
| 15 | + .optional(); | |
| 16 | + | |
| 17 | +export const eventsQuery = z.object({ | |
| 11 | 18 | after: z.string().optional(), |
| 12 | 19 | before: z.string().optional(), |
| 13 | 20 | category: z.string().optional(), |
@@ -18,56 +25,66 @@ const eventsQuery = z.object({ | ||
| 18 | 25 | cluster: z.string().optional(), |
| 19 | 26 | importance_min: z.coerce.number().min(0).max(100).optional(), |
| 20 | 27 | confidence_min: z.coerce.number().min(0).max(100).optional(), |
| 28 | + signal_min: z.coerce.number().min(0).max(100).optional(), | |
| 21 | 29 | event_type: z.string().optional(), |
| 22 | − silent_change: z | |
| 23 | − .enum(["true", "false"]) | |
| 24 | − .transform((v) => v === "true") | |
| 25 | − .optional(), | |
| 26 | − q: z.string().max(200).optional(), | |
| 30 | + group: z.string().optional(), | |
| 31 | + silent_change: bool, | |
| 32 | + first_party: bool, | |
| 33 | + confirmed: bool, | |
| 34 | + country: z.string().max(3).optional(), | |
| 35 | + language: z.string().max(2).optional(), | |
| 36 | + change_class: z.string().optional(), | |
| 37 | + q: z.string().max(300).optional(), | |
| 27 | 38 | limit: z.coerce.number().int().min(1).max(200).default(50), |
| 28 | 39 | cursor: z.string().optional(), |
| 29 | − order: z.enum(["recent", "importance"]).default("recent"), | |
| 40 | + order: z.enum(["recent", "importance", "signal"]).default("recent"), | |
| 30 | 41 | }); |
| 31 | 42 | |
| 32 | −function ownerToken(headers: Record<string, unknown>): string | null { | |
| 33 | − const t = headers["x-websensor-owner"]; | |
| 34 | − return typeof t === "string" && /^[A-Za-z0-9_-]{16,80}$/.test(t) ? t : null; | |
| 35 | −} | |
| 36 | − | |
| 37 | 43 | export async function registerRoutes(app: FastifyInstance): Promise<void> { |
| 38 | 44 | // ---- Health --------------------------------------------------------------------------- |
| 39 | 45 | app.get("/api/health", async () => ({ status: "ok", service: "api", version: config.version, time: new Date().toISOString() })); |
| 40 | 46 | app.get("/api/ready", async (_req, reply) => { |
| 41 | − const checks: Record<string, boolean | number> = {}; | |
| 47 | + const checks: Record<string, boolean | number | string | null> = {}; | |
| 48 | + const t0 = Date.now(); | |
| 42 | 49 | try { |
| 43 | 50 | await db.execute(sql`select 1`); |
| 44 | 51 | checks.database = true; |
| 52 | + checks.database_latency_ms = Date.now() - t0; | |
| 45 | 53 | } catch { |
| 46 | 54 | checks.database = false; |
| 47 | 55 | } |
| 48 | − const engine = await db.execute<{ last: Date | null }>(sql`select max(started_at) as last from sensor_runs where started_at >= now() - interval '15 minutes'`); | |
| 56 | + const engine = await db.execute<{ last: Date | null }>(sql`select max(started_at) as last from sensor_runs where started_at >= now() - interval '15 minutes'`).catch(() => ({ rows: [{ last: null }] })); | |
| 49 | 57 | checks.engine_recent = Boolean(engine.rows[0]?.last); |
| 58 | + const es = await engineStatus(); | |
| 59 | + checks.engine_heartbeat = es ? String(es.at) : null; | |
| 50 | 60 | checks.ws_clients = liveStats().clients; |
| 51 | 61 | return reply.status(checks.database ? 200 : 503).send({ status: checks.database ? "ready" : "degraded", checks }); |
| 52 | 62 | }); |
| 53 | 63 | |
| 54 | 64 | // ---- Events --------------------------------------------------------------------------- |
| 55 | − app.get("/api/v1/events", async (req) => { | |
| 65 | + app.get("/api/v1/events", async (req, reply) => { | |
| 66 | + const q = eventsQuery.parse(req.query); | |
| 67 | + const page = await listEvents(q); | |
| 68 | + reply.header("x-websensor-order", q.order); | |
| 69 | + return { ...page, meta: { limit: q.limit, order: q.order, filters: stripEmpty({ ...q, limit: undefined, cursor: undefined, order: undefined }) } }; | |
| 70 | + }); | |
| 71 | + app.get("/api/v1/events/count", async (req) => { | |
| 56 | 72 | const q = eventsQuery.parse(req.query); |
| 57 | − return listEvents(q); | |
| 73 | + return { count: await countEvents(q) }; | |
| 58 | 74 | }); |
| 59 | 75 | app.get<{ Params: { id: string } }>("/api/v1/events/:id", async (req, reply) => { |
| 60 | 76 | const ev = await getEvent(req.params.id); |
| 61 | 77 | if (!ev) return reply.status(404).send({ error: "not_found" }); |
| 62 | − const [related, cluster, change, interp] = await Promise.all([ | |
| 78 | + const [related, cluster, change, interp, history] = await Promise.all([ | |
| 63 | 79 | relatedEvents(ev), |
| 64 | 80 | ev.cluster_id ? db.execute<Record<string, unknown>>(sql`select * from event_clusters where id = ${String(ev.cluster_id)}`).then((r) => r.rows[0] ?? null) : null, |
| 65 | − ev.change_id ? db.execute<Record<string, unknown>>(sql`select id, kind, diff, signal, noise_ratio, magnitude, heuristic, detected_at, old_snapshot_id, new_snapshot_id from changes where id = ${String(ev.change_id)}`).then((r) => r.rows[0] ?? null) : null, | |
| 81 | + ev.change_id ? db.execute<Record<string, unknown>>(sql`select id, kind, diff, signal, noise_ratio, magnitude, heuristic, detected_at, old_snapshot_id, new_snapshot_id, change_class, field_changes from changes where id = ${String(ev.change_id)}`).then((r) => r.rows[0] ?? null) : null, | |
| 66 | 82 | db.execute<Record<string, unknown>>(sql`select version, model, created_at from interpretations where event_id = ${String(ev.id)} order by version`).then((r) => r.rows), |
| 83 | + sensorHistory(String(ev.sensor_id), String(ev.id)), | |
| 67 | 84 | ]); |
| 68 | − const snaps = await db.execute<Record<string, unknown>>(sql`select id, url, captured_at, http_status, content_type, content_length, content_hash, canonical_hash, etag, last_modified, title, mode, fetch_duration_ms, extraction_confidence from snapshots where id in (${String(ev.old_snapshot_id ?? "")}, ${String(ev.new_snapshot_id ?? "")})`); | |
| 85 | + const snaps = await db.execute<Record<string, unknown>>(sql`select id, url, captured_at, http_status, content_type, content_length, content_hash, canonical_hash, etag, last_modified, title, mode, fetch_duration_ms, extraction_confidence, storage_key is not null as has_raw from snapshots where id in (${String(ev.old_snapshot_id ?? "")}, ${String(ev.new_snapshot_id ?? "")})`); | |
| 69 | 86 | const sourceRel = await db.execute<Record<string, unknown>>(sql`select health, success_rate, avg_latency_ms, total_runs, raw_changes, meaningful_changes, last_check_at from (select s.health, ch.success_rate, s.avg_latency_ms, s.total_runs, s.raw_changes, s.meaningful_changes, s.last_check_at from sensors s left join connector_health ch on ch.connector = s.connector where s.id = ${String(ev.sensor_id)}) x`); |
| 70 | − return { event: ev, related, cluster, change, interpretations: interp, snapshots: snaps.rows, sensor_reliability: sourceRel.rows[0] ?? null }; | |
| 87 | + return { event: ev, related, cluster, change, interpretations: interp, snapshots: snaps.rows, sensor_reliability: sourceRel.rows[0] ?? null, history }; | |
| 71 | 88 | }); |
| 72 | 89 | |
| 73 | 90 | // ---- Changes / snapshots / diffs ------------------------------------------------------- |
@@ -84,7 +101,8 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> { | ||
| 84 | 101 | const s = r.rows[0]; |
| 85 | 102 | if (!s) return reply.status(404).send({ error: "not_found" }); |
| 86 | 103 | const store = getBlobStore(); |
| 87 | − if (req.query.raw === "1" && s.storage_key) { | |
| 104 | + if (req.query.raw === "1") { | |
| 105 | + if (!s.storage_key) return reply.status(410).send({ error: "raw_body_pruned", detail: "The raw body of this snapshot was pruned by the retention policy; the canonical representation and hashes are preserved." }); | |
| 88 | 106 | const buf = await store.get(String(s.storage_key)); |
| 89 | 107 | reply.header("content-type", String(s.content_type ?? "text/plain") + (String(s.content_type ?? "").includes("charset") ? "" : "; charset=utf-8")); |
| 90 | 108 | reply.header("x-content-type-options", "nosniff"); |
@@ -108,76 +126,111 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> { | ||
| 108 | 126 | const d = diffText(ta, tb, `${a}@${String(sa.captured_at)}`, `${b}@${String(sb.captured_at)}`); |
| 109 | 127 | return { a: sa, b: sb, before: ta, after: tb, diff: { unified: d.unified, stats: d.stats, added: d.added, removed: d.removed, modified: d.modified } }; |
| 110 | 128 | }); |
| 129 | + /** Historical memory (spec §79): how a sensor's page looked at any point — list of snapshots with day grouping. */ | |
| 130 | + app.get<{ Params: { id: string }; Querystring: { limit?: string; before?: string } }>("/api/v1/sensors/:id/snapshots", async (req) => { | |
| 131 | + const lim = Math.min(500, Number(req.query.limit ?? 200)); | |
| 132 | + const rows = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title, mode, storage_key is not null as has_raw, (select count(*) from changes c where c.new_snapshot_id = snapshots.id) > 0 as has_change, (select e.slug from events e where e.new_snapshot_id = snapshots.id limit 1) as event_slug from snapshots where sensor_id = ${req.params.id} ${req.query.before ? sql`and captured_at < ${new Date(req.query.before)}` : sql``} order by captured_at desc limit ${lim}`); | |
| 133 | + return { items: rows.rows }; | |
| 134 | + }); | |
| 111 | 135 | |
| 112 | 136 | // ---- Sources & sensors ------------------------------------------------------------------ |
| 113 | − app.get<{ Querystring: { category?: string; q?: string; limit?: string } }>("/api/v1/sources", async (req) => { | |
| 137 | + app.get<{ Querystring: { category?: string; q?: string; limit?: string; country?: string; tier?: string; first_party?: string } }>("/api/v1/sources", async (req) => { | |
| 114 | 138 | const cat = req.query.category; |
| 115 | 139 | const q = req.query.q; |
| 116 | 140 | const rows = await db.execute<Record<string, unknown>>(sql` |
| 117 | − select s.*, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, | |
| 141 | + select s.id, s.name, s.domain, s.homepage, s.description, s.categories, s.tier, s.importance_weight, s.enabled, s.notes, s.first_party, s.country, s.language, s.robots_checked_at, | |
| 142 | + (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, | |
| 118 | 143 | (select count(*) from events e where e.source_id = s.id)::int as event_count, |
| 119 | 144 | (select count(*) from events e where e.source_id = s.id and e.detected_at >= now() - interval '24 hours')::int as events_24h, |
| 120 | 145 | (select max(detected_at) from events e where e.source_id = s.id) as last_event_at, |
| 121 | 146 | (select max(last_check_at) from sensors x where x.source_id = s.id) as last_check_at, |
| 122 | 147 | (select count(*) from sensors x where x.source_id = s.id and x.enabled and x.health <> 'UP')::int as sensors_degraded |
| 123 | − from sources s where s.enabled ${cat ? sql`and ${cat} = any(s.categories)` : sql``} ${q ? sql`and (s.name ilike ${"%" + q + "%"} or s.domain ilike ${"%" + q + "%"})` : sql``} | |
| 124 | − order by s.tier asc, events_24h desc, s.name asc limit ${Math.min(500, Number(req.query.limit ?? 300))}`); | |
| 148 | + from sources s where s.enabled and s.kind = 'registry' ${cat ? sql`and ${cat} = any(s.categories)` : sql``} ${q ? sql`and (s.name ilike ${"%" + q + "%"} or s.domain ilike ${"%" + q + "%"})` : sql``} ${req.query.country ? sql`and s.country = ${req.query.country.toUpperCase()}` : sql``} ${req.query.tier ? sql`and s.tier = ${req.query.tier.toUpperCase()}` : sql``} ${req.query.first_party ? sql`and s.first_party = ${req.query.first_party === "true"}` : sql``} | |
| 149 | + order by s.tier asc, events_24h desc, s.name asc limit ${Math.min(5000, Number(req.query.limit ?? 300))}`); | |
| 125 | 150 | return { items: rows.rows }; |
| 126 | 151 | }); |
| 127 | 152 | app.get<{ Params: { id: string } }>("/api/v1/sources/:id", async (req, reply) => { |
| 128 | − const s = (await db.execute<Record<string, unknown>>(sql`select * from sources where id = ${req.params.id} or domain = ${req.params.id} limit 1`)).rows[0]; | |
| 153 | + const s = (await db.execute<Record<string, unknown>>(sql`select * from sources where (id = ${req.params.id} or domain = ${req.params.id}) and kind = 'registry' limit 1`)).rows[0]; | |
| 129 | 154 | if (!s) return reply.status(404).send({ error: "not_found" }); |
| 130 | − const [sensors, ents, activity, candidates] = await Promise.all([ | |
| 131 | − db.execute<Record<string, unknown>>(sql`select id, name, url, type, connector, tier, health, enabled, next_check_at, last_check_at, last_change_at, last_event_at, last_status, last_error, consecutive_errors, total_runs, total_not_modified, raw_changes, meaningful_changes, avg_latency_ms, base_interval_seconds, etag is not null as has_etag, last_modified is not null as has_last_modified from sensors where source_id = ${String(s.id)} order by tier, name`).then((r) => r.rows), | |
| 155 | + const { owner_token: _o, ...pub } = s; | |
| 156 | + const [sensors, ents, activity, quality, candidates, byType, series] = await Promise.all([ | |
| 157 | + sourceSensors(String(s.id)), | |
| 132 | 158 | db.execute<Record<string, unknown>>(sql`select en.id, en.name, en.type, en.importance, en.event_count from source_entities se join entities en on en.id = se.entity_id where se.source_id = ${String(s.id)} order by en.type, en.name`).then((r) => r.rows), |
| 133 | 159 | sourceActivity(String(s.id)), |
| 160 | + sourceQuality(String(s.id)), | |
| 134 | 161 | db.execute<Record<string, unknown>>(sql`select url, kind, evidence, score, status, found_at from discovery_candidates where source_id = ${String(s.id)} order by (score->>'value')::float desc nulls last limit 50`).then((r) => r.rows), |
| 162 | + db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where source_id = ${String(s.id)} group by 1 order by 2 desc limit 16`).then((r) => r.rows), | |
| 163 | + db.execute<Record<string, unknown>>(sql`select day::text as day, checks, not_modified, errors, raw_changes, events from source_daily where source_id = ${String(s.id)} and day >= (now() at time zone 'UTC')::date - 30 order by day`).then((r) => r.rows), | |
| 135 | 164 | ]); |
| 136 | − return { source: s, sensors, entities: ents, activity, discovery: candidates }; | |
| 165 | + return { source: pub, sensors, entities: ents, activity, quality, discovery: candidates, by_type: byType, daily: series }; | |
| 137 | 166 | }); |
| 138 | 167 | app.get<{ Params: { id: string } }>("/api/v1/sensors/:id", async (req, reply) => { |
| 139 | − const s = (await db.execute<Record<string, unknown>>(sql`select s.*, so.name as source_name, so.domain from sensors s join sources so on so.id = s.source_id where s.id = ${req.params.id}`)).rows[0]; | |
| 140 | − if (!s) return reply.status(404).send({ error: "not_found" }); | |
| 141 | − const [runs, snaps, changes] = await Promise.all([ | |
| 168 | + const s = (await db.execute<Record<string, unknown>>(sql`select s.*, so.name as source_name, so.domain, so.kind as source_kind from sensors s join sources so on so.id = s.source_id where s.id = ${req.params.id}`)).rows[0]; | |
| 169 | + if (!s || s.source_kind === "custom") return reply.status(404).send({ error: "not_found" }); | |
| 170 | + const [runs, snaps, changes, events] = await Promise.all([ | |
| 142 | 171 | db.execute<Record<string, unknown>>(sql`select id, started_at, finished_at, http_status, outcome, error, duration_ms, bytes, fetch_method, snapshot_id from sensor_runs where sensor_id = ${String(s.id)} order by started_at desc limit 50`).then((r) => r.rows), |
| 143 | − db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_type, content_length, canonical_hash, title, mode, extraction_confidence from snapshots where sensor_id = ${String(s.id)} order by captured_at desc limit 50`).then((r) => r.rows), | |
| 144 | − db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, noise_ratio, magnitude, meaningful, event_id, old_snapshot_id, new_snapshot_id, heuristic->>'eventType' as heuristic_type from changes where sensor_id = ${String(s.id)} order by detected_at desc limit 50`).then((r) => r.rows), | |
| 172 | + db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_type, content_length, canonical_hash, title, mode, extraction_confidence, storage_key is not null as has_raw from snapshots where sensor_id = ${String(s.id)} order by captured_at desc limit 50`).then((r) => r.rows), | |
| 173 | + db.execute<Record<string, unknown>>(sql`select id, detected_at, kind, signal, noise_ratio, magnitude, meaningful, event_id, old_snapshot_id, new_snapshot_id, change_class, field_changes, heuristic->>'eventType' as heuristic_type from changes where sensor_id = ${String(s.id)} order by detected_at desc limit 50`).then((r) => r.rows), | |
| 174 | + listEvents({ sensor: String(s.id), limit: 30 }).then((r) => r.items), | |
| 145 | 175 | ]); |
| 146 | − const { etag: _e, last_modified: _lm, state: _st, ...pub } = s as Record<string, unknown>; | |
| 147 | − return { sensor: pub, runs, snapshots: snaps, changes }; | |
| 176 | + const { state: _st, source_kind: _k, ...pub } = s as Record<string, unknown>; | |
| 177 | + const runs24 = await db.execute<Record<string, unknown>>(sql`select count(*)::int as checks_24h, count(*) filter (where outcome = 'not_modified')::int as not_modified_24h, count(*) filter (where outcome in ('error','parse_error','rate_limited'))::int as errors_24h, avg(duration_ms)::int as avg_ms_24h from sensor_runs where sensor_id = ${String(s.id)} and started_at >= now() - interval '24 hours'`); | |
| 178 | + return { sensor: { ...pub, ...runs24.rows[0] }, runs, snapshots: snaps, changes, events }; | |
| 148 | 179 | }); |
| 149 | 180 | |
| 150 | 181 | // ---- Entities ---------------------------------------------------------------------------- |
| 151 | − app.get<{ Querystring: { type?: string; q?: string; limit?: string } }>("/api/v1/entities", async (req) => { | |
| 182 | + app.get<{ Querystring: { type?: string; q?: string; limit?: string; category?: string } }>("/api/v1/entities", async (req) => { | |
| 152 | 183 | const rows = await db.execute<Record<string, unknown>>(sql` |
| 153 | 184 | select en.*, (select count(*) from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = en.id and e.detected_at >= now() - interval '24 hours')::int as events_24h |
| 154 | − from entities en where true ${req.query.type ? sql`and en.type = ${req.query.type}` : sql``} ${req.query.q ? sql`and (en.search @@ plainto_tsquery('simple', ${req.query.q}) or en.name ilike ${"%" + req.query.q + "%"})` : sql``} | |
| 155 | − order by en.event_count desc, en.importance desc, en.name limit ${Math.min(500, Number(req.query.limit ?? 200))}`); | |
| 185 | + from entities en where true ${req.query.type ? sql`and en.type = ${req.query.type}` : sql``} ${req.query.category ? sql`and ${req.query.category} = any(en.categories)` : sql``} ${req.query.q ? sql`and (en.search @@ plainto_tsquery('simple', ${req.query.q}) or en.name ilike ${"%" + req.query.q + "%"})` : sql``} | |
| 186 | + order by en.event_count desc, en.importance desc, en.name limit ${Math.min(1000, Number(req.query.limit ?? 200))}`); | |
| 156 | 187 | return { items: rows.rows }; |
| 157 | 188 | }); |
| 189 | + app.get<{ Querystring: { limit?: string } }>("/api/v1/entities/rank", async (req) => ({ items: await entityRankings(Math.min(500, Number(req.query.limit ?? 100))) })); | |
| 158 | 190 | app.get<{ Params: { id: string } }>("/api/v1/entities/:id", async (req, reply) => { |
| 159 | 191 | const e = (await db.execute<Record<string, unknown>>(sql`select * from entities where id = ${req.params.id} or id = ${"org_" + req.params.id} limit 1`)).rows[0]; |
| 160 | 192 | if (!e) return reply.status(404).send({ error: "not_found" }); |
| 161 | 193 | const id = String(e.id); |
| 162 | − const [children, relations, sources, aliases, recent, byType] = await Promise.all([ | |
| 194 | + const [children, relations, sources, aliases, recent, byType, insights, silent, parent] = await Promise.all([ | |
| 163 | 195 | db.execute<Record<string, unknown>>(sql`select id, name, type, importance, event_count, last_event_at from entities where parent_id = ${id} order by event_count desc, name`).then((r) => r.rows), |
| 164 | 196 | db.execute<Record<string, unknown>>(sql`select r.relation, r.from_id, r.to_id, f.name as from_name, t.name as to_name, t.type as to_type from entity_relations r join entities f on f.id = r.from_id join entities t on t.id = r.to_id where r.from_id = ${id} or r.to_id = ${id} limit 100`).then((r) => r.rows), |
| 165 | − db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier from source_entities se join sources s on s.id = se.source_id where se.entity_id = ${id}`).then((r) => r.rows), | |
| 197 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.tier, s.first_party, s.country, (select count(*) from sensors x where x.source_id = s.id and x.enabled)::int as sensor_count, (select count(*) from events ev where ev.source_id = s.id and ev.detected_at >= now() - interval '24 hours')::int as events_24h from source_entities se join sources s on s.id = se.source_id where se.entity_id = ${id} and s.kind = 'registry'`).then((r) => r.rows), | |
| 166 | 198 | db.execute<{ alias: string }>(sql`select alias from entity_aliases where entity_id = ${id} order by alias`).then((r) => r.rows.map((x) => x.alias)), |
| 167 | 199 | listEvents({ entity: id, limit: 30 }), |
| 168 | 200 | db.execute<Record<string, unknown>>(sql`select e.event_type, count(*)::int as n from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = ${id} group by 1 order by 2 desc`).then((r) => r.rows), |
| 201 | + entityInsights(id), | |
| 202 | + listEvents({ entity: id, silent_change: true, limit: 10 }).then((r) => r.items), | |
| 203 | + e.parent_id ? db.execute<Record<string, unknown>>(sql`select id, name, type from entities where id = ${String(e.parent_id)}`).then((r) => r.rows[0] ?? null) : null, | |
| 169 | 204 | ]); |
| 170 | − return { entity: e, children, relations, sources, aliases, recent: recent.items, by_type: byType }; | |
| 205 | + // Related entities: co-occurring in events over 30 days | |
| 206 | + const related = await db.execute<Record<string, unknown>>(sql` | |
| 207 | + select en.id, en.name, en.type, count(*)::int as shared_events from event_entities a join event_entities b on a.event_id = b.event_id and b.entity_id <> a.entity_id join events e on e.id = a.event_id join entities en on en.id = b.entity_id | |
| 208 | + where a.entity_id = ${id} and e.detected_at >= now() - interval '30 days' group by en.id, en.name, en.type order by shared_events desc limit 12`).then((r) => r.rows); | |
| 209 | + return { entity: e, parent, children, relations, sources, aliases, recent: recent.items, nextCursor: recent.nextCursor, by_type: byType, insights, silent, related }; | |
| 171 | 210 | }); |
| 172 | − app.get<{ Params: { id: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/entities/:id/timeline", async (req) => { | |
| 211 | + app.get<{ Params: { id: string }; Querystring: { limit?: string; cursor?: string; after?: string; before?: string; event_type?: string; silent_change?: string } }>("/api/v1/entities/:id/timeline", async (req) => { | |
| 173 | 212 | const id = req.params.id.startsWith("org_") || req.params.id.startsWith("prd_") ? req.params.id : `org_${req.params.id}`; |
| 174 | − return listEvents({ entity: id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor }); | |
| 213 | + return listEvents({ entity: id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor, after: req.query.after, before: req.query.before, event_type: req.query.event_type, silent_change: req.query.silent_change === "true" ? true : undefined }); | |
| 214 | + }); | |
| 215 | + | |
| 216 | + // ---- Clusters ------------------------------------------------------------------------------ | |
| 217 | + app.get<{ Querystring: { limit?: string; since?: string; state?: string; min_events?: string } }>("/api/v1/clusters", async (req) => { | |
| 218 | + const rows = await db.execute<Record<string, unknown>>(sql` | |
| 219 | + select c.*, (select json_agg(json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'event_type', e.event_type, 'detected_at', e.detected_at, 'source_id', e.source_id, 'url', e.url, 'first_party', e.first_party) order by e.importance desc) from events e where e.cluster_id = c.id) as events, | |
| 220 | + (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source | |
| 221 | + from event_clusters c where c.last_at >= now() - make_interval(hours => ${Math.min(720, Number(req.query.since ?? 72))}) ${req.query.state ? sql`and c.state = ${req.query.state}` : sql``} ${req.query.min_events ? sql`and c.event_count >= ${Number(req.query.min_events)}` : sql``} order by c.last_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`); | |
| 222 | + return { items: rows.rows }; | |
| 223 | + }); | |
| 224 | + app.get<{ Params: { id: string } }>("/api/v1/clusters/:id", async (req, reply) => { | |
| 225 | + const d = await clusterDetail(req.params.id); | |
| 226 | + if (!d) return reply.status(404).send({ error: "not_found" }); | |
| 227 | + return d; | |
| 175 | 228 | }); |
| 176 | 229 | |
| 177 | 230 | // ---- Domains / URLs ---------------------------------------------------------------------- |
| 178 | 231 | app.get<{ Params: { domain: string }; Querystring: { limit?: string; cursor?: string } }>("/api/v1/domains/:domain/timeline", async (req) => { |
| 179 | 232 | const d = req.params.domain.toLowerCase(); |
| 180 | − const src = (await db.execute<{ id: string }>(sql`select id from sources where domain = ${d} or domain = ${"www." + d} or ${d} = 'www.' || domain limit 1`)).rows[0]; | |
| 233 | + const src = (await db.execute<{ id: string }>(sql`select id from sources where (domain = ${d} or domain = ${"www." + d} or ${d} = 'www.' || domain) and kind = 'registry' limit 1`)).rows[0]; | |
| 181 | 234 | const urls = await db.execute<Record<string, unknown>>(sql`select url, status, first_seen_at, last_seen_at, snapshot_count, change_count, sensor_id from urls where domain = ${d} or domain like ${"%." + d} order by change_count desc, last_seen_at desc limit 200`); |
| 182 | 235 | const ev = src ? await listEvents({ source: src.id, limit: Math.min(200, Number(req.query.limit ?? 100)), cursor: req.query.cursor }) : { items: [], nextCursor: null }; |
| 183 | 236 | return { domain: d, source_id: src?.id ?? null, urls: urls.rows, events: ev.items, nextCursor: ev.nextCursor }; |
@@ -186,149 +239,103 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> { | ||
| 186 | 239 | if (!req.query.url) return reply.status(400).send({ error: "url required" }); |
| 187 | 240 | const u = (await db.execute<Record<string, unknown>>(sql`select * from urls where url = ${req.query.url}`)).rows[0]; |
| 188 | 241 | const history = await db.execute<Record<string, unknown>>(sql` |
| 189 | − select h.id, h.at, h.kind, h.snapshot_id, h.change_id, h.event_id, h.note, e.title as event_title, e.importance, e.event_type, e.slug as event_slug, c.kind as change_kind, c.signal | |
| 242 | + select h.id, h.at, h.kind, h.snapshot_id, h.change_id, h.event_id, h.note, e.title as event_title, e.importance, e.event_type, e.slug as event_slug, c.kind as change_kind, c.signal, c.change_class | |
| 190 | 243 | from url_history h left join events e on e.id = h.event_id left join changes c on c.id = h.change_id where h.url = ${req.query.url} order by h.at desc limit 300`); |
| 191 | − const snaps = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title from snapshots where url = ${req.query.url} or sensor_id = (select sensor_id from urls where url = ${req.query.url}) order by captured_at desc limit 200`); | |
| 244 | + const snaps = await db.execute<Record<string, unknown>>(sql`select id, captured_at, http_status, content_length, canonical_hash, title, storage_key is not null as has_raw from snapshots where url = ${req.query.url} or sensor_id = (select sensor_id from urls where url = ${req.query.url}) order by captured_at desc limit 200`); | |
| 192 | 245 | return { url: u ?? { url: req.query.url }, history: history.rows, snapshots: snaps.rows }; |
| 193 | 246 | }); |
| 194 | 247 | |
| 195 | − // ---- Explore / trending / stats ------------------------------------------------------------ | |
| 196 | − app.get("/api/v1/stats", async () => stats()); | |
| 197 | − app.get<{ Querystring: { hours?: string; limit?: string } }>("/api/v1/trending", async (req) => ({ items: await trending(Math.min(168, Number(req.query.hours ?? 24)), Math.min(50, Number(req.query.limit ?? 12))) })); | |
| 198 | − app.get("/api/v1/explore", async () => { | |
| 199 | − const [mostActive, biggest, silent, clusters, unusual, byType, byCategory] = await Promise.all([ | |
| 200 | − db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '24 hours' group by s.id, s.name, s.domain order by events_24h desc limit 10`).then((r) => r.rows), | |
| 201 | − listEvents({ limit: 10, order: "importance", after: new Date(Date.now() - 48 * 3600e3).toISOString() }).then((r) => r.items), | |
| 202 | − listEvents({ limit: 10, silent_change: true }).then((r) => r.items), | |
| 203 | − db.execute<Record<string, unknown>>(sql`select c.*, (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source from event_clusters c where c.event_count >= 2 and c.last_at >= now() - interval '48 hours' order by c.max_importance desc, c.event_count desc limit 10`).then((r) => r.rows), | |
| 204 | − db.execute<Record<string, unknown>>(sql` | |
| 205 | − with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id), | |
| 206 | − base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id) | |
| 207 | − select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day, | |
| 208 | − round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score | |
| 209 | − from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id order by activity_score desc limit 10`).then((r) => r.rows), | |
| 210 | − db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows), | |
| 211 | − db.execute<Record<string, unknown>>(sql`select c as category, count(*)::int as n from events e, unnest(e.categories) c where e.detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows), | |
| 212 | − ]); | |
| 213 | − return { most_active_sources: mostActive, biggest_changes: biggest, silent_changes: silent, clusters, unusual_activity: unusual, by_type: byType, by_category: byCategory, channels: FEED_CHANNELS, event_types: Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [k, v.label])) }; | |
| 248 | + // ---- Intelligence desks ---------------------------------------------------------------------- | |
| 249 | + app.get("/api/v1/stats", async () => cached("stats", 5_000, stats)); | |
| 250 | + app.get<{ Querystring: { hours?: string; limit?: string } }>("/api/v1/trending", async (req) => { | |
| 251 | + const hours = Math.min(168, Number(req.query.hours ?? 24)); | |
| 252 | + const limit = Math.min(50, Number(req.query.limit ?? 12)); | |
| 253 | + return { items: await cached(`trending:${hours}:${limit}`, 15_000, () => trending(hours, limit)) }; | |
| 214 | 254 | }); |
| 215 | − app.get<{ Querystring: { limit?: string; since?: string } }>("/api/v1/clusters", async (req) => { | |
| 216 | − const rows = await db.execute<Record<string, unknown>>(sql` | |
| 217 | − select c.*, (select json_agg(json_build_object('id', e.id, 'slug', e.slug, 'title', e.title, 'importance', e.importance, 'event_type', e.event_type, 'detected_at', e.detected_at, 'source_id', e.source_id, 'url', e.url) order by e.importance desc) from events e where e.cluster_id = c.id) as events | |
| 218 | − from event_clusters c where c.last_at >= now() - make_interval(hours => ${Math.min(720, Number(req.query.since ?? 72))}) order by c.last_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`); | |
| 219 | − return { items: rows.rows }; | |
| 255 | + app.get("/api/v1/breaking", async () => breakingDesk()); | |
| 256 | + app.get("/api/v1/pulse", async () => pulse()); | |
| 257 | + app.get("/api/v1/radar", async () => radar()); | |
| 258 | + app.get("/api/v1/countries", async () => ({ items: await countryList(), known: COUNTRIES })); | |
| 259 | + app.get<{ Params: { code: string } }>("/api/v1/countries/:code", async (req, reply) => { | |
| 260 | + const c = countryBySlug(req.params.code) ?? (COUNTRIES[req.params.code.toUpperCase()] ? { code: req.params.code.toUpperCase() } : null); | |
| 261 | + if (!c) return reply.status(404).send({ error: "not_found" }); | |
| 262 | + return cached(`country:${c.code}`, 20_000, () => countryDesk(c.code)); | |
| 263 | + }); | |
| 264 | + app.get<{ Params: { channel: string } }>("/api/v1/categories/:channel", async (req, reply) => { | |
| 265 | + const ch = req.params.channel.toLowerCase(); | |
| 266 | + if (!FEED_CHANNELS[ch] && !/^[a-z-]{2,30}$/.test(ch)) return reply.status(404).send({ error: "not_found" }); | |
| 267 | + return cached(`category:${ch}`, 15_000, () => categoryDesk(ch)); | |
| 220 | 268 | }); |
| 269 | + app.get("/api/v1/explore", async () => | |
| 270 | + cached("explore", 20_000, async () => { | |
| 271 | + const since48 = new Date(Date.now() - 48 * 3600e3).toISOString(); | |
| 272 | + const [mostActive, biggest, silent, clusters, unusual, byType, byCategory, newly, firstParty] = await Promise.all([ | |
| 273 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.domain, s.first_party, count(*)::int as events_24h, max(e.importance)::float as max_importance from events e join sources s on s.id = e.source_id where e.detected_at >= now() - interval '24 hours' and s.kind = 'registry' group by s.id, s.name, s.domain, s.first_party order by events_24h desc limit 12`).then((r) => r.rows), | |
| 274 | + listEvents({ limit: 12, order: "signal", after: since48 }).then((r) => r.items), | |
| 275 | + listEvents({ limit: 12, silent_change: true, order: "signal", after: since48 }).then((r) => r.items), | |
| 276 | + db.execute<Record<string, unknown>>(sql`select c.*, (select json_build_object('id', s.id, 'name', s.name, 'domain', s.domain) from events e join sources s on s.id = e.source_id where e.id = c.primary_event_id) as source, (select e.slug from events e where e.id = c.primary_event_id) as primary_slug from event_clusters c where c.event_count >= 2 and c.last_at >= now() - interval '48 hours' order by c.max_importance desc, c.event_count desc limit 12`).then((r) => r.rows), | |
| 277 | + db.execute<Record<string, unknown>>(sql` | |
| 278 | + with cur as (select s.source_id, count(*) as n from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '2 hours' group by s.source_id), | |
| 279 | + base as (select s.source_id, count(*)::float / (14*24) as per_hour from changes c join sensors s on s.id = c.sensor_id where c.detected_at >= now() - interval '14 days' group by s.source_id) | |
| 280 | + select so.id, so.name, so.domain, cur.n::int as changes_2h, round(coalesce(base.per_hour,0)::numeric*24,1)::float as baseline_per_day, | |
| 281 | + round((case when coalesce(base.per_hour,0) = 0 then (case when cur.n/2.0 > 2 then 70 else 40 end) else least(100, case when cur.n/2.0/base.per_hour <= 1 then cur.n/2.0/base.per_hour*30 else 30 + 25*(ln(cur.n/2.0/base.per_hour)/ln(2)) end) end)::numeric, 1)::float as activity_score, | |
| 282 | + (case when coalesce(base.per_hour,0) > 0 then round(((cur.n/2.0/base.per_hour - 1) * 100)::numeric) else null end)::int as pct_vs_baseline | |
| 283 | + from cur join sources so on so.id = cur.source_id left join base on base.source_id = cur.source_id where so.kind = 'registry' order by activity_score desc limit 12`).then((r) => r.rows), | |
| 284 | + db.execute<Record<string, unknown>>(sql`select event_type, count(*)::int as n from events where detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows), | |
| 285 | + db.execute<Record<string, unknown>>(sql`select c as category, count(*)::int as n from events e, unnest(e.categories) c where e.detected_at >= now() - interval '7 days' group by 1 order by 2 desc`).then((r) => r.rows), | |
| 286 | + listEvents({ limit: 12, order: "recent", confidence_min: 60 }).then((r) => r.items), | |
| 287 | + listEvents({ limit: 12, order: "signal", first_party: true, confirmed: true, after: since48 }).then((r) => r.items), | |
| 288 | + ]); | |
| 289 | + return { most_active_sources: mostActive, biggest_changes: biggest, silent_changes: silent, clusters, unusual_activity: unusual, newly_detected: newly, confirmed_first_party: firstParty, by_type: byType, by_category: byCategory, channels: FEED_CHANNELS, groups: Object.fromEntries(Object.entries(EVENT_GROUPS).map(([k, v]) => [k, v.label])), event_types: Object.fromEntries(Object.entries(EVENT_TYPES).map(([k, v]) => [k, v.label])) }; | |
| 290 | + }), | |
| 291 | + ); | |
| 221 | 292 | |
| 222 | 293 | // ---- Search ---------------------------------------------------------------------------- |
| 223 | 294 | app.get<{ Querystring: { q?: string; limit?: string } }>("/api/v1/search", async (req) => { |
| 224 | 295 | const q = (req.query.q ?? "").trim(); |
| 225 | − if (q.length < 2) return { query: q, events: [], entities: [], sources: [], urls: [] }; | |
| 296 | + const parsed = parseSearch(q); | |
| 297 | + if (q.length < 2) return { query: q, parsed, events: [], entities: [], sources: [], urls: [], clusters: [] }; | |
| 226 | 298 | const lim = Math.min(50, Number(req.query.limit ?? 20)); |
| 227 | − const [ev, ents, srcs, urls] = await Promise.all([ | |
| 228 | − listEvents({ q, limit: lim }).then((r) => r.items), | |
| 229 | − db.execute<Record<string, unknown>>(sql`select id, name, type, domain, importance, event_count from entities where search @@ plainto_tsquery('simple', ${q}) or name ilike ${"%" + q + "%"} order by event_count desc, importance desc limit ${lim}`).then((r) => r.rows), | |
| 230 | − db.execute<Record<string, unknown>>(sql`select id, name, domain, tier, categories from sources where name ilike ${"%" + q + "%"} or domain ilike ${"%" + q + "%"} limit ${lim}`).then((r) => r.rows), | |
| 231 | − db.execute<Record<string, unknown>>(sql`select url, domain, status, change_count, last_seen_at from urls where url ilike ${"%" + q + "%"} order by change_count desc limit ${lim}`).then((r) => r.rows), | |
| 299 | + const free = parsed.text; | |
| 300 | + const [ev, ents, srcs, urls, clusters] = await Promise.all([ | |
| 301 | + listEvents({ q, limit: lim, order: Object.keys(parsed.filters).length && !free ? "recent" : "recent" }).then((r) => r.items), | |
| 302 | + free.length >= 2 ? db.execute<Record<string, unknown>>(sql`select id, name, type, domain, importance, event_count from entities where search @@ plainto_tsquery('simple', ${free}) or name ilike ${"%" + free + "%"} order by event_count desc, importance desc limit ${lim}`).then((r) => r.rows) : [], | |
| 303 | + free.length >= 2 ? db.execute<Record<string, unknown>>(sql`select id, name, domain, tier, categories, first_party, country from sources where kind = 'registry' and (name ilike ${"%" + free + "%"} or domain ilike ${"%" + free + "%"}) limit ${lim}`).then((r) => r.rows) : [], | |
| 304 | + free.length >= 4 ? db.execute<Record<string, unknown>>(sql`select url, domain, status, change_count, last_seen_at from urls where url ilike ${"%" + free + "%"} order by change_count desc limit ${lim}`).then((r) => r.rows) : [], | |
| 305 | + free.length >= 2 ? db.execute<Record<string, unknown>>(sql`select id, slug, title, state, event_count, source_count, max_importance, last_at from event_clusters where title ilike ${"%" + free + "%"} and last_at >= now() - interval '30 days' order by max_importance desc limit 10`).then((r) => r.rows) : [], | |
| 232 | 306 | ]); |
| 233 | − return { query: q, events: ev, entities: ents, sources: srcs, urls }; | |
| 307 | + return { query: q, parsed, events: ev, entities: ents, sources: srcs, urls, clusters }; | |
| 234 | 308 | }); |
| 235 | 309 | |
| 236 | − // ---- Connector health ---------------------------------------------------------------------- | |
| 237 | − app.get("/api/v1/health/connectors", async () => { | |
| 238 | − const [connectors, sensorsByHealth, worst, noisy, daily] = await Promise.all([ | |
| 239 | − db.execute<Record<string, unknown>>(sql`select * from connector_health order by connector`).then((r) => r.rows), | |
| 240 | − db.execute<Record<string, unknown>>(sql`select health, count(*)::int as n from sensors where enabled group by health`).then((r) => r.rows), | |
| 241 | − db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.url, s.source_id, s.connector, s.health, s.consecutive_errors, s.last_error, s.last_status, s.last_check_at from sensors s where s.enabled and s.health <> 'UP' order by s.consecutive_errors desc, s.last_check_at desc limit 50`).then((r) => r.rows), | |
| 242 | − db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.raw_changes, s.meaningful_changes, case when s.raw_changes > 0 then round(1 - s.meaningful_changes::numeric / s.raw_changes, 3) else null end as noise_ratio from sensors s where s.raw_changes >= 5 order by noise_ratio desc nulls last, raw_changes desc limit 25`).then((r) => r.rows), | |
| 243 | − db.execute<Record<string, unknown>>(sql`select * from metrics_daily order by day desc limit 30`).then((r) => r.rows), | |
| 244 | − ]); | |
| 245 | − return { connectors, sensors_by_health: sensorsByHealth, degraded_sensors: worst, noisy_sensors: noisy, daily, live: liveStats() }; | |
| 246 | − }); | |
| 247 | − | |
| 248 | − // ---- Watchlists & alerts (anonymous owner token, phase 1) ---------------------------------- | |
| 249 | − app.get("/api/v1/watchlists", async (req, reply) => { | |
| 250 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 251 | − if (!owner) return reply.status(401).send({ error: "owner token required (X-WebSensor-Owner)" }); | |
| 252 | − const rows = await db.execute<Record<string, unknown>>(sql`select w.*, coalesce((select json_agg(json_build_object('kind', i.kind, 'value', i.value, 'added_at', i.added_at)) from watchlist_items i where i.watchlist_id = w.id), '[]'::json) as items from watchlists w where owner_token = ${owner} order by created_at`); | |
| 253 | − return { items: rows.rows }; | |
| 254 | − }); | |
| 255 | − app.post<{ Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists", async (req, reply) => { | |
| 256 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 257 | − if (!owner) return reply.status(401).send({ error: "owner token required" }); | |
| 258 | − const body = z.object({ name: z.string().min(1).max(80).default("My watchlist"), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).default([]) }).parse(req.body ?? {}); | |
| 259 | − const count = (await db.execute<{ n: string }>(sql`select count(*)::text as n from watchlists where owner_token = ${owner}`)).rows[0]?.n; | |
| 260 | − if (Number(count) >= 20) return reply.status(429).send({ error: "too many watchlists" }); | |
| 261 | − const id = newId("wl"); | |
| 262 | − await db.execute(sql`insert into watchlists (id, owner_token, name) values (${id}, ${owner}, ${body.name})`); | |
| 263 | − for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${id}, ${it.kind}, ${it.value}) on conflict do nothing`); | |
| 264 | − return { id, name: body.name, items: body.items }; | |
| 265 | − }); | |
| 266 | − app.put<{ Params: { id: string }; Body: { name?: string; items?: { kind: string; value: string }[] } }>("/api/v1/watchlists/:id", async (req, reply) => { | |
| 267 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 268 | − if (!owner) return reply.status(401).send({ error: "owner token required" }); | |
| 269 | − const w = (await db.execute<{ id: string }>(sql`select id from watchlists where id = ${req.params.id} and owner_token = ${owner}`)).rows[0]; | |
| 270 | − if (!w) return reply.status(404).send({ error: "not_found" }); | |
| 271 | − const body = z.object({ name: z.string().min(1).max(80).optional(), items: z.array(z.object({ kind: z.enum(["entity", "source", "keyword", "category", "url"]), value: z.string().min(1).max(200) })).max(200).optional() }).parse(req.body ?? {}); | |
| 272 | − if (body.name) await db.execute(sql`update watchlists set name = ${body.name} where id = ${w.id}`); | |
| 273 | − if (body.items) { | |
| 274 | − await db.execute(sql`delete from watchlist_items where watchlist_id = ${w.id}`); | |
| 275 | − for (const it of body.items) await db.execute(sql`insert into watchlist_items (watchlist_id, kind, value) values (${w.id}, ${it.kind}, ${it.value}) on conflict do nothing`); | |
| 276 | − } | |
| 277 | − return { ok: true }; | |
| 278 | − }); | |
| 279 | − app.delete<{ Params: { id: string } }>("/api/v1/watchlists/:id", async (req, reply) => { | |
| 280 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 281 | − if (!owner) return reply.status(401).send({ error: "owner token required" }); | |
| 282 | − await db.execute(sql`delete from watchlists where id = ${req.params.id} and owner_token = ${owner}`); | |
| 283 | − return { ok: true }; | |
| 284 | − }); | |
| 285 | − app.get<{ Params: { id: string }; Querystring: { limit?: string } }>("/api/v1/watchlists/:id/events", async (req, reply) => { | |
| 286 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 287 | − if (!owner) return reply.status(401).send({ error: "owner token required" }); | |
| 288 | − const items = (await db.execute<{ kind: string; value: string }>(sql`select i.kind, i.value from watchlist_items i join watchlists w on w.id = i.watchlist_id where w.id = ${req.params.id} and w.owner_token = ${owner}`)).rows; | |
| 289 | − if (!items.length) return reply.send({ items: [] }); | |
| 290 | − const ents = items.filter((i) => i.kind === "entity").map((i) => i.value); | |
| 291 | − const srcs = items.filter((i) => i.kind === "source").map((i) => i.value); | |
| 292 | − const cats = items.filter((i) => i.kind === "category").map((i) => i.value); | |
| 293 | − const kws = items.filter((i) => i.kind === "keyword").map((i) => i.value); | |
| 294 | − const conds = [] as ReturnType<typeof sql>[]; | |
| 295 | − if (ents.length) conds.push(sql`exists (select 1 from event_entities x where x.event_id = e.id and x.entity_id = any(${textArray(ents)}))`); | |
| 296 | − if (srcs.length) conds.push(sql`e.source_id = any(${textArray(srcs)})`); | |
| 297 | − if (cats.length) conds.push(sql`e.categories && ${textArray(cats)}`); | |
| 298 | − for (const k of kws) conds.push(sql`(e.title ilike ${"%" + k + "%"} or e.summary ilike ${"%" + k + "%"})`); | |
| 299 | − if (!conds.length) return { items: [] }; | |
| 300 | − const rows = await db.execute<Record<string, unknown>>(sql`select ${EVENT_SELECT} from events e join sources s on s.id = e.source_id join sensors sen on sen.id = e.sensor_id where ${sql.join(conds, sql` or `)} order by e.detected_at desc limit ${Math.min(200, Number(req.query.limit ?? 50))}`); | |
| 301 | − return { items: rows.rows }; | |
| 302 | − }); | |
| 303 | − | |
| 304 | − app.get("/api/v1/alerts", async (req, reply) => { | |
| 305 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 306 | − if (!owner) return reply.status(401).send({ error: "owner token required" }); | |
| 307 | − return { items: (await db.execute<Record<string, unknown>>(sql`select * from alerts where owner_token = ${owner} order by created_at`)).rows }; | |
| 308 | − }); | |
| 309 | − app.post<{ Body: Record<string, unknown> }>("/api/v1/alerts", async (req, reply) => { | |
| 310 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 311 | − if (!owner) return reply.status(401).send({ error: "owner token required" }); | |
| 312 | − const body = z.object({ name: z.string().min(1).max(80), rule: z.object({ importance_min: z.number().min(0).max(100).optional(), event_types: z.array(z.string()).optional(), entities: z.array(z.string()).optional(), sources: z.array(z.string()).optional(), keywords: z.array(z.string()).optional(), silent_only: z.boolean().optional(), categories: z.array(z.string()).optional() }), channel: z.enum(["web"]).default("web") }).parse(req.body ?? {}); | |
| 313 | − const id = newId("alr"); | |
| 314 | − await db.execute(sql`insert into alerts (id, owner_token, name, rule, channel) values (${id}, ${owner}, ${body.name}, ${JSON.stringify(body.rule)}::jsonb, ${body.channel})`); | |
| 315 | − return { id, ...body }; | |
| 316 | − }); | |
| 317 | − app.delete<{ Params: { id: string } }>("/api/v1/alerts/:id", async (req, reply) => { | |
| 318 | − const owner = ownerToken(req.headers as Record<string, unknown>); | |
| 319 | − if (!owner) return reply.status(401).send({ error: "owner token required" }); | |
| 320 | − await db.execute(sql`delete from alerts where id = ${req.params.id} and owner_token = ${owner}`); | |
| 321 | − return { ok: true }; | |
| 322 | − }); | |
| 310 | + // ---- Connector health / system -------------------------------------------------------------- | |
| 311 | + app.get("/api/v1/health/connectors", async () => | |
| 312 | + cached("health", 10_000, async () => { | |
| 313 | + const [connectors, sensorsByHealth, sensorsByStatus, worst, noisy, daily, byConnectorSensors, throughput, es, topFailingDomains, slowest] = await Promise.all([ | |
| 314 | + db.execute<Record<string, unknown>>(sql`select * from connector_health order by connector`).then((r) => r.rows), | |
| 315 | + db.execute<Record<string, unknown>>(sql`select health, count(*)::int as n from sensors where enabled group by health`).then((r) => r.rows), | |
| 316 | + db.execute<Record<string, unknown>>(sql`select status, count(*)::int as n from sensors group by status`).then((r) => r.rows), | |
| 317 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.url, s.source_id, s.connector, s.health, s.consecutive_errors, s.last_error, s.last_status, s.last_check_at from sensors s where s.enabled and s.health <> 'UP' order by s.consecutive_errors desc, s.last_check_at desc limit 50`).then((r) => r.rows), | |
| 318 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.url, s.raw_changes, s.meaningful_changes, case when s.raw_changes > 0 then round(1 - s.meaningful_changes::numeric / s.raw_changes, 3) else null end as noise_ratio from sensors s where s.raw_changes >= 5 order by noise_ratio desc nulls last, raw_changes desc limit 25`).then((r) => r.rows), | |
| 319 | + db.execute<Record<string, unknown>>(sql`select * from metrics_daily order by day desc limit 30`).then((r) => r.rows), | |
| 320 | + db.execute<Record<string, unknown>>(sql`select connector, count(*)::int as sensors, count(*) filter (where health = 'UP')::int as up, avg(avg_latency_ms)::int as avg_latency_ms from sensors where enabled group by connector order by sensors desc`).then((r) => r.rows), | |
| 321 | + db.execute<Record<string, unknown>>(sql`select (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes')::int as checks_5m, (select count(*) from sensor_runs where started_at >= now() - interval '5 minutes' and outcome = 'not_modified')::int as not_modified_5m, (select count(*) from events where detected_at >= now() - interval '5 minutes')::int as events_5m, (select count(*) from changes where detected_at >= now() - interval '5 minutes')::int as changes_5m, (select count(*) from sensors where enabled and next_check_at <= now())::int as queue_due, (select avg(duration_ms)::int from sensor_runs where started_at >= now() - interval '5 minutes')::int as avg_latency_5m_ms, (select count(*) from changes where detected_at >= now() - interval '24 hours' and change_class in ('cosmetic','navigation','timestamp','advertisement','boilerplate'))::int as noise_filtered_24h`).then((r) => r.rows[0]), | |
| 322 | + engineStatus(), | |
| 323 | + db.execute<Record<string, unknown>>(sql`select split_part(split_part(s.url, '/', 3), ':', 1) as host, count(*)::int as failures, max(r.error) as last_error from sensor_runs r join sensors s on s.id = r.sensor_id where r.started_at >= now() - interval '24 hours' and r.outcome in ('error','parse_error','rate_limited') group by 1 order by failures desc limit 15`).then((r) => r.rows), | |
| 324 | + db.execute<Record<string, unknown>>(sql`select s.id, s.name, s.source_id, s.avg_latency_ms, s.connector from sensors s where s.enabled and s.avg_latency_ms is not null order by s.avg_latency_ms desc limit 15`).then((r) => r.rows), | |
| 325 | + ]); | |
| 326 | + const t = throughput ?? {}; | |
| 327 | + return { connectors, sensors_by_health: sensorsByHealth, sensors_by_status: sensorsByStatus, degraded_sensors: worst, noisy_sensors: noisy, daily, sensors_by_connector: byConnectorSensors, throughput: { ...t, checks_per_min: Math.round(Number(t.checks_5m ?? 0) / 5), events_per_min: Math.round((Number(t.events_5m ?? 0) / 5) * 10) / 10, not_modified_ratio: Number(t.checks_5m) ? Math.round((Number(t.not_modified_5m) / Number(t.checks_5m)) * 100) / 100 : null }, engine: es, top_failing_domains: topFailingDomains, slowest_sensors: slowest, live: liveStats() }; | |
| 328 | + }), | |
| 329 | + ); | |
| 323 | 330 | |
| 324 | 331 | // ---- Machine-readable feeds ------------------------------------------------------------------ |
| 325 | − app.get<{ Querystring: { category?: string; importance_min?: string; silent_change?: string } }>("/api/v1/feed.rss", async (req, reply) => { | |
| 326 | − const q = eventsQuery.parse({ ...req.query, limit: 50 }); | |
| 332 | + app.get("/api/v1/feed.rss", async (req, reply) => { | |
| 333 | + const q = eventsQuery.parse({ ...(req.query as Record<string, unknown>), limit: 50 }); | |
| 327 | 334 | const { items } = await listEvents(q); |
| 328 | 335 | const esc = (s: unknown): string => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| 329 | 336 | const base = config.publicBaseUrl; |
| 330 | − const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebSensor — ${esc(q.category ? q.category + " events" : "live events")}</title><link>${base}</link><description>Meaningful changes detected on the public Web by WebSensor.</description><atom:link href="${base}/api/v1/feed.rss" rel="self" type="application/rss+xml"/>${items | |
| 331 | − .map((e) => `<item><title>${esc(e.title)}</title><link>${base}/event/${esc(e.slug)}</link><guid isPermaLink="false">${esc(e.id)}</guid><pubDate>${new Date(String(e.detected_at)).toUTCString()}</pubDate><category>${esc(e.event_type)}</category><description>${esc(e.summary)} (importance ${esc(e.importance)}, confidence ${esc(e.confidence)}${e.silent_change ? ", silent change" : ""}) — source: ${esc(e.url)}</description></item>`) | |
| 337 | + const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebSensor — ${esc(q.category ? q.category + " events" : q.silent_change ? "silent changes" : "live events")}</title><link>${base}</link><description>Meaningful changes detected on the public Web by WebSensor.</description><atom:link href="${base}/api/v1/feed.rss" rel="self" type="application/rss+xml"/>${items | |
| 338 | + .map((e) => `<item><title>${esc(e.title)}</title><link>${base}/event/${esc(e.slug)}</link><guid isPermaLink="false">${esc(e.id)}</guid><pubDate>${new Date(String(e.detected_at)).toUTCString()}</pubDate><category>${esc(e.event_type)}</category><description>${esc(e.summary)} (signal ${esc(e.signal_score ?? e.importance)}, importance ${esc(e.importance)}, confidence ${esc(e.confidence)}${e.silent_change ? ", silent change" : ""}${e.first_party ? ", first-party" : ""}) — source: ${esc(e.url)}</description></item>`) | |
| 332 | 339 | .join("")}</channel></rss>`; |
| 333 | 340 | reply.header("content-type", "application/rss+xml; charset=utf-8"); |
| 334 | 341 | return reply.send(xml); |
@@ -337,13 +344,59 @@ export async function registerRoutes(app: FastifyInstance): Promise<void> { | ||
| 337 | 344 | app.get("/api/v1", async () => ({ |
| 338 | 345 | name: "WebSensor API", |
| 339 | 346 | version: "v1", |
| 347 | + release: config.version, | |
| 340 | 348 | docs: `${config.publicBaseUrl}/api`, |
| 341 | − endpoints: ["/api/v1/events", "/api/v1/events/{id|slug}", "/api/v1/changes/{id}", "/api/v1/snapshots/{id}", "/api/v1/snapshots/compare?a=&b=", "/api/v1/sources", "/api/v1/sources/{id}", "/api/v1/sensors/{id}", "/api/v1/entities", "/api/v1/entities/{id}", "/api/v1/entities/{id}/timeline", "/api/v1/domains/{domain}/timeline", "/api/v1/urls/history?url=", "/api/v1/search?q=", "/api/v1/stats", "/api/v1/trending", "/api/v1/explore", "/api/v1/clusters", "/api/v1/health/connectors", "/api/v1/watchlists", "/api/v1/alerts", "/api/v1/feed.rss", "wss://…/api/v1/live"], | |
| 349 | + rate_limit: "600 requests / minute / IP (headers x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset)", | |
| 350 | + endpoints: [ | |
| 351 | + "/api/v1/events", | |
| 352 | + "/api/v1/events/count", | |
| 353 | + "/api/v1/events/{id|slug}", | |
| 354 | + "/api/v1/changes/{id}", | |
| 355 | + "/api/v1/snapshots/{id}", | |
| 356 | + "/api/v1/snapshots/compare?a=&b=", | |
| 357 | + "/api/v1/sensors/{id}", | |
| 358 | + "/api/v1/sensors/{id}/snapshots", | |
| 359 | + "/api/v1/sources", | |
| 360 | + "/api/v1/sources/{id}", | |
| 361 | + "/api/v1/entities", | |
| 362 | + "/api/v1/entities/rank", | |
| 363 | + "/api/v1/entities/{id}", | |
| 364 | + "/api/v1/entities/{id}/timeline", | |
| 365 | + "/api/v1/clusters", | |
| 366 | + "/api/v1/clusters/{id|slug}", | |
| 367 | + "/api/v1/domains/{domain}/timeline", | |
| 368 | + "/api/v1/urls/history?url=", | |
| 369 | + "/api/v1/search?q=", | |
| 370 | + "/api/v1/stats", | |
| 371 | + "/api/v1/trending", | |
| 372 | + "/api/v1/breaking", | |
| 373 | + "/api/v1/pulse", | |
| 374 | + "/api/v1/radar", | |
| 375 | + "/api/v1/explore", | |
| 376 | + "/api/v1/countries", | |
| 377 | + "/api/v1/countries/{code|slug}", | |
| 378 | + "/api/v1/categories/{channel}", | |
| 379 | + "/api/v1/health/connectors", | |
| 380 | + "/api/v1/watchlists", | |
| 381 | + "/api/v1/alerts", | |
| 382 | + "/api/v1/notifications", | |
| 383 | + "/api/v1/bookmarks", | |
| 384 | + "/api/v1/views", | |
| 385 | + "/api/v1/monitors", | |
| 386 | + "/api/v1/feed.rss", | |
| 387 | + "wss://…/api/v1/live", | |
| 388 | + ], | |
| 342 | 389 | })); |
| 343 | 390 | } |
| 344 | 391 | |
| 392 | +function stripEmpty(o: Record<string, unknown>): Record<string, unknown> { | |
| 393 | + return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "")); | |
| 394 | +} | |
| 395 | + | |
| 345 | 396 | function renderCanonical(c: { mode: string; text?: string; items?: Record<string, unknown>[]; json?: unknown }): string { |
| 346 | 397 | if (c.mode === "text") return c.text ?? ""; |
| 347 | 398 | if (c.mode === "list") return (c.items ?? []).map((i) => [i.title ?? i.url ?? i.key, i.url && i.title ? i.url : null, i.summary ? String(i.summary).slice(0, 300) : null].filter(Boolean).join(" — ")).join("\n"); |
| 348 | 399 | return JSON.stringify(c.json ?? null, null, 2); |
| 349 | 400 | } |
| 401 | + | |
| 402 | +export { textArray }; | |
modified
apps/api/src/server.ts
+9 −2
@@ -9,6 +9,8 @@ import { closeDb } from "@websensor/db"; | ||
| 9 | 9 | import { config } from "./config"; |
| 10 | 10 | import { closeLive, registerLive } from "./live"; |
| 11 | 11 | import { registerRoutes } from "./routes"; |
| 12 | +import { registerAdminRoutes } from "./routes-admin"; | |
| 13 | +import { registerUserRoutes } from "./routes-user"; | |
| 12 | 14 | |
| 13 | 15 | /** |
| 14 | 16 | * WebSensor gateway: public entry point behind ngrok. |
@@ -42,14 +44,17 @@ export async function buildServer() { | ||
| 42 | 44 | } |
| 43 | 45 | }); |
| 44 | 46 | |
| 45 | − await app.register(cors, { origin: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["content-type", "x-websensor-owner", "authorization"], exposedHeaders: ["x-request-id"] }); | |
| 47 | + await app.register(cors, { origin: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: ["content-type", "x-websensor-owner", "x-websensor-admin", "authorization"], exposedHeaders: ["x-request-id", "x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset"] }); | |
| 46 | 48 | await app.register(rateLimit, { max: 600, timeWindow: "1 minute", allowList: (req) => !req.url.startsWith("/api/") }); |
| 47 | 49 | await app.register(websocket, { options: { maxPayload: 64 * 1024 } }); |
| 50 | + // Raw YAML bodies for the admin import endpoint. | |
| 51 | + app.addContentTypeParser(["text/yaml", "application/yaml", "application/x-yaml", "text/plain"], { parseAs: "string" }, (_req, body, done) => done(null, body)); | |
| 48 | 52 | |
| 49 | 53 | app.addHook("onSend", async (req, reply) => { |
| 50 | 54 | reply.header("x-request-id", req.id); |
| 51 | 55 | if (req.url.startsWith("/api/")) { |
| 52 | − reply.header("cache-control", req.method === "GET" ? "public, max-age=5, stale-while-revalidate=30" : "no-store"); | |
| 56 | + const isOwner = Boolean(req.headers["x-websensor-owner"]) || req.url.includes("/admin/"); | |
| 57 | + reply.header("cache-control", req.method === "GET" && !isOwner ? "public, max-age=5, stale-while-revalidate=30" : "no-store"); | |
| 53 | 58 | httpRequests.inc({ route: req.routeOptions?.url ?? "unknown", status: String(reply.statusCode) }); |
| 54 | 59 | } |
| 55 | 60 | }); |
@@ -67,6 +72,8 @@ export async function buildServer() { | ||
| 67 | 72 | }); |
| 68 | 73 | |
| 69 | 74 | await registerRoutes(app); |
| 75 | + await registerUserRoutes(app); | |
| 76 | + await registerAdminRoutes(app); | |
| 70 | 77 | await registerLive(app); |
| 71 | 78 | |
| 72 | 79 | // Frontend proxy — everything that is not /api/* goes to Next.js (loopback) |
added
apps/engine/src/alerts.ts
+132 −0
@@ -0,0 +1,132 @@ | ||
| 1 | +import { createHmac } from "node:crypto"; | |
| 2 | +import { db, sql } from "@websensor/db"; | |
| 3 | +import { postJson } from "@websensor/connectors"; | |
| 4 | +import { log } from "./config"; | |
| 5 | +import { m } from "./metrics"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Server-side alert evaluation (spec §43–44). Rules are matched against every published event; | |
| 9 | + * matches become `notifications` rows (channel web = read by the UI) and, for webhook alerts, an | |
| 10 | + * HTTP POST signed with HMAC-SHA256 (`X-WebSensor-Signature: sha256=<hex>`). Delivery goes through | |
| 11 | + * the same SSRF policy as every other URL the engine touches. | |
| 12 | + */ | |
| 13 | +export interface AlertRule { | |
| 14 | + importance_min?: number; | |
| 15 | + signal_min?: number; | |
| 16 | + event_types?: string[]; | |
| 17 | + groups?: string[]; | |
| 18 | + entities?: string[]; | |
| 19 | + sources?: string[]; | |
| 20 | + keywords?: string[]; | |
| 21 | + categories?: string[]; | |
| 22 | + countries?: string[]; | |
| 23 | + silent_only?: boolean; | |
| 24 | + first_party_only?: boolean; | |
| 25 | + confirmed_only?: boolean; | |
| 26 | +} | |
| 27 | + | |
| 28 | +interface AlertRow { | |
| 29 | + id: string; | |
| 30 | + owner_token: string; | |
| 31 | + name: string; | |
| 32 | + rule: AlertRule; | |
| 33 | + channel: string; | |
| 34 | + channel_config: { url?: string; secret?: string } | null; | |
| 35 | + enabled: boolean; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export interface PublishedEvent { | |
| 39 | + id: string; | |
| 40 | + slug: string; | |
| 41 | + type: string; | |
| 42 | + group?: string; | |
| 43 | + title: string; | |
| 44 | + summary: string; | |
| 45 | + importance: number; | |
| 46 | + signal?: number; | |
| 47 | + confidence: number; | |
| 48 | + silent: boolean; | |
| 49 | + evidence: string; | |
| 50 | + firstParty?: boolean; | |
| 51 | + country?: string | null; | |
| 52 | + source: { id: string; name: string; domain: string; tier?: string }; | |
| 53 | + entities: { id: string; name: string; type: string }[]; | |
| 54 | + categories: string[]; | |
| 55 | + url: string; | |
| 56 | + detectedAt: string; | |
| 57 | +} | |
| 58 | + | |
| 59 | +let cache: { at: number; rows: AlertRow[] } | null = null; | |
| 60 | + | |
| 61 | +async function loadAlerts(): Promise<AlertRow[]> { | |
| 62 | + if (cache && Date.now() - cache.at < 30_000) return cache.rows; | |
| 63 | + const r = await db.execute<Record<string, unknown>>(sql`select id, owner_token, name, rule, channel, channel_config, enabled from alerts where enabled`); | |
| 64 | + cache = { at: Date.now(), rows: r.rows as unknown as AlertRow[] }; | |
| 65 | + return cache.rows; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export function invalidateAlerts(): void { | |
| 69 | + cache = null; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export function matchesRule(rule: AlertRule, e: PublishedEvent): boolean { | |
| 73 | + if (rule.importance_min !== undefined && e.importance < rule.importance_min) return false; | |
| 74 | + if (rule.signal_min !== undefined && (e.signal ?? e.importance) < rule.signal_min) return false; | |
| 75 | + if (rule.silent_only && !e.silent) return false; | |
| 76 | + if (rule.first_party_only && e.firstParty === false) return false; | |
| 77 | + if (rule.confirmed_only && e.evidence !== "CONFIRMED") return false; | |
| 78 | + if (rule.event_types?.length && !rule.event_types.includes(e.type)) return false; | |
| 79 | + if (rule.groups?.length && !(e.group && rule.groups.includes(e.group))) return false; | |
| 80 | + if (rule.categories?.length && !rule.categories.some((c) => e.categories.includes(c))) return false; | |
| 81 | + if (rule.countries?.length && !(e.country && rule.countries.includes(e.country))) return false; | |
| 82 | + if (rule.entities?.length && !e.entities.some((x) => rule.entities!.includes(x.id))) return false; | |
| 83 | + if (rule.sources?.length && !rule.sources.includes(e.source.id)) return false; | |
| 84 | + if (rule.keywords?.length) { | |
| 85 | + const hay = `${e.title} ${e.summary}`.toLowerCase(); | |
| 86 | + if (!rule.keywords.some((k) => hay.includes(k.toLowerCase()))) return false; | |
| 87 | + } | |
| 88 | + // A rule with no positive constraint at all would match everything — require at least one. | |
| 89 | + const constrained = rule.importance_min !== undefined || rule.signal_min !== undefined || rule.silent_only || rule.event_types?.length || rule.groups?.length || rule.categories?.length || rule.countries?.length || rule.entities?.length || rule.sources?.length || rule.keywords?.length; | |
| 90 | + return Boolean(constrained); | |
| 91 | +} | |
| 92 | + | |
| 93 | +export async function evaluateAlerts(e: PublishedEvent): Promise<number> { | |
| 94 | + let alerts: AlertRow[]; | |
| 95 | + try { | |
| 96 | + alerts = await loadAlerts(); | |
| 97 | + } catch (err) { | |
| 98 | + log.warn({ err: (err as Error).message }, "alerts load failed"); | |
| 99 | + return 0; | |
| 100 | + } | |
| 101 | + let fired = 0; | |
| 102 | + for (const a of alerts) { | |
| 103 | + if (!matchesRule(a.rule ?? {}, e)) continue; | |
| 104 | + fired++; | |
| 105 | + try { | |
| 106 | + const ins = await db.execute<{ id: number }>(sql`insert into notifications (alert_id, event_id, channel, status) values (${a.id}, ${e.id}, ${a.channel}, ${a.channel === "web" ? "delivered" : "queued"}) returning id`); | |
| 107 | + await db.execute(sql`update alerts set last_fired_at = now(), fired_count = fired_count + 1 where id = ${a.id}`); | |
| 108 | + m.alertsFired.inc({ channel: a.channel }); | |
| 109 | + if (a.channel === "webhook" && a.channel_config?.url) void deliverWebhook(ins.rows[0]!.id, a, e); | |
| 110 | + } catch (err) { | |
| 111 | + log.warn({ alert: a.id, err: (err as Error).message }, "notification insert failed"); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + return fired; | |
| 115 | +} | |
| 116 | + | |
| 117 | +async function deliverWebhook(notificationId: number, a: AlertRow, e: PublishedEvent): Promise<void> { | |
| 118 | + const url = String(a.channel_config?.url ?? ""); | |
| 119 | + const body = JSON.stringify({ alert: { id: a.id, name: a.name }, event: e, delivered_at: new Date().toISOString() }); | |
| 120 | + const headers: Record<string, string> = { "content-type": "application/json", "user-agent": "WebSensor-Alerts/1.0 (+https://www.websensor.io/api)", "x-websensor-alert": a.id, "x-websensor-event": e.id }; | |
| 121 | + if (a.channel_config?.secret) headers["x-websensor-signature"] = "sha256=" + createHmac("sha256", String(a.channel_config.secret)).update(body).digest("hex"); | |
| 122 | + try { | |
| 123 | + const res = await postJson(url, body, headers); | |
| 124 | + const ok = res.status >= 200 && res.status < 300; | |
| 125 | + await db.execute(sql`update notifications set status = ${ok ? "delivered" : "failed"}, delivered_at = ${ok ? new Date() : null}, error = ${ok ? null : (res.error ?? `HTTP ${res.status}`).slice(0, 300)} where id = ${notificationId}`); | |
| 126 | + m.webhookDeliveries.inc({ ok: String(ok) }); | |
| 127 | + } catch (err) { | |
| 128 | + await db.execute(sql`update notifications set status = 'failed', error = ${String((err as Error).message).slice(0, 300)} where id = ${notificationId}`).catch(() => undefined); | |
| 129 | + m.webhookDeliveries.inc({ ok: "false" }); | |
| 130 | + log.warn({ alert: a.id, err: (err as Error).message }, "webhook delivery failed"); | |
| 131 | + } | |
| 132 | +} | |
modified
apps/engine/src/cli.ts
+18 −1
@@ -52,6 +52,23 @@ async function main(): Promise<void> { | ||
| 52 | 52 | } |
| 53 | 53 | break; |
| 54 | 54 | } |
| 55 | + case "relink-entities": { | |
| 56 | + const { relinkMentionedEntities } = await import("./entities"); | |
| 57 | + const r = await relinkMentionedEntities(Number(args[0] ?? 7)); | |
| 58 | + console.log(JSON.stringify(r)); | |
| 59 | + break; | |
| 60 | + } | |
| 61 | + case "prune-blobs": { | |
| 62 | + const { pruneRawSnapshots } = await import("./retention"); | |
| 63 | + console.log(JSON.stringify(await pruneRawSnapshots({ batch: Number(args[0] ?? 5000) }))); | |
| 64 | + break; | |
| 65 | + } | |
| 66 | + case "refresh-clusters": { | |
| 67 | + const { refreshClusterStates } = await import("./cluster"); | |
| 68 | + await refreshClusterStates(); | |
| 69 | + console.log("cluster states refreshed"); | |
| 70 | + break; | |
| 71 | + } | |
| 55 | 72 | case "llm-test": { |
| 56 | 73 | if (!llmAvailable()) throw new Error("ANTHROPIC_API_KEY not set"); |
| 57 | 74 | const before = "API Pricing\nInput: $10 / million tokens\nOutput: $30 / million tokens\nBatch API: 50% discount"; |
@@ -64,7 +81,7 @@ async function main(): Promise<void> { | ||
| 64 | 81 | break; |
| 65 | 82 | } |
| 66 | 83 | default: |
| 67 | − console.error("usage: cli.ts sync | discover [sourceId…] | probe <domain> | run-once <sensorId> | run-due [n]"); | |
| 84 | + console.error("usage: cli.ts sync | discover [sourceId…] | probe <domain> | run-once <sensorId> | run-due [n] | relink-entities [days] | prune-blobs [batch] | refresh-clusters"); | |
| 68 | 85 | process.exitCode = 1; |
| 69 | 86 | } |
| 70 | 87 | } |
modified
apps/engine/src/cluster.ts
+78 −22
@@ -1,9 +1,13 @@ | ||
| 1 | −import { jaccard, newId, shingles } from "@websensor/core"; | |
| 2 | −import { db, eventClusters, events, gte, sql, textArray } from "@websensor/db"; | |
| 1 | +import { breakingState, jaccard, newId, shingles, slugify, velocityScore } from "@websensor/core"; | |
| 2 | +import { db, eventClusters, events, gte, sql, textArray, type ClusterTimelineStep } from "@websensor/db"; | |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * Novelty + clustering over a rolling in-memory window of recent events (loaded from |
| 6 | 6 | * Postgres at startup). Similarity = Jaccard over word 3-shingles of title+summary. |
| 7 | + * | |
| 8 | + * 2026-09-11: clusters now track propagation (spec §24, §35, §36): first-party vs external | |
| 9 | + * signals, a timeline of every signal, velocity, lead time (first-party detection → first | |
| 10 | + * external report) and a breaking state (spec §34). | |
| 7 | 11 | */ |
| 8 | 12 | interface RecentEvent { |
| 9 | 13 | id: string; |
@@ -15,6 +19,7 @@ interface RecentEvent { | ||
| 15 | 19 | detectedAt: number; |
| 16 | 20 | sh: Set<string>; |
| 17 | 21 | importance: number; |
| 22 | + firstParty: boolean; | |
| 18 | 23 | } |
| 19 | 24 | |
| 20 | 25 | const WINDOW_MS = 72 * 3600e3; |
@@ -24,11 +29,11 @@ let loaded = false; | ||
| 24 | 29 | |
| 25 | 30 | export async function loadRecent(): Promise<void> { |
| 26 | 31 | const since = new Date(Date.now() - WINDOW_MS); |
| 27 | − const rows = await db.execute<{ id: string; cluster_id: string | null; source_id: string; sensor_id: string; event_type: string; title: string; summary: string; detected_at: Date; importance: number; entity_ids: string[] | null }>(sql` | |
| 28 | − select e.id, e.cluster_id, e.source_id, e.sensor_id, e.event_type, e.title, e.summary, e.detected_at, e.importance, | |
| 32 | + const rows = await db.execute<{ id: string; cluster_id: string | null; source_id: string; sensor_id: string; event_type: string; title: string; summary: string; detected_at: Date; importance: number; first_party: boolean | null; entity_ids: string[] | null }>(sql` | |
| 33 | + select e.id, e.cluster_id, e.source_id, e.sensor_id, e.event_type, e.title, e.summary, e.detected_at, e.importance, e.first_party, | |
| 29 | 34 | (select array_agg(entity_id) from event_entities ee where ee.event_id = e.id) as entity_ids |
| 30 | − from events e where e.detected_at >= ${since} order by e.detected_at desc limit 3000`); | |
| 31 | − recent = rows.rows.map((r) => ({ id: r.id, clusterId: r.cluster_id, sourceId: r.source_id, sensorId: r.sensor_id, eventType: r.event_type, entityIds: r.entity_ids ?? [], detectedAt: new Date(r.detected_at).getTime(), sh: shingles(`${r.title}\n${r.summary}`), importance: r.importance })); | |
| 35 | + from events e where e.detected_at >= ${since} order by e.detected_at desc limit 4000`); | |
| 36 | + recent = rows.rows.map((r) => ({ id: r.id, clusterId: r.cluster_id, sourceId: r.source_id, sensorId: r.sensor_id, eventType: r.event_type, entityIds: r.entity_ids ?? [], detectedAt: new Date(r.detected_at).getTime(), sh: shingles(`${r.title}\n${r.summary}`), importance: r.importance, firstParty: r.first_party ?? true })); | |
| 32 | 37 | loaded = true; |
| 33 | 38 | } |
| 34 | 39 | |
@@ -40,8 +45,10 @@ function prune(): void { | ||
| 40 | 45 | export interface NoveltyResult { |
| 41 | 46 | novelty: number; |
| 42 | 47 | nearest: { id: string; similarity: number } | null; |
| 43 | − /** number of distinct sources reporting near-identical content */ | |
| 48 | + /** number of distinct OTHER sources reporting near-identical content */ | |
| 44 | 49 | confirmations: number; |
| 50 | + /** among those, how many are first-party channels */ | |
| 51 | + firstPartyConfirmations: number; | |
| 45 | 52 | } |
| 46 | 53 | |
| 47 | 54 | export async function assessNovelty(text: string, sourceId: string): Promise<NoveltyResult> { |
@@ -50,21 +57,30 @@ export async function assessNovelty(text: string, sourceId: string): Promise<Nov | ||
| 50 | 57 | const sh = shingles(text); |
| 51 | 58 | let best = 0; |
| 52 | 59 | let nearest: RecentEvent | null = null; |
| 53 | − const confirmingSources = new Set<string>(); | |
| 60 | + const confirming = new Map<string, boolean>(); | |
| 54 | 61 | for (const r of recent) { |
| 55 | 62 | const s = jaccard(sh, r.sh); |
| 56 | 63 | if (s > best) { |
| 57 | 64 | best = s; |
| 58 | 65 | nearest = r; |
| 59 | 66 | } |
| 60 | − if (s >= 0.45 && r.sourceId !== sourceId) confirmingSources.add(r.sourceId); | |
| 67 | + if (s >= 0.45 && r.sourceId !== sourceId) confirming.set(r.sourceId, r.firstParty); | |
| 61 | 68 | } |
| 62 | − return { novelty: Math.round((1 - best) * 100), nearest: nearest ? { id: nearest.id, similarity: Math.round(best * 100) / 100 } : null, confirmations: confirmingSources.size }; | |
| 69 | + return { novelty: Math.round((1 - best) * 100), nearest: nearest ? { id: nearest.id, similarity: Math.round(best * 100) / 100 } : null, confirmations: confirming.size, firstPartyConfirmations: [...confirming.values()].filter(Boolean).length }; | |
| 63 | 70 | } |
| 64 | 71 | |
| 65 | 72 | export interface ClusterDecision { |
| 66 | 73 | clusterId: string; |
| 74 | + slug: string; | |
| 67 | 75 | created: boolean; |
| 76 | + /** stats after this event was attached */ | |
| 77 | + eventCount: number; | |
| 78 | + sourceCount: number; | |
| 79 | + firstPartyCount: number; | |
| 80 | + externalCount: number; | |
| 81 | + velocity: number; | |
| 82 | + state: string; | |
| 83 | + leadTimeMs: number | null; | |
| 68 | 84 | } |
| 69 | 85 | |
| 70 | 86 | /** |
@@ -72,7 +88,7 @@ export interface ClusterDecision { | ||
| 72 | 88 | * with a recent event and is textually related, or when it is the same event type on the |
| 73 | 89 | * same source within 30 minutes (e.g. one launch touching six pages). Otherwise open one. |
| 74 | 90 | */ |
| 75 | −export async function clusterEvent(ev: { id: string; sourceId: string; sensorId: string; eventType: string; entityIds: string[]; detectedAt: Date; title: string; summary: string; importance: number; categories: string[] }): Promise<ClusterDecision> { | |
| 91 | +export async function clusterEvent(ev: { id: string; sourceId: string; sourceName: string; sensorId: string; sensorType: string; eventType: string; entityIds: string[]; detectedAt: Date; title: string; summary: string; importance: number; signal: number; categories: string[]; firstParty: boolean; sourceTier?: string }): Promise<ClusterDecision> { | |
| 76 | 92 | if (!loaded) await loadRecent(); |
| 77 | 93 | const sh = shingles(`${ev.title}\n${ev.summary}`); |
| 78 | 94 | const now = ev.detectedAt.getTime(); |
@@ -89,29 +105,57 @@ export async function clusterEvent(ev: { id: string; sourceId: string; sensorId: | ||
| 89 | 105 | if (sim >= 0.22) score = sim + (sharedEntity ? 0.2 : 0); |
| 90 | 106 | else if (sameSource && r.eventType === ev.eventType && closeInTime && r.sensorId !== ev.sensorId) score = 0.3; |
| 91 | 107 | else if (sameSource && closeInTime && sim >= 0.12) score = 0.25; |
| 108 | + // cross-source, shared entity, moderately similar and same broad type → the same story reported elsewhere | |
| 109 | + else if (!sameSource && sharedEntity && sim >= 0.15 && now - r.detectedAt < 2 * 3600e3) score = 0.2 + sim; | |
| 92 | 110 | if (score > bestScore) { |
| 93 | 111 | bestScore = score; |
| 94 | 112 | bestCluster = r.clusterId; |
| 95 | 113 | } |
| 96 | 114 | } |
| 115 | + const step: ClusterTimelineStep = { at: ev.detectedAt.toISOString(), eventId: ev.id, sourceId: ev.sourceId, sourceName: ev.sourceName, sensorType: ev.sensorType, firstParty: ev.firstParty, eventType: ev.eventType, importance: ev.importance }; | |
| 97 | 116 | let clusterId: string; |
| 117 | + let slug: string; | |
| 98 | 118 | let created = false; |
| 119 | + let row: { event_count: number; source_count: number; first_party_count: number; external_count: number; first_at: Date; last_at: Date; first_party_at: Date | null; first_external_at: Date | null; lead_time_ms: number | null; slug: string | null; max_importance: number }; | |
| 99 | 120 | if (bestCluster) { |
| 100 | 121 | clusterId = bestCluster; |
| 101 | − await db.execute(sql`update event_clusters set event_count = event_count + 1, last_at = greatest(last_at, ${ev.detectedAt}), max_importance = greatest(max_importance, ${ev.importance}), | |
| 102 | − entity_ids = (select array(select distinct unnest(entity_ids || ${textArray(ev.entityIds)}))), | |
| 103 | − categories = (select array(select distinct unnest(categories || ${textArray(ev.categories)}))), | |
| 104 | − title = case when ${ev.importance} > max_importance then ${ev.title} else title end, | |
| 105 | − primary_event_id = case when ${ev.importance} > max_importance then ${ev.id} else primary_event_id end | |
| 106 | − where id = ${clusterId}`); | |
| 122 | + const r = await db.execute<typeof row>(sql` | |
| 123 | + update event_clusters set | |
| 124 | + event_count = event_count + 1, | |
| 125 | + last_at = greatest(last_at, ${ev.detectedAt}), | |
| 126 | + max_importance = greatest(max_importance, ${ev.importance}), | |
| 127 | + entity_ids = (select array(select distinct unnest(entity_ids || ${textArray(ev.entityIds)}))), | |
| 128 | + categories = (select array(select distinct unnest(categories || ${textArray(ev.categories)}))), | |
| 129 | + title = case when ${ev.importance} > max_importance then ${ev.title} else title end, | |
| 130 | + summary = case when ${ev.importance} > max_importance then ${ev.summary} else summary end, | |
| 131 | + primary_event_id = case when ${ev.importance} > max_importance then ${ev.id} else primary_event_id end, | |
| 132 | + source_count = (select count(distinct source_id) from events where cluster_id = ${clusterId}) + (case when exists (select 1 from events where cluster_id = ${clusterId} and source_id = ${ev.sourceId}) then 0 else 1 end), | |
| 133 | + first_party_count = first_party_count + ${ev.firstParty ? 1 : 0}, | |
| 134 | + external_count = external_count + ${ev.firstParty ? 0 : 1}, | |
| 135 | + first_party_at = case when ${ev.firstParty} then least(coalesce(first_party_at, ${ev.detectedAt}), ${ev.detectedAt}) else first_party_at end, | |
| 136 | + first_external_at = case when ${!ev.firstParty} then least(coalesce(first_external_at, ${ev.detectedAt}), ${ev.detectedAt}) else first_external_at end, | |
| 137 | + timeline = (case when jsonb_array_length(timeline) < 200 then timeline || ${JSON.stringify([step])}::jsonb else timeline end) | |
| 138 | + where id = ${clusterId} | |
| 139 | + returning event_count, source_count, first_party_count, external_count, first_at, last_at, first_party_at, first_external_at, lead_time_ms, slug, max_importance`); | |
| 140 | + row = r.rows[0]!; | |
| 141 | + slug = row.slug ?? clusterId; | |
| 107 | 142 | } else { |
| 108 | 143 | clusterId = newId("clu"); |
| 144 | + slug = `${slugify(ev.title).slice(0, 60)}-${clusterId.slice(-6)}`; | |
| 109 | 145 | created = true; |
| 110 | − await db.insert(eventClusters).values({ id: clusterId, title: ev.title, summary: ev.summary, primaryEventId: ev.id, entityIds: ev.entityIds, categories: ev.categories, eventCount: 1, maxImportance: ev.importance, firstAt: ev.detectedAt, lastAt: ev.detectedAt }); | |
| 146 | + await db.insert(eventClusters).values({ id: clusterId, slug, title: ev.title, summary: ev.summary, primaryEventId: ev.id, entityIds: ev.entityIds, categories: ev.categories, eventCount: 1, maxImportance: ev.importance, firstAt: ev.detectedAt, lastAt: ev.detectedAt, sourceCount: 1, firstPartyCount: ev.firstParty ? 1 : 0, externalCount: ev.firstParty ? 0 : 1, firstPartyAt: ev.firstParty ? ev.detectedAt : null, firstExternalAt: ev.firstParty ? null : ev.detectedAt, timeline: [step], state: "watching" }); | |
| 147 | + row = { event_count: 1, source_count: 1, first_party_count: ev.firstParty ? 1 : 0, external_count: ev.firstParty ? 0 : 1, first_at: ev.detectedAt, last_at: ev.detectedAt, first_party_at: ev.firstParty ? ev.detectedAt : null, first_external_at: ev.firstParty ? null : ev.detectedAt, lead_time_ms: null, slug, max_importance: ev.importance }; | |
| 111 | 148 | } |
| 112 | − recent.unshift({ id: ev.id, clusterId, sourceId: ev.sourceId, sensorId: ev.sensorId, eventType: ev.eventType, entityIds: ev.entityIds, detectedAt: now, sh, importance: ev.importance }); | |
| 113 | − if (recent.length > 5000) recent.length = 5000; | |
| 114 | − return { clusterId, created }; | |
| 149 | + // Derived: velocity, lead time, state. | |
| 150 | + const windowHours = Math.max(0.25, (new Date(row.last_at).getTime() - new Date(row.first_at).getTime()) / 3600e3); | |
| 151 | + const velocity = velocityScore({ signals: row.event_count, windowHours: Math.min(windowHours, 6), uniqueSources: row.source_count, firstPartySignals: row.first_party_count }); | |
| 152 | + const leadTimeMs = row.first_party_at && row.first_external_at ? new Date(row.first_external_at).getTime() - new Date(row.first_party_at).getTime() : null; | |
| 153 | + const confirmations = Math.max(0, row.source_count - 1); | |
| 154 | + const state = breakingState({ signal: ev.signal, importance: Math.max(ev.importance, row.max_importance), velocity, confirmations, firstPartyCount: row.first_party_count, ageMinutes: (Date.now() - new Date(row.first_at).getTime()) / 60e3, sourceTier: ev.sourceTier }); | |
| 155 | + await db.execute(sql`update event_clusters set velocity = ${velocity}, state = ${state}, lead_time_ms = ${leadTimeMs} where id = ${clusterId}`); | |
| 156 | + recent.unshift({ id: ev.id, clusterId, sourceId: ev.sourceId, sensorId: ev.sensorId, eventType: ev.eventType, entityIds: ev.entityIds, detectedAt: now, sh, importance: ev.importance, firstParty: ev.firstParty }); | |
| 157 | + if (recent.length > 6000) recent.length = 6000; | |
| 158 | + return { clusterId, slug, created, eventCount: row.event_count, sourceCount: row.source_count, firstPartyCount: row.first_party_count, externalCount: row.external_count, velocity, state, leadTimeMs }; | |
| 115 | 159 | } |
| 116 | 160 | |
| 117 | 161 | /** Recent events of the same source published through announcement-type sensors (for silent-change detection). */ |
@@ -121,7 +165,7 @@ export function recentAnnouncementSimilarity(sourceId: string, text: string, sin | ||
| 121 | 165 | const cutoff = Date.now() - sinceMs; |
| 122 | 166 | for (const r of recent) { |
| 123 | 167 | if (r.sourceId !== sourceId || r.detectedAt < cutoff) continue; |
| 124 | − if (!/announcement|product_launch|model_release|software_release|repository_release|incident|outage|maintenance|security_advisory/.test(r.eventType)) continue; | |
| 168 | + if (!/announcement|product_launch|model_release|software_release|repository_release|incident|outage|maintenance|security_advisory|service_launch|patch_release/.test(r.eventType)) continue; | |
| 125 | 169 | best = Math.max(best, jaccard(sh, r.sh)); |
| 126 | 170 | } |
| 127 | 171 | return best; |
@@ -132,4 +176,16 @@ export async function recentClusterCount(sinceMs: number): Promise<number> { | ||
| 132 | 176 | return Number(rows[0]?.n ?? 0); |
| 133 | 177 | } |
| 134 | 178 | |
| 179 | +/** Periodic: age out states (breaking → developing/confirmed → closed) for clusters that stopped receiving signals. */ | |
| 180 | +export async function refreshClusterStates(): Promise<void> { | |
| 181 | + await db.execute(sql` | |
| 182 | + update event_clusters set state = case | |
| 183 | + when last_at < now() - interval '72 hours' then 'closed' | |
| 184 | + when state = 'breaking' and first_at < now() - interval '6 hours' then (case when source_count >= 3 then 'confirmed' else 'developing' end) | |
| 185 | + when state = 'developing' and first_at < now() - interval '12 hours' then (case when source_count >= 3 then 'confirmed' else 'watching' end) | |
| 186 | + when state = 'confirmed' and last_at < now() - interval '24 hours' then 'watching' | |
| 187 | + else state end | |
| 188 | + where state <> 'closed' and last_at >= now() - interval '10 days'`); | |
| 189 | +} | |
| 190 | + | |
| 135 | 191 | export { events }; |
modified
apps/engine/src/config.ts
+6 −2
@@ -15,7 +15,7 @@ export const config = { | ||
| 15 | 15 | perHostConcurrency: Number(env.WS_PER_HOST_CONCURRENCY ?? 2), |
| 16 | 16 | metricsPort: Number(env.ENGINE_METRICS_PORT ?? 8262), |
| 17 | 17 | metricsHost: env.ENGINE_METRICS_HOST ?? "127.0.0.1", |
| 18 | − processingVersion: "event-pipeline-v1", | |
| 18 | + processingVersion: "event-pipeline-v2", | |
| 19 | 19 | /** heuristic signal threshold to promote a raw change to an event */ |
| 20 | 20 | meaningfulSignal: Number(env.WS_MEANINGFUL_SIGNAL ?? 0.32), |
| 21 | 21 | llm: { |
@@ -32,7 +32,11 @@ export const config = { | ||
| 32 | 32 | intervalDays: Number(env.WS_DISCOVERY_INTERVAL_DAYS ?? 7), |
| 33 | 33 | }, |
| 34 | 34 | deletion: { confirmations: Number(env.WS_DELETE_CONFIRMATIONS ?? 3), minSeparationMin: Number(env.WS_DELETE_SEPARATION_MIN ?? 60) }, |
| 35 | − version: "0.1.0", | |
| 35 | + /** storage lifecycle (spec §58): raw bodies of unchanged snapshots are dropped after N days; canonical + hashes kept */ | |
| 36 | + retention: { rawDays: Number(env.WS_RETENTION_RAW_DAYS ?? 21), changedRawDays: Number(env.WS_RETENTION_CHANGED_RAW_DAYS ?? 60), enabled: (env.WS_RETENTION ?? "1") !== "0" }, | |
| 37 | + /** silent-change bar: only eligible types with importance ≥ this become "silent" */ | |
| 38 | + silentMinImportance: Number(env.WS_SILENT_MIN_IMPORTANCE ?? 45), | |
| 39 | + version: "0.2.0", | |
| 36 | 40 | }; |
| 37 | 41 | |
| 38 | 42 | export const log = pino({ |
added
apps/engine/src/engine.test.ts
+73 −0
@@ -0,0 +1,73 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { matchesRule, type PublishedEvent } from "./alerts"; | |
| 3 | +import { inferCountry, inferLanguage, priorityFor } from "./registry"; | |
| 4 | + | |
| 5 | +const ev: PublishedEvent = { | |
| 6 | + id: "evt_1", | |
| 7 | + slug: "openai-pricing", | |
| 8 | + type: "pricing_change", | |
| 9 | + group: "commercial", | |
| 10 | + title: "OpenAI: price changed $10 → $8", | |
| 11 | + summary: "Input tokens now $8 per million.", | |
| 12 | + importance: 84, | |
| 13 | + signal: 88, | |
| 14 | + confidence: 90, | |
| 15 | + silent: true, | |
| 16 | + evidence: "OBSERVED", | |
| 17 | + firstParty: true, | |
| 18 | + country: "US", | |
| 19 | + source: { id: "openai", name: "OpenAI", domain: "openai.com", tier: "S" }, | |
| 20 | + entities: [{ id: "org_openai", name: "OpenAI", type: "organization" }], | |
| 21 | + categories: ["ai", "technology"], | |
| 22 | + url: "https://openai.com/api/pricing/", | |
| 23 | + detectedAt: new Date().toISOString(), | |
| 24 | +}; | |
| 25 | + | |
| 26 | +describe("alert rules (spec §43)", () => { | |
| 27 | + it("matches 'OpenAI changes pricing'", () => { | |
| 28 | + expect(matchesRule({ entities: ["org_openai"], event_types: ["pricing_change"] }, ev)).toBe(true); | |
| 29 | + }); | |
| 30 | + it("matches importance thresholds and silent-only", () => { | |
| 31 | + expect(matchesRule({ importance_min: 80 }, ev)).toBe(true); | |
| 32 | + expect(matchesRule({ importance_min: 90 }, ev)).toBe(false); | |
| 33 | + expect(matchesRule({ silent_only: true, categories: ["ai"] }, ev)).toBe(true); | |
| 34 | + }); | |
| 35 | + it("matches groups, countries, keywords and first-party", () => { | |
| 36 | + expect(matchesRule({ groups: ["commercial"] }, ev)).toBe(true); | |
| 37 | + expect(matchesRule({ groups: ["security"] }, ev)).toBe(false); | |
| 38 | + expect(matchesRule({ countries: ["US"] }, ev)).toBe(true); | |
| 39 | + expect(matchesRule({ keywords: ["million"] }, ev)).toBe(true); | |
| 40 | + expect(matchesRule({ first_party_only: true, sources: ["openai"] }, ev)).toBe(true); | |
| 41 | + expect(matchesRule({ confirmed_only: true, sources: ["openai"] }, ev)).toBe(false); | |
| 42 | + }); | |
| 43 | + it("rejects a rule without any positive constraint", () => { | |
| 44 | + expect(matchesRule({}, ev)).toBe(false); | |
| 45 | + expect(matchesRule({ first_party_only: true }, ev)).toBe(false); | |
| 46 | + }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +describe("registry inference", () => { | |
| 50 | + it("infers country from unambiguous TLDs only", () => { | |
| 51 | + expect(inferCountry("canada.ca", [])).toBe("CA"); | |
| 52 | + expect(inferCountry("quebec.ca", [])).toBe("CA"); | |
| 53 | + expect(inferCountry("gov.uk", [])).toBe("GB"); | |
| 54 | + expect(inferCountry("legifrance.gouv.fr", [])).toBe("FR"); | |
| 55 | + expect(inferCountry("cisa.gov", [])).toBe("US"); | |
| 56 | + expect(inferCountry("europa.eu", [])).toBe("EU"); | |
| 57 | + expect(inferCountry("who.int", [])).toBe("INT"); | |
| 58 | + expect(inferCountry("openai.com", [])).toBeNull(); | |
| 59 | + expect(inferCountry("example.io", [])).toBeNull(); | |
| 60 | + }); | |
| 61 | + it("infers language for francophone and other national domains", () => { | |
| 62 | + expect(inferLanguage("quebec.ca")).toBe("fr"); | |
| 63 | + expect(inferLanguage("spiegel.de")).toBe("de"); | |
| 64 | + expect(inferLanguage("openai.com")).toBeNull(); | |
| 65 | + }); | |
| 66 | + it("assigns priority tiers", () => { | |
| 67 | + expect(priorityFor("S", ["cloud"])).toBe(0); | |
| 68 | + expect(priorityFor("A", ["cyber"])).toBe(0); | |
| 69 | + expect(priorityFor("A", ["retail"])).toBe(1); | |
| 70 | + expect(priorityFor("B", ["retail"])).toBe(2); | |
| 71 | + expect(priorityFor("D", ["web-policy"])).toBe(3); | |
| 72 | + }); | |
| 73 | +}); | |
modified
apps/engine/src/entities.ts
+79 −3
@@ -8,19 +8,34 @@ import { log } from "./config"; | ||
| 8 | 8 | */ |
| 9 | 9 | interface AliasIndex { |
| 10 | 10 | loadedAt: number; |
| 11 | − byLen: { alias: string; re: RegExp; entityId: string }[]; | |
| 11 | + byLen: { alias: string; re: RegExp; entityId: string; ambiguous: boolean }[]; | |
| 12 | 12 | importance: Map<string, number>; |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | let index: AliasIndex | null = null; |
| 16 | 16 | |
| 17 | +/** | |
| 18 | + * Aliases that are ordinary words. Matching them in free text would attach the entity to almost every | |
| 19 | + * event ("FIRST" → Forum of Incident Response, "HAS" → Haute Autorité de santé, "make", "who", "cell"…). | |
| 20 | + * They only count when written exactly as an upper-case acronym in the original text (WHO, NASA, FIRST), | |
| 21 | + * or when the entity already belongs to the source (subject). | |
| 22 | + */ | |
| 23 | +export const AMBIGUOUS_ALIASES = new Set( | |
| 24 | + "first has who make cell nature science shell orange apple bell block square meta oracle mint target gap next box slack zoom stripe uber lyft ring nest arm hp ge box tesla sky sun star time life people vice wired verge edge chrome safari brave signal telegram discord slack notion linear figma canva medium substack ghost dash zapier make render fly neon turso tigris planet scale cockroach confluent elastic redis mongo mongodb vercel netlify heroku render railway supabase firebase play store market cloud one plus max pro air mini studio watch music tv news post times globe mail star sun herald standard journal press daily weekly review register wire record hill point line frontier alliance alpha beta gamma delta omega origin echo nova atlas titan vector prism pulse radar forge anchor arc arrow beam bolt bond bridge canvas circle core crest crown drift ember flare flow fuse glow grid halo haven horizon iris jet key lift loop lumen mesh nexus node oasis orbit peak pillar pivot quest realm relay ridge rise river rock root sage scope shift spark sphere spire stone stream summit surge swift tide torch trace trail unity vault venture verge vista wave zenith bank trust fund capital global national international federal united american canadian european royal general standard central pacific atlantic western eastern northern southern".split(/\s+/), | |
| 25 | +); | |
| 26 | + | |
| 17 | 27 | async function loadIndex(): Promise<AliasIndex> { |
| 18 | 28 | if (index && Date.now() - index.loadedAt < 5 * 60_000) return index; |
| 19 | 29 | const rows = await db.select({ alias: entityAliases.alias, entityId: entityAliases.entityId }).from(entityAliases); |
| 20 | 30 | const ents = await db.select({ id: entities.id, importance: entities.importance }).from(entities); |
| 21 | 31 | const byLen = rows |
| 22 | 32 | .filter((r) => r.alias.length >= 3 && !/^\d+$/.test(r.alias)) |
| 23 | − .map((r) => ({ alias: r.alias, entityId: r.entityId, re: new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(r.alias)}(?=$|[^\\p{L}\\p{N}])`, "iu") })) | |
| 33 | + .map((r) => { | |
| 34 | + const ambiguous = AMBIGUOUS_ALIASES.has(r.alias) || (r.alias.length <= 3 && !/[.-]/.test(r.alias)); | |
| 35 | + // Ambiguous / very short aliases: case-sensitive upper-case acronym match only (WHO, FIRST, HAS as acronym). | |
| 36 | + const re = ambiguous ? new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(r.alias.toUpperCase())}(?=$|[^\\p{L}\\p{N}])`, "u") : new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(r.alias)}(?=$|[^\\p{L}\\p{N}])`, "iu"); | |
| 37 | + return { alias: r.alias, entityId: r.entityId, re, ambiguous }; | |
| 38 | + }) | |
| 24 | 39 | .sort((a, b) => b.alias.length - a.alias.length); |
| 25 | 40 | index = { loadedAt: Date.now(), byLen, importance: new Map(ents.map((e) => [e.id, e.importance])) }; |
| 26 | 41 | log.debug({ aliases: byLen.length }, "alias index loaded"); |
@@ -47,6 +62,7 @@ export async function resolveEntities(input: { sourceId: string; text: string; h | ||
| 47 | 62 | if (mentioned.size >= 12) break; |
| 48 | 63 | if (orgSubjects.includes(a.entityId)) continue; |
| 49 | 64 | if (!hayLower.includes(a.alias)) continue; |
| 65 | + if (a.ambiguous && !subject.includes(a.entityId) && !a.re.test(hay)) continue; // needs the exact acronym form | |
| 50 | 66 | if (a.re.test(hay)) mentioned.add(a.entityId); |
| 51 | 67 | } |
| 52 | 68 | // Products of the source named in the text are subjects too. |
@@ -56,7 +72,67 @@ export async function resolveEntities(input: { sourceId: string; text: string; h | ||
| 56 | 72 | return { subject: orgSubjects, mentioned: [...mentioned], importance }; |
| 57 | 73 | } |
| 58 | 74 | |
| 59 | −export async function bumpEntityCounters(entityIds: string[], at: Date): Promise<void> { | |
| 75 | +/** | |
| 76 | + * Maintenance: re-resolve "mentioned" entity links for recent events with the current alias rules | |
| 77 | + * (drops links created by ambiguous aliases). Subjects (source entities) are never touched. | |
| 78 | + */ | |
| 79 | +export async function relinkMentionedEntities(days = 7): Promise<{ events: number; removed: number; added: number }> { | |
| 80 | + invalidateEntityIndex(); | |
| 81 | + const idx = await loadIndex(); | |
| 82 | + const rows = await db.execute<{ id: string; source_id: string; title: string; summary: string; keywords: string[] | null }>(sql`select id, source_id, title, summary, keywords from events where detected_at >= now() - make_interval(days => ${days}) order by detected_at desc`); | |
| 83 | + let removed = 0; | |
| 84 | + let added = 0; | |
| 85 | + const subjectsBySource = new Map<string, string[]>(); | |
| 86 | + for (const ev of rows.rows) { | |
| 87 | + let subs = subjectsBySource.get(ev.source_id); | |
| 88 | + if (!subs) { | |
| 89 | + subs = (await db.select({ entityId: sourceEntities.entityId }).from(sourceEntities).where(eq(sourceEntities.sourceId, ev.source_id))).map((r) => r.entityId); | |
| 90 | + subjectsBySource.set(ev.source_id, subs); | |
| 91 | + } | |
| 92 | + const hay = `${ev.title}\n${ev.summary}\n${(ev.keywords ?? []).join("\n")}`; | |
| 93 | + const hayLower = hay.toLowerCase(); | |
| 94 | + const want = new Set<string>(); | |
| 95 | + for (const a of idx.byLen) { | |
| 96 | + if (want.size >= 12) break; | |
| 97 | + if (subs.some((s) => s.startsWith("org_") && s === a.entityId)) continue; | |
| 98 | + if (!hayLower.includes(a.alias)) continue; | |
| 99 | + if (a.ambiguous && !subs.includes(a.entityId) && !a.re.test(hay)) continue; | |
| 100 | + if (a.re.test(hay)) want.add(a.entityId); | |
| 101 | + } | |
| 102 | + const current = (await db.execute<{ entity_id: string; role: string }>(sql`select entity_id, role from event_entities where event_id = ${ev.id}`)).rows; | |
| 103 | + for (const c of current) { | |
| 104 | + if (c.role === "mentioned" && !want.has(c.entity_id) && !subs.includes(c.entity_id)) { | |
| 105 | + await db.execute(sql`delete from event_entities where event_id = ${ev.id} and entity_id = ${c.entity_id}`); | |
| 106 | + removed++; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + for (const w of want) { | |
| 110 | + if (!current.some((c) => c.entity_id === w)) { | |
| 111 | + await db.execute(sql`insert into event_entities (event_id, entity_id, role) values (${ev.id}, ${w}, 'mentioned') on conflict do nothing`); | |
| 112 | + added++; | |
| 113 | + } | |
| 114 | + } | |
| 115 | + } | |
| 116 | + // Recount entity totals + daily table from the corrected links. | |
| 117 | + await db.execute(sql`update entities en set event_count = (select count(*) from event_entities ee where ee.entity_id = en.id), last_event_at = (select max(e.detected_at) from events e join event_entities ee on ee.event_id = e.id where ee.entity_id = en.id)`); | |
| 118 | + await db.execute(sql`delete from entity_daily where day >= (now() at time zone 'UTC')::date - ${sql.raw(String(Math.max(1, Math.floor(days))))}`); | |
| 119 | + await db.execute(sql`insert into entity_daily (entity_id, day, events, silent, breaking, max_importance) | |
| 120 | + select ee.entity_id, (e.detected_at at time zone 'UTC')::date, count(*), sum(case when e.silent_change then 1 else 0 end), sum(case when e.importance >= 80 then 1 else 0 end), max(e.importance) | |
| 121 | + from events e join event_entities ee on ee.event_id = e.id where e.detected_at >= now() - make_interval(days => ${days}) group by 1, 2 | |
| 122 | + on conflict (entity_id, day) do update set events = excluded.events, silent = excluded.silent, breaking = excluded.breaking, max_importance = excluded.max_importance`); | |
| 123 | + log.info({ events: rows.rows.length, removed, added }, "mentioned entity links re-resolved"); | |
| 124 | + return { events: rows.rows.length, removed, added }; | |
| 125 | +} | |
| 126 | + | |
| 127 | +export async function bumpEntityCounters(entityIds: string[], at: Date, opts: { silent?: boolean; importance?: number } = {}): Promise<void> { | |
| 60 | 128 | if (!entityIds.length) return; |
| 61 | 129 | await db.execute(sql`update entities set event_count = event_count + 1, last_event_at = ${at} where id = any(${textArray(entityIds)})`); |
| 130 | + const imp = Number(opts.importance ?? 0); | |
| 131 | + await db | |
| 132 | + .execute( | |
| 133 | + sql`insert into entity_daily (entity_id, day, events, silent, breaking, max_importance) | |
| 134 | + select unnest(${textArray(entityIds)}), (${at} at time zone 'UTC')::date, 1, ${opts.silent ? 1 : 0}, ${imp >= 80 ? 1 : 0}, ${imp} | |
| 135 | + on conflict (entity_id, day) do update set events = entity_daily.events + 1, silent = entity_daily.silent + ${opts.silent ? 1 : 0}, breaking = entity_daily.breaking + ${imp >= 80 ? 1 : 0}, max_importance = greatest(entity_daily.max_importance, ${imp})`, | |
| 136 | + ) | |
| 137 | + .catch((e) => log.warn({ err: (e as Error).message }, "entity_daily update failed")); | |
| 62 | 138 | } |
modified
apps/engine/src/index.ts
+8 −1
@@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { closeDb, migrate } from "@websensor/db"; |
| 2 | 2 | import { closeDispatcher } from "@websensor/connectors"; |
| 3 | −import { loadRecent } from "./cluster"; | |
| 3 | +import { loadRecent, refreshClusterStates } from "./cluster"; | |
| 4 | 4 | import { config, log } from "./config"; |
| 5 | 5 | import { startMetricsServer } from "./metrics"; |
| 6 | 6 | import { closeRedis } from "./redis"; |
| 7 | 7 | import { runDiscovery, syncRegistry } from "./registry"; |
| 8 | +import { pruneNotifications, pruneRawSnapshots } from "./retention"; | |
| 8 | 9 | import { pruneOldRuns, rollupConnectorHealth, Scheduler } from "./scheduler"; |
| 9 | 10 | |
| 10 | 11 | async function main(): Promise<void> { |
@@ -21,6 +22,12 @@ async function main(): Promise<void> { | ||
| 21 | 22 | const timers: NodeJS.Timeout[] = []; |
| 22 | 23 | timers.push(setInterval(() => rollupConnectorHealth().catch((e) => log.warn({ err: (e as Error).message }, "health rollup failed")), 60_000)); |
| 23 | 24 | timers.push(setInterval(() => pruneOldRuns().catch(() => undefined), 6 * 3600e3)); |
| 25 | + timers.push(setInterval(() => refreshClusterStates().catch((e) => log.warn({ err: (e as Error).message }, "cluster state refresh failed")), 5 * 60_000)); | |
| 26 | + if (config.retention.enabled) { | |
| 27 | + timers.push(setInterval(() => pruneRawSnapshots().catch((e) => log.warn({ err: (e as Error).message }, "retention failed")), 30 * 60_000)); | |
| 28 | + timers.push(setInterval(() => pruneNotifications().catch(() => undefined), 24 * 3600e3)); | |
| 29 | + setTimeout(() => pruneRawSnapshots().catch(() => undefined), 60_000); | |
| 30 | + } | |
| 24 | 31 | await rollupConnectorHealth().catch(() => undefined); |
| 25 | 32 | |
| 26 | 33 | if (config.discovery.enabled) { |
modified
apps/engine/src/metrics.ts
+20 −0
@@ -18,8 +18,28 @@ export const m = { | ||
| 18 | 18 | queueDue: new client.Gauge({ name: "websensor_sensors_due", help: "Sensors due for a check", registers: [registry] }), |
| 19 | 19 | inflight: new client.Gauge({ name: "websensor_inflight_checks", help: "Checks in flight", registers: [registry] }), |
| 20 | 20 | httpStatus: new client.Counter({ name: "websensor_http_status_total", help: "HTTP status codes", labelNames: ["status"], registers: [registry] }), |
| 21 | + alertsFired: new client.Counter({ name: "websensor_alerts_fired_total", help: "Alert rules matched", labelNames: ["channel"], registers: [registry] }), | |
| 22 | + webhookDeliveries: new client.Counter({ name: "websensor_webhook_deliveries_total", help: "Webhook deliveries", labelNames: ["ok"], registers: [registry] }), | |
| 23 | + changeClass: new client.Counter({ name: "websensor_change_class_total", help: "Semantic class of raw changes", labelNames: ["class"], registers: [registry] }), | |
| 24 | + suppressed: new client.Counter({ name: "websensor_suppressed_total", help: "Candidates suppressed before becoming events", labelNames: ["reason"], registers: [registry] }), | |
| 25 | + stageLatency: new client.Histogram({ name: "websensor_stage_seconds", help: "Pipeline stage latency", labelNames: ["stage"], buckets: [0.005, 0.02, 0.05, 0.1, 0.25, 0.5, 1, 2, 5], registers: [registry] }), | |
| 26 | + prunedBlobs: new client.Counter({ name: "websensor_pruned_blobs_total", help: "Raw snapshot bodies pruned by retention", registers: [registry] }), | |
| 21 | 27 | }; |
| 22 | 28 | |
| 29 | +/** Per-source daily counters (source quality, heatmaps). */ | |
| 30 | +export async function bumpSourceDaily(sourceId: string, fields: Partial<Record<"checks" | "not_modified" | "errors" | "raw_changes" | "events", number>>): Promise<void> { | |
| 31 | + const keys = Object.keys(fields) as (keyof typeof fields)[]; | |
| 32 | + if (!keys.length) return; | |
| 33 | + const sets = keys.map((k) => sql.raw(`${k} = source_daily.${k} + ${Number(fields[k] ?? 0)}`)); | |
| 34 | + const cols = keys.map((k) => sql.raw(k)); | |
| 35 | + const vals = keys.map((k) => sql`${Number(fields[k] ?? 0)}`); | |
| 36 | + try { | |
| 37 | + await db.execute(sql`insert into source_daily (source_id, day, ${sql.join(cols, sql`, `)}) values (${sourceId}, (now() at time zone 'UTC')::date, ${sql.join(vals, sql`, `)}) on conflict (source_id, day) do update set ${sql.join(sets, sql`, `)}`); | |
| 38 | + } catch (e) { | |
| 39 | + log.warn({ err: (e as Error).message }, "source_daily update failed"); | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 23 | 43 | /** Daily counters in Postgres (feed the public homepage stats). */ |
| 24 | 44 | export async function bumpDaily(fields: Partial<Record<"checks" | "not_modified" | "bytes" | "raw_changes" | "events" | "silent_events" | "errors" | "llm_calls" | "llm_input_tokens" | "llm_output_tokens" | "scrapfly_calls", number>>): Promise<void> { |
| 25 | 45 | const keys = Object.keys(fields) as (keyof typeof fields)[]; |
modified
apps/engine/src/pipeline.ts
+142 −30
@@ -1,12 +1,13 @@ | ||
| 1 | −import { computeConfidence, computeImportance, describeChange, diffIsEmpty, diffJson, diffList, diffText, evaluateChange, eventTypeSpec, newId, nextIntervalSeconds, slugify, sourceImportanceFromTier, summarizeDiff, type DiffResult, type HeuristicResult, type NormalizedContent, type Observation, type SensorEndpoint, type Tier, FEED_CHANNELS } from "@websensor/core"; | |
| 1 | +import { activityAnomaly, classifyChange, computeConfidence, computeImpact, computeImportance, computeSignalScore, describeChange, describeFieldChanges, diffIsEmpty, diffJson, diffList, diffText, evaluateChange, eventGroupOf, eventTypeSpec, newId, nextIntervalSeconds, NOISE_CLASSES, sha256, SILENT_ELIGIBLE_TYPES, slugify, sourceImportanceFromTier, summarizeDiff, type DiffResult, type HeuristicResult, type NormalizedContent, type Observation, type SemanticResult, type SensorEndpoint, type Tier, FEED_CHANNELS } from "@websensor/core"; | |
| 2 | 2 | import { getConnector, NormalizeError, PARSER_VERSION, scrapflyAvailable, scrapflyFetch } from "@websensor/connectors"; |
| 3 | 3 | import { changes, db, eventEntities, events, interpretations, sensorRuns, sensors, snapshots, sql, textArray, type Sensor, type Source } from "@websensor/db"; |
| 4 | 4 | import { getBlobStore } from "@websensor/store"; |
| 5 | +import { evaluateAlerts, type PublishedEvent } from "./alerts"; | |
| 5 | 6 | import { assessNovelty, clusterEvent, recentAnnouncementSimilarity } from "./cluster"; |
| 6 | 7 | import { config, log } from "./config"; |
| 7 | 8 | import { bumpEntityCounters, resolveEntities } from "./entities"; |
| 8 | 9 | import { interpretChange, llmAvailable, renderDiff, type Interpretation } from "./interpret"; |
| 9 | −import { bumpDaily, m } from "./metrics"; | |
| 10 | +import { bumpDaily, bumpSourceDaily, m } from "./metrics"; | |
| 10 | 11 | import { publishChange, publishEvent } from "./redis"; |
| 11 | 12 | |
| 12 | 13 | export type RunOutcome = "baseline" | "unchanged" | "not_modified" | "changed" | "event" | "error" | "missing" | "rate_limited" | "parse_error"; |
@@ -53,6 +54,7 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 53 | 54 | const isErr = outcome === "error" || outcome === "parse_error" || outcome === "rate_limited"; |
| 54 | 55 | const consecutive = isErr ? sensor.consecutiveErrors + 1 : 0; |
| 55 | 56 | const health = outcome === "rate_limited" ? "RATE_LIMITED" : isErr ? (consecutive >= 5 ? "ERROR" : "DEGRADED") : outcome === "missing" ? "DEGRADED" : "UP"; |
| 57 | + const status = !sensor.enabled ? "DISABLED" : health === "ERROR" || health === "RATE_LIMITED" ? "DEGRADED" : "ACTIVE"; | |
| 56 | 58 | const changes7d = await countChanges7d(sensor.id); |
| 57 | 59 | const events7d = await countEvents7d(sensor.id); |
| 58 | 60 | let interval = nextIntervalSeconds({ tier: sensor.tier as Tier, baseIntervalSeconds: sensor.baseIntervalSeconds, lastChangeAt: extra.changed ? new Date() : sensor.lastChangeAt, changes7d, events7d, consecutiveErrors: consecutive, lastWas304: outcome === "not_modified" }); |
@@ -67,6 +69,7 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 67 | 69 | lastError: isErr || outcome === "missing" ? (extra.error ?? obs.error?.message ?? `HTTP ${obs.meta.status}`).slice(0, 500) : null, |
| 68 | 70 | consecutiveErrors: consecutive, |
| 69 | 71 | health, |
| 72 | + status, | |
| 70 | 73 | totalRuns: sensor.totalRuns + 1, |
| 71 | 74 | totalNotModified: sensor.totalNotModified + (outcome === "not_modified" ? 1 : 0), |
| 72 | 75 | avgLatencyMs: avg, |
@@ -74,11 +77,13 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 74 | 77 | updatedAt: new Date(), |
| 75 | 78 | ...(extra.changed ? { lastChangeAt: new Date() } : {}), |
| 76 | 79 | ...(extra.eventAt ? { lastEventAt: extra.eventAt } : {}), |
| 80 | + ...(!sensor.validatedAt && !isErr && outcome !== "missing" ? { validatedAt: new Date() } : {}), | |
| 77 | 81 | ...(obs.meta.status >= 200 && obs.meta.status < 300 ? { etag: obs.meta.etag ?? sensor.etag, lastModified: obs.meta.lastModified ?? sensor.lastModified } : {}), |
| 78 | 82 | }) |
| 79 | 83 | .where(sql`id = ${sensor.id}`); |
| 80 | 84 | m.checks.inc({ connector: sensor.connector, outcome }); |
| 81 | 85 | await bumpDaily({ checks: 1, bytes: obs.meta.contentLength, not_modified: outcome === "not_modified" ? 1 : 0, errors: isErr ? 1 : 0 }); |
| 86 | + await bumpSourceDaily(source.id, { checks: 1, not_modified: outcome === "not_modified" ? 1 : 0, errors: isErr ? 1 : 0 }); | |
| 82 | 87 | return outcome; |
| 83 | 88 | }; |
| 84 | 89 | |
@@ -96,12 +101,15 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 96 | 101 | |
| 97 | 102 | // ---- Normalize -------------------------------------------------------------------- |
| 98 | 103 | let norm: NormalizedContent; |
| 104 | + const tNorm = m.stageLatency.startTimer({ stage: "normalize" }); | |
| 99 | 105 | try { |
| 100 | 106 | norm = await connector.normalize(endpoint, obs); |
| 101 | 107 | } catch (e) { |
| 108 | + tNorm(); | |
| 102 | 109 | const msg = e instanceof NormalizeError ? `${e.code}: ${e.message}` : (e as Error).message; |
| 103 | 110 | return finish("parse_error", { error: msg }); |
| 104 | 111 | } |
| 112 | + tNorm(); | |
| 105 | 113 | if (norm.state) await db.update(sensors).set({ state: norm.state }).where(sql`id = ${sensor.id}`); |
| 106 | 114 | |
| 107 | 115 | // Restore: page was flagged missing but is back. |
@@ -151,13 +159,17 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 151 | 159 | if (!prev) return finish("baseline", { snapshotId: snapId }); |
| 152 | 160 | |
| 153 | 161 | // ---- Diff ----------------------------------------------------------------------------- |
| 162 | + const tDiff = m.stageLatency.startTimer({ stage: "diff" }); | |
| 154 | 163 | let prevBlob: CanonicalBlob | null = null; |
| 155 | 164 | try { |
| 156 | 165 | prevBlob = prev.canonicalStorageKey ? (JSON.parse(await store.getText(prev.canonicalStorageKey)) as CanonicalBlob) : null; |
| 157 | 166 | } catch (e) { |
| 158 | 167 | log.warn({ sensor: sensor.id, err: (e as Error).message }, "previous canonical blob unreadable"); |
| 159 | 168 | } |
| 160 | − if (!prevBlob || prevBlob.mode !== norm.mode) return finish("changed", { snapshotId: snapId, changed: true }); | |
| 169 | + if (!prevBlob || prevBlob.mode !== norm.mode) { | |
| 170 | + tDiff(); | |
| 171 | + return finish("changed", { snapshotId: snapId, changed: true }); | |
| 172 | + } | |
| 161 | 173 | |
| 162 | 174 | let diff: DiffResult; |
| 163 | 175 | if (norm.mode === "list") { |
@@ -184,45 +196,88 @@ export async function runSensor(sensor: Sensor, source: Source): Promise<RunOutc | ||
| 184 | 196 | } else { |
| 185 | 197 | diff = diffText(prevBlob.text ?? "", norm.text ?? "", `${sensor.id}@${prev.capturedAt.toISOString()}`, `${sensor.id}@${obs.fetchedAt.toISOString()}`); |
| 186 | 198 | } |
| 199 | + tDiff(); | |
| 187 | 200 | if (diffIsEmpty(diff)) return finish("unchanged", { snapshotId: snapId }); |
| 188 | 201 | |
| 202 | + // ---- Heuristics + semantic classification ------------------------------------------------ | |
| 203 | + const tCls = m.stageLatency.startTimer({ stage: "classify" }); | |
| 189 | 204 | const heuristic = evaluateChange(diff, { sensorType: sensor.type, url: sensor.url, sourceCategories: source.categories, title: norm.title }); |
| 205 | + const semantic = classifyChange(diff, { url: sensor.url, sensorType: sensor.type, title: norm.title }); | |
| 206 | + tCls(); | |
| 207 | + m.changeClass.inc({ class: semantic.class }); | |
| 190 | 208 | const thinFlip = (prevBlob.extractionConfidence ?? 1) < 0.5 || norm.extractionConfidence < 0.5; |
| 191 | − const meaningfulByRules = heuristic.signal >= config.meaningfulSignal && !thinFlip; | |
| 209 | + const isNoise = NOISE_CLASSES.has(semantic.class) && !heuristic.facts.some((f) => f.kind === "price" && f.before && f.after); | |
| 210 | + const meaningfulByRules = heuristic.signal >= config.meaningfulSignal && !thinFlip && !isNoise; | |
| 192 | 211 | const changeId = newId("chg"); |
| 193 | 212 | const diffBlob = await store.put(diff.kind === "text" ? diff.unified : renderDiff(diff)); |
| 194 | − await db.insert(changes).values({ id: changeId, sensorId: sensor.id, oldSnapshotId: prev.id, newSnapshotId: snapId, detectedAt: obs.fetchedAt, kind: diff.kind, diff: summarizeDiff(diff), diffStorageKey: diffBlob.key, signal: heuristic.signal, noiseRatio: heuristic.noiseRatio, magnitude: heuristic.magnitude, heuristic: heuristic as unknown as Record<string, unknown>, meaningful: false }); | |
| 213 | + await db.insert(changes).values({ id: changeId, sensorId: sensor.id, oldSnapshotId: prev.id, newSnapshotId: snapId, detectedAt: obs.fetchedAt, kind: diff.kind, diff: summarizeDiff(diff), diffStorageKey: diffBlob.key, signal: heuristic.signal, noiseRatio: Math.max(heuristic.noiseRatio, semantic.noiseRatio), magnitude: heuristic.magnitude, heuristic: { ...(heuristic as unknown as Record<string, unknown>), semantic: { class: semantic.class, confidence: semantic.confidence, reasons: semantic.reasons } }, meaningful: false, changeClass: semantic.class, fieldChanges: semantic.fieldChanges.length ? semantic.fieldChanges : null }); | |
| 195 | 214 | await db.update(sensors).set({ rawChanges: sensor.rawChanges + 1 }).where(sql`id = ${sensor.id}`); |
| 196 | 215 | await db.execute(sql`insert into url_history (url, at, kind, snapshot_id, change_id) values (${sensor.url}, ${obs.fetchedAt}, 'change', ${snapId}, ${changeId})`); |
| 197 | 216 | await db.execute(sql`update urls set change_count = change_count + 1 where url = ${sensor.url}`); |
| 198 | 217 | await bumpDaily({ raw_changes: 1 }); |
| 218 | + await bumpSourceDaily(source.id, { raw_changes: 1 }); | |
| 199 | 219 | m.changes.inc({ connector: sensor.connector, meaningful: String(meaningfulByRules) }); |
| 200 | − await publishChange({ id: changeId, sensorId: sensor.id, sourceId: source.id, kind: diff.kind, signal: heuristic.signal, at: obs.fetchedAt.toISOString() }); | |
| 201 | − log.info({ sensor: sensor.id, kind: diff.kind, signal: heuristic.signal.toFixed(2), type: heuristic.eventType, thin: thinFlip }, "change detected"); | |
| 220 | + await publishChange({ id: changeId, sensorId: sensor.id, sourceId: source.id, kind: diff.kind, signal: heuristic.signal, class: semantic.class, at: obs.fetchedAt.toISOString() }); | |
| 221 | + log.info({ sensor: sensor.id, kind: diff.kind, signal: heuristic.signal.toFixed(2), type: heuristic.eventType, class: semantic.class, thin: thinFlip }, "change detected"); | |
| 202 | 222 | |
| 203 | − if (!meaningfulByRules) return finish("changed", { snapshotId: snapId, changed: true }); | |
| 223 | + if (!meaningfulByRules) { | |
| 224 | + if (isNoise) m.suppressed.inc({ reason: `noise_${semantic.class}` }); | |
| 225 | + return finish("changed", { snapshotId: snapId, changed: true }); | |
| 226 | + } | |
| 204 | 227 | |
| 205 | 228 | // ---- Event -------------------------------------------------------------------------- |
| 206 | − const ev = await createEvent({ sensor, source, obs, norm, prev, snapId, changeId, diff, heuristic }); | |
| 229 | + const ev = await createEvent({ sensor, source, obs, norm, prev, snapId, changeId, diff, heuristic, semantic }); | |
| 207 | 230 | if (!ev) return finish("changed", { snapshotId: snapId, changed: true }); |
| 208 | 231 | return finish("event", { snapshotId: snapId, changed: true, eventAt: ev.detectedAt }); |
| 209 | 232 | } |
| 210 | 233 | |
| 211 | −async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observation; norm: NormalizedContent; prev: { id: string; capturedAt: Date }; snapId: string; changeId: string; diff: DiffResult; heuristic: HeuristicResult }): Promise<{ id: string; detectedAt: Date } | null> { | |
| 212 | − const { sensor, source, obs, norm, prev, snapId, changeId, diff, heuristic } = ctx; | |
| 234 | +async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observation; norm: NormalizedContent; prev: { id: string; capturedAt: Date; canonicalHash: string }; snapId: string; changeId: string; diff: DiffResult; heuristic: HeuristicResult; semantic: SemanticResult }): Promise<{ id: string; detectedAt: Date } | null> { | |
| 235 | + const { sensor, source, obs, norm, prev, snapId, changeId, diff, heuristic, semantic } = ctx; | |
| 213 | 236 | const detectedAt = obs.fetchedAt; |
| 237 | + const tEv = m.stageLatency.startTimer({ stage: "event" }); | |
| 238 | + | |
| 239 | + // Idempotency (spec §90): the same observation (same sensor, same before/after canonical content) is one event. | |
| 240 | + const fingerprint = sha256(`${sensor.id}|${prev.canonicalHash}|${norm.canonicalHash}`); | |
| 241 | + const dup = await db.execute<{ id: string }>(sql`select id from events where fingerprint = ${fingerprint} limit 1`); | |
| 242 | + if (dup.rows[0]) { | |
| 243 | + m.suppressed.inc({ reason: "fingerprint_duplicate" }); | |
| 244 | + log.info({ sensor: sensor.id, existing: dup.rows[0].id }, "duplicate observation — event already exists"); | |
| 245 | + tEv(); | |
| 246 | + return null; | |
| 247 | + } | |
| 248 | + | |
| 214 | 249 | const base = describeChange(heuristic, diff, { sourceName: source.name, url: sensor.url, sensorName: sensor.name }); |
| 250 | + if (semantic.fieldChanges.length && diff.kind !== "list" && !heuristic.facts.some((f) => f.kind === "price" && f.before && f.after)) { | |
| 251 | + base.summary = `${describeFieldChanges(semantic.fieldChanges, 4)}. ${base.summary}`.slice(0, 1500); | |
| 252 | + } | |
| 215 | 253 | const textForMatching = `${base.title}\n${base.summary}\n${renderDiff(diff).slice(0, 4000)}`; |
| 216 | 254 | |
| 217 | 255 | const ent = await resolveEntities({ sourceId: source.id, text: textForMatching, hints: heuristic.keywords }); |
| 218 | 256 | const nov = await assessNovelty(`${base.title}\n${base.summary}`, source.id); |
| 219 | 257 | const sourceImportance = sourceImportanceFromTier(sensor.tier, source.importanceWeight * sensor.importanceWeight); |
| 258 | + const firstParty = source.firstParty !== false; | |
| 259 | + const anomaly = await sourceAnomaly(source.id); | |
| 220 | 260 | let eventType = heuristic.eventType; |
| 221 | − let prelim = computeImportance({ eventType, sourceImportance, entityImportance: ent.importance, novelty: nov.novelty, magnitude: heuristic.magnitude, confirmations: nov.confirmations }); | |
| 261 | + let prelim = computeImportance({ eventType, sourceImportance, entityImportance: ent.importance, novelty: nov.novelty, magnitude: heuristic.magnitude, confirmations: nov.confirmations, unusualness: anomaly }); | |
| 262 | + // Routine batches (spec §13, §55): "21 new CVEs" from a firehose that fires several times a day is normal | |
| 263 | + // operation, not a signal. Damp importance for high-volume list sensors; single items keep full weight. | |
| 264 | + const routineBatch = diff.kind === "list" && diff.added.length >= 8 && (await countEvents24h(sensor.id)) >= 3; | |
| 265 | + if (routineBatch) { | |
| 266 | + prelim.score = Math.round(prelim.score * 0.72 * 10) / 10; | |
| 267 | + prelim.components.novelty = Math.min(prelim.components.novelty, 35); | |
| 268 | + } | |
| 222 | 269 | |
| 223 | 270 | // Duplicate suppression: near-identical to something we already published (syndication / re-fetch). |
| 224 | 271 | if (nov.novelty < 12 && nov.nearest) { |
| 272 | + m.suppressed.inc({ reason: "near_duplicate" }); | |
| 225 | 273 | log.info({ sensor: sensor.id, nearest: nov.nearest.id, sim: nov.nearest.similarity }, "suppressed near-duplicate event"); |
| 274 | + tEv(); | |
| 275 | + return null; | |
| 276 | + } | |
| 277 | + // Third-party reports of a story we already hold from several sources add little: raise the bar. | |
| 278 | + if (!firstParty && nov.novelty < 30 && nov.confirmations >= 3 && prelim.score < 70) { | |
| 279 | + m.suppressed.inc({ reason: "redundant_external" }); | |
| 280 | + tEv(); | |
| 226 | 281 | return null; |
| 227 | 282 | } |
| 228 | 283 | |
@@ -230,14 +285,16 @@ async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observati | ||
| 230 | 285 | if (llmAvailable() && source.llmEnabled && prelim.score >= config.llm.minImportance) { |
| 231 | 286 | llm = await interpretChange({ sourceName: source.name, sourceCategories: source.categories, url: sensor.url, sensorName: sensor.name, sensorType: sensor.type, heuristic, diff, prelimImportance: prelim.score, title: norm.title }); |
| 232 | 287 | if (llm && !llm.meaningful) { |
| 288 | + m.suppressed.inc({ reason: "llm_not_meaningful" }); | |
| 233 | 289 | log.info({ sensor: sensor.id, type: llm.event_type }, "LLM judged change not meaningful"); |
| 234 | − await db.update(changes).set({ heuristic: { ...(heuristic as unknown as Record<string, unknown>), llm: { meaningful: false, model: llm.model, title: llm.title } } }).where(sql`id = ${changeId}`); | |
| 290 | + await db.update(changes).set({ heuristic: { ...(heuristic as unknown as Record<string, unknown>), semantic: { class: semantic.class, confidence: semantic.confidence }, llm: { meaningful: false, model: llm.model, title: llm.title } } }).where(sql`id = ${changeId}`); | |
| 291 | + tEv(); | |
| 235 | 292 | return null; |
| 236 | 293 | } |
| 237 | 294 | if (llm) { |
| 238 | 295 | eventType = llm.event_type; |
| 239 | 296 | const spec = eventTypeSpec(eventType); |
| 240 | − prelim = computeImportance({ eventType, sourceImportance, entityImportance: ent.importance, novelty: nov.novelty, magnitude: heuristic.magnitude, confirmations: nov.confirmations }); | |
| 297 | + prelim = computeImportance({ eventType, sourceImportance, entityImportance: ent.importance, novelty: nov.novelty, magnitude: heuristic.magnitude, confirmations: nov.confirmations, unusualness: anomaly }); | |
| 241 | 298 | prelim.components.severity = Math.round((spec.severity + llm.severity) / 2); |
| 242 | 299 | prelim.score = Math.round(10 * (0.25 * prelim.components.severity + 0.2 * prelim.components.source + 0.15 * prelim.components.entity + 0.15 * prelim.components.novelty + 0.1 * prelim.components.magnitude + 0.05 * prelim.components.confirmation + 0.05 * prelim.components.userImpact + 0.05 * prelim.components.unusualness)) / 10; |
| 243 | 300 | } |
@@ -248,16 +305,17 @@ async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observati | ||
| 248 | 305 | const spec = eventTypeSpec(eventType); |
| 249 | 306 | const isAnnouncementSensor = ANNOUNCEMENT_SENSORS.has(sensor.type); |
| 250 | 307 | const announcedSim = isAnnouncementSensor ? 1 : recentAnnouncementSimilarity(source.id, `${title}\n${summary}`, 12 * 3600e3); |
| 251 | − const silentChange = !isAnnouncementSensor && (llm ? llm.announced === false : !spec.usuallyAnnounced) && announcedSim < 0.3 && prelim.score >= 35; | |
| 308 | + // Silent-change bar (spec §23): first-party, silent-eligible type, no matching announcement, real content, importance ≥ bar. | |
| 309 | + const silentChange = firstParty && !isAnnouncementSensor && SILENT_ELIGIBLE_TYPES.has(eventType) && (llm ? llm.announced === false : !spec.usuallyAnnounced) && announcedSim < 0.3 && prelim.score >= config.silentMinImportance && !NOISE_CLASSES.has(semantic.class); | |
| 252 | 310 | |
| 253 | 311 | // Entities named by the LLM that resolve to known aliases |
| 254 | 312 | const extra = llm ? await resolveEntities({ sourceId: source.id, text: llm.entities.join("\n"), hints: [] }) : null; |
| 255 | 313 | const entityIds = [...new Set([...ent.subject, ...ent.mentioned, ...(extra?.mentioned ?? [])])]; |
| 256 | 314 | |
| 257 | 315 | const confidence = computeConfidence({ |
| 258 | − sourceAuthenticity: 1, | |
| 316 | + sourceAuthenticity: firstParty ? 1 : 0.6, | |
| 259 | 317 | extraction: norm.extractionConfidence, |
| 260 | − diffClarity: 1 - heuristic.noiseRatio, | |
| 318 | + diffClarity: 1 - Math.max(heuristic.noiseRatio, semantic.noiseRatio), | |
| 261 | 319 | structured: diff.kind !== "text", |
| 262 | 320 | confirmations: nov.confirmations, |
| 263 | 321 | llmAgreement: llm ? (llm.event_type === heuristic.eventType ? 1 : 0.6) * (llm.confidence / 100) : heuristic.signal, |
@@ -265,11 +323,15 @@ async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observati | ||
| 265 | 323 | const evidenceLabel = nov.confirmations > 0 ? "CONFIRMED" : llm && llm.inferred && llm.confidence < 60 ? "INFERRED" : !llm && heuristic.signal < 0.5 ? "UNCONFIRMED" : "OBSERVED"; |
| 266 | 324 | const categories = [...new Set([...source.categories, ...Object.entries(FEED_CHANNELS).filter(([, cats]) => cats.some((c) => source.categories.includes(c))).map(([ch]) => ch)])]; |
| 267 | 325 | const keywords = [...new Set([...(llm?.keywords ?? []), ...heuristic.keywords])].slice(0, 20); |
| 326 | + const maxDelta = semantic.fieldChanges.reduce((mx, f) => Math.max(mx, Math.abs(f.deltaPct ?? 0)), 0); | |
| 327 | + const impact = computeImpact({ eventType, magnitude: heuristic.magnitude, entityImportance: ent.importance, maxDeltaPct: maxDelta, fieldChanges: semantic.fieldChanges.length, firstParty }); | |
| 268 | 328 | const id = newId("evt"); |
| 269 | 329 | const slug = `${slugify(title).slice(0, 70)}-${id.slice(-6)}`; |
| 270 | 330 | const processedAt = new Date(); |
| 271 | 331 | const publishedAt = pickPublishedAt(diff, norm); |
| 272 | 332 | const observedFrom = prev.capturedAt; |
| 333 | + const country = source.country ?? null; | |
| 334 | + const language = source.language ?? "en"; | |
| 273 | 335 | |
| 274 | 336 | await db.insert(events).values({ |
| 275 | 337 | id, |
@@ -280,6 +342,7 @@ async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observati | ||
| 280 | 342 | oldSnapshotId: prev.id, |
| 281 | 343 | newSnapshotId: snapId, |
| 282 | 344 | url: sensor.url, |
| 345 | + canonicalUrl: obs.meta.finalUrl || sensor.url, | |
| 283 | 346 | eventType, |
| 284 | 347 | title, |
| 285 | 348 | summary, |
@@ -288,10 +351,18 @@ async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observati | ||
| 288 | 351 | importanceComponents: prelim.components as unknown as Record<string, number>, |
| 289 | 352 | confidence, |
| 290 | 353 | novelty: nov.novelty, |
| 354 | + impactScore: impact, | |
| 355 | + anomalyScore: anomaly, | |
| 291 | 356 | categories, |
| 292 | 357 | keywords, |
| 293 | 358 | silentChange, |
| 294 | 359 | evidenceLabel, |
| 360 | + firstParty, | |
| 361 | + country, | |
| 362 | + language, | |
| 363 | + changeClass: semantic.class, | |
| 364 | + fieldChanges: semantic.fieldChanges.length ? semantic.fieldChanges : null, | |
| 365 | + fingerprint, | |
| 295 | 366 | publishedAt, |
| 296 | 367 | observedFrom, |
| 297 | 368 | detectedAt, |
@@ -299,43 +370,63 @@ async function createEvent(ctx: { sensor: Sensor; source: Source; obs: Observati | ||
| 299 | 370 | detectionLatencyMs: publishedAt && detectedAt.getTime() - publishedAt.getTime() < 7 * 86400e3 ? Math.max(0, detectedAt.getTime() - publishedAt.getTime()) : null, |
| 300 | 371 | processingLatencyMs: processedAt.getTime() - detectedAt.getTime(), |
| 301 | 372 | processingVersion: config.processingVersion, |
| 302 | − interpretation: llm ? { model: llm.model, observed: llm.observed, inferred: llm.inferred, who_it_affects: llm.who_it_affects, severity: llm.severity, confidence: llm.confidence, announced: llm.announced, entities: llm.entities } : { model: "heuristics-v1", reasons: heuristic.reasons, facts: heuristic.facts }, | |
| 373 | + interpretation: llm ? { model: llm.model, observed: llm.observed, inferred: llm.inferred, who_it_affects: llm.who_it_affects, severity: llm.severity, confidence: llm.confidence, announced: llm.announced, entities: llm.entities } : { model: "heuristics-v2", reasons: [...heuristic.reasons, ...semantic.reasons], facts: heuristic.facts }, | |
| 303 | 374 | }); |
| 304 | − await db.insert(interpretations).values({ eventId: id, version: 1, model: llm?.model ?? "heuristics-v1", payload: { heuristic: heuristic as unknown as Record<string, unknown>, llm: llm as unknown as Record<string, unknown> | null } }); | |
| 375 | + await db.insert(interpretations).values({ eventId: id, version: 1, model: llm?.model ?? "heuristics-v2", payload: { heuristic: heuristic as unknown as Record<string, unknown>, semantic: { class: semantic.class, confidence: semantic.confidence, reasons: semantic.reasons, fieldChanges: semantic.fieldChanges }, llm: llm as unknown as Record<string, unknown> | null } }); | |
| 305 | 376 | for (const e of entityIds) await db.insert(eventEntities).values({ eventId: id, entityId: e, role: ent.subject.includes(e) ? "subject" : "mentioned" }).onConflictDoNothing(); |
| 306 | − await bumpEntityCounters(entityIds, detectedAt); | |
| 377 | + await bumpEntityCounters(entityIds, detectedAt, { silent: silentChange, importance: prelim.score }); | |
| 307 | 378 | await db.update(changes).set({ meaningful: true, eventId: id }).where(sql`id = ${changeId}`); |
| 308 | 379 | await db.update(sensors).set({ meaningfulChanges: sensor.meaningfulChanges + 1 }).where(sql`id = ${sensor.id}`); |
| 309 | 380 | await db.execute(sql`insert into url_history (url, at, kind, snapshot_id, change_id, event_id) values (${sensor.url}, ${detectedAt}, 'event', ${snapId}, ${changeId}, ${id})`); |
| 310 | 381 | |
| 311 | − const cl = await clusterEvent({ id, sourceId: source.id, sensorId: sensor.id, eventType, entityIds, detectedAt, title, summary, importance: prelim.score, categories }); | |
| 312 | − await db.update(events).set({ clusterId: cl.clusterId, publishedToFeedAt: new Date() }).where(sql`id = ${id}`); | |
| 382 | + // Provisional signal for clustering; recomputed with the cluster's velocity right after. | |
| 383 | + const provisional = computeSignalScore({ importance: prelim.score, confidence, novelty: nov.novelty, velocity: 0, impact, anomaly, confirmations: nov.confirmations, firstParty, silent: silentChange, changeClass: semantic.class, sourceTier: sensor.tier, evidenceLabel }); | |
| 384 | + const cl = await clusterEvent({ id, sourceId: source.id, sourceName: source.name, sensorId: sensor.id, sensorType: sensor.type, eventType, entityIds, detectedAt, title, summary, importance: prelim.score, signal: provisional.score, categories, firstParty, sourceTier: sensor.tier }); | |
| 385 | + const signal = computeSignalScore({ importance: prelim.score, confidence, novelty: nov.novelty, velocity: cl.velocity, impact, anomaly, confirmations: Math.max(nov.confirmations, cl.sourceCount - 1), firstParty, silent: silentChange, changeClass: semantic.class, sourceTier: sensor.tier, evidenceLabel }); | |
| 386 | + if (routineBatch) signal.reasons.push({ sign: "-", text: "routine batch from a high-volume feed", points: -8 }); | |
| 387 | + await db.update(events).set({ clusterId: cl.clusterId, publishedToFeedAt: new Date(), signalScore: signal.score, velocityScore: cl.velocity, scoreReasons: signal.reasons }).where(sql`id = ${id}`); | |
| 388 | + await bumpSourceDaily(source.id, { events: 1 }); | |
| 313 | 389 | |
| 314 | 390 | const entNames = entityIds.length ? await db.execute<{ id: string; name: string; type: string }>(sql`select id, name, type from entities where id = any(${textArray(entityIds)})`) : { rows: [] as { id: string; name: string; type: string }[] }; |
| 315 | − await publishEvent({ | |
| 391 | + const payload: PublishedEvent & Record<string, unknown> = { | |
| 316 | 392 | id, |
| 317 | 393 | slug, |
| 318 | 394 | type: eventType, |
| 395 | + group: eventGroupOf(eventType), | |
| 319 | 396 | title, |
| 320 | 397 | summary: summary.slice(0, 280), |
| 321 | 398 | importance: prelim.score, |
| 399 | + signal: signal.score, | |
| 322 | 400 | confidence, |
| 323 | 401 | novelty: nov.novelty, |
| 402 | + impact, | |
| 403 | + velocity: cl.velocity, | |
| 324 | 404 | silent: silentChange, |
| 325 | 405 | evidence: evidenceLabel, |
| 326 | − source: { id: source.id, name: source.name, domain: source.domain }, | |
| 406 | + firstParty, | |
| 407 | + country, | |
| 408 | + language, | |
| 409 | + changeClass: semantic.class, | |
| 410 | + fieldChanges: semantic.fieldChanges.slice(0, 3), | |
| 411 | + source: { id: source.id, name: source.name, domain: source.domain, tier: source.tier }, | |
| 327 | 412 | sensor: { id: sensor.id, name: sensor.name, type: sensor.type }, |
| 328 | 413 | entities: entNames.rows.map((r) => ({ id: r.id, name: r.name, type: r.type })), |
| 329 | 414 | categories, |
| 330 | 415 | url: sensor.url, |
| 331 | 416 | clusterId: cl.clusterId, |
| 417 | + clusterSlug: cl.slug, | |
| 418 | + clusterSize: cl.eventCount, | |
| 419 | + clusterState: cl.state, | |
| 332 | 420 | detectedAt: detectedAt.toISOString(), |
| 333 | 421 | publishedAt: publishedAt?.toISOString() ?? null, |
| 334 | − }); | |
| 422 | + }; | |
| 423 | + await publishEvent(payload); | |
| 424 | + void evaluateAlerts(payload).catch(() => undefined); | |
| 335 | 425 | m.events.inc({ event_type: eventType, silent: String(silentChange) }); |
| 336 | 426 | m.processingLatency.observe((Date.now() - detectedAt.getTime()) / 1000); |
| 337 | 427 | await bumpDaily({ events: 1, silent_events: silentChange ? 1 : 0 }); |
| 338 | − log.info({ event: id, source: source.id, type: eventType, importance: prelim.score, confidence, silent: silentChange, llm: llm?.model ?? "heuristics" }, title); | |
| 428 | + tEv(); | |
| 429 | + log.info({ event: id, source: source.id, type: eventType, importance: prelim.score, signal: signal.score, confidence, silent: silentChange, class: semantic.class, cluster: cl.state, llm: llm?.model ?? "heuristics" }, title); | |
| 339 | 430 | return { id, detectedAt }; |
| 340 | 431 | } |
| 341 | 432 | |
@@ -361,14 +452,21 @@ async function handleMissing(sensor: Sensor, source: Source, obs: Observation, f | ||
| 361 | 452 | const summary = `${sensor.url} has returned HTTP ${obs.meta.status} on ${missing.count} checks since ${new Date(missing.firstAt).toISOString()}. The last known version is preserved as a snapshot.`; |
| 362 | 453 | const imp = computeImportance({ eventType: "page_removed", sourceImportance: sourceImportanceFromTier(sensor.tier, source.importanceWeight), entityImportance: 50, novelty: 80, magnitude: 60, confirmations: 0 }); |
| 363 | 454 | const slug = `${slugify(title).slice(0, 70)}-${id.slice(-6)}`; |
| 364 | − await db.insert(events).values({ id, slug, sensorId: sensor.id, sourceId: source.id, oldSnapshotId: sensor.lastSnapshotId, url: sensor.url, eventType: "page_removed", title, summary, importance: imp.score, importanceComponents: imp.components as unknown as Record<string, number>, confidence: 85, novelty: 80, categories: source.categories, keywords: ["page removed"], silentChange: true, evidenceLabel: "CONFIRMED", detectedAt: obs.fetchedAt, processedAt: new Date(), processingVersion: config.processingVersion, interpretation: { model: "rules", checks: missing.count } }); | |
| 455 | + const firstParty = source.firstParty !== false; | |
| 456 | + const fingerprint = sha256(`${sensor.id}|removed|${sensor.lastSnapshotId}`); | |
| 457 | + const impact = computeImpact({ eventType: "page_removed", magnitude: 60, entityImportance: 50, firstParty }); | |
| 458 | + const sig = computeSignalScore({ importance: imp.score, confidence: 85, novelty: 80, velocity: 0, impact, anomaly: 0, confirmations: 0, firstParty, silent: true, sourceTier: sensor.tier, evidenceLabel: "CONFIRMED" }); | |
| 459 | + await db.insert(events).values({ id, slug, sensorId: sensor.id, sourceId: source.id, oldSnapshotId: sensor.lastSnapshotId, url: sensor.url, eventType: "page_removed", title, summary, importance: imp.score, importanceComponents: imp.components as unknown as Record<string, number>, confidence: 85, novelty: 80, impactScore: impact, signalScore: sig.score, scoreReasons: sig.reasons, categories: source.categories, keywords: ["page removed"], silentChange: true, evidenceLabel: "CONFIRMED", firstParty, country: source.country ?? null, language: source.language ?? "en", changeClass: "meaningful", fingerprint, detectedAt: obs.fetchedAt, processedAt: new Date(), processingVersion: config.processingVersion, interpretation: { model: "rules", checks: missing.count } }).onConflictDoNothing(); | |
| 365 | 460 | const ent = await resolveEntities({ sourceId: source.id, text: title, hints: [] }); |
| 366 | 461 | for (const e of ent.subject) await db.insert(eventEntities).values({ eventId: id, entityId: e, role: "subject" }).onConflictDoNothing(); |
| 462 | + await bumpEntityCounters(ent.subject, obs.fetchedAt, { silent: true, importance: imp.score }); | |
| 367 | 463 | await db.execute(sql`update urls set status = 'removed' where url = ${sensor.url}`); |
| 368 | 464 | await db.execute(sql`insert into url_history (url, at, kind, event_id, note) values (${sensor.url}, ${obs.fetchedAt}, 'removed', ${id}, ${`HTTP ${obs.meta.status}`})`); |
| 369 | − const cl = await clusterEvent({ id, sourceId: source.id, sensorId: sensor.id, eventType: "page_removed", entityIds: ent.subject, detectedAt: obs.fetchedAt, title, summary, importance: imp.score, categories: source.categories }); | |
| 370 | − await db.update(events).set({ clusterId: cl.clusterId, publishedToFeedAt: new Date() }).where(sql`id = ${id}`); | |
| 371 | − await publishEvent({ id, slug, type: "page_removed", title, summary, importance: imp.score, confidence: 85, novelty: 80, silent: true, evidence: "CONFIRMED", source: { id: source.id, name: source.name, domain: source.domain }, sensor: { id: sensor.id, name: sensor.name, type: sensor.type }, entities: [], categories: source.categories, url: sensor.url, clusterId: cl.clusterId, detectedAt: obs.fetchedAt.toISOString(), publishedAt: null }); | |
| 465 | + const cl = await clusterEvent({ id, sourceId: source.id, sourceName: source.name, sensorId: sensor.id, sensorType: sensor.type, eventType: "page_removed", entityIds: ent.subject, detectedAt: obs.fetchedAt, title, summary, importance: imp.score, signal: sig.score, categories: source.categories, firstParty, sourceTier: sensor.tier }); | |
| 466 | + await db.update(events).set({ clusterId: cl.clusterId, publishedToFeedAt: new Date(), velocityScore: cl.velocity }).where(sql`id = ${id}`); | |
| 467 | + const payload: PublishedEvent & Record<string, unknown> = { id, slug, type: "page_removed", group: "web", title, summary, importance: imp.score, signal: sig.score, confidence: 85, novelty: 80, silent: true, evidence: "CONFIRMED", firstParty, country: source.country ?? null, source: { id: source.id, name: source.name, domain: source.domain, tier: source.tier }, sensor: { id: sensor.id, name: sensor.name, type: sensor.type }, entities: [], categories: source.categories, url: sensor.url, clusterId: cl.clusterId, clusterSlug: cl.slug, clusterState: cl.state, detectedAt: obs.fetchedAt.toISOString(), publishedAt: null }; | |
| 468 | + await publishEvent(payload); | |
| 469 | + void evaluateAlerts(payload).catch(() => undefined); | |
| 372 | 470 | await bumpDaily({ events: 1, silent_events: 1 }); |
| 373 | 471 | log.info({ sensor: sensor.id, checks: missing.count }, "page removal confirmed"); |
| 374 | 472 | } else { |
@@ -398,7 +496,21 @@ async function countChanges7d(sensorId: string): Promise<number> { | ||
| 398 | 496 | const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from changes where sensor_id = ${sensorId} and detected_at >= now() - interval '7 days'`); |
| 399 | 497 | return Number(r.rows[0]?.n ?? 0); |
| 400 | 498 | } |
| 499 | +async function countEvents24h(sensorId: string): Promise<number> { | |
| 500 | + const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from events where sensor_id = ${sensorId} and detected_at >= now() - interval '24 hours'`); | |
| 501 | + return Number(r.rows[0]?.n ?? 0); | |
| 502 | +} | |
| 401 | 503 | async function countEvents7d(sensorId: string): Promise<number> { |
| 402 | 504 | const r = await db.execute<{ n: string }>(sql`select count(*)::text as n from events where sensor_id = ${sensorId} and detected_at >= now() - interval '7 days'`); |
| 403 | 505 | return Number(r.rows[0]?.n ?? 0); |
| 404 | 506 | } |
| 507 | + | |
| 508 | +/** Source activity anomaly at detection time (0–100): last 2 h of raw changes vs the 14-day hourly baseline. */ | |
| 509 | +async function sourceAnomaly(sourceId: string): Promise<number> { | |
| 510 | + const r = await db.execute<{ c2h: string; c14d: string }>(sql` | |
| 511 | + select (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '2 hours')::text as c2h, | |
| 512 | + (select count(*) from changes c join sensors s on s.id = c.sensor_id where s.source_id = ${sourceId} and c.detected_at >= now() - interval '14 days')::text as c14d`); | |
| 513 | + const cur = Number(r.rows[0]?.c2h ?? 0) / 2; | |
| 514 | + const base = Number(r.rows[0]?.c14d ?? 0) / (14 * 24); | |
| 515 | + return activityAnomaly(cur, base); | |
| 516 | +} | |
modified
apps/engine/src/redis.ts
+19 −4
@@ -15,15 +15,30 @@ export const STREAM_EVENTS = "ws:events"; | ||
| 15 | 15 | export const CHANNEL_LIVE = "ws:live"; |
| 16 | 16 | export const STREAM_CHANGES = "ws:changes"; |
| 17 | 17 | |
| 18 | −/** Publish a compact event payload to the stream (durable) and the pub/sub channel (realtime). */ | |
| 19 | −export async function publishEvent(payload: Record<string, unknown>): Promise<void> { | |
| 18 | +/** | |
| 19 | + * Publish a compact event payload to the stream (durable, replayable by stream id) and the pub/sub | |
| 20 | + * channel (realtime). The stream id is embedded as `sid` so clients can resume with `since`. | |
| 21 | + */ | |
| 22 | +export async function publishEvent(payload: Record<string, unknown>): Promise<string | null> { | |
| 20 | 23 | const r = getRedis(); |
| 21 | − const json = JSON.stringify(payload); | |
| 22 | 24 | try { |
| 23 | − await r.xadd(STREAM_EVENTS, "MAXLEN", "~", "20000", "*", "event", json); | |
| 25 | + const sid = await r.xadd(STREAM_EVENTS, "MAXLEN", "~", "20000", "*", "event", JSON.stringify(payload)); | |
| 26 | + const json = JSON.stringify({ ...payload, sid }); | |
| 27 | + // keep the stream entry consistent with what subscribers saw | |
| 24 | 28 | await r.publish(CHANNEL_LIVE, json); |
| 29 | + return sid ?? null; | |
| 25 | 30 | } catch (e) { |
| 26 | 31 | log.warn({ err: (e as Error).message }, "publish failed"); |
| 32 | + return null; | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** Engine heartbeat for the ops dashboard (`/api/v1/admin/ops`, `/api/ready`). */ | |
| 37 | +export async function publishEngineStatus(status: Record<string, unknown>): Promise<void> { | |
| 38 | + try { | |
| 39 | + await getRedis().set("ws:engine:status", JSON.stringify({ ...status, at: new Date().toISOString(), pid: process.pid }), "EX", 30); | |
| 40 | + } catch { | |
| 41 | + // best effort | |
| 27 | 42 | } |
| 28 | 43 | } |
| 29 | 44 | |
modified
apps/engine/src/registry.ts
+46 −6
@@ -21,10 +21,14 @@ export function loadSeeds(file = config.sourcesFile, dir = config.sourcesDir): S | ||
| 21 | 21 | export async function syncRegistry(seeds = loadSeeds()): Promise<{ sources: number; sensors: number }> { |
| 22 | 22 | let nSensors = 0; |
| 23 | 23 | for (const s of seeds) { |
| 24 | + // Provenance: media/aggregators are third-party; everything else is the organization's own channel. | |
| 25 | + const firstParty = s.first_party ?? !s.categories.some((c) => c === "news" || c === "media"); | |
| 26 | + const country = s.country ?? inferCountry(s.domain, s.categories); | |
| 27 | + const language = s.language ?? inferLanguage(s.domain); | |
| 24 | 28 | await db |
| 25 | 29 | .insert(sources) |
| 26 | − .values({ id: s.id, name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, llmEnabled: s.llm }) | |
| 27 | − .onConflictDoUpdate({ target: sources.id, set: { name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, llmEnabled: s.llm, updatedAt: new Date() } }); | |
| 30 | + .values({ id: s.id, name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, llmEnabled: s.llm, firstParty, country, language }) | |
| 31 | + .onConflictDoUpdate({ target: sources.id, set: { name: s.name, domain: s.domain, homepage: s.homepage ?? `https://${s.domain}`, description: s.description, categories: s.categories, tier: s.tier, importanceWeight: s.weight, discover: s.discover, fallback: s.fallback, notes: s.notes, enabled: s.enabled, llmEnabled: s.llm, firstParty, country, language, updatedAt: new Date() } }); | |
| 28 | 32 | |
| 29 | 33 | // Organization entity + aliases |
| 30 | 34 | const entId = `org_${s.id}`; |
@@ -52,14 +56,16 @@ export async function syncRegistry(seeds = loadSeeds()): Promise<{ sources: numb | ||
| 52 | 56 | const id = sen.id ?? `${s.id}_${slugify(sen.name)}`; |
| 53 | 57 | seedIds.push(id); |
| 54 | 58 | const cfg = { ...sen.config, seed: true }; |
| 59 | + const tier = sen.tier ?? s.tier; | |
| 60 | + const priority = priorityFor(tier, s.categories); | |
| 55 | 61 | await db |
| 56 | 62 | .insert(sensors) |
| 57 | − .values({ id, sourceId: s.id, name: sen.name, url: sen.url, type: sen.type, connector: sen.connector, tier: sen.tier ?? s.tier, importanceWeight: sen.weight ?? 1, config: cfg, baseIntervalSeconds: sen.interval ?? null }) | |
| 58 | − .onConflictDoUpdate({ target: sensors.id, set: { name: sen.name, url: sen.url, type: sen.type, connector: sen.connector, tier: sen.tier ?? s.tier, importanceWeight: sen.weight ?? 1, config: cfg, baseIntervalSeconds: sen.interval ?? null, enabled: true, updatedAt: new Date() } }); | |
| 63 | + .values({ id, sourceId: s.id, name: sen.name, url: sen.url, type: sen.type, connector: sen.connector, tier, importanceWeight: sen.weight ?? 1, config: cfg, baseIntervalSeconds: sen.interval ?? null, priority, status: "VALIDATED", validatedAt: new Date() }) | |
| 64 | + .onConflictDoUpdate({ target: sensors.id, set: { name: sen.name, url: sen.url, type: sen.type, connector: sen.connector, tier, importanceWeight: sen.weight ?? 1, config: cfg, baseIntervalSeconds: sen.interval ?? null, priority, enabled: true, updatedAt: new Date() } }); | |
| 59 | 65 | nSensors++; |
| 60 | 66 | } |
| 61 | 67 | // Seed sensors removed from the YAML are disabled (history kept), discovery-created ones are untouched. |
| 62 | − await db.execute(sql`update sensors set enabled = false, updated_at = now() where source_id = ${s.id} and enabled and (config->>'seed') = 'true' and not (id = any(${textArray(seedIds)}))`); | |
| 68 | + await db.execute(sql`update sensors set enabled = false, status = 'DISABLED', updated_at = now() where source_id = ${s.id} and enabled and (config->>'seed') = 'true' and not (id = any(${textArray(seedIds)}))`); | |
| 63 | 69 | } |
| 64 | 70 | log.info({ sources: seeds.length, sensors: nSensors }, "registry synced"); |
| 65 | 71 | return { sources: seeds.length, sensors: nSensors }; |
@@ -112,7 +118,7 @@ export async function runDiscovery(opts: { onlyMissing?: boolean; sourceIds?: st | ||
| 112 | 118 | const tier = f.connector === "statuspage" ? "S" : f.connector === "sitemap" ? (src.tier === "S" ? "A" : src.tier) : src.tier; |
| 113 | 119 | await db |
| 114 | 120 | .insert(sensors) |
| 115 | − .values({ id, sourceId: src.id, name, url: f.url, type: f.type, connector: f.connector, tier, config: f.connector === "sitemap" ? { maxChildren: 4, maxUrls: 3000 } : {} }) | |
| 121 | + .values({ id, sourceId: src.id, name, url: f.url, type: f.type, connector: f.connector, tier, config: f.connector === "sitemap" ? { maxChildren: 4, maxUrls: 3000 } : {}, status: "VALIDATED", validatedAt: new Date(), priority: priorityFor(tier, src.categories) }) | |
| 116 | 122 | .onConflictDoNothing(); |
| 117 | 123 | await db.update(discoveryCandidates).set({ status: "promoted" }).where(sql`source_id = ${src.id} and url = ${f.url}`); |
| 118 | 124 | existingUrls.add(f.url.replace(/\/$/, "")); |
@@ -129,6 +135,40 @@ export async function runDiscovery(opts: { onlyMissing?: boolean; sourceIds?: st | ||
| 129 | 135 | return promoted; |
| 130 | 136 | } |
| 131 | 137 | |
| 138 | +/** Priority tier (spec §76): 0 critical infrastructure/government/major AI/cyber · 1 major companies/markets/science/health · 2 specialized · 3 low. */ | |
| 139 | +export function priorityFor(tier: string, categories: string[]): number { | |
| 140 | + if (tier === "S") return 0; | |
| 141 | + if (tier === "A" && categories.some((c) => ["cyber", "government", "ai", "cloud", "internet", "infrastructure", "finance", "health"].includes(c))) return 0; | |
| 142 | + if (tier === "A" || (tier === "B" && categories.some((c) => ["finance", "science", "health", "pharma", "ai", "cyber", "government"].includes(c)))) return 1; | |
| 143 | + if (tier === "B" || tier === "C") return 2; | |
| 144 | + return 3; | |
| 145 | +} | |
| 146 | + | |
| 147 | +const TLD_COUNTRY: Record<string, string> = { ca: "CA", "gc.ca": "CA", "gouv.qc.ca": "CA", uk: "GB", "gov.uk": "GB", fr: "FR", "gouv.fr": "FR", de: "DE", it: "IT", es: "ES", jp: "JP", "go.jp": "JP", kr: "KR", "go.kr": "KR", au: "AU", "gov.au": "AU", in: "IN", "gov.in": "IN", br: "BR", "gov.br": "BR", mx: "MX", "gob.mx": "MX", ch: "CH", nl: "NL", se: "SE", no: "NO", fi: "FI", dk: "DK", ie: "IE", be: "BE", at: "AT", pt: "PT", pl: "PL", cn: "CN", hk: "HK", tw: "TW", sg: "SG", il: "IL", ae: "AE", sa: "SA", qa: "QA", za: "ZA", ng: "NG", ke: "KE", ar: "AR", cl: "CL", co: "CO", nz: "NZ", ru: "RU", tr: "TR", id: "ID", th: "TH", vn: "VN", my: "MY", ph: "PH", eg: "EG", ua: "UA", cz: "CZ", gr: "GR", lu: "LU", eu: "EU", "europa.eu": "EU", gov: "US", mil: "US", "us.com": "US" }; | |
| 148 | + | |
| 149 | +/** Country inference for seeds without an explicit `country:` — only from unambiguous TLDs; `.com/.org/.io` stay null. */ | |
| 150 | +export function inferCountry(domain: string, categories: string[]): string | null { | |
| 151 | + const d = domain.toLowerCase(); | |
| 152 | + for (const [suffix, code] of Object.entries(TLD_COUNTRY).sort((a, b) => b[0].length - a[0].length)) if (d === suffix || d.endsWith("." + suffix)) return code; | |
| 153 | + if (/\.int$|^un\.org$|\.who\.int$|\.imf\.org$|\.worldbank\.org$|\.oecd\.org$|\.bis\.org$|\.wto\.org$|\.iso\.org$|\.ietf\.org$|\.w3\.org$|\.icann\.org$/.test(d) || categories.includes("international")) return "INT"; | |
| 154 | + return null; | |
| 155 | +} | |
| 156 | + | |
| 157 | +export function inferLanguage(domain: string): string | null { | |
| 158 | + const d = domain.toLowerCase(); | |
| 159 | + if (/\.qc\.ca$|quebec\.ca$|gouv\.fr$|\.fr$|lapresse\.ca|ledevoir\.com|journaldemontreal\.com|tvanouvelles\.ca|radio-canada\.ca|lemonde\.fr|lefigaro\.fr|24heures\.ca|noovo\.ca|lesoleil\.com|ledroit\.com|lapresse\.ca/.test(d)) return "fr"; | |
| 160 | + if (/\.de$|spiegel\.de|faz\.net|sueddeutsche\.de|handelsblatt\.com|\.at$|swissinfo\.ch/.test(d)) return "de"; | |
| 161 | + if (/\.es$|elpais\.com|elmundo\.es|\.mx$|eluniversal\.com\.mx|\.ar$|\.cl$|\.co$/.test(d)) return "es"; | |
| 162 | + if (/\.it$|repubblica\.it|corriere\.it/.test(d)) return "it"; | |
| 163 | + if (/\.br$|\.pt$|globo\.com|folha\.uol\.com\.br/.test(d)) return "pt"; | |
| 164 | + if (/\.jp$|nhk\.or\.jp|nikkei\.com$/.test(d)) return "ja"; | |
| 165 | + if (/\.kr$/.test(d)) return "ko"; | |
| 166 | + if (/\.cn$|xinhuanet\.com/.test(d)) return "zh"; | |
| 167 | + if (/\.nl$/.test(d)) return "nl"; | |
| 168 | + if (/\.ru$|tass\.(ru|com)/.test(d)) return "ru"; | |
| 169 | + return null; | |
| 170 | +} | |
| 171 | + | |
| 132 | 172 | function feedName(url: string, title?: string): string { |
| 133 | 173 | const p = new URL(url).pathname.toLowerCase(); |
| 134 | 174 | if (/blog/.test(p)) return "blog feed"; |
added
apps/engine/src/retention.ts
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +import { unlink } from "node:fs/promises"; | |
| 2 | +import { join, resolve } from "node:path"; | |
| 3 | +import { db, sql } from "@websensor/db"; | |
| 4 | +import { config, log } from "./config"; | |
| 5 | +import { m } from "./metrics"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Storage lifecycle (spec §58). Evidence that matters is never touched: | |
| 9 | + * - snapshots referenced by an event (old or new side) keep raw body + canonical forever; | |
| 10 | + * - snapshots referenced by a *change* keep raw + canonical for `keepChangedRawDays`; | |
| 11 | + * - "baseline / unchanged" snapshots (no change, no event) lose their RAW body after `keepRawDays` | |
| 12 | + * but keep their canonical representation and every hash, so history/compare still work. | |
| 13 | + * Blobs are content-addressed and shared: a raw blob is deleted only when no other snapshot row | |
| 14 | + * still references its key. Runs incrementally (bounded batches). | |
| 15 | + */ | |
| 16 | +export async function pruneRawSnapshots(opts: { keepRawDays?: number; keepChangedRawDays?: number; batch?: number } = {}): Promise<{ examined: number; pruned: number; freedKeys: number }> { | |
| 17 | + const keepRawDays = opts.keepRawDays ?? config.retention.rawDays; | |
| 18 | + const keepChangedRawDays = opts.keepChangedRawDays ?? config.retention.changedRawDays; | |
| 19 | + const batch = opts.batch ?? 2000; | |
| 20 | + const rows = await db.execute<{ id: string; storage_key: string }>(sql` | |
| 21 | + select s.id, s.storage_key from snapshots s | |
| 22 | + where s.storage_key is not null | |
| 23 | + and s.captured_at < now() - make_interval(days => ${keepRawDays}) | |
| 24 | + and not exists (select 1 from events e where e.new_snapshot_id = s.id or e.old_snapshot_id = s.id) | |
| 25 | + and not exists (select 1 from changes c where (c.new_snapshot_id = s.id or c.old_snapshot_id = s.id) and c.detected_at >= now() - make_interval(days => ${keepChangedRawDays})) | |
| 26 | + order by s.captured_at asc limit ${batch}`); | |
| 27 | + let pruned = 0; | |
| 28 | + let freed = 0; | |
| 29 | + const root = resolve(process.env.BLOB_STORE_DIR ?? "./data/blobs"); | |
| 30 | + for (const r of rows.rows) { | |
| 31 | + // detach the raw body from this snapshot (canonical_storage_key stays) | |
| 32 | + await db.execute(sql`update snapshots set storage_key = null, extra = coalesce(extra, '{}'::jsonb) || jsonb_build_object('raw_pruned_at', now()) where id = ${r.id}`); | |
| 33 | + pruned++; | |
| 34 | + // delete the file only if no other row references the same content-addressed key | |
| 35 | + const still = await db.execute<{ n: string }>(sql`select count(*)::text as n from snapshots where storage_key = ${r.storage_key} or canonical_storage_key = ${r.storage_key} union all select count(*)::text from changes where diff_storage_key = ${r.storage_key}`); | |
| 36 | + if (still.rows.every((x) => Number(x.n) === 0)) { | |
| 37 | + await unlink(join(root, r.storage_key + ".zst")).catch(() => undefined); | |
| 38 | + freed++; | |
| 39 | + m.prunedBlobs.inc(); | |
| 40 | + } | |
| 41 | + } | |
| 42 | + if (pruned) log.info({ examined: rows.rows.length, pruned, freed }, "raw snapshot bodies pruned"); | |
| 43 | + return { examined: rows.rows.length, pruned, freedKeys: freed }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** Old raw changes that never became events: keep metadata, drop nothing (they are small). Old runs are pruned in scheduler.ts. */ | |
| 47 | +export async function pruneNotifications(): Promise<void> { | |
| 48 | + await db.execute(sql`delete from notifications where created_at < now() - interval '90 days'`); | |
| 49 | +} | |
modified
apps/engine/src/scheduler.ts
+41 −2
@@ -2,6 +2,7 @@ import { db, sensors, sources, sql, type Sensor, type Source } from "@websensor/ | ||
| 2 | 2 | import { config, log } from "./config"; |
| 3 | 3 | import { m } from "./metrics"; |
| 4 | 4 | import { runSensor } from "./pipeline"; |
| 5 | +import { publishEngineStatus } from "./redis"; | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * Scheduler: claims due sensors with `FOR UPDATE SKIP LOCKED` (safe with several engine |
@@ -44,15 +45,43 @@ export class Scheduler { | ||
| 44 | 45 | this.sourceCacheAt = Date.now(); |
| 45 | 46 | } |
| 46 | 47 | |
| 48 | + private lastStatusAt = 0; | |
| 49 | + private hostFailures = new Map<string, { count: number; until: number }>(); | |
| 50 | + | |
| 51 | + /** Snapshot for the ops dashboard. */ | |
| 52 | + status(): Record<string, unknown> { | |
| 53 | + const busyHosts = [...this.perHost.entries()].filter(([, n]) => n > 0).map(([h, n]) => ({ host: h, inflight: n })); | |
| 54 | + const tripped = [...this.hostFailures.entries()].filter(([, f]) => f.until > Date.now()).map(([h, f]) => ({ host: h, failures: f.count, until: new Date(f.until).toISOString() })); | |
| 55 | + return { inflight: this.inflight, concurrency: config.fetchConcurrency, perHostConcurrency: config.perHostConcurrency, busyHosts, circuitOpen: tripped, sources: this.sourceCache.size }; | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** Domain circuit breaker (spec §86): after 5 consecutive failures on a host, pause it for 10 minutes. */ | |
| 59 | + noteHostOutcome(host: string, failed: boolean): void { | |
| 60 | + if (!failed) { | |
| 61 | + this.hostFailures.delete(host); | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + const f = this.hostFailures.get(host) ?? { count: 0, until: 0 }; | |
| 65 | + f.count++; | |
| 66 | + if (f.count >= 5) f.until = Date.now() + 10 * 60e3; | |
| 67 | + this.hostFailures.set(host, f); | |
| 68 | + } | |
| 69 | + | |
| 47 | 70 | private async tick(): Promise<void> { |
| 48 | 71 | await this.refreshSources(); |
| 49 | 72 | const due = await db.execute<{ n: string }>(sql`select count(*)::text as n from sensors where enabled and next_check_at <= now()`); |
| 50 | − m.queueDue.set(Number(due.rows[0]?.n ?? 0)); | |
| 73 | + const dueN = Number(due.rows[0]?.n ?? 0); | |
| 74 | + m.queueDue.set(dueN); | |
| 75 | + if (Date.now() - this.lastStatusAt > 10_000) { | |
| 76 | + this.lastStatusAt = Date.now(); | |
| 77 | + void publishEngineStatus({ ...this.status(), due: dueN, version: config.version }); | |
| 78 | + } | |
| 51 | 79 | const slots = config.fetchConcurrency - this.inflight; |
| 52 | 80 | if (slots <= 0) return; |
| 81 | + // Priority-aware claim: critical sensors (priority 0) go first among what is due; within a priority the most overdue first. | |
| 53 | 82 | const claimed = await db.execute<Sensor>(sql` |
| 54 | 83 | update sensors set next_check_at = now() + interval '10 minutes' |
| 55 | − where id in (select id from sensors where enabled and next_check_at <= now() order by next_check_at asc limit ${slots} for update skip locked) | |
| 84 | + where id in (select id from sensors where enabled and next_check_at <= now() order by priority asc, next_check_at asc limit ${slots} for update skip locked) | |
| 56 | 85 | returning *`); |
| 57 | 86 | for (const row of claimed.rows) { |
| 58 | 87 | const sensor = normalizeRow(row as unknown as Record<string, unknown>); |
@@ -62,6 +91,11 @@ export class Scheduler { | ||
| 62 | 91 | await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 5000) }).where(sql`id = ${sensor.id}`); |
| 63 | 92 | continue; |
| 64 | 93 | } |
| 94 | + const trip = this.hostFailures.get(host); | |
| 95 | + if (trip && trip.until > Date.now()) { | |
| 96 | + await db.update(sensors).set({ nextCheckAt: new Date(trip.until + Math.random() * 30_000) }).where(sql`id = ${sensor.id}`); | |
| 97 | + continue; | |
| 98 | + } | |
| 65 | 99 | const source = this.sourceCache.get(sensor.sourceId); |
| 66 | 100 | if (!source || !source.enabled) { |
| 67 | 101 | await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 3600e3) }).where(sql`id = ${sensor.id}`); |
@@ -71,8 +105,10 @@ export class Scheduler { | ||
| 71 | 105 | this.perHost.set(host, (this.perHost.get(host) ?? 0) + 1); |
| 72 | 106 | m.inflight.set(this.inflight); |
| 73 | 107 | void runSensor(sensor, source) |
| 108 | + .then((outcome) => this.noteHostOutcome(host, outcome === "error" || outcome === "rate_limited")) | |
| 74 | 109 | .catch(async (e) => { |
| 75 | 110 | log.error({ sensor: sensor.id, err: (e as Error).stack ?? (e as Error).message }, "pipeline crashed"); |
| 111 | + this.noteHostOutcome(host, true); | |
| 76 | 112 | await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 15 * 60e3), lastError: `pipeline: ${(e as Error).message}`.slice(0, 500), consecutiveErrors: sensor.consecutiveErrors + 1, health: "DEGRADED" }).where(sql`id = ${sensor.id}`).catch(() => undefined); |
| 77 | 113 | }) |
| 78 | 114 | .finally(() => { |
@@ -116,6 +152,9 @@ function normalizeRow(r: Record<string, unknown>): Sensor { | ||
| 116 | 152 | rawChanges: Number(r.raw_changes ?? 0), |
| 117 | 153 | meaningfulChanges: Number(r.meaningful_changes ?? 0), |
| 118 | 154 | avgLatencyMs: (r.avg_latency_ms as number | null) ?? null, |
| 155 | + status: (r.status as string) ?? "ACTIVE", | |
| 156 | + validatedAt: d(r.validated_at), | |
| 157 | + priority: Number(r.priority ?? 2), | |
| 119 | 158 | createdAt: d(r.created_at) ?? new Date(), |
| 120 | 159 | updatedAt: d(r.updated_at) ?? new Date(), |
| 121 | 160 | }; |
modified
apps/engine/src/seeds.ts
+6 −52
@@ -1,8 +1,6 @@ | ||
| 1 | 1 | import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; |
| 2 | 2 | import { join } from "node:path"; |
| 3 | 3 | import YAML from "yaml"; |
| 4 | −import { z } from "zod"; | |
| 5 | −import { SENSOR_TYPES, TIERS } from "@websensor/core"; | |
| 6 | 4 | |
| 7 | 5 | /** |
| 8 | 6 | * Source registry seeds. `config/sources.yaml` is the founding file; every `config/sources.d/*.yaml` |
@@ -13,56 +11,9 @@ import { SENSOR_TYPES, TIERS } from "@websensor/core"; | ||
| 13 | 11 | * |
| 14 | 12 | * This module has no database dependency so that the validator CLI can load seeds anywhere. |
| 15 | 13 | */ |
| 16 | −export const sensorSchema = z.object({ | |
| 17 | − id: z.string().optional(), | |
| 18 | − name: z.string(), | |
| 19 | − url: z.string().url(), | |
| 20 | − type: z.enum(SENSOR_TYPES), | |
| 21 | − connector: z.string().default("http"), | |
| 22 | − tier: z.enum(TIERS).optional(), | |
| 23 | − interval: z.number().int().positive().optional(), | |
| 24 | − weight: z.number().positive().optional(), | |
| 25 | − config: z.record(z.string(), z.unknown()).default({}), | |
| 26 | −}); | |
| 27 | −export type SensorSeed = z.infer<typeof sensorSchema>; | |
| 28 | − | |
| 29 | −export const productSchema = z.object({ id: z.string().optional(), name: z.string(), type: z.string().default("product"), aliases: z.array(z.string()).default([]) }); | |
| 30 | − | |
| 31 | −export const sourceSchema = z.object({ | |
| 32 | − id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "id must be kebab-case"), | |
| 33 | − name: z.string(), | |
| 34 | − domain: z.string(), | |
| 35 | − homepage: z.string().url().optional(), | |
| 36 | − description: z.string().optional(), | |
| 37 | − categories: z.array(z.string()).default([]), | |
| 38 | − tier: z.enum(TIERS).default("B"), | |
| 39 | − weight: z.number().positive().default(1), | |
| 40 | − entity_type: z.string().default("organization"), | |
| 41 | − aliases: z.array(z.string()).default([]), | |
| 42 | − products: z.array(productSchema).default([]), | |
| 43 | − discover: z.object({ rss: z.boolean().optional(), sitemap: z.boolean().optional(), status: z.boolean().optional(), pages: z.boolean().optional() }).default({}), | |
| 44 | − fallback: z.object({ firecrawl: z.boolean().optional(), scrapfly: z.boolean().optional() }).default({}), | |
| 45 | − sensors: z.array(sensorSchema).default([]), | |
| 46 | − notes: z.string().optional(), | |
| 47 | − enabled: z.boolean().default(true), | |
| 48 | − /** false = heuristics only (high-volume feeds such as news wires) */ | |
| 49 | − llm: z.boolean().default(true), | |
| 50 | − /** fragment-only: merge into an already declared source instead of redefining it */ | |
| 51 | − extend: z.boolean().default(false), | |
| 52 | −}); | |
| 53 | −export type SourceSeed = z.infer<typeof sourceSchema>; | |
| 54 | − | |
| 55 | −/** Partial schema for `extend: true` entries — only `id` is required. */ | |
| 56 | −const extendSchema = z.object({ | |
| 57 | − id: z.string(), | |
| 58 | − extend: z.literal(true), | |
| 59 | − aliases: z.array(z.string()).default([]), | |
| 60 | − products: z.array(productSchema).default([]), | |
| 61 | − sensors: z.array(sensorSchema).default([]), | |
| 62 | − categories: z.array(z.string()).default([]), | |
| 63 | − fallback: z.object({ firecrawl: z.boolean().optional(), scrapfly: z.boolean().optional() }).optional(), | |
| 64 | − notes: z.string().optional(), | |
| 65 | −}); | |
| 14 | +export { sensorSchema, productSchema, sourceSchema, extendSchema } from "@websensor/core"; | |
| 15 | +export type { SensorSeed, SourceSeed } from "@websensor/core"; | |
| 16 | +import { extendSchema, sourceSchema, type SourceSeed } from "@websensor/core"; | |
| 66 | 17 | |
| 67 | 18 | export interface SeedIssue { |
| 68 | 19 | file: string; |
@@ -124,6 +75,9 @@ export function loadSeedsDetailed(file: string, dir: string): LoadedSeeds { | ||
| 124 | 75 | base.sensors = [...base.sensors, ...parsed.data.sensors.filter((n) => !base.sensors.some((o) => o.url === n.url))]; |
| 125 | 76 | if (parsed.data.fallback) base.fallback = { ...base.fallback, ...parsed.data.fallback }; |
| 126 | 77 | if (parsed.data.notes) base.notes = [base.notes, parsed.data.notes].filter(Boolean).join(" "); |
| 78 | + if (parsed.data.country && !base.country) base.country = parsed.data.country; | |
| 79 | + if (parsed.data.language && !base.language) base.language = parsed.data.language; | |
| 80 | + if (parsed.data.first_party !== undefined && base.first_party === undefined) base.first_party = parsed.data.first_party; | |
| 127 | 81 | continue; |
| 128 | 82 | } |
| 129 | 83 | const parsed = sourceSchema.safeParse(s); |
modified
apps/web/src/app/alerts/alerts.tsx
+362 −71
@@ -1,22 +1,28 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { Bell, BellRing, Trash2 } from "lucide-react"; | |
| 3 | +import { Bell, BellOff, BellRing, Check, Trash2, Webhook } from "lucide-react"; | |
| 4 | 4 | import Link from "next/link"; |
| 5 | −import { useCallback, useEffect, useState } from "react"; | |
| 6 | −import { EVENT_TYPES } from "@websensor/core/client"; | |
| 5 | +import { useSearchParams } from "next/navigation"; | |
| 6 | +import { useEffect, useMemo, useState } from "react"; | |
| 7 | +import { COUNTRIES, EVENT_TYPES, eventGroupOf } from "@websensor/core/client"; | |
| 7 | 8 | import { LiveDot } from "@/components/live-feed"; |
| 8 | 9 | import { useMounted } from "@/components/theme"; |
| 9 | −import { Chip, Empty, Panel, Score } from "@/components/ui"; | |
| 10 | −import type { Alert, AlertRule, LiveEvent } from "@/lib/api"; | |
| 11 | −import { relTime, typeLabel } from "@/lib/format"; | |
| 10 | +import { Chip, Empty, Flag, Panel, Score, SkeletonRows } from "@/components/ui"; | |
| 11 | +import type { Alert, AlertRule, LiveEvent, Notification } from "@/lib/api"; | |
| 12 | +import { CHANNEL_KEYS, GROUP_LABELS, relTime, typeLabel, utcDateTime } from "@/lib/format"; | |
| 12 | 13 | import { ownerFetch } from "@/lib/owner"; |
| 13 | 14 | import { useLive } from "@/lib/use-live"; |
| 14 | 15 | |
| 15 | 16 | function matches(rule: AlertRule, e: LiveEvent): boolean { |
| 16 | 17 | if (rule.importance_min !== undefined && e.importance < rule.importance_min) return false; |
| 18 | + if (rule.signal_min !== undefined && (e.signal ?? e.importance) < rule.signal_min) return false; | |
| 17 | 19 | if (rule.silent_only && !e.silent) return false; |
| 20 | + if (rule.first_party_only && e.firstParty === false) return false; | |
| 21 | + if (rule.confirmed_only && e.evidence !== "CONFIRMED" && e.clusterState !== "confirmed") return false; | |
| 18 | 22 | if (rule.event_types?.length && !rule.event_types.includes(e.type)) return false; |
| 23 | + if (rule.groups?.length && !rule.groups.includes(e.group ?? eventGroupOf(e.type))) return false; | |
| 19 | 24 | if (rule.categories?.length && !rule.categories.some((c) => e.categories.includes(c))) return false; |
| 25 | + if (rule.countries?.length && !(e.country && rule.countries.includes(e.country.toUpperCase()))) return false; | |
| 20 | 26 | if (rule.entities?.length && !e.entities.some((x) => rule.entities!.includes(x.id))) return false; |
| 21 | 27 | if (rule.sources?.length && !rule.sources.includes(e.source?.id)) return false; |
| 22 | 28 | if (rule.keywords?.length) { |
@@ -26,28 +32,112 @@ function matches(rule: AlertRule, e: LiveEvent): boolean { | ||
| 26 | 32 | return true; |
| 27 | 33 | } |
| 28 | 34 | |
| 35 | +interface Form { | |
| 36 | + name: string; | |
| 37 | + importance_min: number; | |
| 38 | + signal_min: number; | |
| 39 | + silent_only: boolean; | |
| 40 | + first_party_only: boolean; | |
| 41 | + confirmed_only: boolean; | |
| 42 | + event_types: string[]; | |
| 43 | + groups: string[]; | |
| 44 | + categories: string[]; | |
| 45 | + countries: string[]; | |
| 46 | + entities: string; | |
| 47 | + sources: string; | |
| 48 | + keywords: string; | |
| 49 | + channel: "web" | "webhook"; | |
| 50 | + webhook_url: string; | |
| 51 | + webhook_secret: string; | |
| 52 | +} | |
| 53 | + | |
| 54 | +const EMPTY: Form = { name: "", importance_min: 0, signal_min: 60, silent_only: false, first_party_only: false, confirmed_only: false, event_types: [], groups: [], categories: [], countries: [], entities: "", sources: "", keywords: "", channel: "web", webhook_url: "", webhook_secret: "" }; | |
| 55 | + | |
| 56 | +const PRESETS: { label: string; form: Partial<Form> }[] = [ | |
| 57 | + { label: "OpenAI pricing", form: { name: "OpenAI pricing", entities: "org_openai", event_types: ["pricing_change"], signal_min: 0 } }, | |
| 58 | + { label: "Anthropic model release", form: { name: "Anthropic model release", entities: "org_anthropic", event_types: ["model_release"], signal_min: 0 } }, | |
| 59 | + { label: "Cloudflare outage", form: { name: "Cloudflare outage", entities: "org_cloudflare", groups: ["reliability"], signal_min: 0 } }, | |
| 60 | + { label: "Critical CISA/KEV", form: { name: "Critical CISA / KEV", sources: "cisa", groups: ["security"], signal_min: 70, first_party_only: true } }, | |
| 61 | + { label: "Tesla pricing", form: { name: "Tesla pricing", entities: "org_tesla", event_types: ["pricing_change"], signal_min: 0 } }, | |
| 62 | + { label: "NVIDIA ≥ 80", form: { name: "NVIDIA ≥ 80", entities: "org_nvidia", signal_min: 80 } }, | |
| 63 | +]; | |
| 64 | + | |
| 65 | +const COUNTRY_CODES = Object.keys(COUNTRIES); | |
| 66 | + | |
| 67 | +function toggle<T>(list: T[], v: T): T[] { | |
| 68 | + return list.includes(v) ? list.filter((x) => x !== v) : [...list, v]; | |
| 69 | +} | |
| 70 | + | |
| 71 | +function ChipToggle({ on, onClick, children, tone = "signal", title }: { on: boolean; onClick: () => void; children: React.ReactNode; tone?: "signal" | "silent" | "info" | "ok"; title?: string }) { | |
| 72 | + const onCls = tone === "silent" ? "border-silent/50 bg-silent-soft text-silent" : tone === "info" ? "border-info/50 bg-info/10 text-info" : tone === "ok" ? "border-ok/50 bg-ok/10 text-ok" : "border-signal/50 bg-signal-soft text-signal"; | |
| 73 | + return ( | |
| 74 | + <button type="button" aria-pressed={on} title={title} onClick={onClick} className={`inline-flex items-center gap-1 rounded-sm border px-1.5 py-px text-[10.5px] leading-4 ${on ? onCls : "border-line text-fg-muted hover:text-fg"}`}> | |
| 75 | + {children} | |
| 76 | + </button> | |
| 77 | + ); | |
| 78 | +} | |
| 79 | + | |
| 80 | +function ruleChips(rule: AlertRule) { | |
| 81 | + const out: React.ReactNode[] = []; | |
| 82 | + if (rule.signal_min) out.push(<Chip key="sig" tone="signal">signal ≥ {rule.signal_min}</Chip>); | |
| 83 | + if (rule.importance_min) out.push(<Chip key="imp">importance ≥ {rule.importance_min}</Chip>); | |
| 84 | + if (rule.silent_only) out.push(<Chip key="silent" tone="silent">silent only</Chip>); | |
| 85 | + if (rule.first_party_only) out.push(<Chip key="fp" tone="signal">first-party</Chip>); | |
| 86 | + if (rule.confirmed_only) out.push(<Chip key="cf" tone="ok">confirmed</Chip>); | |
| 87 | + rule.groups?.forEach((g) => out.push(<Chip key={`g-${g}`} tone="info">{GROUP_LABELS[g] ?? g}</Chip>)); | |
| 88 | + rule.event_types?.forEach((t) => out.push(<Chip key={`t-${t}`}>{typeLabel(t)}</Chip>)); | |
| 89 | + rule.categories?.forEach((c) => out.push(<Chip key={`c-${c}`}>{c}</Chip>)); | |
| 90 | + rule.countries?.forEach((c) => out.push(<Chip key={`co-${c}`}><Flag code={c} /> {c}</Chip>)); | |
| 91 | + rule.entities?.forEach((t) => out.push(<Chip key={`e-${t}`} tone="signal" href={`/entity/${t}`}>{t.replace(/^(org|prd|ent)_/, "")}</Chip>)); | |
| 92 | + rule.sources?.forEach((t) => out.push(<Chip key={`s-${t}`} tone="info" href={`/source/${t}`}>{t}</Chip>)); | |
| 93 | + rule.keywords?.forEach((t) => out.push(<Chip key={`k-${t}`}>“{t}”</Chip>)); | |
| 94 | + if (!out.length) out.push(<Chip key="all">every event</Chip>); | |
| 95 | + return out; | |
| 96 | +} | |
| 97 | + | |
| 29 | 98 | export function Alerts() { |
| 99 | + const sp = useSearchParams(); | |
| 100 | + const prefillEntity = sp.get("entity") ?? ""; | |
| 30 | 101 | const [alerts, setAlerts] = useState<Alert[] | null>(null); |
| 102 | + const [notifs, setNotifs] = useState<{ items: Notification[]; unread: number } | null>(null); | |
| 31 | 103 | const [fired, setFired] = useState<{ alert: Alert; event: LiveEvent; at: number }[]>([]); |
| 32 | 104 | const mounted = useMounted(); |
| 33 | 105 | const [permOverride, setPerm] = useState<NotificationPermission | null>(null); |
| 34 | 106 | const perm: NotificationPermission | "unsupported" = !mounted ? "default" : permOverride ?? (typeof Notification === "undefined" ? "unsupported" : Notification.permission); |
| 35 | − const [form, setForm] = useState<{ name: string; importance_min: number; event_types: string[]; entities: string; sources: string; keywords: string; silent_only: boolean }>({ name: "", importance_min: 70, event_types: [], entities: "", sources: "", keywords: "", silent_only: false }); | |
| 107 | + const [form, setForm] = useState<Form>({ ...EMPTY, entities: prefillEntity, name: prefillEntity ? `${prefillEntity.replace(/^(org|prd|ent)_/, "")} alerts` : "" }); | |
| 36 | 108 | const [err, setErr] = useState<string | null>(null); |
| 109 | + const [busy, setBusy] = useState(false); | |
| 110 | + const [created, setCreated] = useState<string | null>(null); | |
| 111 | + const [tick, setTick] = useState(0); | |
| 112 | + const reload = (): void => setTick((t) => t + 1); | |
| 37 | 113 | |
| 38 | − const load = useCallback( | |
| 39 | − () => | |
| 40 | − ownerFetch<{ items: Alert[] }>("/api/v1/alerts") | |
| 41 | − .then((r) => setAlerts(r.items)) | |
| 42 | − .catch((e: Error) => { | |
| 43 | − setErr(e.message); | |
| 44 | − setAlerts([]); | |
| 45 | − }), | |
| 46 | − [], | |
| 47 | − ); | |
| 48 | 114 | useEffect(() => { |
| 49 | − void load(); | |
| 50 | − }, [load]); | |
| 115 | + let cancelled = false; | |
| 116 | + ownerFetch<{ items: Alert[] }>("/api/v1/alerts") | |
| 117 | + .then((r) => { | |
| 118 | + if (!cancelled) setAlerts(r.items); | |
| 119 | + }) | |
| 120 | + .catch((e: Error) => { | |
| 121 | + if (cancelled) return; | |
| 122 | + setErr(e.message); | |
| 123 | + setAlerts([]); | |
| 124 | + }); | |
| 125 | + const pull = (): void => { | |
| 126 | + ownerFetch<{ items: Notification[]; unread: number }>("/api/v1/notifications?limit=50") | |
| 127 | + .then((r) => { | |
| 128 | + if (!cancelled) setNotifs(r); | |
| 129 | + }) | |
| 130 | + .catch(() => { | |
| 131 | + if (!cancelled) setNotifs({ items: [], unread: 0 }); | |
| 132 | + }); | |
| 133 | + }; | |
| 134 | + pull(); | |
| 135 | + const t = setInterval(pull, 30_000); | |
| 136 | + return () => { | |
| 137 | + cancelled = true; | |
| 138 | + clearInterval(t); | |
| 139 | + }; | |
| 140 | + }, [tick]); | |
| 51 | 141 | |
| 52 | 142 | const status = useLive(["events:global"], (e) => { |
| 53 | 143 | for (const a of alerts ?? []) { |
@@ -55,7 +145,7 @@ export function Alerts() { | ||
| 55 | 145 | setFired((prev) => [{ alert: a, event: e, at: Date.now() }, ...prev].slice(0, 50)); |
| 56 | 146 | if (typeof Notification !== "undefined" && Notification.permission === "granted") { |
| 57 | 147 | try { |
| 58 | − const n = new Notification(`${e.source?.name ?? "WebSensor"} · ${Math.round(e.importance)}`, { body: e.title, tag: e.id, icon: "/icon.svg" }); | |
| 148 | + const n = new Notification(`${e.source?.name ?? "WebSensor"} · ${Math.round(e.signal ?? e.importance)}`, { body: e.title, tag: e.id, icon: "/icon.svg" }); | |
| 59 | 149 | n.onclick = () => window.open(`/event/${e.slug}`, "_blank"); |
| 60 | 150 | } catch { |
| 61 | 151 | // notifications unavailable |
@@ -64,69 +154,232 @@ export function Alerts() { | ||
| 64 | 154 | } |
| 65 | 155 | }); |
| 66 | 156 | |
| 157 | + const split = (s: string): string[] | undefined => { | |
| 158 | + const arr = s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean); | |
| 159 | + return arr.length ? arr : undefined; | |
| 160 | + }; | |
| 161 | + | |
| 67 | 162 | const create = async (): Promise<void> => { |
| 68 | − const split = (s: string): string[] | undefined => s.split(",").map((x) => x.trim()).filter(Boolean).length ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined; | |
| 69 | − const rule: AlertRule = { importance_min: form.importance_min, event_types: form.event_types.length ? form.event_types : undefined, entities: split(form.entities), sources: split(form.sources), keywords: split(form.keywords), silent_only: form.silent_only || undefined }; | |
| 163 | + setErr(null); | |
| 164 | + setCreated(null); | |
| 165 | + const rule: AlertRule = { | |
| 166 | + importance_min: form.importance_min > 0 ? form.importance_min : undefined, | |
| 167 | + signal_min: form.signal_min > 0 ? form.signal_min : undefined, | |
| 168 | + event_types: form.event_types.length ? form.event_types : undefined, | |
| 169 | + groups: form.groups.length ? form.groups : undefined, | |
| 170 | + categories: form.categories.length ? form.categories : undefined, | |
| 171 | + countries: form.countries.length ? form.countries : undefined, | |
| 172 | + entities: split(form.entities), | |
| 173 | + sources: split(form.sources), | |
| 174 | + keywords: split(form.keywords), | |
| 175 | + silent_only: form.silent_only || undefined, | |
| 176 | + first_party_only: form.first_party_only || undefined, | |
| 177 | + confirmed_only: form.confirmed_only || undefined, | |
| 178 | + }; | |
| 179 | + if (form.channel === "webhook" && !/^https:\/\//i.test(form.webhook_url.trim())) { | |
| 180 | + setErr("Webhook URL must start with https:// and be publicly reachable."); | |
| 181 | + return; | |
| 182 | + } | |
| 183 | + setBusy(true); | |
| 184 | + try { | |
| 185 | + const body = { name: form.name.trim() || "Alert", rule, channel: form.channel, channel_config: form.channel === "webhook" ? { url: form.webhook_url.trim(), ...(form.webhook_secret.trim() ? { secret: form.webhook_secret.trim() } : {}) } : {} }; | |
| 186 | + const r = await ownerFetch<{ id: string; name: string }>("/api/v1/alerts", { method: "POST", body: JSON.stringify(body) }); | |
| 187 | + setForm(EMPTY); | |
| 188 | + setCreated(r.name); | |
| 189 | + reload(); | |
| 190 | + } catch (e) { | |
| 191 | + setErr((e as Error).message); | |
| 192 | + } finally { | |
| 193 | + setBusy(false); | |
| 194 | + } | |
| 195 | + }; | |
| 196 | + | |
| 197 | + const setEnabled = async (a: Alert, enabled: boolean): Promise<void> => { | |
| 198 | + setAlerts((prev) => prev?.map((x) => (x.id === a.id ? { ...x, enabled } : x)) ?? prev); | |
| 70 | 199 | try { |
| 71 | − await ownerFetch("/api/v1/alerts", { method: "POST", body: JSON.stringify({ name: form.name.trim() || "Alert", rule, channel: "web" }) }); | |
| 72 | − setForm({ name: "", importance_min: 70, event_types: [], entities: "", sources: "", keywords: "", silent_only: false }); | |
| 73 | − await load(); | |
| 200 | + await ownerFetch(`/api/v1/alerts/${a.id}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); | |
| 74 | 201 | } catch (e) { |
| 75 | 202 | setErr((e as Error).message); |
| 203 | + reload(); | |
| 76 | 204 | } |
| 77 | 205 | }; |
| 206 | + const remove = async (a: Alert): Promise<void> => { | |
| 207 | + setAlerts((prev) => prev?.filter((x) => x.id !== a.id) ?? prev); | |
| 208 | + try { | |
| 209 | + await ownerFetch(`/api/v1/alerts/${a.id}`, { method: "DELETE" }); | |
| 210 | + } catch (e) { | |
| 211 | + setErr((e as Error).message); | |
| 212 | + reload(); | |
| 213 | + } | |
| 214 | + }; | |
| 215 | + const markAllRead = async (): Promise<void> => { | |
| 216 | + try { | |
| 217 | + await ownerFetch("/api/v1/notifications/read", { method: "POST", body: JSON.stringify({}) }); | |
| 218 | + reload(); | |
| 219 | + } catch (e) { | |
| 220 | + setErr((e as Error).message); | |
| 221 | + } | |
| 222 | + }; | |
| 223 | + | |
| 224 | + const applyPreset = (p: Partial<Form>): void => setForm({ ...EMPTY, ...p }); | |
| 225 | + const activeFilters = useMemo(() => [form.signal_min > 0, form.importance_min > 0, form.silent_only, form.first_party_only, form.confirmed_only, form.event_types.length > 0, form.groups.length > 0, form.categories.length > 0, form.countries.length > 0, Boolean(form.entities.trim()), Boolean(form.sources.trim()), Boolean(form.keywords.trim())].filter(Boolean).length, [form]); | |
| 78 | 226 | |
| 79 | 227 | return ( |
| 80 | − <div className="grid gap-4 lg:grid-cols-[380px_1fr]"> | |
| 81 | − <aside className="flex flex-col gap-4"> | |
| 82 | − <Panel title="New rule"> | |
| 228 | + <div className="grid gap-4 lg:grid-cols-[400px_minmax(0,1fr)]"> | |
| 229 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 230 | + <Panel title={<span>New rule <span className="normal-case tracking-normal text-fg-subtle">· {activeFilters} condition{activeFilters === 1 ? "" : "s"}</span></span>}> | |
| 83 | 231 | <form |
| 84 | − className="flex flex-col gap-2 text-[12.5px]" | |
| 232 | + className="flex flex-col gap-3 text-[12.5px]" | |
| 85 | 233 | onSubmit={(e) => { |
| 86 | 234 | e.preventDefault(); |
| 87 | 235 | void create(); |
| 88 | 236 | }} |
| 89 | 237 | > |
| 90 | − <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Rule name" className="h-8 rounded-md border border-line bg-panel px-2" /> | |
| 91 | − <label className="flex items-center justify-between gap-2"> | |
| 92 | − <span className="text-fg-muted">Importance ≥ <span className="font-mono">{form.importance_min}</span></span> | |
| 93 | − <input type="range" min={0} max={100} value={form.importance_min} onChange={(e) => setForm({ ...form, importance_min: Number(e.target.value) })} className="w-40 accent-[var(--signal)]" /> | |
| 94 | − </label> | |
| 95 | − <label className="flex items-center gap-2"> | |
| 96 | − <input type="checkbox" checked={form.silent_only} onChange={(e) => setForm({ ...form, silent_only: e.target.checked })} className="accent-[var(--silent)]" /> | |
| 97 | − <span className="text-silent">Silent changes only</span> | |
| 238 | + <div> | |
| 239 | + <div className="label mb-1">Presets</div> | |
| 240 | + <div className="flex flex-wrap gap-1"> | |
| 241 | + {PRESETS.map((p) => ( | |
| 242 | + <button key={p.label} type="button" onClick={() => applyPreset(p.form)} className="rounded-sm border border-line px-1.5 py-px text-[10.5px] leading-4 text-fg-muted hover:border-line-strong hover:text-fg"> | |
| 243 | + {p.label} | |
| 244 | + </button> | |
| 245 | + ))} | |
| 246 | + </div> | |
| 247 | + </div> | |
| 248 | + | |
| 249 | + <label className="flex flex-col gap-1"> | |
| 250 | + <span className="label">Name</span> | |
| 251 | + <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Rule name" maxLength={80} className="h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]" /> | |
| 98 | 252 | </label> |
| 253 | + | |
| 254 | + <div className="grid grid-cols-2 gap-3"> | |
| 255 | + <label className="flex flex-col gap-1"> | |
| 256 | + <span className="flex items-center justify-between"><span className="label">Signal ≥</span><span className="font-mono text-[12px] tabular">{form.signal_min || "any"}</span></span> | |
| 257 | + <input type="range" min={0} max={100} step={5} value={form.signal_min} onChange={(e) => setForm({ ...form, signal_min: Number(e.target.value) })} className="w-full accent-[var(--signal)]" aria-label="Minimum signal score" /> | |
| 258 | + </label> | |
| 259 | + <label className="flex flex-col gap-1"> | |
| 260 | + <span className="flex items-center justify-between"><span className="label">Importance ≥</span><span className="font-mono text-[12px] tabular">{form.importance_min || "any"}</span></span> | |
| 261 | + <input type="range" min={0} max={100} step={5} value={form.importance_min} onChange={(e) => setForm({ ...form, importance_min: Number(e.target.value) })} className="w-full accent-[var(--signal)]" aria-label="Minimum importance" /> | |
| 262 | + </label> | |
| 263 | + </div> | |
| 264 | + | |
| 265 | + <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 266 | + <label className="flex items-center gap-1.5"> | |
| 267 | + <input type="checkbox" checked={form.silent_only} onChange={(e) => setForm({ ...form, silent_only: e.target.checked })} className="accent-[var(--silent)]" /> | |
| 268 | + <span className="text-silent">Silent only</span> | |
| 269 | + </label> | |
| 270 | + <label className="flex items-center gap-1.5"> | |
| 271 | + <input type="checkbox" checked={form.first_party_only} onChange={(e) => setForm({ ...form, first_party_only: e.target.checked })} className="accent-[var(--signal)]" /> | |
| 272 | + <span>First-party only</span> | |
| 273 | + </label> | |
| 274 | + <label className="flex items-center gap-1.5"> | |
| 275 | + <input type="checkbox" checked={form.confirmed_only} onChange={(e) => setForm({ ...form, confirmed_only: e.target.checked })} className="accent-[var(--ok)]" /> | |
| 276 | + <span>Confirmed only</span> | |
| 277 | + </label> | |
| 278 | + </div> | |
| 279 | + | |
| 280 | + <div> | |
| 281 | + <div className="label mb-1">Groups</div> | |
| 282 | + <div className="flex flex-wrap gap-1"> | |
| 283 | + {Object.entries(GROUP_LABELS).map(([k, v]) => ( | |
| 284 | + <ChipToggle key={k} on={form.groups.includes(k)} tone="info" onClick={() => setForm({ ...form, groups: toggle(form.groups, k) })}>{v}</ChipToggle> | |
| 285 | + ))} | |
| 286 | + </div> | |
| 287 | + </div> | |
| 288 | + | |
| 289 | + <div> | |
| 290 | + <div className="mb-1 flex items-center justify-between"> | |
| 291 | + <span className="label">Event types</span> | |
| 292 | + {form.event_types.length > 0 && <button type="button" onClick={() => setForm({ ...form, event_types: [] })} className="text-[10.5px] text-fg-subtle hover:text-fg">clear {form.event_types.length}</button>} | |
| 293 | + </div> | |
| 294 | + <div className="flex max-h-28 flex-wrap gap-1 overflow-auto rounded-md border border-line p-1.5"> | |
| 295 | + {Object.entries(EVENT_TYPES).map(([k, v]) => ( | |
| 296 | + <ChipToggle key={k} on={form.event_types.includes(k)} onClick={() => setForm({ ...form, event_types: toggle(form.event_types, k) })} title={`${k} · severity ${v.severity}`}>{v.label}</ChipToggle> | |
| 297 | + ))} | |
| 298 | + </div> | |
| 299 | + </div> | |
| 300 | + | |
| 99 | 301 | <div> |
| 100 | − <div className="label mb-1">Event types</div> | |
| 101 | − <div className="flex max-h-32 flex-wrap gap-1 overflow-auto rounded-md border border-line p-1.5"> | |
| 102 | − {Object.entries(EVENT_TYPES).map(([k, v]) => { | |
| 103 | − const on = form.event_types.includes(k); | |
| 104 | − return ( | |
| 105 | − <button key={k} type="button" onClick={() => setForm({ ...form, event_types: on ? form.event_types.filter((x) => x !== k) : [...form.event_types, k] })} className={`rounded-sm border px-1.5 py-px text-[10.5px] ${on ? "border-signal/50 bg-signal-soft text-signal" : "border-line text-fg-muted hover:text-fg"}`}> | |
| 106 | − {v.label} | |
| 107 | − </button> | |
| 108 | − ); | |
| 109 | − })} | |
| 302 | + <div className="label mb-1">Categories</div> | |
| 303 | + <div className="flex flex-wrap gap-1"> | |
| 304 | + {CHANNEL_KEYS.map((c) => ( | |
| 305 | + <ChipToggle key={c} on={form.categories.includes(c)} tone="ok" onClick={() => setForm({ ...form, categories: toggle(form.categories, c) })}>{c}</ChipToggle> | |
| 306 | + ))} | |
| 110 | 307 | </div> |
| 111 | 308 | </div> |
| 112 | − <input value={form.entities} onChange={(e) => setForm({ ...form, entities: e.target.value })} placeholder="Entity ids, comma-separated (org_openai, prd_openai_api)" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" /> | |
| 113 | − <input value={form.sources} onChange={(e) => setForm({ ...form, sources: e.target.value })} placeholder="Source ids (openai, cisa)" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" /> | |
| 114 | − <input value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="Keywords (pricing, CVE, outage)" className="h-8 rounded-md border border-line bg-panel px-2" /> | |
| 115 | − <button type="submit" className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-line bg-panel-2 px-3 hover:border-line-strong"><Bell className="size-3.5" /> Create rule</button> | |
| 309 | + | |
| 310 | + <div> | |
| 311 | + <div className="mb-1 flex items-center justify-between"> | |
| 312 | + <span className="label">Countries</span> | |
| 313 | + {form.countries.length > 0 && <button type="button" onClick={() => setForm({ ...form, countries: [] })} className="text-[10.5px] text-fg-subtle hover:text-fg">clear {form.countries.length}</button>} | |
| 314 | + </div> | |
| 315 | + <div className="flex max-h-20 flex-wrap gap-1 overflow-auto rounded-md border border-line p-1.5"> | |
| 316 | + {COUNTRY_CODES.map((c) => ( | |
| 317 | + <ChipToggle key={c} on={form.countries.includes(c)} onClick={() => setForm({ ...form, countries: toggle(form.countries, c) })} title={COUNTRIES[c]?.name}> | |
| 318 | + <span className="font-mono">{c}</span> | |
| 319 | + </ChipToggle> | |
| 320 | + ))} | |
| 321 | + </div> | |
| 322 | + </div> | |
| 323 | + | |
| 324 | + <label className="flex flex-col gap-1"> | |
| 325 | + <span className="label">Entities <span className="normal-case tracking-normal">· ids, comma-separated</span></span> | |
| 326 | + <input value={form.entities} onChange={(e) => setForm({ ...form, entities: e.target.value })} placeholder="org_openai, prd_openai_openai-api" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" /> | |
| 327 | + </label> | |
| 328 | + <label className="flex flex-col gap-1"> | |
| 329 | + <span className="label">Sources <span className="normal-case tracking-normal">· ids</span></span> | |
| 330 | + <input value={form.sources} onChange={(e) => setForm({ ...form, sources: e.target.value })} placeholder="openai, cisa" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" /> | |
| 331 | + </label> | |
| 332 | + <label className="flex flex-col gap-1"> | |
| 333 | + <span className="label">Keywords <span className="normal-case tracking-normal">· in title or summary</span></span> | |
| 334 | + <input value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="pricing, CVE, outage" className="h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]" /> | |
| 335 | + </label> | |
| 336 | + | |
| 337 | + <fieldset className="rounded-md border border-line p-2"> | |
| 338 | + <legend className="label px-1">Delivery channel</legend> | |
| 339 | + <div className="flex flex-wrap gap-x-4 gap-y-1"> | |
| 340 | + <label className="flex items-center gap-1.5"> | |
| 341 | + <input type="radio" name="channel" checked={form.channel === "web"} onChange={() => setForm({ ...form, channel: "web" })} className="accent-[var(--signal)]" /> | |
| 342 | + <Bell className="size-3.5 text-fg-muted" /> Web (this browser) | |
| 343 | + </label> | |
| 344 | + <label className="flex items-center gap-1.5"> | |
| 345 | + <input type="radio" name="channel" checked={form.channel === "webhook"} onChange={() => setForm({ ...form, channel: "webhook" })} className="accent-[var(--signal)]" /> | |
| 346 | + <Webhook className="size-3.5 text-fg-muted" /> Webhook | |
| 347 | + </label> | |
| 348 | + </div> | |
| 349 | + {form.channel === "webhook" && ( | |
| 350 | + <div className="mt-2 flex flex-col gap-1.5"> | |
| 351 | + <input type="url" value={form.webhook_url} onChange={(e) => setForm({ ...form, webhook_url: e.target.value })} placeholder="https://example.com/hooks/websensor" required className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" aria-label="Webhook URL" /> | |
| 352 | + <input value={form.webhook_secret} onChange={(e) => setForm({ ...form, webhook_secret: e.target.value })} placeholder="Shared secret (optional)" className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" aria-label="Webhook secret" autoComplete="off" /> | |
| 353 | + <p className="text-[11px] leading-relaxed text-fg-subtle"> | |
| 354 | + POST JSON per matching event. With a secret, each request carries <code className="text-fg-muted">X-WebSensor-Signature: sha256=…</code> — an HMAC-SHA256 of the raw body you can verify. HTTPS only; private, loopback and cloud-metadata addresses are rejected. | |
| 355 | + </p> | |
| 356 | + </div> | |
| 357 | + )} | |
| 358 | + </fieldset> | |
| 359 | + | |
| 360 | + <button type="submit" disabled={busy} className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-line bg-panel-2 px-3 text-[12.5px] hover:border-line-strong disabled:opacity-60"> | |
| 361 | + <Bell className="size-3.5" /> {busy ? "Creating…" : "Create rule"} | |
| 362 | + </button> | |
| 116 | 363 | {err && <p className="text-[11px] text-danger">{err}</p>} |
| 364 | + {created && <p className="flex items-center gap-1 text-[11px] text-signal"><Check className="size-3" /> Rule “{created}” created.</p>} | |
| 117 | 365 | </form> |
| 118 | 366 | </Panel> |
| 119 | − <Panel title="Delivery"> | |
| 367 | + | |
| 368 | + <Panel title="Delivery status"> | |
| 120 | 369 | <div className="flex flex-col gap-2 text-[12.5px]"> |
| 121 | 370 | <div className="flex items-center justify-between"> |
| 122 | 371 | <span>Web (this browser)</span> |
| 123 | 372 | <Chip tone="ok">active</Chip> |
| 124 | 373 | </div> |
| 374 | + <div className="flex items-center justify-between"> | |
| 375 | + <span>Webhook (HTTPS, signed)</span> | |
| 376 | + <Chip tone="ok">active</Chip> | |
| 377 | + </div> | |
| 125 | 378 | <div className="flex items-center justify-between"> |
| 126 | 379 | <span>Browser notifications</span> |
| 127 | − {perm === "granted" ? <Chip tone="ok">granted</Chip> : perm === "unsupported" ? <Chip>unsupported</Chip> : perm === "denied" ? <Chip tone="danger">denied</Chip> : <button type="button" onClick={() => Notification.requestPermission().then(setPerm)} className="rounded-md border border-line bg-panel-2 px-2 py-0.5 text-[12px]">Enable</button>} | |
| 380 | + {perm === "granted" ? <Chip tone="ok">granted</Chip> : perm === "unsupported" ? <Chip>unsupported</Chip> : perm === "denied" ? <Chip tone="danger">denied</Chip> : <button type="button" onClick={() => Notification.requestPermission().then(setPerm)} className="h-7 rounded-md border border-line bg-panel-2 px-2 text-[12px] hover:border-line-strong">Enable</button>} | |
| 128 | 381 | </div> |
| 129 | − {["Email", "Push", "Webhook", "Slack", "Discord"].map((c) => ( | |
| 382 | + {["Email", "Push", "Slack", "Discord", "Telegram"].map((c) => ( | |
| 130 | 383 | <div key={c} className="flex items-center justify-between text-fg-muted"> |
| 131 | 384 | <span>{c}</span> |
| 132 | 385 | <Chip>planned</Chip> |
@@ -135,40 +388,78 @@ export function Alerts() { | ||
| 135 | 388 | </div> |
| 136 | 389 | </Panel> |
| 137 | 390 | </aside> |
| 138 | − <div className="flex flex-col gap-4"> | |
| 139 | − <Panel title={`Rules · ${alerts?.length ?? 0}`} dense> | |
| 391 | + | |
| 392 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 393 | + <Panel title={<span>Rules <span className="font-mono text-fg-subtle">{alerts?.length ?? "…"}</span></span>} dense> | |
| 140 | 394 | {alerts === null ? ( |
| 141 | − <Empty>Loading…</Empty> | |
| 395 | + <SkeletonRows rows={3} /> | |
| 142 | 396 | ) : alerts.length ? ( |
| 143 | 397 | <ul className="divide-y divide-line"> |
| 144 | 398 | {alerts.map((a) => ( |
| 145 | − <li key={a.id} className="flex items-start gap-3 px-3 py-2 text-[13px]"> | |
| 146 | − <BellRing className="mt-0.5 size-4 text-signal" /> | |
| 147 | − <div className="min-w-0 flex-1"> | |
| 148 | − <div className="font-medium">{a.name}</div> | |
| 149 | − <div className="mt-0.5 flex flex-wrap gap-1"> | |
| 150 | − {a.rule.importance_min !== undefined && <Chip>importance ≥ {a.rule.importance_min}</Chip>} | |
| 151 | − {a.rule.silent_only && <Chip tone="silent">silent only</Chip>} | |
| 152 | − {a.rule.event_types?.map((t) => <Chip key={t}>{typeLabel(t)}</Chip>)} | |
| 153 | − {a.rule.entities?.map((t) => <Chip key={t} tone="signal">{t}</Chip>)} | |
| 154 | − {a.rule.sources?.map((t) => <Chip key={t} tone="info">{t}</Chip>)} | |
| 155 | − {a.rule.keywords?.map((t) => <Chip key={t}>“{t}”</Chip>)} | |
| 399 | + <li key={a.id} className={`grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 text-[13px] ${a.enabled ? "" : "opacity-60"}`}> | |
| 400 | + <button type="button" role="switch" aria-checked={a.enabled} aria-label={a.enabled ? "Disable rule" : "Enable rule"} title={a.enabled ? "Disable" : "Enable"} onClick={() => setEnabled(a, !a.enabled)} className={`mt-0.5 inline-flex size-6 items-center justify-center rounded-md border ${a.enabled ? "border-signal/40 bg-signal-soft text-signal" : "border-line text-fg-subtle"}`}> | |
| 401 | + {a.enabled ? <BellRing className="size-3.5" /> : <BellOff className="size-3.5" />} | |
| 402 | + </button> | |
| 403 | + <div className="min-w-0"> | |
| 404 | + <div className="flex flex-wrap items-center gap-2"> | |
| 405 | + <span className="font-medium">{a.name}</span> | |
| 406 | + <Chip tone={a.channel === "webhook" ? "info" : "default"} className="font-mono">{a.channel === "webhook" ? <><Webhook className="size-3" /> webhook</> : "web"}</Chip> | |
| 407 | + {!a.enabled && <Chip>paused</Chip>} | |
| 408 | + </div> | |
| 409 | + <div className="mt-1 flex flex-wrap gap-1">{ruleChips(a.rule)}</div> | |
| 410 | + <div className="mt-1 flex flex-wrap gap-x-3 font-mono text-[11px] text-fg-subtle tabular"> | |
| 411 | + <span>fired 24 h <span className="text-fg-muted">{a.fired_24h ?? 0}</span></span> | |
| 412 | + <span>total <span className="text-fg-muted">{a.fired_count ?? 0}</span></span> | |
| 413 | + <span>last {a.last_fired_at ? relTime(a.last_fired_at) : "never"}</span> | |
| 414 | + {a.channel === "webhook" && a.channel_config?.url && <span className="truncate">{a.channel_config.url}</span>} | |
| 415 | + </div> | |
| 416 | + </div> | |
| 417 | + <button type="button" aria-label={`Delete rule ${a.name}`} onClick={() => remove(a)} className="mt-0.5 text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button> | |
| 418 | + </li> | |
| 419 | + ))} | |
| 420 | + </ul> | |
| 421 | + ) : ( | |
| 422 | + <Empty>No rules yet — pick a preset or define conditions on the left.</Empty> | |
| 423 | + )} | |
| 424 | + </Panel> | |
| 425 | + | |
| 426 | + <Panel | |
| 427 | + title={<span>Notifications {notifs && notifs.unread > 0 && <span className="ml-1 rounded-sm border border-signal/40 bg-signal-soft px-1 font-mono text-[10px] text-signal">{notifs.unread} unread</span>}</span>} | |
| 428 | + action={notifs && notifs.unread > 0 ? <button type="button" onClick={markAllRead} className="text-[11px] text-fg-subtle hover:text-fg">mark all read</button> : undefined} | |
| 429 | + dense | |
| 430 | + > | |
| 431 | + {notifs === null ? ( | |
| 432 | + <SkeletonRows rows={3} /> | |
| 433 | + ) : notifs.items.length ? ( | |
| 434 | + <ul className="divide-y divide-line"> | |
| 435 | + {notifs.items.map((n) => ( | |
| 436 | + <li key={n.id} className={`grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-1.5 text-[12.5px] ${n.read_at ? "" : "bg-panel-2/60"}`}> | |
| 437 | + <Score value={n.event.signal_score ?? n.event.importance} size="sm" kind="signal" /> | |
| 438 | + <div className="min-w-0"> | |
| 439 | + <Link href={`/event/${n.event.slug}`} className="block truncate font-medium hover:underline">{n.event.title}</Link> | |
| 440 | + <div className="flex flex-wrap gap-x-2 text-[11px] text-fg-subtle"> | |
| 441 | + <span>rule “{n.alert_name}”</span> | |
| 442 | + <span>· {n.event.source.name}</span> | |
| 443 | + <span>· <span className="font-mono">{n.channel}</span></span> | |
| 444 | + <span className={n.status === "failed" || n.status === "error" ? "text-danger" : n.status === "delivered" || n.status === "sent" ? "text-ok" : ""}>· {n.status}</span> | |
| 445 | + {!n.read_at && <span className="text-signal">· new</span>} | |
| 156 | 446 | </div> |
| 157 | 447 | </div> |
| 158 | − <button type="button" aria-label="Delete" onClick={() => ownerFetch(`/api/v1/alerts/${a.id}`, { method: "DELETE" }).then(load)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button> | |
| 448 | + <time dateTime={n.created_at} title={utcDateTime(n.created_at)} className="whitespace-nowrap font-mono text-[11px] text-fg-subtle tabular">{relTime(n.created_at)}</time> | |
| 159 | 449 | </li> |
| 160 | 450 | ))} |
| 161 | 451 | </ul> |
| 162 | 452 | ) : ( |
| 163 | − <Empty>No rules yet.</Empty> | |
| 453 | + <Empty>No notifications yet. Rules are evaluated by the engine on every new event; deliveries are logged here.</Empty> | |
| 164 | 454 | )} |
| 165 | 455 | </Panel> |
| 456 | + | |
| 166 | 457 | <Panel title={<span className="flex items-center gap-3">Fired in this session <LiveDot status={status} /></span>} dense> |
| 167 | 458 | {fired.length ? ( |
| 168 | 459 | <ul className="divide-y divide-line"> |
| 169 | 460 | {fired.map((f, i) => ( |
| 170 | 461 | <li key={`${f.event.id}-${i}`} className="flex items-center gap-3 px-3 py-2 text-[13px] animate-fade-in"> |
| 171 | − <Score value={f.event.importance} size="sm" /> | |
| 462 | + <Score value={f.event.signal ?? f.event.importance} size="sm" kind="signal" /> | |
| 172 | 463 | <div className="min-w-0 flex-1"> |
| 173 | 464 | <Link href={`/event/${f.event.slug}`} className="block truncate font-medium hover:underline">{f.event.title}</Link> |
| 174 | 465 | <div className="text-[11px] text-fg-subtle">rule “{f.alert.name}” · {f.event.source?.name} · {relTime(new Date(f.at))}</div> |
modified
apps/web/src/app/alerts/page.tsx
+7 −4
@@ -1,14 +1,17 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | −import { PageHeader } from "@/components/ui"; | |
| 2 | +import { Suspense } from "react"; | |
| 3 | +import { PageHeader, SkeletonPanel } from "@/components/ui"; | |
| 3 | 4 | import { Alerts } from "./alerts"; |
| 4 | 5 | |
| 5 | −export const metadata: Metadata = { title: "Alerts", description: "Alert rules on importance, event type, entity, source, keyword or silent changes. Delivered in this browser today; email, webhooks, Slack and Discord are planned.", robots: { index: false } }; | |
| 6 | +export const metadata: Metadata = { title: "Alerts", description: "Alert rules on signal, importance, event type, group, entity, source, keyword, country, silent or first-party changes. Delivered in this browser or to a signed HTTPS webhook.", robots: { index: false } }; | |
| 6 | 7 | |
| 7 | 8 | export default function AlertsPage() { |
| 8 | 9 | return ( |
| 9 | 10 | <> |
| 10 | − <PageHeader kicker="Rules evaluated on the live stream" title="Alerts" description="Define conditions; while this page (or any WebSensor tab with alerts enabled) is open, matching events trigger a toast and a browser notification. Email, push, webhook, Slack and Discord delivery are planned." /> | |
| 11 | − <Alerts /> | |
| 11 | + <PageHeader compact kicker="Rules evaluated by the engine on every new event" title="Alerts" description="Define conditions once. Web rules notify this browser while any WebSensor tab is open; webhook rules POST signed JSON to your HTTPS endpoint. Every delivery is logged below." /> | |
| 12 | + <Suspense fallback={<div className="grid gap-4 lg:grid-cols-[400px_minmax(0,1fr)]"><SkeletonPanel lines={10} /><SkeletonPanel lines={6} /></div>}> | |
| 13 | + <Alerts /> | |
| 14 | + </Suspense> | |
| 12 | 15 | </> |
| 13 | 16 | ); |
| 14 | 17 | } |
modified
apps/web/src/app/api/page.tsx
+144 −62
@@ -1,34 +1,63 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import { Code } from "@/components/code"; |
| 3 | −import { PageHeader, Panel, Table, Td } from "@/components/ui"; | |
| 3 | +import { Chip, PageHeader, Panel, Table, Td } from "@/components/ui"; | |
| 4 | 4 | import { SITE_URL } from "@/lib/api"; |
| 5 | 5 | |
| 6 | −export const metadata: Metadata = { title: "API", description: "WebSensor REST API, RSS feed and WebSocket live stream. Public, no key required in phase 1." }; | |
| 6 | +export const metadata: Metadata = { title: "API", description: "WebSensor REST API, RSS feed and replayable WebSocket live stream (protocol 2). Public, no key required in phase 1; owner-scoped tools use an anonymous header." }; | |
| 7 | 7 | |
| 8 | −const ENDPOINTS: [string, string][] = [ | |
| 9 | − ["GET /api/v1/events", "List events. Filters: after, before, category, entity, source, domain, sensor, cluster, importance_min, confidence_min, event_type (comma list), silent_change, q, limit (≤200), cursor, order=recent|importance"], | |
| 10 | − ["GET /api/v1/events/{id|slug}", "Event detail: related events, cluster, change summary, interpretation versions, snapshots, sensor reliability"], | |
| 11 | − ["GET /api/v1/changes/{id}", "Raw change with stored heuristic and the unified patch"], | |
| 12 | − ["GET /api/v1/snapshots/{id}", "Snapshot metadata + canonical content; ?raw=1 streams the original body"], | |
| 8 | +const PUBLIC: [string, string][] = [ | |
| 9 | + ["GET /api/v1/events", "List events (cursor pagination). See filters below. order=recent|importance|signal"], | |
| 10 | + ["GET /api/v1/events/count", "Count of events matching the same filters as /events"], | |
| 11 | + ["GET /api/v1/events/{id|slug}", "Event detail: change + field changes + unified diff, score reasons, related, cluster with propagation, interpretations, snapshots (has_raw), sensor reliability, history of the URL"], | |
| 12 | + ["GET /api/v1/changes/{id}", "Raw change with heuristic, semantic class, field changes and the unified patch"], | |
| 13 | + ["GET /api/v1/snapshots/{id}", "Snapshot metadata + canonical content; ?raw=1 streams the original body when retention kept it"], | |
| 13 | 14 | ["GET /api/v1/snapshots/compare?a=&b=", "Diff between any two snapshots of the same URL"], |
| 14 | − ["GET /api/v1/sources", "Monitored organizations (category, q)"], | |
| 15 | − ["GET /api/v1/sources/{id}", "Source detail: sensors, entities, activity anomaly, discovered endpoints"], | |
| 16 | − ["GET /api/v1/sensors/{id}", "Sensor detail: runs, snapshots, changes"], | |
| 15 | + ["GET /api/v1/breaking", "Breaking desk: breaking_now, developing, recently_confirmed clusters + high-signal watching list"], | |
| 16 | + ["GET /api/v1/pulse", "Pulse: 6 h activity series, desks, rising entities, anomalous sources, silent changes, group totals"], | |
| 17 | + ["GET /api/v1/radar", "Radar: indicators (unusual source activity, silent clusters, doc/repo bursts, status changes, new coverage)"], | |
| 18 | + ["GET /api/v1/clusters?limit=&since=", "Event clusters (state, signals, sources, lead time)"], | |
| 19 | + ["GET /api/v1/clusters/{id|slug}", "Cluster detail: events, entities, propagation timeline, first-party vs external signals, lead_time_ms"], | |
| 17 | 20 | ["GET /api/v1/entities", "Entities (type, q)"], |
| 18 | − ["GET /api/v1/entities/{id}", "Entity detail: children, relations, sources, aliases, recent events"], | |
| 21 | + ["GET /api/v1/entities/rank?limit=", "Entity ranking: 24 h / 7 d activity, avg signal, confirmed ratio, baseline, rank score"], | |
| 22 | + ["GET /api/v1/entities/{id}", "Entity detail: children, relations, sources, aliases, recent, insights (heatmap, anomaly, rank)"], | |
| 19 | 23 | ["GET /api/v1/entities/{id}/timeline", "Entity timeline (cursor pagination)"], |
| 24 | + ["GET /api/v1/countries", "Countries with sources, events and breaking counts (24 h)"], | |
| 25 | + ["GET /api/v1/countries/{code}", "Country desk: breaking, by category, sources, by type, silent, recent"], | |
| 26 | + ["GET /api/v1/categories/{channel}", "Category desk (ai · cyber · finance · health · government · science · products · infrastructure · news)"], | |
| 27 | + ["GET /api/v1/sources", "Monitored organizations (category, q, country, tier, first_party)"], | |
| 28 | + ["GET /api/v1/sources/{id}", "Source detail: sensors, entities, activity anomaly, quality, discovery, daily"], | |
| 29 | + ["GET /api/v1/sensors/{id}", "Sensor detail: runs, snapshots, changes, events"], | |
| 30 | + ["GET /api/v1/sensors/{id}/snapshots?limit=", "Snapshot history of one sensor (hashes, HTTP status, has_raw, event slug)"], | |
| 20 | 31 | ["GET /api/v1/domains/{domain}/timeline", "Domain: monitored URLs + events"], |
| 21 | 32 | ["GET /api/v1/urls/history?url=", "URL history: snapshots, changes, events, removals"], |
| 22 | − ["GET /api/v1/search?q=", "Search events, entities, sources, URLs"], | |
| 23 | − ["GET /api/v1/stats", "Platform counters"], | |
| 33 | + ["GET /api/v1/search?q=", "Search events, entities, sources, URLs, clusters — accepts the q syntax below"], | |
| 34 | + ["GET /api/v1/stats", "Platform counters (checks/min, events/min, breaking_now, 304 ratio…)"], | |
| 24 | 35 | ["GET /api/v1/trending?hours=24", "Trending entities"], |
| 25 | 36 | ["GET /api/v1/explore", "Explore aggregates"], |
| 26 | − ["GET /api/v1/clusters", "Event clusters"], | |
| 27 | − ["GET /api/v1/health/connectors", "Connector health"], | |
| 28 | − ["GET/POST/PUT/DELETE /api/v1/watchlists", "Anonymous watchlists (header X-WebSensor-Owner)"], | |
| 29 | − ["GET/POST/DELETE /api/v1/alerts", "Alert rules (header X-WebSensor-Owner)"], | |
| 37 | + ["GET /api/v1/health/connectors", "Connector health, throughput, engine heartbeat, failing domains, slowest sensors"], | |
| 30 | 38 | ["GET /api/v1/feed.rss", "RSS 2.0 of the latest events (same filters as /events)"], |
| 31 | − ["WSS /api/v1/live", "Real-time event stream"], | |
| 39 | + ["WSS /api/v1/live", "Real-time event stream, protocol 2 (replayable)"], | |
| 40 | +]; | |
| 41 | + | |
| 42 | +const OWNER: [string, string][] = [ | |
| 43 | + ["GET/POST/PUT/DELETE /api/v1/watchlists", "Watchlists; item kinds entity · source · keyword · category · url · event_type · country · group. GET /watchlists/{id}/events"], | |
| 44 | + ["GET/POST/PATCH/DELETE /api/v1/alerts", "Alert rules {name, rule, channel: web|webhook, channel_config: {url, secret}}. PATCH {enabled, name}. Webhooks: https only, public hosts only"], | |
| 45 | + ["GET /api/v1/notifications?limit=&unread=1", "Deliveries of your rules with event summary; POST /notifications/read {ids?} marks read (all when omitted)"], | |
| 46 | + ["GET/POST/DELETE /api/v1/bookmarks", "Saved events {event_id, note?}; GET /bookmarks/ids for a quick membership check"], | |
| 47 | + ["GET/POST/DELETE /api/v1/views", "Saved live-feed views {name, query} where query is a /live query string"], | |
| 48 | + ["GET/POST/DELETE /api/v1/monitors", "Custom URL monitors {url, name?, frequency: hourly|daily, sensitivity: low|normal|high, selector?, keywords?}; GET /monitors/{id}/events. 5 per owner"], | |
| 49 | +]; | |
| 50 | + | |
| 51 | +const FILTERS: [string, string][] = [ | |
| 52 | + ["after · before", "ISO-8601 bounds on detected_at"], | |
| 53 | + ["category · group", "Category slug (ai, cyber…) · event group (security, reliability, product, commercial, corporate, government, science, transport, sports, web)"], | |
| 54 | + ["event_type", "Comma list of event types"], | |
| 55 | + ["entity · source · domain · sensor · cluster", "Scope to one id"], | |
| 56 | + ["importance_min · confidence_min · signal_min", "Score floors (0–100)"], | |
| 57 | + ["silent_change · first_party · confirmed", "Booleans (true)"], | |
| 58 | + ["country · language · change_class", "ISO-2 country (CA, US, EU…) · language code · semantic class (pricing, policy, product, personnel, meaningful…)"], | |
| 59 | + ["q", "Free text + search syntax (right)"], | |
| 60 | + ["order · limit · cursor", "recent (default) | importance | signal · ≤ 200 · opaque nextCursor"], | |
| 32 | 61 | ]; |
| 33 | 62 | |
| 34 | 63 | export default function ApiPage() { |
@@ -36,92 +65,145 @@ export default function ApiPage() { | ||
| 36 | 65 | const wss = base.replace(/^http/, "ws"); |
| 37 | 66 | return ( |
| 38 | 67 | <> |
| 39 | − <PageHeader kicker="Machine-readable WebSensor" title="API" description="WebSensor practises what it monitors: stable URLs, JSON, RSS, JSON-LD and a WebSocket feed. Phase 1 is public and unauthenticated; rate limit 600 requests / minute / IP." /> | |
| 40 | − <div className="grid gap-4 lg:grid-cols-[1fr_380px]"> | |
| 41 | − <div className="flex flex-col gap-4"> | |
| 42 | − <Panel title="REST endpoints" dense> | |
| 68 | + <PageHeader compact kicker="Machine-readable WebSensor" title="API" description="WebSensor practises what it monitors: stable URLs, JSON, RSS, JSON-LD and a replayable WebSocket feed. Phase 1 is public and unauthenticated; rate limit 600 requests / minute / IP. Owner-scoped tools (watchlists, alerts, bookmarks, views, monitors) use an anonymous X-WebSensor-Owner token generated by your browser." /> | |
| 69 | + <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_400px]"> | |
| 70 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 71 | + <Panel title="Public REST endpoints" dense> | |
| 43 | 72 | <Table head={["Endpoint", "Description"]}> |
| 44 | − {ENDPOINTS.map(([ep, desc]) => ( | |
| 73 | + {PUBLIC.map(([ep, desc]) => ( | |
| 45 | 74 | <tr key={ep}> |
| 46 | − <Td mono className="whitespace-nowrap">{ep}</Td> | |
| 75 | + <Td mono className="sm:whitespace-nowrap">{ep}</Td> | |
| 47 | 76 | <Td className="text-fg-muted">{desc}</Td> |
| 48 | 77 | </tr> |
| 49 | 78 | ))} |
| 50 | 79 | </Table> |
| 51 | 80 | </Panel> |
| 81 | + <Panel title={<span>Owner-scoped endpoints <span className="normal-case tracking-normal text-fg-subtle">· header X-WebSensor-Owner: <16–80 chars [A-Za-z0-9_-]></span></span>} dense> | |
| 82 | + <Table head={["Endpoint", "Description"]}> | |
| 83 | + {OWNER.map(([ep, desc]) => ( | |
| 84 | + <tr key={ep}> | |
| 85 | + <Td mono className="sm:whitespace-nowrap">{ep}</Td> | |
| 86 | + <Td className="text-fg-muted">{desc}</Td> | |
| 87 | + </tr> | |
| 88 | + ))} | |
| 89 | + </Table> | |
| 90 | + <p className="border-t border-line px-3 py-2 text-[11.5px] text-fg-subtle">No accounts yet: the token is your identity. Anyone holding it can read and edit these resources. Webhook deliveries are signed when a secret is set: <code>X-WebSensor-Signature: sha256=<HMAC-SHA256(secret, raw body)></code>.</p> | |
| 91 | + </Panel> | |
| 92 | + <Panel title="Event filters · /events, /events/count, /feed.rss" dense> | |
| 93 | + <Table head={["Parameter", "Meaning"]}> | |
| 94 | + {FILTERS.map(([k, v]) => ( | |
| 95 | + <tr key={k}> | |
| 96 | + <Td mono className="sm:whitespace-nowrap">{k}</Td> | |
| 97 | + <Td className="text-fg-muted">{v}</Td> | |
| 98 | + </tr> | |
| 99 | + ))} | |
| 100 | + </Table> | |
| 101 | + </Panel> | |
| 52 | 102 | <Panel title="Examples"> |
| 53 | 103 | <div className="flex flex-col gap-3 text-[13px]"> |
| 54 | − <p>Latest AI events with importance ≥ 70:</p> | |
| 55 | − <Code>{`curl "${base}/api/v1/events?category=ai&importance_min=70&limit=20"`}</Code> | |
| 56 | − <p>Silent pricing / terms changes only:</p> | |
| 57 | − <Code>{`curl "${base}/api/v1/events?silent_change=true&event_type=pricing_change,terms_change"`}</Code> | |
| 58 | − <p>OpenAI timeline as git-log style history:</p> | |
| 59 | − <Code>{`curl "${base}/api/v1/entities/org_openai/timeline?limit=50"`}</Code> | |
| 60 | − <p>Subscribe to an RSS reader:</p> | |
| 61 | − <Code>{`${base}/api/v1/feed.rss?importance_min=60`}</Code> | |
| 62 | − <p>Compare two snapshots of the same URL:</p> | |
| 63 | − <Code>{`curl "${base}/api/v1/snapshots/compare?a=snap_…&b=snap_…"`}</Code> | |
| 104 | + <p>Strongest signals of the last 48 h, first-party only:</p> | |
| 105 | + <Code>{`curl "${base}/api/v1/events?signal_min=80&first_party=true&order=signal&after=$(date -u -v-48H +%FT%TZ)"`}</Code> | |
| 106 | + <p>Silent pricing / terms changes in the commercial group:</p> | |
| 107 | + <Code>{`curl "${base}/api/v1/events?silent_change=true&group=commercial&change_class=pricing"`}</Code> | |
| 108 | + <p>Everything from Canada in the government category, counted:</p> | |
| 109 | + <Code>{`curl "${base}/api/v1/events/count?country=CA&category=government"`}</Code> | |
| 110 | + <p>Search syntax (same as the ⌘K palette):</p> | |
| 111 | + <Code>{`curl "${base}/api/v1/search?q=$(printf %s 'openai pricing type:pricing_change signal:>70 after:2026-09-01' | jq -sRr @uri)"`}</Code> | |
| 112 | + <p>Cluster with propagation timeline and lead time:</p> | |
| 113 | + <Code>{`curl "${base}/api/v1/clusters/<slug>" | jq '{lead_time_ms, first_party_signals, external_signals, propagation: .propagation[:3]}'`}</Code> | |
| 114 | + <p>Create a signed webhook alert (owner token = any 16–80 char string you keep):</p> | |
| 115 | + <Code>{`curl -X POST "${base}/api/v1/alerts" \\ | |
| 116 | + -H "content-type: application/json" -H "X-WebSensor-Owner: $OWNER" \\ | |
| 117 | + -d '{"name":"Critical security","rule":{"groups":["security"],"signal_min":80,"first_party_only":true}, | |
| 118 | + "channel":"webhook","channel_config":{"url":"https://example.com/hooks/ws","secret":"…"}}'`}</Code> | |
| 119 | + <p>Subscribe an RSS reader:</p> | |
| 120 | + <Code>{`${base}/api/v1/feed.rss?signal_min=60&group=reliability`}</Code> | |
| 64 | 121 | </div> |
| 65 | 122 | </Panel> |
| 66 | 123 | <Panel title="Event object"> |
| 67 | 124 | <Code lang="json">{`{ |
| 68 | 125 | "id": "evt_…", "slug": "openai-api-pricing-changed-…", |
| 69 | − "event_type": "pricing_change", | |
| 126 | + "event_type": "pricing_change", "change_class": "pricing", | |
| 70 | 127 | "title": "OpenAI: price changed $10 / million tokens → $8 / million tokens", |
| 71 | 128 | "summary": "…", "why_it_matters": "…", |
| 72 | − "importance": 91.2, "confidence": 98.1, "novelty": 87.4, | |
| 129 | + "signal_score": 91, "importance": 88.2, "confidence": 98.1, "novelty": 87.4, | |
| 130 | + "impact_score": 85, "velocity_score": 40, "anomaly_score": 12, | |
| 131 | + "score_reasons": [{ "sign": "+", "text": "First-party source", "points": 8 }, { "sign": "-", "text": "Single source so far", "points": -5 }], | |
| 73 | 132 | "importance_components": { "severity": 82, "source": 92, "entity": 92, "novelty": 87, "magnitude": 40, "confirmation": 0, "userImpact": 85, "unusualness": 30 }, |
| 133 | + "field_changes": [{ "label": "gpt-4o input", "kind": "price", "before": "$10.00", "after": "$8.00", "deltaPct": -20 }], | |
| 74 | 134 | "categories": ["ai", "technology"], "keywords": ["pricing"], |
| 75 | − "silent_change": true, "evidence_label": "OBSERVED", | |
| 135 | + "silent_change": true, "first_party": true, "country": "US", "language": "en", | |
| 136 | + "evidence_label": "OBSERVED", | |
| 76 | 137 | "url": "https://openai.com/api/pricing/", |
| 77 | 138 | "published_at": null, "observed_from": "…", "detected_at": "…", "processed_at": "…", |
| 78 | 139 | "detection_latency_ms": null, "processing_latency_ms": 412, |
| 79 | − "cluster_id": "clu_…", "change_id": "chg_…", "old_snapshot_id": "snap_…", "new_snapshot_id": "snap_…", | |
| 140 | + "cluster": { "id": "clu_…", "slug": "openai-pricing-…", "state": "developing", "event_count": 3, "source_count": 2, | |
| 141 | + "first_party_count": 1, "external_count": 2, "velocity": 61, "lead_time_ms": 5400000 }, | |
| 142 | + "change_id": "chg_…", "old_snapshot_id": "snap_…", "new_snapshot_id": "snap_…", | |
| 80 | 143 | "source": { "id": "openai", "name": "OpenAI", "domain": "openai.com", "tier": "S" }, |
| 81 | 144 | "sensor": { "id": "openai_pricing", "name": "pricing", "type": "HTML", "connector": "http" }, |
| 82 | − "entities": [{ "id": "org_openai", "name": "OpenAI", "type": "organization", "role": "subject" }], | |
| 83 | − "cluster_size": 3 | |
| 145 | + "entities": [{ "id": "org_openai", "name": "OpenAI", "type": "organization", "role": "subject" }] | |
| 84 | 146 | }`}</Code> |
| 85 | 147 | </Panel> |
| 86 | 148 | </div> |
| 87 | − <aside className="flex flex-col gap-4"> | |
| 88 | − <Panel title="WebSocket live stream"> | |
| 149 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 150 | + <Panel title="WebSocket live stream · protocol 2"> | |
| 89 | 151 | <div className="flex flex-col gap-3 text-[13px]"> |
| 90 | 152 | <Code>{`wscat -c ${wss}/api/v1/live |
| 91 | −> {"subscribe":["events:breaking","entity:org_openai"]}`}</Code> | |
| 153 | +> {"subscribe":["group:security","country:CA","entity:org_openai"]} | |
| 154 | +> {"since":"1757333722000-0"} # after a reconnect`}</Code> | |
| 92 | 155 | <p className="text-fg-muted">Channels:</p> |
| 93 | − <ul className="list-disc space-y-0.5 pl-5 font-mono text-[12px] text-fg-muted"> | |
| 94 | − <li>events:global (default)</li> | |
| 95 | − <li>events:breaking (importance ≥ 80)</li> | |
| 96 | − <li>events:silent</li> | |
| 97 | − <li>events:ai · cyber · finance · health · government · science · products · infrastructure · news</li> | |
| 98 | − <li>entity:{"{entity_id}"}</li> | |
| 99 | − <li>source:{"{source_id}"}</li> | |
| 100 | − <li>type:{"{event_type}"}</li> | |
| 101 | − <li>watchlist:{"{watchlist_id}"}</li> | |
| 156 | + <ul className="list-disc space-y-0.5 pl-5 font-mono text-[12px] text-fg-muted [overflow-wrap:anywhere]"> | |
| 157 | + <li>events:global (default) · events:breaking · events:silent · events:first-party</li> | |
| 158 | + <li>events:{"{ai|cyber|finance|health|government|science|products|infrastructure|news}"}</li> | |
| 159 | + <li>group:{"{security|reliability|product|commercial|corporate|government|science|transport|sports|web}"}</li> | |
| 160 | + <li>country:{"{CA}"} · state:{"{breaking|developing|confirmed}"} · type:{"{event_type}"}</li> | |
| 161 | + <li>entity:{"{entity_id}"} · source:{"{source_id}"} · watchlist:{"{watchlist_id}"}</li> | |
| 102 | 162 | </ul> |
| 103 | 163 | <p className="text-fg-muted">Frames:</p> |
| 104 | 164 | <Code lang="json">{`{"type":"hello","channels":["events:global"]} |
| 105 | −{"type":"event","channels":["events:global","events:ai"], | |
| 165 | +{"type":"event","sid":"1757333722000-0","channels":["events:global","group:product"], | |
| 106 | 166 | "event":{"id":"evt_…","slug":"…","type":"model_release","title":"…", |
| 107 | − "importance":97,"confidence":95,"novelty":90,"silent":false, | |
| 108 | − "evidence":"OBSERVED","source":{"id":"openai","name":"OpenAI","domain":"openai.com"}, | |
| 167 | + "signal":93,"importance":97,"confidence":95,"novelty":90,"impact":80,"velocity":55, | |
| 168 | + "silent":false,"firstParty":true,"country":"US","changeClass":"product", | |
| 169 | + "fieldChanges":[…],"evidence":"OBSERVED","group":"product", | |
| 170 | + "source":{"id":"openai","name":"OpenAI","domain":"openai.com"}, | |
| 109 | 171 | "sensor":{"id":"openai_news","name":"news feed","type":"RSS"}, |
| 110 | 172 | "entities":[{"id":"org_openai","name":"OpenAI","type":"organization"}], |
| 111 | − "categories":["ai"],"url":"https://…","clusterId":"clu_…", | |
| 173 | + "categories":["ai"],"url":"https://…","clusterId":"clu_…","clusterSlug":"…","clusterState":"breaking", | |
| 112 | 174 | "detectedAt":"2026-09-08T12:15:22Z","publishedAt":"2026-09-08T12:14:55Z"}} |
| 175 | +{"type":"replay_done","since":"1757333722000-0","count":12,"truncated":false} | |
| 113 | 176 | {"type":"heartbeat","t":1757333722000}`}</Code> |
| 114 | − <p className="text-fg-muted">Send <code>{`{"ping":1}`}</code> for a <code>pong</code>. Heartbeats every 25 s keep proxies alive.</p> | |
| 177 | + <ul className="list-disc space-y-1 pl-5 text-[12.5px] text-fg-muted"> | |
| 178 | + <li>Every event frame carries a stream id <code>sid</code>. Keep the last one; after reconnecting send <code>{`{"since": sid}`}</code> and up to 500 missed events are replayed in order, then <code>replay_done</code>.</li> | |
| 179 | + <li>Send <code>{`{"ping":1}`}</code> for a <code>pong</code>. Heartbeats every 25 s keep proxies alive.</li> | |
| 180 | + <li>Subscriptions are additive; <code>unsubscribe</code> removes channels (including the default <code>events:global</code>).</li> | |
| 181 | + </ul> | |
| 115 | 182 | </div> |
| 116 | 183 | </Panel> |
| 184 | + <Panel title="Search syntax · q"> | |
| 185 | + <Code>{`openai pricing entity:org_openai type:pricing_change,terms_change | |
| 186 | +after:2026-09-01 before:2026-09-10 after:7d | |
| 187 | +silent:true first_party:true confirmed:true | |
| 188 | +importance:>70 confidence:>=50 signal:>80 | |
| 189 | +source:"bank of canada" category:ai country:CA lang:fr | |
| 190 | +class:pricing group:security cluster:clu_…`}</Code> | |
| 191 | + <p className="mt-2 text-[12px] text-fg-muted">Quoted values allowed; unknown keys stay in the free-text part. Works in <code>/events?q=</code>, <code>/search?q=</code> and the ⌘K palette.</p> | |
| 192 | + </Panel> | |
| 117 | 193 | <Panel title="Conventions"> |
| 118 | 194 | <ul className="list-disc space-y-1 pl-5 text-[12.5px] text-fg-muted"> |
| 119 | − <li>All timestamps are UTC ISO-8601.</li> | |
| 195 | + <li>All timestamps are UTC ISO-8601. Ids are prefixed (<code>evt_ chg_ snap_ clu_ src_ sen_ ent_</code>).</li> | |
| 120 | 196 | <li>Cursor pagination: pass <code>nextCursor</code> back as <code>cursor</code>.</li> |
| 121 | − <li>Scores are 0–100. Importance and confidence are independent.</li> | |
| 122 | − <li>Every event traces to change → snapshots → sensor → source; snapshots are immutable.</li> | |
| 123 | − <li>Labels OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED are always exposed.</li> | |
| 124 | − <li>Planned: API keys, customer webhooks, MCP server.</li> | |
| 197 | + <li>Scores are 0–100. Signal, importance and confidence are independent; <code>score_reasons</code> explain each signal score.</li> | |
| 198 | + <li>Every event traces to change → snapshots → sensor → source; snapshots are immutable. Retention may drop raw bodies (<code>has_raw: false</code>) but never canonical forms or hashes.</li> | |
| 199 | + <li>Labels OBSERVED / INFERRED / CONFIRMED / UNCONFIRMED are always exposed; AI text is labelled analysis.</li> | |
| 200 | + <li> | |
| 201 | + Errors share one shape: <code>{`{"error": "snake_case_code", "detail"?: "…"}`}</code>. Validation failures add <code>details</code> (issues); 404 is <code>not_found</code>, 429 <code>monitor_limit_reached</code> / <code>too_many_*</code>. | |
| 202 | + </li> | |
| 203 | + <li> | |
| 204 | + Rate limit 600 req / min / IP on <code>/api/*</code>, exposed as <Chip className="font-mono">x-ratelimit-limit</Chip> <Chip className="font-mono">x-ratelimit-remaining</Chip> <Chip className="font-mono">x-ratelimit-reset</Chip>; every response has <code>x-request-id</code>. | |
| 205 | + </li> | |
| 206 | + <li>Planned: API keys, MCP server, e-mail / Slack / Discord channels.</li> | |
| 125 | 207 | </ul> |
| 126 | 208 | </Panel> |
| 127 | 209 | </aside> |
added
apps/web/src/app/bookmarks/bookmarks.tsx
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { Bookmark as BookmarkIcon, Plus, Trash2 } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useCallback, useEffect, useState } from "react"; | |
| 6 | +import { EventRow } from "@/components/event-row"; | |
| 7 | +import { Chip, Empty, Panel, SkeletonRows } from "@/components/ui"; | |
| 8 | +import type { Bookmark } from "@/lib/api"; | |
| 9 | +import { relTime, SAVED_VIEWS, utcDateTime } from "@/lib/format"; | |
| 10 | +import { ownerFetch } from "@/lib/owner"; | |
| 11 | + | |
| 12 | +interface SavedView { | |
| 13 | + id: string; | |
| 14 | + name: string; | |
| 15 | + query: string; | |
| 16 | + created_at?: string; | |
| 17 | +} | |
| 18 | + | |
| 19 | +function normalizeQuery(q: string): string { | |
| 20 | + const s = q.trim().replace(/^\/?live/, "").replace(/^\?/, ""); | |
| 21 | + return s; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export function Bookmarks() { | |
| 25 | + const [items, setItems] = useState<Bookmark[] | null>(null); | |
| 26 | + const [views, setViews] = useState<SavedView[] | null>(null); | |
| 27 | + const [err, setErr] = useState<string | null>(null); | |
| 28 | + const [viewErr, setViewErr] = useState<string | null>(null); | |
| 29 | + const [form, setForm] = useState({ name: "", query: "" }); | |
| 30 | + | |
| 31 | + const load = useCallback( | |
| 32 | + () => | |
| 33 | + ownerFetch<{ items: Bookmark[] }>("/api/v1/bookmarks?limit=200") | |
| 34 | + .then((r) => setItems(r.items)) | |
| 35 | + .catch((e: Error) => { | |
| 36 | + setErr(e.message); | |
| 37 | + setItems([]); | |
| 38 | + }), | |
| 39 | + [], | |
| 40 | + ); | |
| 41 | + const loadViews = useCallback( | |
| 42 | + () => | |
| 43 | + ownerFetch<{ items: SavedView[] }>("/api/v1/views") | |
| 44 | + .then((r) => setViews(r.items)) | |
| 45 | + .catch((e: Error) => { | |
| 46 | + setViewErr(e.message); | |
| 47 | + setViews([]); | |
| 48 | + }), | |
| 49 | + [], | |
| 50 | + ); | |
| 51 | + useEffect(() => { | |
| 52 | + void load(); | |
| 53 | + void loadViews(); | |
| 54 | + }, [load, loadViews]); | |
| 55 | + | |
| 56 | + const remove = async (id: string): Promise<void> => { | |
| 57 | + setItems((prev) => prev?.filter((b) => b.id !== id) ?? prev); | |
| 58 | + try { | |
| 59 | + await ownerFetch(`/api/v1/bookmarks/${encodeURIComponent(id)}`, { method: "DELETE" }); | |
| 60 | + } catch (e) { | |
| 61 | + setErr((e as Error).message); | |
| 62 | + await load(); | |
| 63 | + } | |
| 64 | + }; | |
| 65 | + | |
| 66 | + const createView = async (): Promise<void> => { | |
| 67 | + const query = normalizeQuery(form.query); | |
| 68 | + if (!query) { | |
| 69 | + setViewErr("Paste a live-feed query string, e.g. group=security&signal_min=70"); | |
| 70 | + return; | |
| 71 | + } | |
| 72 | + setViewErr(null); | |
| 73 | + try { | |
| 74 | + await ownerFetch("/api/v1/views", { method: "POST", body: JSON.stringify({ name: form.name.trim() || "My view", query }) }); | |
| 75 | + setForm({ name: "", query: "" }); | |
| 76 | + await loadViews(); | |
| 77 | + } catch (e) { | |
| 78 | + setViewErr((e as Error).message); | |
| 79 | + } | |
| 80 | + }; | |
| 81 | + const removeView = async (id: string): Promise<void> => { | |
| 82 | + setViews((prev) => prev?.filter((v) => v.id !== id) ?? prev); | |
| 83 | + try { | |
| 84 | + await ownerFetch(`/api/v1/views/${encodeURIComponent(id)}`, { method: "DELETE" }); | |
| 85 | + } catch (e) { | |
| 86 | + setViewErr((e as Error).message); | |
| 87 | + await loadViews(); | |
| 88 | + } | |
| 89 | + }; | |
| 90 | + | |
| 91 | + return ( | |
| 92 | + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]"> | |
| 93 | + <Panel title={<span>Saved events <span className="font-mono text-fg-subtle">{items?.length ?? "…"}</span></span>} dense> | |
| 94 | + {items === null ? ( | |
| 95 | + <SkeletonRows rows={6} /> | |
| 96 | + ) : items.length ? ( | |
| 97 | + <ul className="divide-y divide-line"> | |
| 98 | + {items.map((b) => ( | |
| 99 | + <li key={b.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2 pr-2"> | |
| 100 | + <div className="min-w-0 [&>article]:border-b-0"> | |
| 101 | + <EventRow ev={b} showDate /> | |
| 102 | + </div> | |
| 103 | + <div className="flex flex-col items-end gap-1 pt-2"> | |
| 104 | + <button type="button" aria-label="Remove bookmark" title="Remove bookmark" onClick={() => remove(b.id)} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-subtle hover:border-line-strong hover:text-danger"> | |
| 105 | + <Trash2 className="size-3.5" /> | |
| 106 | + </button> | |
| 107 | + <span className="inline-flex items-center gap-1 whitespace-nowrap font-mono text-[10.5px] text-fg-subtle" title={`Saved ${utcDateTime(b.bookmarked_at)}${b.note ? ` · ${b.note}` : ""}`}> | |
| 108 | + <BookmarkIcon className="size-3 text-signal" /> {relTime(b.bookmarked_at)} | |
| 109 | + </span> | |
| 110 | + </div> | |
| 111 | + </li> | |
| 112 | + ))} | |
| 113 | + </ul> | |
| 114 | + ) : ( | |
| 115 | + <Empty icon={<BookmarkIcon className="size-5 text-fg-subtle" />}> | |
| 116 | + No saved events yet — use the bookmark icon in the intelligence panel. | |
| 117 | + <div className="mt-2"> | |
| 118 | + <Link href="/live" className="text-info hover:underline">Open the live feed →</Link> | |
| 119 | + </div> | |
| 120 | + </Empty> | |
| 121 | + )} | |
| 122 | + {err && <p className="border-t border-line px-3 py-2 text-[11px] text-danger">{err}</p>} | |
| 123 | + </Panel> | |
| 124 | + | |
| 125 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 126 | + <Panel title={<span>Saved views <span className="font-mono text-fg-subtle">{views?.length ?? "…"}</span></span>} dense> | |
| 127 | + {views === null ? ( | |
| 128 | + <SkeletonRows rows={3} /> | |
| 129 | + ) : views.length ? ( | |
| 130 | + <ul className="divide-y divide-line"> | |
| 131 | + {views.map((v) => ( | |
| 132 | + <li key={v.id} className="flex items-start gap-2 px-3 py-2 text-[13px]"> | |
| 133 | + <div className="min-w-0 flex-1"> | |
| 134 | + <Link href={`/live?${v.query}`} className="block truncate font-medium hover:underline">{v.name}</Link> | |
| 135 | + <div className="mt-0.5 truncate font-mono text-[11px] text-fg-subtle" title={v.query}>?{v.query}</div> | |
| 136 | + </div> | |
| 137 | + <button type="button" aria-label={`Delete view ${v.name}`} onClick={() => removeView(v.id)} className="mt-0.5 text-fg-subtle hover:text-danger"> | |
| 138 | + <Trash2 className="size-3.5" /> | |
| 139 | + </button> | |
| 140 | + </li> | |
| 141 | + ))} | |
| 142 | + </ul> | |
| 143 | + ) : ( | |
| 144 | + <Empty>No saved views yet. Filter the live feed, then paste its query string below.</Empty> | |
| 145 | + )} | |
| 146 | + <form | |
| 147 | + className="flex flex-col gap-1.5 border-t border-line p-2 text-[12.5px]" | |
| 148 | + onSubmit={(e) => { | |
| 149 | + e.preventDefault(); | |
| 150 | + void createView(); | |
| 151 | + }} | |
| 152 | + > | |
| 153 | + <label className="sr-only" htmlFor="view-name">View name</label> | |
| 154 | + <input id="view-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="View name" maxLength={60} className="h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]" /> | |
| 155 | + <label className="sr-only" htmlFor="view-query">Query string</label> | |
| 156 | + <input id="view-query" value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} placeholder="group=security&signal_min=70" maxLength={600} className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" /> | |
| 157 | + <div className="flex items-center justify-between gap-2"> | |
| 158 | + <button type="button" onClick={() => setForm({ ...form, query: typeof window !== "undefined" && window.location.pathname.startsWith("/live") ? normalizeQuery(window.location.search) : form.query })} className="text-[11px] text-fg-subtle hover:text-fg" title="Only works from a /live page">use current query</button> | |
| 159 | + <button type="submit" className="inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-2 text-[12px] hover:border-line-strong"> | |
| 160 | + <Plus className="size-3.5" /> Save view | |
| 161 | + </button> | |
| 162 | + </div> | |
| 163 | + {viewErr && <p className="text-[11px] text-danger">{viewErr}</p>} | |
| 164 | + </form> | |
| 165 | + </Panel> | |
| 166 | + <Panel title="Built-in views" dense> | |
| 167 | + <ul className="divide-y divide-line"> | |
| 168 | + {SAVED_VIEWS.map((v) => { | |
| 169 | + const q = new URLSearchParams(Object.entries(v.query).map(([k, val]) => [k, String(val)])).toString(); | |
| 170 | + return ( | |
| 171 | + <li key={v.key} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 172 | + <Link href={`/live?${q}`} className="min-w-0 truncate hover:underline">{v.label}</Link> | |
| 173 | + <button type="button" onClick={() => setForm({ name: v.label, query: q })} className="shrink-0" title="Copy into the form"> | |
| 174 | + <Chip className="hover:border-line-strong">copy</Chip> | |
| 175 | + </button> | |
| 176 | + </li> | |
| 177 | + ); | |
| 178 | + })} | |
| 179 | + </ul> | |
| 180 | + </Panel> | |
| 181 | + </aside> | |
| 182 | + </div> | |
| 183 | + ); | |
| 184 | +} | |
added
apps/web/src/app/bookmarks/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { PageHeader } from "@/components/ui"; | |
| 3 | +import { Bookmarks } from "./bookmarks"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { title: "Saved events", description: "Events you bookmarked in this browser, plus your saved live-feed views.", robots: { index: false } }; | |
| 6 | + | |
| 7 | +export default function BookmarksPage() { | |
| 8 | + return ( | |
| 9 | + <> | |
| 10 | + <PageHeader compact kicker="Stored in this browser (no account yet)" title="Saved" description="Bookmarked events and saved live-feed views. Everything is scoped to this browser's anonymous owner token." /> | |
| 11 | + <Bookmarks /> | |
| 12 | + </> | |
| 13 | + ); | |
| 14 | +} | |
added
apps/web/src/app/breaking/loading.tsx
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import { Skeleton, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +function PanelSkeleton({ rows }: { rows: number }) { | |
| 4 | + return ( | |
| 5 | + <div className="panel"> | |
| 6 | + <div className="border-b border-line px-3 py-2"> | |
| 7 | + <Skeleton className="h-2.5 w-28" /> | |
| 8 | + </div> | |
| 9 | + <SkeletonRows rows={rows} /> | |
| 10 | + </div> | |
| 11 | + ); | |
| 12 | +} | |
| 13 | + | |
| 14 | +export default function Loading() { | |
| 15 | + return ( | |
| 16 | + <> | |
| 17 | + <div className="mb-4 flex flex-col gap-2"> | |
| 18 | + <div className="flex items-center gap-2"> | |
| 19 | + <Skeleton className="h-4 w-20" /> | |
| 20 | + <Skeleton className="h-3 w-80 max-w-full" /> | |
| 21 | + </div> | |
| 22 | + <Skeleton className="h-7 w-40" /> | |
| 23 | + </div> | |
| 24 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-2"> | |
| 25 | + <PanelSkeleton rows={4} /> | |
| 26 | + <PanelSkeleton rows={4} /> | |
| 27 | + </div> | |
| 28 | + <div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2"> | |
| 29 | + <PanelSkeleton rows={3} /> | |
| 30 | + <PanelSkeleton rows={3} /> | |
| 31 | + </div> | |
| 32 | + <div className="mt-4 panel"> | |
| 33 | + <div className="flex items-center gap-3 border-b border-line px-3 py-2"> | |
| 34 | + <Skeleton className="h-3 w-48" /> | |
| 35 | + <Skeleton className="h-3 w-12" /> | |
| 36 | + </div> | |
| 37 | + <SkeletonRows rows={8} /> | |
| 38 | + </div> | |
| 39 | + </> | |
| 40 | + ); | |
| 41 | +} | |
modified
apps/web/src/app/breaking/page.tsx
+67 −6
@@ -1,17 +1,78 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | +import Link from "next/link"; | |
| 3 | +import { EventRow } from "@/components/event-row"; | |
| 2 | 4 | import { LiveFeed } from "@/components/live-feed"; |
| 3 | −import { PageHeader } from "@/components/ui"; | |
| 4 | −import { api } from "@/lib/api"; | |
| 5 | +import { Badge, Chip, Empty, Flag, PageHeader, Panel, Score, StateLabelText } from "@/components/ui"; | |
| 6 | +import { api, type Cluster, type EventItem } from "@/lib/api"; | |
| 7 | +import { fmtOffset, relTime, typeLabel, utcTime } from "@/lib/format"; | |
| 5 | 8 | |
| 6 | 9 | export const dynamic = "force-dynamic"; |
| 7 | −export const metadata: Metadata = { title: "Breaking", description: "Events with importance ≥ 80 detected in the last 48 hours." }; | |
| 10 | +export const metadata: Metadata = { title: "Breaking", description: "Breaking now, developing, recently confirmed and watching — ranked by signal, velocity, novelty and confirmation, not by recency." }; | |
| 8 | 11 | |
| 12 | +/** Breaking desk (spec §34). */ | |
| 9 | 13 | export default async function BreakingPage() { |
| 10 | − const events = await api.breaking(60); | |
| 14 | + const [desk, live] = await Promise.all([api.breakingDesk(), api.breaking(40)]); | |
| 15 | + const d = desk ?? { breaking_now: [], developing: [], recently_confirmed: [], watching: [], generated_at: "" }; | |
| 11 | 16 | return ( |
| 12 | 17 | <> |
| 13 | − <PageHeader kicker="Importance ≥ 80 · last 48 h" title="Breaking" description="Highest-importance events across every category, ranked by score. New breaking events stream in live." /> | |
| 14 | − <LiveFeed initial={events.items} initialCursor={events.nextCursor} fixed="breaking" title="BREAKING" /> | |
| 18 | + <PageHeader kicker={<span className="flex items-center gap-2"><Badge kind="breaking" /> <span className="text-fg-subtle">Breaking is not recent: it needs strong signal, velocity, novelty, source quality, confirmation and cross-source activity.</span></span>} title="Breaking" /> | |
| 19 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_1fr] [&>*]:min-w-0"> | |
| 20 | + <ClusterSection title="Breaking now" tone="hot" items={d.breaking_now} empty="Nothing is breaking right now." /> | |
| 21 | + <ClusterSection title="Developing" tone="high" items={d.developing} empty="No developing story: signals are not accumulating fast enough anywhere." /> | |
| 22 | + </div> | |
| 23 | + <div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[1fr_1fr] [&>*]:min-w-0"> | |
| 24 | + <ClusterSection title="Recently confirmed" tone="ok" items={d.recently_confirmed} empty="No cluster reached independent confirmation in the last 48 h." /> | |
| 25 | + <Panel title="Watching · high signal, not yet breaking" dense> | |
| 26 | + {d.watching.length ? d.watching.slice(0, 12).map((e) => <EventRow key={e.id} ev={e} />) : <Empty>Nothing to watch.</Empty>} | |
| 27 | + </Panel> | |
| 28 | + </div> | |
| 29 | + <div className="mt-4"> | |
| 30 | + <LiveFeed initial={live.items} initialCursor={live.nextCursor} fixed="breaking" title="BREAKING · SIGNAL ≥ 80 · LIVE" showFilters={false} /> | |
| 31 | + </div> | |
| 15 | 32 | </> |
| 16 | 33 | ); |
| 17 | 34 | } |
| 35 | + | |
| 36 | +function ClusterSection({ title, tone, items, empty }: { title: string; tone: "hot" | "high" | "ok"; items: Cluster[]; empty: string }) { | |
| 37 | + const color = tone === "hot" ? "text-hot" : tone === "high" ? "text-high" : "text-ok"; | |
| 38 | + return ( | |
| 39 | + <Panel title={<span className={color}>{title} <span className="font-mono text-fg-subtle">{items.length}</span></span>} dense> | |
| 40 | + {items.length === 0 ? ( | |
| 41 | + <Empty>{empty}</Empty> | |
| 42 | + ) : ( | |
| 43 | + <ul className="divide-y divide-line"> | |
| 44 | + {items.map((c) => { | |
| 45 | + const e = c.event as EventItem | null | undefined; | |
| 46 | + return ( | |
| 47 | + <li key={c.id} className={`px-3 py-2.5 ${tone === "hot" ? "border-l-2 border-l-hot/70" : tone === "high" ? "border-l-2 border-l-high/60" : "border-l-2 border-l-ok/50"}`}> | |
| 48 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-fg-subtle"> | |
| 49 | + <StateLabelText state={c.state} /> | |
| 50 | + {e?.source && <Link href={`/source/${e.source.id}`} className="font-mono uppercase text-fg-muted hover:text-fg">{e.source.name}</Link>} | |
| 51 | + {e?.country && <Flag code={e.country} />} | |
| 52 | + {e?.event_type && <Chip>{typeLabel(e.event_type)}</Chip>} | |
| 53 | + {e?.silent_change && <Badge kind="silent" compact />} | |
| 54 | + <span className="ml-auto font-mono tabular">{utcTime(c.last_at)} · {relTime(c.last_at)}</span> | |
| 55 | + </div> | |
| 56 | + <div className="mt-1 flex items-start gap-3"> | |
| 57 | + <Score value={e?.signal_score ?? c.max_importance} kind="signal" /> | |
| 58 | + <div className="min-w-0 flex-1"> | |
| 59 | + <Link href={e?.slug ? `/event/${e.slug}` : `/cluster/${c.slug ?? c.id}`} className="block font-medium leading-snug hover:underline">{c.title}</Link> | |
| 60 | + {e?.summary && <p className="mt-0.5 line-clamp-2 text-[12.5px] text-fg-muted">{e.summary}</p>} | |
| 61 | + <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-fg-subtle"> | |
| 62 | + <Link href={`/cluster/${c.slug ?? c.id}`} className="text-info hover:underline">{c.event_count} signal{c.event_count === 1 ? "" : "s"} · {c.source_count ?? 1} source{(c.source_count ?? 1) === 1 ? "" : "s"}</Link> | |
| 63 | + {(c.first_party_count ?? 0) > 0 && <Chip tone="signal">{c.first_party_count} first-party</Chip>} | |
| 64 | + {(c.external_count ?? 0) > 0 && <Chip>{c.external_count} external</Chip>} | |
| 65 | + {c.velocity !== undefined && c.velocity >= 40 && <Chip tone="high">velocity {Math.round(c.velocity)}</Chip>} | |
| 66 | + {c.lead_time_ms && c.lead_time_ms > 0 ? <Chip tone="ok" title="WebSensor detected the first-party signal this long before the first external report">lead {fmtOffset(c.lead_time_ms).replace("+", "")}</Chip> : null} | |
| 67 | + {c.sources && c.sources.length > 1 && <span className="min-w-0 max-w-full truncate">{c.sources.slice(0, 4).map((s) => s.name).join(" · ")}{c.sources.length > 4 ? ` +${c.sources.length - 4}` : ""}</span>} | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + </li> | |
| 72 | + ); | |
| 73 | + })} | |
| 74 | + </ul> | |
| 75 | + )} | |
| 76 | + </Panel> | |
| 77 | + ); | |
| 78 | +} | |
added
apps/web/src/app/category/[channel]/loading.tsx
+28 −0
@@ -0,0 +1,28 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3 flex items-end justify-between gap-3"> | |
| 7 | + <div> | |
| 8 | + <Skeleton className="mb-1 h-2.5 w-24" /> | |
| 9 | + <Skeleton className="h-5 w-40" /> | |
| 10 | + <Skeleton className="mt-2 h-3 w-72" /> | |
| 11 | + </div> | |
| 12 | + <Skeleton className="h-9 w-40" /> | |
| 13 | + </div> | |
| 14 | + <div className="grid gap-4 xl:grid-cols-[1fr_340px]"> | |
| 15 | + <div className="panel"> | |
| 16 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-32" /></div> | |
| 17 | + <SkeletonRows rows={14} /> | |
| 18 | + </div> | |
| 19 | + <div className="flex flex-col gap-4"> | |
| 20 | + <SkeletonPanel lines={6} /> | |
| 21 | + <SkeletonPanel lines={6} /> | |
| 22 | + <SkeletonPanel lines={8} /> | |
| 23 | + <SkeletonPanel lines={6} /> | |
| 24 | + </div> | |
| 25 | + </div> | |
| 26 | + </> | |
| 27 | + ); | |
| 28 | +} | |
modified
apps/web/src/app/category/[channel]/page.tsx
+111 −10
@@ -1,29 +1,130 @@ | ||
| 1 | +import Link from "next/link"; | |
| 1 | 2 | import type { Metadata } from "next"; |
| 2 | 3 | import { notFound } from "next/navigation"; |
| 4 | +import { EventRow } from "@/components/event-row"; | |
| 3 | 5 | import { LiveFeed } from "@/components/live-feed"; |
| 4 | −import { PageHeader } from "@/components/ui"; | |
| 6 | +import { Badge, Bar, Chip, Empty, PageHeader, Panel, Score, Sparkline, Table, Td, TierBadge } from "@/components/ui"; | |
| 5 | 7 | import { api } from "@/lib/api"; |
| 6 | −import { CHANNELS } from "@/lib/format"; | |
| 8 | +import { CHANNELS, CHANNEL_KEYS, feedHref, fmtInt, typeLabel } from "@/lib/format"; | |
| 7 | 9 | |
| 8 | 10 | export const dynamic = "force-dynamic"; |
| 9 | 11 | |
| 10 | −const ALLOWED = new Set(["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure", "cloud", "developer", "consumer-tech", "semiconductors", "pharma", "statistics", "space", "automotive", "commerce", "payments", "crypto", "enterprise", "internet", "standards", "technology", "news", "media", "politics"]); | |
| 12 | +/** Categories that are valid desks even when they have no recent event. */ | |
| 13 | +const KNOWN = new Set<string>([...CHANNEL_KEYS, "cloud", "developer", "consumer-tech", "semiconductors", "pharma", "statistics", "space", "automotive", "commerce", "payments", "crypto", "enterprise", "internet", "standards", "technology", "media", "politics", "sports", "energy", "transport", "telecom", "education", "retail", "gaming", "web-policy", "packages", "api"]); | |
| 14 | + | |
| 15 | +function channelOf(channel: string) { | |
| 16 | + return CHANNELS.find((c) => c.key === channel && c.key !== "all" && c.key !== "breaking" && c.key !== "silent"); | |
| 17 | +} | |
| 11 | 18 | |
| 12 | 19 | export async function generateMetadata({ params }: { params: Promise<{ channel: string }> }): Promise<Metadata> { |
| 13 | 20 | const { channel } = await params; |
| 14 | − const label = CHANNELS.find((c) => c.key === channel)?.label ?? channel; | |
| 15 | − return { title: `${label} events`, description: `Live ${label} events detected by WebSensor.`, alternates: { canonical: `/category/${channel}` } }; | |
| 21 | + const label = channelOf(channel)?.label ?? channel; | |
| 22 | + return { title: `${label} desk`, description: `Live ${label} events detected by WebSensor: breaking, silent changes, active sources and trending entities.`, alternates: { canonical: `/category/${channel}` } }; | |
| 16 | 23 | } |
| 17 | 24 | |
| 25 | +/** Specialized real-time desk (spec §103). */ | |
| 18 | 26 | export default async function CategoryPage({ params }: { params: Promise<{ channel: string }> }) { |
| 19 | 27 | const { channel } = await params; |
| 20 | − if (!ALLOWED.has(channel)) notFound(); | |
| 21 | − const chan = CHANNELS.find((c) => c.key === channel); | |
| 22 | − const events = await api.events({ category: channel, limit: 60 }); | |
| 28 | + if (!/^[a-z0-9-]{2,32}$/.test(channel)) notFound(); | |
| 29 | + const desk = await api.categoryDesk(channel); | |
| 30 | + if (!desk || (desk.recent.length === 0 && !KNOWN.has(channel))) notFound(); | |
| 31 | + const chan = channelOf(channel); | |
| 32 | + const label = chan?.label ?? channel; | |
| 33 | + const series = desk.series.map((p) => p.n); | |
| 34 | + const total48 = series.reduce((a, b) => a + b, 0); | |
| 35 | + const maxType = Math.max(1, ...desk.by_type.map((t) => t.n)); | |
| 36 | + const maxSrc = Math.max(1, ...desk.active_sources.map((s) => s.events_24h)); | |
| 23 | 37 | return ( |
| 24 | 38 | <> |
| 25 | − <PageHeader kicker="Category" title={chan?.label ?? channel} description={`Meaningful changes from sources categorized as “${channel}”.`} /> | |
| 26 | − <LiveFeed initial={events.items} initialCursor={events.nextCursor} fixed={chan ? chan.key : undefined} title={(chan?.label ?? channel).toUpperCase()} extraQuery={chan ? undefined : { category: channel }} showTabs={false} /> | |
| 39 | + <PageHeader | |
| 40 | + compact | |
| 41 | + kicker={ | |
| 42 | + <span className="flex flex-wrap items-center gap-1.5"> | |
| 43 | + <span>Desk</span> | |
| 44 | + <span className="text-fg-subtle">·</span> | |
| 45 | + {desk.categories.map((c) => ( | |
| 46 | + <Chip key={c} href={`/category/${c}`} tone={c === channel ? "signal" : "default"}>{c}</Chip> | |
| 47 | + ))} | |
| 48 | + </span> | |
| 49 | + } | |
| 50 | + title={label} | |
| 51 | + description={`Meaningful changes from sources categorized as ${desk.categories.map((c) => `“${c}”`).join(", ")} — official pages, feeds, status pages, documentation, filings.`} | |
| 52 | + actions={ | |
| 53 | + <div className="flex items-center gap-3"> | |
| 54 | + <div className="text-right"> | |
| 55 | + <div className="label">48 h</div> | |
| 56 | + <div className="font-mono text-base font-semibold tabular">{fmtInt(total48)}</div> | |
| 57 | + </div> | |
| 58 | + <Sparkline values={series} width={160} height={36} tone="signal" /> | |
| 59 | + </div> | |
| 60 | + } | |
| 61 | + /> | |
| 62 | + <div className="grid gap-4 xl:grid-cols-[1fr_340px]"> | |
| 63 | + <div className="min-w-0"> | |
| 64 | + <LiveFeed initial={desk.recent} initialCursor={desk.nextCursor} fixed={chan ? chan.key : undefined} extraQuery={chan ? undefined : { category: channel }} showTabs={false} title={`${label.toUpperCase()} · LIVE`} /> | |
| 65 | + </div> | |
| 66 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 67 | + <Panel title={<span className="text-hot">Breaking · 24 h</span>} dense action={<Link href={feedHref({ category: channel, signal_min: 80 }, "/live")} className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}> | |
| 68 | + {desk.breaking.length ? desk.breaking.slice(0, 8).map((e) => <EventRow key={e.id} ev={e} />) : <Empty>Nothing is breaking in this desk right now.</Empty>} | |
| 69 | + </Panel> | |
| 70 | + <Panel title={<span className="text-silent">Silent</span>} dense action={<Link href={feedHref({ category: channel, silent_change: true }, "/live")} className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 71 | + {desk.silent.length ? desk.silent.slice(0, 8).map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No silent change in this desk.</Empty>} | |
| 72 | + </Panel> | |
| 73 | + <Panel title="Active sources · 24 h" dense action={<Link href={`/sources?category=${channel}`} className="text-[11px] text-fg-subtle hover:text-fg">sources →</Link>}> | |
| 74 | + {desk.active_sources.length ? ( | |
| 75 | + <Table head={["Source", "Kind", "24 h", "", "Max"]}> | |
| 76 | + {desk.active_sources.map((s) => ( | |
| 77 | + <tr key={s.id} className="hover:bg-panel-2/60"> | |
| 78 | + <Td> | |
| 79 | + <span className="flex items-center gap-1.5"><TierBadge tier={s.tier} /><Link href={`/source/${s.id}`} className="truncate font-medium hover:underline">{s.name}</Link></span> | |
| 80 | + <div className="truncate font-mono text-[10.5px] text-fg-subtle">{s.domain}</div> | |
| 81 | + </Td> | |
| 82 | + <Td>{s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td> | |
| 83 | + <Td mono>{fmtInt(s.events_24h)}</Td> | |
| 84 | + <Td><div className="w-12"><Bar value={s.events_24h} max={maxSrc} /></div></Td> | |
| 85 | + <Td><Score value={s.max_importance} size="sm" /></Td> | |
| 86 | + </tr> | |
| 87 | + ))} | |
| 88 | + </Table> | |
| 89 | + ) : ( | |
| 90 | + <Empty>No source produced an event in this desk in the last 24 h.</Empty> | |
| 91 | + )} | |
| 92 | + </Panel> | |
| 93 | + <Panel title="Trending entities · 24 h" dense action={<Link href="/explore?tab=trending" className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 94 | + {desk.trending_entities.length ? ( | |
| 95 | + <ol className="divide-y divide-line"> | |
| 96 | + {desk.trending_entities.map((t, i) => ( | |
| 97 | + <li key={t.id} className="grid grid-cols-[1.25rem_1fr_auto] items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 98 | + <span className="font-mono text-[11px] text-fg-subtle tabular">{i + 1}</span> | |
| 99 | + <div className="min-w-0"> | |
| 100 | + <Link href={`/entity/${t.id}`} className="block truncate font-medium hover:underline">{t.name}</Link> | |
| 101 | + <div className="truncate text-[11px] text-fg-subtle">{t.type.replace(/_/g, " ")} · {t.sources} source{t.sources === 1 ? "" : "s"}</div> | |
| 102 | + </div> | |
| 103 | + <span className="font-mono text-[12px] font-semibold tabular">{fmtInt(t.events_24h)}</span> | |
| 104 | + </li> | |
| 105 | + ))} | |
| 106 | + </ol> | |
| 107 | + ) : ( | |
| 108 | + <Empty>No entity is trending in this desk.</Empty> | |
| 109 | + )} | |
| 110 | + </Panel> | |
| 111 | + <Panel title="By type · 7 d" dense> | |
| 112 | + {desk.by_type.length ? ( | |
| 113 | + <ul className="divide-y divide-line"> | |
| 114 | + {desk.by_type.map((t) => ( | |
| 115 | + <li key={t.event_type} className="grid grid-cols-[8.5rem_1fr_3rem] items-center gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 116 | + <Link href={feedHref({ category: channel, event_type: t.event_type }, "/live")} className="truncate hover:underline">{typeLabel(t.event_type)}</Link> | |
| 117 | + <Bar value={t.n} max={maxType} tone="info" /> | |
| 118 | + <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span> | |
| 119 | + </li> | |
| 120 | + ))} | |
| 121 | + </ul> | |
| 122 | + ) : ( | |
| 123 | + <Empty /> | |
| 124 | + )} | |
| 125 | + </Panel> | |
| 126 | + </aside> | |
| 127 | + </div> | |
| 27 | 128 | </> |
| 28 | 129 | ); |
| 29 | 130 | } |
added
apps/web/src/app/cluster/[slug]/layout.tsx
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +import { notFound } from "next/navigation"; | |
| 2 | +import type { ReactNode } from "react"; | |
| 3 | +import { api } from "@/lib/api"; | |
| 4 | + | |
| 5 | +/** Existence check outside the `loading.tsx` boundary so unknown clusters answer with a real 404 (see entity/[id]/layout.tsx). */ | |
| 6 | +export default async function ClusterLayout({ params, children }: { params: Promise<{ slug: string }>; children: ReactNode }) { | |
| 7 | + const { slug } = await params; | |
| 8 | + const d = await api.cluster(slug); | |
| 9 | + if (!d) notFound(); | |
| 10 | + return children; | |
| 11 | +} | |
added
apps/web/src/app/cluster/[slug]/loading.tsx
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3 flex flex-wrap items-end justify-between gap-3"> | |
| 7 | + <div className="flex min-w-0 flex-col gap-2"> | |
| 8 | + <div className="flex items-center gap-2"> | |
| 9 | + <Skeleton className="h-4 w-20" /> | |
| 10 | + <Skeleton className="h-4 w-24" /> | |
| 11 | + <Skeleton className="h-4 w-16" /> | |
| 12 | + </div> | |
| 13 | + <Skeleton className="h-6 w-[28rem] max-w-full" /> | |
| 14 | + <Skeleton className="h-3 w-80 max-w-full" /> | |
| 15 | + </div> | |
| 16 | + <div className="flex gap-2"> | |
| 17 | + <Skeleton className="h-7 w-20" /> | |
| 18 | + <Skeleton className="h-7 w-28" /> | |
| 19 | + </div> | |
| 20 | + </div> | |
| 21 | + <Skeleton className="mb-3 h-3 w-96 max-w-full" /> | |
| 22 | + <div className="mb-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-line bg-line sm:grid-cols-4"> | |
| 23 | + {Array.from({ length: 4 }, (_, i) => ( | |
| 24 | + <div key={i} className="flex flex-col gap-1.5 bg-panel px-3 py-2"> | |
| 25 | + <Skeleton className="h-2.5 w-16" /> | |
| 26 | + <Skeleton className="h-5 w-12" /> | |
| 27 | + <Skeleton className="h-2.5 w-24" /> | |
| 28 | + </div> | |
| 29 | + ))} | |
| 30 | + </div> | |
| 31 | + <div className="grid gap-4 xl:grid-cols-[1fr_340px]"> | |
| 32 | + <div className="flex flex-col gap-4"> | |
| 33 | + <div className="panel"> | |
| 34 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-40" /></div> | |
| 35 | + <SkeletonRows rows={6} /> | |
| 36 | + </div> | |
| 37 | + <div className="panel"> | |
| 38 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-20" /></div> | |
| 39 | + <SkeletonRows rows={6} /> | |
| 40 | + </div> | |
| 41 | + </div> | |
| 42 | + <aside className="flex flex-col gap-4"> | |
| 43 | + <SkeletonPanel lines={2} /> | |
| 44 | + <SkeletonPanel lines={6} /> | |
| 45 | + <SkeletonPanel lines={4} /> | |
| 46 | + </aside> | |
| 47 | + </div> | |
| 48 | + </> | |
| 49 | + ); | |
| 50 | +} | |
added
apps/web/src/app/cluster/[slug]/page.tsx
+240 −0
@@ -0,0 +1,240 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import type { Metadata } from "next"; | |
| 3 | +import { notFound } from "next/navigation"; | |
| 4 | +import { EventRow } from "@/components/event-row"; | |
| 5 | +import { Badge, Chip, Empty, Flag, PageHeader, Panel, Score, Sparkline, StateBadge, Stat, TypeChip } from "@/components/ui"; | |
| 6 | +import { ShareButton } from "@/components/watch-button"; | |
| 7 | +import { api, SITE_URL, type ClusterDetail, type EventItem } from "@/lib/api"; | |
| 8 | +import { fmtInt, fmtOffset, fmtScore, relTime, typeLabel, utcDate, utcDateTime, utcTime } from "@/lib/format"; | |
| 9 | + | |
| 10 | +export const dynamic = "force-dynamic"; | |
| 11 | + | |
| 12 | +function num(v: number | string | null | undefined): number { | |
| 13 | + if (v === null || v === undefined) return 0; | |
| 14 | + const n = typeof v === "string" ? Number(v) : v; | |
| 15 | + return Number.isFinite(n) ? n : 0; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { | |
| 19 | + const { slug } = await params; | |
| 20 | + const d = await api.cluster(slug); | |
| 21 | + if (!d) return { title: "Cluster not found" }; | |
| 22 | + const c = d.cluster; | |
| 23 | + const desc = (c.summary?.trim() || `${c.event_count} signal${c.event_count === 1 ? "" : "s"} from ${c.source_count ?? 1} source${(c.source_count ?? 1) === 1 ? "" : "s"} — ${d.first_party_signals} first-party, ${d.external_signals} external. Propagation timeline with WebSensor lead time.`).slice(0, 200); | |
| 24 | + const path = `/cluster/${c.slug ?? c.id}`; | |
| 25 | + return { | |
| 26 | + title: c.title, | |
| 27 | + description: desc, | |
| 28 | + alternates: { canonical: path }, | |
| 29 | + openGraph: { type: "article", title: c.title, description: desc, url: `${SITE_URL}${path}`, publishedTime: new Date(c.first_at).toISOString(), modifiedTime: new Date(c.last_at).toISOString(), tags: c.categories }, | |
| 30 | + twitter: { card: "summary", title: c.title, description: desc }, | |
| 31 | + }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** Signals per hour (or per day when the story spans more than 3 days) — computed from the events. */ | |
| 35 | +function series(events: EventItem[], first: number, last: number): { values: number[]; unit: "hour" | "day" } { | |
| 36 | + const span = Math.max(0, last - first); | |
| 37 | + const unit: "hour" | "day" = span > 72 * 3600e3 ? "day" : "hour"; | |
| 38 | + const step = unit === "hour" ? 3600e3 : 86400e3; | |
| 39 | + const start = Math.floor(first / step) * step; | |
| 40 | + const n = Math.min(120, Math.floor((last - start) / step) + 1); | |
| 41 | + const values = new Array<number>(Math.max(1, n)).fill(0); | |
| 42 | + for (const e of events) { | |
| 43 | + const i = Math.floor((new Date(e.detected_at).getTime() - start) / step); | |
| 44 | + if (i >= 0 && i < values.length) values[i]! += 1; | |
| 45 | + } | |
| 46 | + return { values, unit }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export default async function ClusterPage({ params }: { params: Promise<{ slug: string }> }) { | |
| 50 | + const { slug } = await params; | |
| 51 | + const d = await api.cluster(slug); | |
| 52 | + if (!d) notFound(); | |
| 53 | + const c = d.cluster; | |
| 54 | + const path = `/cluster/${c.slug ?? c.id}`; | |
| 55 | + const lead = num(d.lead_time_ms ?? c.lead_time_ms); | |
| 56 | + const velocity = num(c.velocity); | |
| 57 | + const firstMs = new Date(c.first_at).getTime(); | |
| 58 | + const lastMs = new Date(c.last_at).getTime(); | |
| 59 | + const spanMs = Math.max(0, lastMs - firstMs); | |
| 60 | + const sources = sourcesOf(d); | |
| 61 | + const countryOf = (p: ClusterDetail["propagation"][number]): string | null => (p.source as { country?: string | null }).country ?? null; | |
| 62 | + const spark = series(d.events, firstMs, lastMs); | |
| 63 | + const primary = d.events.find((e) => e.id === c.primary_event_id) ?? d.events[0]; | |
| 64 | + const jsonLd = { | |
| 65 | + "@context": "https://schema.org", | |
| 66 | + "@type": "NewsArticle", | |
| 67 | + headline: c.title, | |
| 68 | + description: c.summary ?? undefined, | |
| 69 | + datePublished: new Date(c.first_at).toISOString(), | |
| 70 | + dateModified: new Date(c.last_at).toISOString(), | |
| 71 | + url: `${SITE_URL}${path}`, | |
| 72 | + mainEntityOfPage: `${SITE_URL}${path}`, | |
| 73 | + author: { "@type": "Organization", name: "WebSensor", url: SITE_URL }, | |
| 74 | + publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL }, | |
| 75 | + about: d.entities.slice(0, 12).map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/entity/${x.id}` })), | |
| 76 | + keywords: c.categories.join(", "), | |
| 77 | + ...(primary?.url ? { isBasedOn: primary.url } : {}), | |
| 78 | + }; | |
| 79 | + | |
| 80 | + return ( | |
| 81 | + <> | |
| 82 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> | |
| 83 | + <PageHeader | |
| 84 | + compact | |
| 85 | + kicker={ | |
| 86 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 87 | + <StateBadge state={c.state} /> | |
| 88 | + <span className="font-mono text-fg-subtle">CLUSTER</span> | |
| 89 | + {d.entities.slice(0, 6).map((x) => ( | |
| 90 | + <Chip key={x.id} href={`/entity/${x.id}`} title={typeLabel(x.type)}>{x.name}</Chip> | |
| 91 | + ))} | |
| 92 | + {d.entities.length > 6 && <span className="font-mono text-fg-subtle tabular">+{d.entities.length - 6}</span>} | |
| 93 | + {c.categories.slice(0, 3).map((k) => <Chip key={k} href={`/category/${k}`} className="!text-fg-subtle">{k}</Chip>)} | |
| 94 | + </span> | |
| 95 | + } | |
| 96 | + title={c.title} | |
| 97 | + description={c.summary?.trim() ? <span className="whitespace-pre-line">{c.summary.trim()}</span> : undefined} | |
| 98 | + actions={ | |
| 99 | + <> | |
| 100 | + <ShareButton path={path} /> | |
| 101 | + {primary && <Link href={`/event/${primary.slug}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Primary event →</Link>} | |
| 102 | + </> | |
| 103 | + } | |
| 104 | + /> | |
| 105 | + | |
| 106 | + <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-fg-muted"> | |
| 107 | + <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(c.event_count)}</span> signal{c.event_count === 1 ? "" : "s"}</span> | |
| 108 | + <span className="text-fg-subtle">·</span> | |
| 109 | + <span><span className="font-mono font-semibold text-signal tabular">{fmtInt(d.first_party_signals)}</span> first-party</span> | |
| 110 | + <span className="text-fg-subtle">·</span> | |
| 111 | + <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(d.external_signals)}</span> external</span> | |
| 112 | + <span className="text-fg-subtle">·</span> | |
| 113 | + <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(c.source_count ?? sources.length)}</span> source{(c.source_count ?? sources.length) === 1 ? "" : "s"}</span> | |
| 114 | + <span className="text-fg-subtle">·</span> | |
| 115 | + <span className="font-mono text-[11.5px] text-fg-subtle tabular" title={`${utcDateTime(c.first_at)} → ${utcDateTime(c.last_at)}`}>{utcDateTime(c.first_at).replace(" UTC", "")} → {utcDate(c.last_at) === utcDate(c.first_at) ? utcTime(c.last_at) : utcDateTime(c.last_at).replace(" UTC", "")} UTC</span> | |
| 116 | + <span className="text-fg-subtle">·</span> | |
| 117 | + <span className="text-fg-subtle">updated {relTime(c.last_at)}</span> | |
| 118 | + </div> | |
| 119 | + | |
| 120 | + {lead > 0 && ( | |
| 121 | + <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 border-l-2 border-l-signal bg-signal-soft/50 px-3 py-2 text-[12.5px]" role="note"> | |
| 122 | + <span className="font-mono text-[10.5px] font-semibold tracking-wider text-signal">WEBSENSOR LEAD TIME</span> | |
| 123 | + <span className="text-fg"> | |
| 124 | + detected <span className="font-mono font-semibold text-signal tabular">{fmtOffset(lead).replace("+", "")}</span> before the first external report | |
| 125 | + </span> | |
| 126 | + {c.first_party_at && c.first_external_at && ( | |
| 127 | + <span className="font-mono text-[11px] text-fg-subtle tabular">first-party {utcTime(c.first_party_at, false)} · external {utcTime(c.first_external_at, false)} UTC</span> | |
| 128 | + )} | |
| 129 | + </div> | |
| 130 | + )} | |
| 131 | + | |
| 132 | + <div className="mb-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-line bg-line sm:grid-cols-4"> | |
| 133 | + <div className="bg-panel"><Stat label="Peak signal" value={fmtScore(Math.max(c.max_importance, ...d.events.map((e) => e.signal_score ?? 0)))} hint={`importance ${fmtScore(c.max_importance)}`} tone={c.max_importance >= 80 ? "hot" : undefined} /></div> | |
| 134 | + <div className="bg-panel"><Stat label="Velocity" value={velocity > 0 ? fmtScore(velocity) : "0"} hint={velocity >= 40 ? "signals accumulating fast" : "signals per unit of time"} tone={velocity >= 40 ? "warn" : undefined} /></div> | |
| 135 | + <div className="bg-panel"><Stat label="Span" value={fmtOffset(spanMs).replace("+", "")} hint={`${utcTime(c.first_at, false)} → ${utcTime(c.last_at, false)} UTC`} /></div> | |
| 136 | + <div className="bg-panel"><Stat label="Lead time" value={lead > 0 ? fmtOffset(lead).replace("+", "") : "—"} hint={lead > 0 ? "first-party before external" : d.external_signals === 0 ? "no external report yet" : lead < 0 ? `external first by ${fmtOffset(-lead).replace("+", "")}` : "n/a"} tone={lead > 0 ? "signal" : undefined} /></div> | |
| 137 | + </div> | |
| 138 | + | |
| 139 | + <div className="grid gap-4 xl:grid-cols-[1fr_340px]"> | |
| 140 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 141 | + <Panel title={<>Web velocity · propagation <span className="font-mono text-fg-subtle">{d.propagation.length}</span></>} dense action={<span className="hidden items-center gap-3 font-mono text-[10.5px] text-fg-subtle sm:inline-flex"><span className="inline-flex items-center gap-1"><span className="inline-block h-3 w-0.5 bg-signal" /> first-party</span><span className="inline-flex items-center gap-1"><span className="inline-block h-3 w-0.5 bg-line-strong" /> external</span></span>}> | |
| 142 | + {d.propagation.length === 0 ? ( | |
| 143 | + <Empty>No propagation recorded yet — the cluster has a single signal.</Empty> | |
| 144 | + ) : ( | |
| 145 | + <ol className="divide-y divide-line"> | |
| 146 | + {d.propagation.map((p, i) => ( | |
| 147 | + <li key={p.id} className={`grid grid-cols-[4.25rem_1fr] gap-x-3 border-l-2 px-3 py-2 sm:grid-cols-[4.25rem_1fr_auto] ${p.first_party ? "border-l-signal" : "border-l-line-strong"}`}> | |
| 148 | + <div className="flex flex-col font-mono text-[12px] leading-4 tabular"> | |
| 149 | + <span className={`font-semibold ${i === 0 ? "text-fg" : p.first_party ? "text-signal" : "text-fg-muted"}`}>{i === 0 || p.offset_ms === 0 ? "00:00" : fmtOffset(p.offset_ms)}</span> | |
| 150 | + <time dateTime={p.at} title={utcDateTime(p.at)} className="text-[10.5px] text-fg-subtle">{utcTime(p.at)}</time> | |
| 151 | + </div> | |
| 152 | + <div className="min-w-0 sm:col-start-2"> | |
| 153 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]"> | |
| 154 | + <Link href={`/source/${p.source.id}`} className="font-mono font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{p.source.name}</Link> | |
| 155 | + {countryOf(p) && <Flag code={countryOf(p)} />} | |
| 156 | + <span className="sm:hidden"><Score value={p.importance} size="sm" /></span> | |
| 157 | + <Badge kind={p.first_party ? "first-party" : "external"} compact /> | |
| 158 | + {p.sensor?.type && <Chip className="font-mono" title={p.sensor.name}>{p.sensor.type}</Chip>} | |
| 159 | + <TypeChip type={p.event_type} /> | |
| 160 | + </div> | |
| 161 | + <Link href={`/event/${p.slug}`} className="mt-0.5 block font-medium leading-snug text-fg hover:underline">{p.title}</Link> | |
| 162 | + </div> | |
| 163 | + <div className="hidden items-start pt-0.5 sm:flex"><Score value={p.importance} /></div> | |
| 164 | + </li> | |
| 165 | + ))} | |
| 166 | + </ol> | |
| 167 | + )} | |
| 168 | + </Panel> | |
| 169 | + <Panel title={<>Signals <span className="font-mono text-fg-subtle">{d.events.length}</span></>} dense action={primary && <Link href={`/event/${primary.slug}`} className="text-[11px] text-fg-subtle hover:text-fg">primary event →</Link>}> | |
| 170 | + {d.events.length ? d.events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No signal in this cluster.</Empty>} | |
| 171 | + </Panel> | |
| 172 | + </div> | |
| 173 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 174 | + <Panel title={spark.unit === "hour" ? "Signals per hour" : "Signals per day"}> | |
| 175 | + <Sparkline values={spark.values} width={300} height={44} tone={c.state === "breaking" ? "hot" : "signal"} /> | |
| 176 | + <div className="mt-1 flex justify-between font-mono text-[10.5px] text-fg-subtle tabular"> | |
| 177 | + <span>{utcDate(c.first_at)} {utcTime(c.first_at, false)}</span> | |
| 178 | + <span>peak {Math.max(0, ...spark.values)}/{spark.unit === "hour" ? "h" : "d"}</span> | |
| 179 | + <span>{utcDate(c.last_at)} {utcTime(c.last_at, false)}</span> | |
| 180 | + </div> | |
| 181 | + </Panel> | |
| 182 | + <Panel title={<>Entities <span className="font-mono text-fg-subtle">{d.entities.length}</span></>} dense> | |
| 183 | + {d.entities.length === 0 ? ( | |
| 184 | + <Empty>No entity resolved for this cluster.</Empty> | |
| 185 | + ) : ( | |
| 186 | + <ul className="divide-y divide-line"> | |
| 187 | + {d.entities.slice(0, 14).map((x) => ( | |
| 188 | + <li key={x.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 189 | + <Link href={`/entity/${x.id}`} className="min-w-0 truncate font-medium hover:underline">{x.name}</Link> | |
| 190 | + <span className="flex shrink-0 items-center gap-1.5"><Chip>{typeLabel(x.type)}</Chip><Score value={x.importance} size="sm" /></span> | |
| 191 | + </li> | |
| 192 | + ))} | |
| 193 | + {d.entities.length > 14 && <li className="px-3 py-1.5 text-[11px] text-fg-subtle">+{d.entities.length - 14} more entities</li>} | |
| 194 | + </ul> | |
| 195 | + )} | |
| 196 | + </Panel> | |
| 197 | + <Panel title={<>Sources <span className="font-mono text-fg-subtle">{sources.length}</span></>} dense> | |
| 198 | + {sources.length === 0 ? ( | |
| 199 | + <Empty>No source.</Empty> | |
| 200 | + ) : ( | |
| 201 | + <ul className="divide-y divide-line"> | |
| 202 | + {sources.map((s) => ( | |
| 203 | + <li key={s.id} className="grid grid-cols-[1fr_auto_2rem] items-center gap-x-2 px-3 py-1.5 text-[12.5px]"> | |
| 204 | + <div className="min-w-0"> | |
| 205 | + <Link href={`/source/${s.id}`} className="block truncate font-medium hover:underline">{s.name}</Link> | |
| 206 | + <div className="flex items-center gap-1.5 font-mono text-[10.5px] text-fg-subtle"><span className="truncate">{s.domain}</span>{s.country && <Flag code={s.country} />}</div> | |
| 207 | + </div> | |
| 208 | + <Badge kind={s.first_party ? "first-party" : "external"} compact /> | |
| 209 | + <span className="text-right font-mono text-[12px] tabular text-fg-muted" title="Signals from this source">{s.n}</span> | |
| 210 | + </li> | |
| 211 | + ))} | |
| 212 | + </ul> | |
| 213 | + )} | |
| 214 | + </Panel> | |
| 215 | + <Panel title="Share"> | |
| 216 | + <div className="flex flex-wrap items-center gap-2"> | |
| 217 | + <ShareButton path={path} /> | |
| 218 | + <Link href={`/api/v1/clusters/${encodeURIComponent(c.slug ?? c.id)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 font-mono text-[11.5px] hover:border-line-strong">JSON</Link> | |
| 219 | + </div> | |
| 220 | + <p className="mt-2 break-all font-mono text-[10.5px] text-fg-subtle">{SITE_URL}{path}</p> | |
| 221 | + </Panel> | |
| 222 | + </aside> | |
| 223 | + </div> | |
| 224 | + </> | |
| 225 | + ); | |
| 226 | +} | |
| 227 | + | |
| 228 | +function sourcesOf(d: ClusterDetail): { id: string; name: string; domain: string; first_party: boolean; country: string | null; n: number }[] { | |
| 229 | + const m = new Map<string, { id: string; name: string; domain: string; first_party: boolean; country: string | null; n: number }>(); | |
| 230 | + for (const e of d.events) { | |
| 231 | + const id = e.source?.id ?? e.source_id; | |
| 232 | + const cur = m.get(id); | |
| 233 | + if (cur) cur.n += 1; | |
| 234 | + else m.set(id, { id, name: e.source?.name ?? id, domain: e.source?.domain ?? "", first_party: e.first_party !== false, country: e.country ?? null, n: 1 }); | |
| 235 | + } | |
| 236 | + for (const p of d.propagation) { | |
| 237 | + if (!m.has(p.source.id)) m.set(p.source.id, { id: p.source.id, name: p.source.name, domain: p.source.domain, first_party: p.first_party, country: (p.source as { country?: string | null }).country ?? null, n: 1 }); | |
| 238 | + } | |
| 239 | + return [...m.values()].sort((a, b) => Number(b.first_party) - Number(a.first_party) || b.n - a.n); | |
| 240 | +} | |
modified
apps/web/src/app/company/[id]/page.tsx
+10 −106
@@ -1,111 +1,15 @@ | ||
| 1 | −import Link from "next/link"; | |
| 2 | −import type { Metadata } from "next"; | |
| 3 | −import { notFound } from "next/navigation"; | |
| 4 | −import { Timeline } from "@/components/timeline"; | |
| 5 | −import { Bar, Chip, Empty, ExtLink, PageHeader, Panel, Stat } from "@/components/ui"; | |
| 6 | −import { WatchButton } from "@/components/watch-button"; | |
| 7 | −import { api } from "@/lib/api"; | |
| 8 | −import { fmtInt, fmtScore, relTime, typeLabel } from "@/lib/format"; | |
| 1 | +import { permanentRedirect } from "next/navigation"; | |
| 9 | 2 | |
| 10 | 3 | export const dynamic = "force-dynamic"; |
| 11 | 4 | |
| 12 | −export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { | |
| 5 | +/** `/company/:id` is the legacy entity URL — permanently redirected to `/entity/:id` (tab preserved). */ | |
| 6 | +export default async function CompanyPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ tab?: string; range?: string; cursor?: string }> }) { | |
| 13 | 7 | const { id } = await params; |
| 14 | − const d = await api.entity(id); | |
| 15 | − if (!d) return { title: "Entity not found" }; | |
| 16 | − return { title: `${d.entity.name} — timeline`, description: d.entity.description ?? `Every meaningful change detected for ${d.entity.name}: pricing, products, documentation, incidents, silent changes.`, alternates: { canonical: `/company/${d.entity.id}` } }; | |
| 17 | −} | |
| 18 | − | |
| 19 | −export default async function CompanyPage({ params }: { params: Promise<{ id: string }> }) { | |
| 20 | − const { id } = await params; | |
| 21 | − const d = await api.entity(id); | |
| 22 | − if (!d) notFound(); | |
| 23 | − const e = d.entity; | |
| 24 | − const maxType = Math.max(1, ...d.by_type.map((t) => t.n)); | |
| 25 | − return ( | |
| 26 | − <> | |
| 27 | − <PageHeader | |
| 28 | − kicker={<span className="flex items-center gap-2"><Chip>{e.type}</Chip>{e.domain && <Link href={`/domain/${e.domain}`} className="font-mono hover:underline">{e.domain}</Link>}</span>} | |
| 29 | − title={e.name} | |
| 30 | − description={e.description} | |
| 31 | − actions={ | |
| 32 | − <> | |
| 33 | − <WatchButton kind="entity" value={e.id} /> | |
| 34 | − <Link href={`/timeline/${e.id}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Full timeline →</Link> | |
| 35 | − {e.homepage && <ExtLink href={e.homepage} className="text-[12px]">Website ↗</ExtLink>} | |
| 36 | − </> | |
| 37 | − } | |
| 38 | − /> | |
| 39 | − <div className="panel mb-4 grid grid-cols-2 divide-x divide-line sm:grid-cols-4"> | |
| 40 | − <Stat label="Events" value={fmtInt(e.event_count)} hint={e.last_event_at ? `last ${relTime(e.last_event_at)}` : undefined} /> | |
| 41 | − <Stat label="Importance" value={fmtScore(e.importance)} hint="entity weight" /> | |
| 42 | − <Stat label="Sources" value={fmtInt(d.sources.length)} /> | |
| 43 | − <Stat label="Silent" value={fmtInt(d.recent.filter((x) => x.silent_change).length)} hint="in recent 30" /> | |
| 44 | − </div> | |
| 45 | − <div className="grid gap-4 lg:grid-cols-[1fr_320px]"> | |
| 46 | − <Panel title="Timeline" dense action={<Link href={`/timeline/${e.id}`} className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 47 | − <Timeline events={d.recent} /> | |
| 48 | − </Panel> | |
| 49 | − <aside className="flex flex-col gap-4"> | |
| 50 | − {d.children.length > 0 && ( | |
| 51 | − <Panel title="Products & children" dense> | |
| 52 | − <ul className="divide-y divide-line"> | |
| 53 | − {d.children.map((c) => ( | |
| 54 | − <li key={c.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]"> | |
| 55 | − <Link href={`/company/${c.id}`} className="hover:underline">{c.name}</Link> | |
| 56 | − <span className="font-mono text-[11px] text-fg-subtle">{c.type} · {c.event_count}</span> | |
| 57 | − </li> | |
| 58 | − ))} | |
| 59 | − </ul> | |
| 60 | − </Panel> | |
| 61 | − )} | |
| 62 | − <Panel title="Event types"> | |
| 63 | − {d.by_type.length ? ( | |
| 64 | − <ul className="space-y-1.5"> | |
| 65 | − {d.by_type.slice(0, 12).map((t) => ( | |
| 66 | − <li key={t.event_type} className="grid grid-cols-[8rem_1fr_2.5rem] items-center gap-2 text-[12px]"> | |
| 67 | − <span className="truncate">{typeLabel(t.event_type)}</span> | |
| 68 | − <Bar value={t.n} max={maxType} tone="info" /> | |
| 69 | − <span className="text-right font-mono text-fg-subtle tabular">{t.n}</span> | |
| 70 | − </li> | |
| 71 | − ))} | |
| 72 | − </ul> | |
| 73 | − ) : ( | |
| 74 | − <Empty>No events yet.</Empty> | |
| 75 | − )} | |
| 76 | − </Panel> | |
| 77 | − <Panel title="Sources" dense> | |
| 78 | − {d.sources.length ? ( | |
| 79 | − <ul className="divide-y divide-line"> | |
| 80 | − {d.sources.map((s) => ( | |
| 81 | − <li key={s.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]"> | |
| 82 | − <Link href={`/source/${s.id}`} className="hover:underline">{s.name}</Link> | |
| 83 | − <span className="font-mono text-[11px] text-fg-subtle">{s.domain}</span> | |
| 84 | − </li> | |
| 85 | − ))} | |
| 86 | − </ul> | |
| 87 | − ) : ( | |
| 88 | − <Empty>No monitored source linked.</Empty> | |
| 89 | − )} | |
| 90 | − </Panel> | |
| 91 | − {d.relations.length > 0 && ( | |
| 92 | − <Panel title="Knowledge graph" dense> | |
| 93 | − <ul className="divide-y divide-line"> | |
| 94 | − {d.relations.map((r, i) => ( | |
| 95 | − <li key={i} className="px-3 py-1.5 text-[12.5px]"> | |
| 96 | − <Link href={`/company/${r.from_id}`} className="hover:underline">{r.from_name}</Link> <span className="font-mono text-[11px] text-fg-subtle">→ {r.relation} →</span> <Link href={`/company/${r.to_id}`} className="hover:underline">{r.to_name}</Link> | |
| 97 | − </li> | |
| 98 | − ))} | |
| 99 | − </ul> | |
| 100 | − </Panel> | |
| 101 | − )} | |
| 102 | − {d.aliases.length > 0 && ( | |
| 103 | − <Panel title="Aliases"> | |
| 104 | − <div className="flex flex-wrap gap-1">{d.aliases.map((a) => <Chip key={a}>{a}</Chip>)}</div> | |
| 105 | − </Panel> | |
| 106 | − )} | |
| 107 | − </aside> | |
| 108 | − </div> | |
| 109 | − </> | |
| 110 | − ); | |
| 8 | + const sp = await searchParams; | |
| 9 | + const q = new URLSearchParams(); | |
| 10 | + if (sp.tab) q.set("tab", sp.tab); | |
| 11 | + if (sp.range) q.set("range", sp.range); | |
| 12 | + if (sp.cursor) q.set("cursor", sp.cursor); | |
| 13 | + const s = q.toString(); | |
| 14 | + permanentRedirect(`/entity/${encodeURIComponent(id)}${s ? `?${s}` : ""}`); | |
| 111 | 15 | } |
added
apps/web/src/app/country/[slug]/loading.tsx
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3"> | |
| 7 | + <Skeleton className="mb-1 h-2.5 w-24" /> | |
| 8 | + <Skeleton className="h-5 w-48" /> | |
| 9 | + <Skeleton className="mt-2 h-3 w-full max-w-2xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-line sm:grid-cols-4"> | |
| 12 | + {Array.from({ length: 4 }, (_, i) => ( | |
| 13 | + <div key={i} className="flex flex-col gap-1.5 px-3 py-2"> | |
| 14 | + <Skeleton className="h-2.5 w-16" /> | |
| 15 | + <Skeleton className="h-5 w-12" /> | |
| 16 | + <Skeleton className="h-2.5 w-20" /> | |
| 17 | + </div> | |
| 18 | + ))} | |
| 19 | + </div> | |
| 20 | + <div className="grid gap-4 xl:grid-cols-[1fr_360px]"> | |
| 21 | + <div className="flex flex-col gap-4"> | |
| 22 | + <div className="panel"> | |
| 23 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-32" /></div> | |
| 24 | + <SkeletonRows rows={5} /> | |
| 25 | + </div> | |
| 26 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 27 | + {Array.from({ length: 4 }, (_, i) => ( | |
| 28 | + <div key={i} className="panel"> | |
| 29 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-24" /></div> | |
| 30 | + <SkeletonRows rows={4} /> | |
| 31 | + </div> | |
| 32 | + ))} | |
| 33 | + </div> | |
| 34 | + </div> | |
| 35 | + <div className="flex flex-col gap-4"> | |
| 36 | + <SkeletonPanel lines={6} /> | |
| 37 | + <SkeletonPanel lines={10} /> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + </> | |
| 41 | + ); | |
| 42 | +} | |
added
apps/web/src/app/country/[slug]/page.tsx
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import type { Metadata } from "next"; | |
| 3 | +import { notFound } from "next/navigation"; | |
| 4 | +import { EventRow } from "@/components/event-row"; | |
| 5 | +import { LiveFeed } from "@/components/live-feed"; | |
| 6 | +import { Badge, Bar, Chip, Empty, PageHeader, Panel, Stat, Table, Td, TierBadge } from "@/components/ui"; | |
| 7 | +import { api } from "@/lib/api"; | |
| 8 | +import { feedHref, fmtInt, relTime, typeLabel } from "@/lib/format"; | |
| 9 | + | |
| 10 | +export const dynamic = "force-dynamic"; | |
| 11 | + | |
| 12 | +/** Section order and labels for the desk (spec §104). Unknown categories are appended in API order. */ | |
| 13 | +const SECTIONS: { key: string; label: string }[] = [ | |
| 14 | + { key: "government", label: "Government" }, | |
| 15 | + { key: "finance", label: "Business & markets" }, | |
| 16 | + { key: "infrastructure", label: "Infrastructure" }, | |
| 17 | + { key: "health", label: "Health" }, | |
| 18 | + { key: "news", label: "Media" }, | |
| 19 | + { key: "cyber", label: "Cyber" }, | |
| 20 | + { key: "ai", label: "AI" }, | |
| 21 | + { key: "science", label: "Science" }, | |
| 22 | +]; | |
| 23 | + | |
| 24 | +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { | |
| 25 | + const { slug } = await params; | |
| 26 | + const d = await api.country(slug); | |
| 27 | + if (!d) return { title: "Country not found" }; | |
| 28 | + return { title: `${d.country.name} desk`, description: `Government, business, infrastructure, health, media, cyber, AI and science signals from ${d.country.name}, detected live by WebSensor.`, alternates: { canonical: `/country/${slug}` } }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export default async function CountryPage({ params }: { params: Promise<{ slug: string }> }) { | |
| 32 | + const { slug } = await params; | |
| 33 | + const d = await api.country(slug); | |
| 34 | + if (!d) notFound(); | |
| 35 | + const code = d.country.code; | |
| 36 | + const name = d.country.name; | |
| 37 | + const events24 = d.sources.reduce((n, s) => n + (s.events_24h ?? 0), 0); | |
| 38 | + const ordered = [...SECTIONS.map((s) => ({ ...s, items: d.by_category.find((c) => c.category === s.key)?.items ?? [] })), ...d.by_category.filter((c) => !SECTIONS.some((s) => s.key === c.category)).map((c) => ({ key: c.category, label: c.category, items: c.items }))]; | |
| 39 | + const maxType = Math.max(1, ...d.by_type.map((t) => t.n)); | |
| 40 | + const firstParty = d.sources.filter((s) => s.first_party !== false).length; | |
| 41 | + return ( | |
| 42 | + <> | |
| 43 | + <PageHeader | |
| 44 | + compact | |
| 45 | + kicker={<span className="flex items-center gap-2"><Link href="/country" className="hover:text-fg">Countries</Link> <span className="text-fg-subtle">/</span> <span className="font-mono">{code}</span></span>} | |
| 46 | + title={<span className="flex items-center gap-2"><span aria-hidden>{d.country.flag}</span> {name}</span>} | |
| 47 | + description={`Official government, business, infrastructure, health and media signals from ${name}, grouped by desk. Sources are the organizations' own channels; media reports are marked EXTERNAL.`} | |
| 48 | + actions={<Link href={feedHref({ country: code }, "/live")} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Live feed →</Link>} | |
| 49 | + /> | |
| 50 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 sm:divide-y-0"> | |
| 51 | + <Stat label="Sources" value={fmtInt(d.sources.length)} hint={`${fmtInt(firstParty)} first-party · ${fmtInt(d.sources.length - firstParty)} media`} /> | |
| 52 | + <Stat label="Events · 24 h" value={fmtInt(events24)} tone="signal" hint="meaningful signals" /> | |
| 53 | + <Stat label="Breaking · 24 h" value={fmtInt(d.breaking.length)} tone={d.breaking.length ? "hot" : undefined} hint="signal ≥ 80" /> | |
| 54 | + <Stat label="Silent" value={fmtInt(d.silent.length)} tone={d.silent.length ? "silent" : undefined} hint="no matching announcement" /> | |
| 55 | + </div> | |
| 56 | + | |
| 57 | + <div className="grid gap-4 xl:grid-cols-[1fr_360px]"> | |
| 58 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 59 | + <Panel title={<span className="text-hot">Major events · 24 h</span>} dense action={<Link href={feedHref({ country: code, signal_min: 80 }, "/live")} className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}> | |
| 60 | + {d.breaking.length ? d.breaking.map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No breaking signal from {name} in the last 24 h.</Empty>} | |
| 61 | + </Panel> | |
| 62 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 63 | + {ordered.map((s) => ( | |
| 64 | + <Panel key={s.key} title={s.label} dense action={<Link href={feedHref({ country: code, category: s.key }, "/live")} className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}> | |
| 65 | + {s.items.length ? s.items.map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No {s.label.toLowerCase()} signal from {name} recently.</Empty>} | |
| 66 | + </Panel> | |
| 67 | + ))} | |
| 68 | + </div> | |
| 69 | + <LiveFeed initial={d.recent} initialCursor={d.nextCursor} extraQuery={{ country: code }} initialFilters={{ country: code }} showTabs={false} title={`LIVE · ${name.toUpperCase()}`} /> | |
| 70 | + </div> | |
| 71 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 72 | + <Panel title={<span className="text-silent">Silent changes</span>} dense action={<Link href={feedHref({ country: code, silent_change: true }, "/live")} className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 73 | + {d.silent.length ? d.silent.map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No silent change from {name}.</Empty>} | |
| 74 | + </Panel> | |
| 75 | + <Panel title="By type · 7 d" dense> | |
| 76 | + {d.by_type.length ? ( | |
| 77 | + <ul className="divide-y divide-line"> | |
| 78 | + {d.by_type.map((t) => ( | |
| 79 | + <li key={t.event_type} className="grid grid-cols-[9rem_1fr_3.5rem] items-center gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 80 | + <Link href={feedHref({ country: code, event_type: t.event_type }, "/live")} className="truncate hover:underline">{typeLabel(t.event_type)}</Link> | |
| 81 | + <Bar value={t.n} max={maxType} tone="info" /> | |
| 82 | + <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span> | |
| 83 | + </li> | |
| 84 | + ))} | |
| 85 | + </ul> | |
| 86 | + ) : ( | |
| 87 | + <Empty /> | |
| 88 | + )} | |
| 89 | + </Panel> | |
| 90 | + </aside> | |
| 91 | + </div> | |
| 92 | + | |
| 93 | + <Panel title={`Sources · ${d.sources.length}`} dense className="mt-4" action={<Link href={`/sources?country=${code}`} className="text-[11px] text-fg-subtle hover:text-fg">filter sources →</Link>}> | |
| 94 | + {d.sources.length === 0 ? ( | |
| 95 | + <Empty>No source is attributed to {name} yet.</Empty> | |
| 96 | + ) : ( | |
| 97 | + <Table head={["Tier", "Source", "Domain", "Kind", "Categories", "Sensors", "Events 24 h", "Last event"]}> | |
| 98 | + {d.sources.map((s) => ( | |
| 99 | + <tr key={s.id} className="hover:bg-panel-2/60"> | |
| 100 | + <Td><TierBadge tier={s.tier} /></Td> | |
| 101 | + <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td> | |
| 102 | + <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td> | |
| 103 | + <Td>{s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td> | |
| 104 | + <Td><div className="flex flex-wrap gap-1">{s.categories.slice(0, 3).map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}</div></Td> | |
| 105 | + <Td mono>{s.sensor_count ?? 0}</Td> | |
| 106 | + <Td mono>{fmtInt(s.events_24h ?? 0)}</Td> | |
| 107 | + <Td mono className="text-fg-subtle">{s.last_event_at ? relTime(s.last_event_at) : "—"}</Td> | |
| 108 | + </tr> | |
| 109 | + ))} | |
| 110 | + </Table> | |
| 111 | + )} | |
| 112 | + </Panel> | |
| 113 | + </> | |
| 114 | + ); | |
| 115 | +} | |
added
apps/web/src/app/country/loading.tsx
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +import { Skeleton, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3"> | |
| 7 | + <Skeleton className="mb-1 h-2.5 w-12" /> | |
| 8 | + <Skeleton className="h-5 w-32" /> | |
| 9 | + <Skeleton className="mt-2 h-3 w-full max-w-2xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="panel"> | |
| 12 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-64" /></div> | |
| 13 | + <SkeletonRows rows={16} /> | |
| 14 | + </div> | |
| 15 | + </> | |
| 16 | + ); | |
| 17 | +} | |
added
apps/web/src/app/country/page.tsx
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import type { Metadata } from "next"; | |
| 3 | +import { Bar, Empty, Flag, PageHeader, Panel, Table, Td } from "@/components/ui"; | |
| 4 | +import { api } from "@/lib/api"; | |
| 5 | +import { fmtInt } from "@/lib/format"; | |
| 6 | + | |
| 7 | +export const dynamic = "force-dynamic"; | |
| 8 | +export const metadata: Metadata = { title: "Countries", description: "Country desks: government, business, infrastructure, health, media, cyber, AI and science signals for every country WebSensor covers.", alternates: { canonical: "/country" } }; | |
| 9 | + | |
| 10 | +export default async function CountriesPage() { | |
| 11 | + const { items } = await api.countries(); | |
| 12 | + const totals = items.reduce((a, c) => ({ sources: a.sources + c.sources, events: a.events + c.events_24h, breaking: a.breaking + c.breaking_24h }), { sources: 0, events: 0, breaking: 0 }); | |
| 13 | + const max = Math.max(1, ...items.map((c) => c.events_24h)); | |
| 14 | + return ( | |
| 15 | + <> | |
| 16 | + <PageHeader compact kicker="Desks" title="Countries" description={`${fmtInt(items.length)} countries · ${fmtInt(totals.sources)} sources · ${fmtInt(totals.events)} events and ${fmtInt(totals.breaking)} breaking signals in the last 24 h. Each desk groups official government, business, infrastructure, health and media signals by jurisdiction.`} /> | |
| 17 | + <Panel dense> | |
| 18 | + {items.length === 0 ? ( | |
| 19 | + <Empty>Country desks appear once sources carry a country.</Empty> | |
| 20 | + ) : ( | |
| 21 | + <Table head={["", "Country", "Code", "Sources", "Events 24 h", "", "Breaking 24 h", ""]}> | |
| 22 | + {items.map((c) => ( | |
| 23 | + <tr key={c.country} className="hover:bg-panel-2/60"> | |
| 24 | + <Td className="w-8 text-base leading-none">{c.flag || <Flag code={c.country} />}</Td> | |
| 25 | + <Td><Link href={`/country/${c.slug}`} className="font-medium hover:underline">{c.name}</Link></Td> | |
| 26 | + <Td mono className="text-fg-subtle">{c.country}</Td> | |
| 27 | + <Td mono>{fmtInt(c.sources)}</Td> | |
| 28 | + <Td mono>{fmtInt(c.events_24h)}</Td> | |
| 29 | + <Td><div className="w-20 sm:w-40"><Bar value={c.events_24h} max={max} /></div></Td> | |
| 30 | + <Td mono className={c.breaking_24h ? "text-hot" : "text-fg-subtle"}>{fmtInt(c.breaking_24h)}</Td> | |
| 31 | + <Td><Link href={`/country/${c.slug}`} className="whitespace-nowrap text-[11px] text-fg-subtle hover:text-fg">desk →</Link></Td> | |
| 32 | + </tr> | |
| 33 | + ))} | |
| 34 | + </Table> | |
| 35 | + )} | |
| 36 | + </Panel> | |
| 37 | + </> | |
| 38 | + ); | |
| 39 | +} | |
added
apps/web/src/app/entities/loading.tsx
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +import { Skeleton } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3 flex flex-col gap-2"> | |
| 7 | + <Skeleton className="h-6 w-28" /> | |
| 8 | + <Skeleton className="h-3 w-96 max-w-full" /> | |
| 9 | + </div> | |
| 10 | + <div className="mb-3 flex gap-4 border-b border-line pb-2"> | |
| 11 | + <Skeleton className="h-3 w-14" /> | |
| 12 | + <Skeleton className="h-3 w-10" /> | |
| 13 | + </div> | |
| 14 | + <div className="panel"> | |
| 15 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-32" /></div> | |
| 16 | + <div className="divide-y divide-line"> | |
| 17 | + {Array.from({ length: 14 }, (_, i) => ( | |
| 18 | + <div key={i} className="grid grid-cols-[2rem_1fr_5rem_3rem_3rem_3rem] items-center gap-x-3 px-3 py-2 sm:grid-cols-[2rem_1fr_5rem_3rem_3rem_3rem_4rem_4rem_4rem_4rem]"> | |
| 19 | + <Skeleton className="h-3 w-5" /> | |
| 20 | + <div className="flex flex-col gap-1.5"><Skeleton className={`h-3.5 ${i % 3 === 0 ? "w-40" : i % 3 === 1 ? "w-28" : "w-52"}`} /><Skeleton className="h-2.5 w-24" /></div> | |
| 21 | + <Skeleton className="h-4 w-16" /> | |
| 22 | + <Skeleton className="h-5 w-8" /> | |
| 23 | + <Skeleton className="h-3 w-7" /> | |
| 24 | + <Skeleton className="h-3 w-7" /> | |
| 25 | + <Skeleton className="hidden h-3 w-6 sm:block" /> | |
| 26 | + <Skeleton className="hidden h-3 w-8 sm:block" /> | |
| 27 | + <Skeleton className="hidden h-3 w-8 sm:block" /> | |
| 28 | + <Skeleton className="hidden h-3 w-10 sm:block" /> | |
| 29 | + </div> | |
| 30 | + ))} | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + </> | |
| 34 | + ); | |
| 35 | +} | |
modified
apps/web/src/app/entities/page.tsx
+70 −11
@@ -1,29 +1,88 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { ENTITY_TYPES } from "@websensor/core/client"; |
| 4 | −import { Chip, Empty, PageHeader, Panel, Table, Td } from "@/components/ui"; | |
| 4 | +import { Chip, Empty, PageHeader, Panel, Score, Table, Tabs, Td } from "@/components/ui"; | |
| 5 | 5 | import { api } from "@/lib/api"; |
| 6 | −import { fmtInt, fmtScore, relTime } from "@/lib/format"; | |
| 6 | +import { fmtInt, fmtScore, relTime, typeLabel } from "@/lib/format"; | |
| 7 | 7 | |
| 8 | 8 | export const dynamic = "force-dynamic"; |
| 9 | −export const metadata: Metadata = { title: "Entities", description: "Organizations, products, models, APIs and other entities that events resolve to." }; | |
| 9 | +export const metadata: Metadata = { title: "Entities", description: "Organizations, products, models, APIs and other entities that events resolve to — ranked by importance, activity, velocity, quality and confirmation.", alternates: { canonical: "/entities" } }; | |
| 10 | 10 | |
| 11 | −export default async function EntitiesPage({ searchParams }: { searchParams: Promise<{ type?: string; q?: string }> }) { | |
| 11 | +type Search = { tab?: string; type?: string; q?: string }; | |
| 12 | + | |
| 13 | +export default async function EntitiesPage({ searchParams }: { searchParams: Promise<Search> }) { | |
| 12 | 14 | const sp = await searchParams; |
| 15 | + const tab = sp.tab === "all" || sp.type || sp.q ? "all" : "ranked"; | |
| 16 | + return ( | |
| 17 | + <> | |
| 18 | + <PageHeader compact title="Entities" description="Everything important resolves to an entity. Each entity has a timeline — the equivalent of git history for its public Web presence." /> | |
| 19 | + <Tabs | |
| 20 | + className="mb-3" | |
| 21 | + current={tab} | |
| 22 | + items={[ | |
| 23 | + { key: "ranked", label: "Ranked", href: "/entities" }, | |
| 24 | + { key: "all", label: "All", href: "/entities?tab=all" }, | |
| 25 | + ]} | |
| 26 | + /> | |
| 27 | + {tab === "ranked" ? <Ranked /> : <All sp={sp} />} | |
| 28 | + </> | |
| 29 | + ); | |
| 30 | +} | |
| 31 | + | |
| 32 | +async function Ranked() { | |
| 33 | + const { items } = await api.rank(200); | |
| 34 | + return ( | |
| 35 | + <Panel | |
| 36 | + title={<>WebSensor rank <span className="font-mono text-fg-subtle">{fmtInt(items.length)}</span></>} | |
| 37 | + dense | |
| 38 | + action={<span className="hidden text-[11px] text-fg-subtle md:inline">importance × activity × velocity × quality × confirmation — not raw counts</span>} | |
| 39 | + > | |
| 40 | + <p className="border-b border-line px-3 py-2 text-[12px] text-fg-muted md:hidden">Ranking uses importance × activity × velocity × quality × confirmation, not raw counts.</p> | |
| 41 | + {items.length === 0 ? ( | |
| 42 | + <Empty>The ranking is computed from the last 7 days of activity — nothing to rank yet.</Empty> | |
| 43 | + ) : ( | |
| 44 | + <Table head={["#", "Entity", "Type", "Rank", "24 h", "7 d", "Sources", "Avg signal", "Confirmed", "Silent 24 h", "Breaking 24 h", "Last"]}> | |
| 45 | + {items.map((e) => ( | |
| 46 | + <tr key={e.id} className="hover:bg-panel-2/60"> | |
| 47 | + <Td mono className="text-fg-subtle">{e.rank}</Td> | |
| 48 | + <Td> | |
| 49 | + <Link href={`/entity/${e.id}`} className="font-medium hover:underline">{e.name}</Link> | |
| 50 | + {e.domain && <div className="truncate font-mono text-[10.5px] text-fg-subtle">{e.domain}</div>} | |
| 51 | + </Td> | |
| 52 | + <Td><Chip href={`/entities?tab=all&type=${e.type}`}>{typeLabel(e.type)}</Chip></Td> | |
| 53 | + <Td><Score value={e.rank_score} size="sm" title={`Rank score ${fmtScore(e.rank_score)} · entity importance ${fmtScore(e.importance)}`} /></Td> | |
| 54 | + <Td mono className={e.events_24h ? "" : "text-fg-subtle"}>{fmtInt(e.events_24h)}</Td> | |
| 55 | + <Td mono className="text-fg-muted">{fmtInt(e.events_7d)}</Td> | |
| 56 | + <Td mono>{fmtInt(e.sources)}</Td> | |
| 57 | + <Td mono>{fmtScore(e.avg_signal)}</Td> | |
| 58 | + <Td mono className={e.confirmed_ratio > 0 ? "text-ok" : "text-fg-subtle"}>{Math.round(e.confirmed_ratio * 100)}%</Td> | |
| 59 | + <Td mono className={e.silent_24h ? "text-silent" : "text-fg-subtle"}>{fmtInt(e.silent_24h)}</Td> | |
| 60 | + <Td mono className={e.breaking_24h ? "text-hot" : "text-fg-subtle"}>{fmtInt(e.breaking_24h)}</Td> | |
| 61 | + <Td mono className="whitespace-nowrap text-fg-subtle">{relTime(e.last_at)}</Td> | |
| 62 | + </tr> | |
| 63 | + ))} | |
| 64 | + </Table> | |
| 65 | + )} | |
| 66 | + </Panel> | |
| 67 | + ); | |
| 68 | +} | |
| 69 | + | |
| 70 | +async function All({ sp }: { sp: Search }) { | |
| 13 | 71 | const { items } = await api.entities({ type: sp.type, q: sp.q, limit: 400 }); |
| 14 | 72 | const types = new Set(items.map((e) => e.type)); |
| 15 | 73 | return ( |
| 16 | 74 | <> |
| 17 | − <PageHeader kicker={`${fmtInt(items.length)} entities`} title="Entities" description="Everything important resolves to an entity. Each entity has a timeline — the equivalent of git history for its public Web presence." /> | |
| 18 | 75 | <form className="mb-3 flex flex-wrap items-center gap-2" action="/entities"> |
| 19 | − <input name="q" defaultValue={sp.q ?? ""} placeholder="Search entities…" className="h-8 w-64 rounded-md border border-line bg-panel px-2.5 text-[13px] placeholder:text-fg-subtle" /> | |
| 76 | + <input type="hidden" name="tab" value="all" /> | |
| 77 | + <input name="q" defaultValue={sp.q ?? ""} placeholder="Search entities…" className="h-8 w-64 max-w-full rounded-md border border-line bg-panel px-2.5 text-[13px] placeholder:text-fg-subtle" /> | |
| 20 | 78 | {sp.type && <input type="hidden" name="type" value={sp.type} />} |
| 21 | 79 | <button type="submit" className="h-8 rounded-md border border-line bg-panel-2 px-3 text-[12.5px]">Search</button> |
| 80 | + <span className="font-mono text-[11px] text-fg-subtle tabular">{fmtInt(items.length)} entit{items.length === 1 ? "y" : "ies"}</span> | |
| 22 | 81 | </form> |
| 23 | 82 | <div className="mb-3 flex flex-wrap gap-1"> |
| 24 | − <Chip href="/entities" tone={!sp.type ? "signal" : "default"}>all</Chip> | |
| 83 | + <Chip href={sp.q ? `/entities?tab=all&q=${encodeURIComponent(sp.q)}` : "/entities?tab=all"} tone={!sp.type ? "signal" : "default"}>all</Chip> | |
| 25 | 84 | {ENTITY_TYPES.filter((t) => types.has(t) || t === sp.type).map((t) => ( |
| 26 | − <Chip key={t} href={`/entities?type=${t}`} tone={sp.type === t ? "signal" : "default"}>{t}</Chip> | |
| 85 | + <Chip key={t} href={`/entities?tab=all&type=${t}${sp.q ? `&q=${encodeURIComponent(sp.q)}` : ""}`} tone={sp.type === t ? "signal" : "default"}>{typeLabel(t)}</Chip> | |
| 27 | 86 | ))} |
| 28 | 87 | </div> |
| 29 | 88 | <Panel dense> |
@@ -33,13 +92,13 @@ export default async function EntitiesPage({ searchParams }: { searchParams: Pro | ||
| 33 | 92 | <Table head={["Entity", "Type", "Domain", "Importance", "Events", "24 h", "Last event"]}> |
| 34 | 93 | {items.map((e) => ( |
| 35 | 94 | <tr key={e.id} className="hover:bg-panel-2/60"> |
| 36 | − <Td><Link href={`/company/${e.id}`} className="font-medium hover:underline">{e.name}</Link></Td> | |
| 37 | − <Td><Chip>{e.type}</Chip></Td> | |
| 95 | + <Td><Link href={`/entity/${e.id}`} className="font-medium hover:underline">{e.name}</Link></Td> | |
| 96 | + <Td><Chip>{typeLabel(e.type)}</Chip></Td> | |
| 38 | 97 | <Td mono className="text-fg-subtle">{e.domain ?? "—"}</Td> |
| 39 | 98 | <Td mono>{fmtScore(e.importance)}</Td> |
| 40 | 99 | <Td mono>{fmtInt(e.event_count)}</Td> |
| 41 | 100 | <Td mono className={e.events_24h ? "text-signal" : "text-fg-subtle"}>{e.events_24h ?? 0}</Td> |
| 42 | − <Td mono className="text-fg-subtle">{e.last_event_at ? relTime(e.last_event_at) : "—"}</Td> | |
| 101 | + <Td mono className="whitespace-nowrap text-fg-subtle">{e.last_event_at ? relTime(e.last_event_at) : "—"}</Td> | |
| 43 | 102 | </tr> |
| 44 | 103 | ))} |
| 45 | 104 | </Table> |
added
apps/web/src/app/entity/[id]/layout.tsx
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +import { notFound } from "next/navigation"; | |
| 2 | +import type { ReactNode } from "react"; | |
| 3 | +import { api } from "@/lib/api"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Existence check outside the `loading.tsx` boundary: the page streams behind a skeleton, so a | |
| 7 | + * `notFound()` thrown there would arrive after the 200 shell. Checking here yields a real 404. | |
| 8 | + * (The page's own `api.entity()` call is deduplicated by the fetch memoization of the same render.) | |
| 9 | + */ | |
| 10 | +export default async function EntityLayout({ params, children }: { params: Promise<{ id: string }>; children: ReactNode }) { | |
| 11 | + const { id } = await params; | |
| 12 | + const d = await api.entity(id); | |
| 13 | + if (!d) notFound(); | |
| 14 | + return children; | |
| 15 | +} | |
added
apps/web/src/app/entity/[id]/loading.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3 flex flex-wrap items-end justify-between gap-3"> | |
| 7 | + <div className="flex flex-col gap-2"> | |
| 8 | + <div className="flex items-center gap-2"> | |
| 9 | + <Skeleton className="h-4 w-20" /> | |
| 10 | + <Skeleton className="h-3 w-24" /> | |
| 11 | + <Skeleton className="h-4 w-16" /> | |
| 12 | + <Skeleton className="h-3 w-32" /> | |
| 13 | + </div> | |
| 14 | + <Skeleton className="h-6 w-56" /> | |
| 15 | + </div> | |
| 16 | + <div className="flex gap-2"> | |
| 17 | + <Skeleton className="h-7 w-20" /> | |
| 18 | + <Skeleton className="h-7 w-24" /> | |
| 19 | + </div> | |
| 20 | + </div> | |
| 21 | + <div className="mb-3 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-line bg-line sm:grid-cols-3 xl:grid-cols-6"> | |
| 22 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 23 | + <div key={i} className="flex flex-col gap-1.5 bg-panel px-3 py-2"> | |
| 24 | + <Skeleton className="h-2.5 w-16" /> | |
| 25 | + <Skeleton className="h-5 w-12" /> | |
| 26 | + <Skeleton className="h-2.5 w-24" /> | |
| 27 | + </div> | |
| 28 | + ))} | |
| 29 | + </div> | |
| 30 | + <div className="panel mb-3 flex flex-wrap items-center gap-4 px-3 py-2"> | |
| 31 | + <Skeleton className="h-2.5 w-24" /> | |
| 32 | + <div className="flex flex-wrap gap-[3px]"> | |
| 33 | + {Array.from({ length: 35 }, (_, i) => ( | |
| 34 | + <Skeleton key={i} className="size-3 !rounded-[2px]" /> | |
| 35 | + ))} | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + <div className="mb-3 flex gap-4 border-b border-line pb-2"> | |
| 39 | + {Array.from({ length: 7 }, (_, i) => ( | |
| 40 | + <Skeleton key={i} className="h-3 w-14" /> | |
| 41 | + ))} | |
| 42 | + </div> | |
| 43 | + <div className="grid gap-4 xl:grid-cols-[1fr_340px]"> | |
| 44 | + <div className="panel"> | |
| 45 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-28" /></div> | |
| 46 | + <SkeletonRows rows={10} /> | |
| 47 | + </div> | |
| 48 | + <aside className="flex flex-col gap-4"> | |
| 49 | + <SkeletonPanel lines={6} /> | |
| 50 | + <SkeletonPanel lines={3} /> | |
| 51 | + <SkeletonPanel lines={4} /> | |
| 52 | + </aside> | |
| 53 | + </div> | |
| 54 | + </> | |
| 55 | + ); | |
| 56 | +} | |
added
apps/web/src/app/entity/[id]/page.tsx
+480 −0
@@ -0,0 +1,480 @@ | ||
| 1 | +import Link from "next/link"; | |
| 2 | +import type { Metadata } from "next"; | |
| 3 | +import { notFound } from "next/navigation"; | |
| 4 | +import { EventRow } from "@/components/event-row"; | |
| 5 | +import { FieldChanges } from "@/components/field-changes"; | |
| 6 | +import { LiveFeed } from "@/components/live-feed"; | |
| 7 | +import { Badge, Bar, Chip, Empty, ExtLink, Flag, Heatmap, PageHeader, Panel, Score, Stat, Table, Tabs, Td, TierBadge, TypeChip } from "@/components/ui"; | |
| 8 | +import { WatchButton } from "@/components/watch-button"; | |
| 9 | +import { api, SITE_URL, type EntityDetail, type EntityInsights, type EventItem } from "@/lib/api"; | |
| 10 | +import { agoIso, dayHeader, fmtInt, fmtScore, relTime, typeLabel, utcDate, utcDateTime, utcTime, withinLast } from "@/lib/format"; | |
| 11 | + | |
| 12 | +export const dynamic = "force-dynamic"; | |
| 13 | + | |
| 14 | +type Tab = "overview" | "live" | "sources" | "silent" | "timeline" | "related" | "metrics"; | |
| 15 | +const TABS: Tab[] = ["overview", "live", "sources", "silent", "timeline", "related", "metrics"]; | |
| 16 | +const RANGES: { key: string; label: string; ms: number | null }[] = [ | |
| 17 | + { key: "1h", label: "1 h", ms: 3600e3 }, | |
| 18 | + { key: "6h", label: "6 h", ms: 6 * 3600e3 }, | |
| 19 | + { key: "24h", label: "24 h", ms: 24 * 3600e3 }, | |
| 20 | + { key: "7d", label: "7 d", ms: 7 * 86400e3 }, | |
| 21 | + { key: "30d", label: "30 d", ms: 30 * 86400e3 }, | |
| 22 | + { key: "all", label: "All", ms: null }, | |
| 23 | +]; | |
| 24 | + | |
| 25 | +type Search = { tab?: string; range?: string; cursor?: string }; | |
| 26 | + | |
| 27 | +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { | |
| 28 | + const { id } = await params; | |
| 29 | + const d = await api.entity(id); | |
| 30 | + if (!d) return { title: "Entity not found" }; | |
| 31 | + const e = d.entity; | |
| 32 | + const desc = e.description ?? `Every meaningful change WebSensor detected for ${e.name}: announcements, releases, pricing, documentation, incidents and silent changes — with evidence.`; | |
| 33 | + return { title: `${e.name} — ${typeLabel(e.type)}`, description: desc, alternates: { canonical: `/entity/${e.id}` }, openGraph: { title: `${e.name} · WebSensor`, description: desc, url: `${SITE_URL}/entity/${e.id}` } }; | |
| 34 | +} | |
| 35 | + | |
| 36 | +const EMPTY_INSIGHTS: EntityInsights = { heatmap: [], baseline_per_day: 0, today: 0, events_24h: 0, events_prev_24h: 0, velocity_ratio: 0, anomaly: { score: 0, ratio: 0, pct: 0 }, silent_24h: 0, breaking_24h: 0, sources_24h: 0, most_active_sensors: [], rank: { rank: null, total: 0, score: null } }; | |
| 37 | + | |
| 38 | +function mainCountry(d: EntityDetail): string | null { | |
| 39 | + const counts = new Map<string, number>(); | |
| 40 | + for (const s of d.sources) if (s.country) counts.set(s.country, (counts.get(s.country) ?? 0) + (s.first_party ? 2 : 1)); | |
| 41 | + let best: string | null = null; | |
| 42 | + let n = 0; | |
| 43 | + for (const [c, k] of counts) if (k > n) [best, n] = [c, k]; | |
| 44 | + return best; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export default async function EntityPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<Search> }) { | |
| 48 | + const { id } = await params; | |
| 49 | + const sp = await searchParams; | |
| 50 | + const d = await api.entity(id); | |
| 51 | + if (!d) notFound(); | |
| 52 | + const e = d.entity; | |
| 53 | + const tab: Tab = TABS.includes(sp.tab as Tab) ? (sp.tab as Tab) : "overview"; | |
| 54 | + const ins = d.insights ?? EMPTY_INSIGHTS; | |
| 55 | + const silent = d.silent ?? []; | |
| 56 | + const related = d.related ?? []; | |
| 57 | + const country = mainCountry(d); | |
| 58 | + const active = withinLast(e.last_event_at, 7 * 86400e3); | |
| 59 | + const anomalous = ins.anomaly.score >= 60; | |
| 60 | + const delta = ins.events_24h - ins.events_prev_24h; | |
| 61 | + const href = (t: Tab): string => (t === "overview" ? `/entity/${e.id}` : `/entity/${e.id}?tab=${t}`); | |
| 62 | + | |
| 63 | + return ( | |
| 64 | + <> | |
| 65 | + <PageHeader | |
| 66 | + compact | |
| 67 | + kicker={ | |
| 68 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 69 | + <TypeChip type={e.type} href={`/entities?tab=all&type=${e.type}`} /> | |
| 70 | + {d.parent && ( | |
| 71 | + <Link href={`/entity/${d.parent.id}`} className="inline-flex items-center gap-1 text-fg-muted hover:text-fg" title={`Parent: ${d.parent.name}`}> | |
| 72 | + <span className="text-fg-subtle">↑</span> {d.parent.name} | |
| 73 | + </Link> | |
| 74 | + )} | |
| 75 | + {e.domain && <Link href={`/domain/${e.domain}`} className="font-mono text-fg-muted hover:text-fg hover:underline">{e.domain}</Link>} | |
| 76 | + {country && <Flag code={country} className="text-[12px]" />} | |
| 77 | + <Chip tone={active ? "signal" : "default"} className="font-mono font-semibold tracking-wider" title={active ? "Events detected in the last 7 days" : "No event in the last 7 days"}> | |
| 78 | + <span className={`inline-block size-1.5 rounded-full ${active ? "bg-signal" : "bg-low"}`} /> {active ? "ACTIVE" : "QUIET"} | |
| 79 | + </Chip> | |
| 80 | + {ins.rank.rank !== null && ( | |
| 81 | + <span className="font-mono text-fg-muted tabular" title={`WebSensor rank score ${fmtScore(ins.rank.score)} — importance × activity × velocity × quality × confirmation`}> | |
| 82 | + <span className="text-fg-subtle">WebSensor rank</span> <Link href="/entities" className="font-semibold text-fg hover:underline">#{fmtInt(ins.rank.rank)}</Link> <span className="text-fg-subtle">of {fmtInt(ins.rank.total)}</span> | |
| 83 | + </span> | |
| 84 | + )} | |
| 85 | + </span> | |
| 86 | + } | |
| 87 | + title={e.name} | |
| 88 | + description={e.description} | |
| 89 | + actions={ | |
| 90 | + <> | |
| 91 | + <WatchButton kind="entity" value={e.id} /> | |
| 92 | + <Link href={`/alerts?entity=${encodeURIComponent(e.id)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Add to alert</Link> | |
| 93 | + {(e.homepage || e.domain) && <ExtLink href={e.homepage ?? `https://${e.domain}`} className="text-[12px]">Website ↗</ExtLink>} | |
| 94 | + </> | |
| 95 | + } | |
| 96 | + /> | |
| 97 | + | |
| 98 | + <div className="mb-3 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-line bg-line sm:grid-cols-3 xl:grid-cols-6"> | |
| 99 | + <div className="bg-panel"> | |
| 100 | + <Stat | |
| 101 | + label="Activity 24 h" | |
| 102 | + value={fmtInt(ins.events_24h)} | |
| 103 | + hint={ | |
| 104 | + <span className={delta > 0 ? "text-signal" : delta < 0 ? "text-fg-subtle" : ""}> | |
| 105 | + {delta > 0 ? "↑" : delta < 0 ? "↓" : "→"} {Math.abs(delta)} vs prev 24 h · ×{ins.velocity_ratio.toFixed(2)} | |
| 106 | + </span> | |
| 107 | + } | |
| 108 | + /> | |
| 109 | + </div> | |
| 110 | + <div className="bg-panel"><Stat label="Breaking 24 h" value={fmtInt(ins.breaking_24h)} tone={ins.breaking_24h > 0 ? "hot" : undefined} hint="signal ≥ 80" /></div> | |
| 111 | + <div className="bg-panel"><Stat label="Silent 24 h" value={fmtInt(ins.silent_24h)} tone={ins.silent_24h > 0 ? "silent" : undefined} hint="no matching announcement" /></div> | |
| 112 | + <div className="bg-panel"><Stat label="Sources 24 h" value={fmtInt(ins.sources_24h)} hint={`${fmtInt(d.sources.length)} linked`} /></div> | |
| 113 | + <div className="bg-panel"><Stat label="30-day baseline" value={<>{ins.baseline_per_day.toFixed(1)}<span className="text-[11px] font-normal text-fg-subtle">/day</span></>} hint={`today ${fmtInt(ins.today)}`} /></div> | |
| 114 | + <div className="bg-panel"> | |
| 115 | + <Stat | |
| 116 | + label={anomalous ? "Anomalous activity" : "Anomaly"} | |
| 117 | + value={ins.anomaly.score > 0 ? fmtScore(ins.anomaly.score) : "0"} | |
| 118 | + tone={anomalous ? "hot" : ins.anomaly.score >= 40 ? "warn" : undefined} | |
| 119 | + hint={<span className={anomalous ? "font-semibold text-hot" : ""}>{ins.anomaly.pct > 0 ? "+" : ""}{Math.round(ins.anomaly.pct)}% vs baseline</span>} | |
| 120 | + /> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + <div className="panel mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 px-3 py-2"> | |
| 125 | + <span className="label">35-day activity</span> | |
| 126 | + <div className="min-w-0"><Heatmap days={ins.heatmap} /></div> | |
| 127 | + <span className="ml-auto flex items-center gap-1 text-[10.5px] text-fg-subtle"> | |
| 128 | + less <span className="size-2.5 rounded-[2px] heat-1" /><span className="size-2.5 rounded-[2px] heat-2" /><span className="size-2.5 rounded-[2px] heat-3" /><span className="size-2.5 rounded-[2px] heat-4" /> more | |
| 129 | + <span className="ml-2 size-2.5 rounded-[2px] heat-hot" /> ≥ 3 breaking | |
| 130 | + </span> | |
| 131 | + </div> | |
| 132 | + | |
| 133 | + <Tabs | |
| 134 | + className="mb-3" | |
| 135 | + current={tab} | |
| 136 | + items={[ | |
| 137 | + { key: "overview", label: "Overview", href: href("overview") }, | |
| 138 | + { key: "live", label: "Live", href: href("live") }, | |
| 139 | + { key: "sources", label: "Sources", href: href("sources"), count: d.sources.length }, | |
| 140 | + { key: "silent", label: <span className={silent.length ? "text-silent" : ""}>Silent</span>, href: href("silent"), count: silent.length }, | |
| 141 | + { key: "timeline", label: "Timeline", href: href("timeline") }, | |
| 142 | + { key: "related", label: "Related", href: href("related"), count: related.length + d.children.length }, | |
| 143 | + { key: "metrics", label: "Metrics", href: href("metrics") }, | |
| 144 | + ]} | |
| 145 | + /> | |
| 146 | + | |
| 147 | + {tab === "overview" && <Overview d={d} ins={ins} />} | |
| 148 | + {tab === "live" && <LiveFeed initial={d.recent} initialCursor={d.nextCursor ?? null} extraQuery={{ entity: e.id }} showTabs={false} title={`LIVE · ${e.name.toUpperCase()}`} />} | |
| 149 | + {tab === "sources" && <SourcesTab d={d} />} | |
| 150 | + {tab === "silent" && <SilentTab items={silent} name={e.name} />} | |
| 151 | + {tab === "timeline" && <TimelineTab id={e.id} range={sp.range} cursor={sp.cursor} />} | |
| 152 | + {tab === "related" && <RelatedTab d={d} />} | |
| 153 | + {tab === "metrics" && <MetricsTab d={d} ins={ins} />} | |
| 154 | + </> | |
| 155 | + ); | |
| 156 | +} | |
| 157 | + | |
| 158 | +// --------------------------------------------------------------------------------------- | |
| 159 | + | |
| 160 | +function Overview({ d, ins }: { d: EntityDetail; ins: EntityInsights }) { | |
| 161 | + const e = d.entity; | |
| 162 | + const related = d.related ?? []; | |
| 163 | + return ( | |
| 164 | + <div className="grid gap-4 xl:grid-cols-[1fr_340px]"> | |
| 165 | + <Panel title={<>Recent signals <span className="font-mono text-fg-subtle">{d.recent.length}</span></>} dense action={<Link href={`/entity/${e.id}?tab=timeline`} className="text-[11px] text-fg-subtle hover:text-fg">full timeline →</Link>}> | |
| 166 | + {d.recent.length ? d.recent.map((ev) => <EventRow key={ev.id} ev={ev} showDate />) : <Empty>No event detected yet for {e.name}. Sensors linked to this entity are checked continuously.</Empty>} | |
| 167 | + </Panel> | |
| 168 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 169 | + <SensorsPanel sensors={ins.most_active_sensors} /> | |
| 170 | + <Panel title="Related entities" dense> | |
| 171 | + {related.length ? ( | |
| 172 | + <div className="flex flex-wrap gap-1 p-2"> | |
| 173 | + {related.slice(0, 16).map((r) => ( | |
| 174 | + <Chip key={r.id} href={`/entity/${r.id}`} title={`${typeLabel(r.type)} · ${r.shared_events} shared event${r.shared_events === 1 ? "" : "s"}`}> | |
| 175 | + {r.name} <span className="font-mono text-fg-subtle tabular">{r.shared_events}</span> | |
| 176 | + </Chip> | |
| 177 | + ))} | |
| 178 | + </div> | |
| 179 | + ) : ( | |
| 180 | + <Empty>No co-occurring entity yet.</Empty> | |
| 181 | + )} | |
| 182 | + </Panel> | |
| 183 | + {d.children.length > 0 && ( | |
| 184 | + <Panel title={<>Products & children <span className="font-mono text-fg-subtle">{d.children.length}</span></>} dense> | |
| 185 | + <ul className="divide-y divide-line"> | |
| 186 | + {d.children.slice(0, 12).map((c) => ( | |
| 187 | + <li key={c.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 188 | + <Link href={`/entity/${c.id}`} className="min-w-0 truncate font-medium hover:underline">{c.name}</Link> | |
| 189 | + <span className="shrink-0 font-mono text-[11px] text-fg-subtle tabular">{typeLabel(c.type)} · {fmtInt(c.event_count)}</span> | |
| 190 | + </li> | |
| 191 | + ))} | |
| 192 | + {d.children.length > 12 && <li className="px-3 py-1.5 text-[11px] text-fg-subtle"><Link href={`/entity/${e.id}?tab=related`} className="hover:text-fg">+{d.children.length - 12} more →</Link></li>} | |
| 193 | + </ul> | |
| 194 | + </Panel> | |
| 195 | + )} | |
| 196 | + {d.relations.length > 0 && ( | |
| 197 | + <Panel title="Knowledge graph" dense> | |
| 198 | + <ul className="divide-y divide-line"> | |
| 199 | + {d.relations.slice(0, 10).map((r, i) => ( | |
| 200 | + <li key={`${r.from_id}-${r.relation}-${r.to_id}-${i}`} className="flex flex-wrap items-center gap-x-1.5 px-3 py-1.5 text-[12.5px]"> | |
| 201 | + <Link href={`/entity/${r.from_id}`} className={r.from_id === e.id ? "text-fg-muted" : "font-medium hover:underline"}>{r.from_name}</Link> | |
| 202 | + <span className="font-mono text-[10.5px] uppercase tracking-wider text-fg-subtle">{r.relation.replace(/_/g, " ")}</span> | |
| 203 | + <Link href={`/entity/${r.to_id}`} className={r.to_id === e.id ? "text-fg-muted" : "font-medium hover:underline"}>{r.to_name}</Link> | |
| 204 | + <Chip className="ml-auto">{typeLabel(r.to_type)}</Chip> | |
| 205 | + </li> | |
| 206 | + ))} | |
| 207 | + </ul> | |
| 208 | + </Panel> | |
| 209 | + )} | |
| 210 | + {d.aliases.length > 0 && ( | |
| 211 | + <Panel title="Aliases"> | |
| 212 | + <div className="flex flex-wrap gap-1">{d.aliases.map((a) => <Chip key={a} className="font-mono">{a}</Chip>)}</div> | |
| 213 | + </Panel> | |
| 214 | + )} | |
| 215 | + </aside> | |
| 216 | + </div> | |
| 217 | + ); | |
| 218 | +} | |
| 219 | + | |
| 220 | +function SensorsPanel({ sensors, title = "Most active sensors · 7 d" }: { sensors: EntityInsights["most_active_sensors"]; title?: string }) { | |
| 221 | + return ( | |
| 222 | + <Panel title={title} dense> | |
| 223 | + {sensors.length ? ( | |
| 224 | + <ul className="divide-y divide-line"> | |
| 225 | + {sensors.slice(0, 8).map((s) => ( | |
| 226 | + <li key={s.id} className="grid grid-cols-[1fr_auto_2.5rem] items-center gap-x-2 px-3 py-1.5 text-[12.5px] hover:bg-panel-2/60"> | |
| 227 | + <div className="min-w-0"> | |
| 228 | + <Link href={`/sensor/${s.id}`} className="block truncate font-medium hover:underline">{s.name}</Link> | |
| 229 | + <div className="flex min-w-0 items-center gap-1.5 font-mono text-[10.5px] text-fg-subtle tabular"> | |
| 230 | + <Link href={`/source/${s.source_id}`} className="truncate uppercase tracking-wide hover:text-fg">{s.source_id}</Link> | |
| 231 | + <span className="shrink-0">· {relTime(s.last_event_at)}</span> | |
| 232 | + </div> | |
| 233 | + </div> | |
| 234 | + <Chip className="font-mono">{s.type}</Chip> | |
| 235 | + <span className="text-right font-mono text-[12px] font-semibold text-signal tabular" title="Events in the last 7 days">{s.events_7d}</span> | |
| 236 | + </li> | |
| 237 | + ))} | |
| 238 | + </ul> | |
| 239 | + ) : ( | |
| 240 | + <Empty>No sensor produced an event for this entity in the last 7 days.</Empty> | |
| 241 | + )} | |
| 242 | + </Panel> | |
| 243 | + ); | |
| 244 | +} | |
| 245 | + | |
| 246 | +function SourcesTab({ d }: { d: EntityDetail }) { | |
| 247 | + return ( | |
| 248 | + <Panel title={<>Monitored sources <span className="font-mono text-fg-subtle">{d.sources.length}</span></>} dense> | |
| 249 | + {d.sources.length === 0 ? ( | |
| 250 | + <Empty>No monitored source is linked to {d.entity.name} yet. Events still resolve to it from third-party coverage.</Empty> | |
| 251 | + ) : ( | |
| 252 | + <Table head={["Source", "Tier", "Evidence", "Country", "Sensors", "24 h", ""]}> | |
| 253 | + {[...d.sources].sort((a, b) => (b.events_24h ?? 0) - (a.events_24h ?? 0) || Number(Boolean(b.first_party)) - Number(Boolean(a.first_party))).map((s) => ( | |
| 254 | + <tr key={s.id} className="hover:bg-panel-2/60"> | |
| 255 | + <Td> | |
| 256 | + <Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link> | |
| 257 | + <div className="truncate font-mono text-[11px] text-fg-subtle">{s.domain}</div> | |
| 258 | + </Td> | |
| 259 | + <Td><TierBadge tier={s.tier} /></Td> | |
| 260 | + <Td>{s.first_party ? <Badge kind="first-party" /> : <Badge kind="external" />}</Td> | |
| 261 | + <Td>{s.country ? <span className="inline-flex items-center gap-1.5"><Flag code={s.country} /><span className="font-mono text-[11px] text-fg-subtle">{s.country}</span></span> : <span className="text-fg-subtle">—</span>}</Td> | |
| 262 | + <Td mono>{s.sensor_count ?? 0}</Td> | |
| 263 | + <Td mono className={s.events_24h ? "text-signal" : "text-fg-subtle"}>{s.events_24h ?? 0}</Td> | |
| 264 | + <Td className="text-right"><Link href={`/source/${s.id}`} className="whitespace-nowrap text-[11.5px] text-fg-subtle hover:text-fg">sensors →</Link></Td> | |
| 265 | + </tr> | |
| 266 | + ))} | |
| 267 | + </Table> | |
| 268 | + )} | |
| 269 | + </Panel> | |
| 270 | + ); | |
| 271 | +} | |
| 272 | + | |
| 273 | +function SilentTab({ items, name }: { items: EventItem[]; name: string }) { | |
| 274 | + return ( | |
| 275 | + <Panel title={<span className="inline-flex items-center gap-2"><Badge kind="silent" /> Silent changes <span className="font-mono text-fg-subtle">{items.length}</span></span>} dense action={<Link href={`/live?entity=${encodeURIComponent(name)}&silent_change=true`} className="hidden text-[11px] text-fg-subtle hover:text-fg sm:inline">all silent →</Link>}> | |
| 276 | + {items.length === 0 ? ( | |
| 277 | + <Empty>No silent changes detected in this period.</Empty> | |
| 278 | + ) : ( | |
| 279 | + <ul className="divide-y divide-line"> | |
| 280 | + {items.map((ev) => ( | |
| 281 | + <li key={ev.id} className="grid grid-cols-[auto_1fr] gap-x-3 border-l-2 border-l-silent/60 px-3 py-2 sm:grid-cols-[6.25rem_1fr_auto]"> | |
| 282 | + <div className="flex flex-col font-mono text-[11px] leading-4 text-fg-subtle tabular"> | |
| 283 | + <time dateTime={ev.detected_at} title={utcDateTime(ev.detected_at)} className="text-fg-muted">{utcTime(ev.detected_at)}</time> | |
| 284 | + <span>{utcDate(ev.detected_at)}</span> | |
| 285 | + </div> | |
| 286 | + <div className="min-w-0 sm:col-start-2"> | |
| 287 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]"> | |
| 288 | + <Link href={`/source/${ev.source?.id ?? ev.source_id}`} className="font-mono font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{ev.source?.name ?? ev.source_id}</Link> | |
| 289 | + {ev.country && <Flag code={ev.country} />} | |
| 290 | + <span className="sm:hidden"><Score value={ev.signal_score ?? ev.importance} size="sm" kind="signal" /></span> | |
| 291 | + <Chip>{typeLabel(ev.event_type)}</Chip> | |
| 292 | + {ev.change_class && <Chip tone="silent">{ev.change_class}</Chip>} | |
| 293 | + </div> | |
| 294 | + <Link href={`/event/${ev.slug}`} className="mt-0.5 block font-medium leading-snug hover:underline">{ev.title}</Link> | |
| 295 | + {ev.field_changes && ev.field_changes.length > 0 ? ( | |
| 296 | + <div className="mt-1.5 max-w-3xl"><FieldChanges items={ev.field_changes} compact max={4} /></div> | |
| 297 | + ) : ( | |
| 298 | + ev.summary && <p className="mt-0.5 line-clamp-2 text-[12.5px] text-fg-muted">{ev.summary}</p> | |
| 299 | + )} | |
| 300 | + </div> | |
| 301 | + <div className="hidden items-start pt-0.5 sm:flex"><Score value={ev.signal_score ?? ev.importance} kind="signal" /></div> | |
| 302 | + </li> | |
| 303 | + ))} | |
| 304 | + </ul> | |
| 305 | + )} | |
| 306 | + </Panel> | |
| 307 | + ); | |
| 308 | +} | |
| 309 | + | |
| 310 | +async function TimelineTab({ id, range, cursor }: { id: string; range?: string; cursor?: string }) { | |
| 311 | + const r = RANGES.find((x) => x.key === range) ?? RANGES[2]!; | |
| 312 | + const after = r.ms ? agoIso(r.ms) : undefined; | |
| 313 | + const page = await api.events({ entity: id, after, limit: 100, cursor }); | |
| 314 | + const groups = new Map<string, EventItem[]>(); | |
| 315 | + for (const ev of page.items) { | |
| 316 | + const day = utcDate(ev.detected_at); | |
| 317 | + if (!groups.has(day)) groups.set(day, []); | |
| 318 | + groups.get(day)!.push(ev); | |
| 319 | + } | |
| 320 | + const rangeHref = (k: string): string => `/entity/${id}?tab=timeline&range=${k}`; | |
| 321 | + return ( | |
| 322 | + <Panel | |
| 323 | + title={<>Timeline <span className="font-mono text-fg-subtle">{page.items.length}{page.nextCursor ? "+" : ""}</span></>} | |
| 324 | + dense | |
| 325 | + action={ | |
| 326 | + <nav className="flex items-center gap-0.5" aria-label="Range"> | |
| 327 | + {RANGES.map((x) => ( | |
| 328 | + <Link key={x.key} href={rangeHref(x.key)} aria-current={x.key === r.key ? "page" : undefined} className={`rounded-sm px-1.5 py-px font-mono text-[11px] tabular ${x.key === r.key ? "bg-panel-2 text-fg" : "text-fg-subtle hover:text-fg"}`}>{x.label}</Link> | |
| 329 | + ))} | |
| 330 | + </nav> | |
| 331 | + } | |
| 332 | + > | |
| 333 | + {page.items.length === 0 ? ( | |
| 334 | + <Empty>No event in the last {r.label.replace(" ", "")}{r.ms ? "" : " — nothing recorded yet"}. Try a wider range.</Empty> | |
| 335 | + ) : ( | |
| 336 | + <div> | |
| 337 | + {[...groups.entries()].map(([day, evs]) => ( | |
| 338 | + <div key={day}> | |
| 339 | + <div className="sticky top-12 z-10 flex items-center justify-between border-y border-line bg-panel-2/95 px-3 py-1 font-mono text-[11px] font-semibold tracking-wider text-fg-muted backdrop-blur"> | |
| 340 | + <span>{dayHeader(evs[0]!.detected_at)}</span> | |
| 341 | + <span className="font-normal text-fg-subtle tabular">{evs.length}</span> | |
| 342 | + </div> | |
| 343 | + {evs.map((ev) => <EventRow key={ev.id} ev={ev} />)} | |
| 344 | + </div> | |
| 345 | + ))} | |
| 346 | + </div> | |
| 347 | + )} | |
| 348 | + <div className="flex items-center justify-between px-3 py-2 text-[12px] text-fg-subtle"> | |
| 349 | + <span>{after ? <>since <span className="font-mono tabular">{utcDateTime(after)}</span></> : "complete history, newest first"}</span> | |
| 350 | + {page.nextCursor && <Link href={`${rangeHref(r.key)}&cursor=${encodeURIComponent(page.nextCursor)}`} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-fg hover:border-line-strong">Older →</Link>} | |
| 351 | + </div> | |
| 352 | + </Panel> | |
| 353 | + ); | |
| 354 | +} | |
| 355 | + | |
| 356 | +function RelatedTab({ d }: { d: EntityDetail }) { | |
| 357 | + const related = d.related ?? []; | |
| 358 | + const max = Math.max(1, ...related.map((r) => r.shared_events)); | |
| 359 | + return ( | |
| 360 | + <div className="grid gap-4 xl:grid-cols-[1fr_1fr]"> | |
| 361 | + <Panel title={<>Co-occurring entities <span className="font-mono text-fg-subtle">{related.length}</span></>} dense> | |
| 362 | + {related.length === 0 ? ( | |
| 363 | + <Empty>No entity shares an event with {d.entity.name} yet.</Empty> | |
| 364 | + ) : ( | |
| 365 | + <Table head={["Entity", "Type", "Shared events", ""]}> | |
| 366 | + {related.map((r) => ( | |
| 367 | + <tr key={r.id} className="hover:bg-panel-2/60"> | |
| 368 | + <Td><Link href={`/entity/${r.id}`} className="font-medium hover:underline">{r.name}</Link></Td> | |
| 369 | + <Td><Chip>{typeLabel(r.type)}</Chip></Td> | |
| 370 | + <Td mono>{r.shared_events}</Td> | |
| 371 | + <Td className="w-32 min-w-24 pt-3"><Bar value={r.shared_events} max={max} tone="info" /></Td> | |
| 372 | + </tr> | |
| 373 | + ))} | |
| 374 | + </Table> | |
| 375 | + )} | |
| 376 | + </Panel> | |
| 377 | + <div className="flex flex-col gap-4"> | |
| 378 | + <Panel title={<>Products & children <span className="font-mono text-fg-subtle">{d.children.length}</span></>} dense> | |
| 379 | + {d.children.length === 0 ? ( | |
| 380 | + <Empty>No child entity.</Empty> | |
| 381 | + ) : ( | |
| 382 | + <Table head={["Entity", "Type", "Events", "Last"]}> | |
| 383 | + {d.children.map((c) => ( | |
| 384 | + <tr key={c.id} className="hover:bg-panel-2/60"> | |
| 385 | + <Td><Link href={`/entity/${c.id}`} className="font-medium hover:underline">{c.name}</Link></Td> | |
| 386 | + <Td><Chip>{typeLabel(c.type)}</Chip></Td> | |
| 387 | + <Td mono>{fmtInt(c.event_count)}</Td> | |
| 388 | + <Td mono className="whitespace-nowrap text-fg-subtle">{c.last_event_at ? relTime(c.last_event_at) : "—"}</Td> | |
| 389 | + </tr> | |
| 390 | + ))} | |
| 391 | + </Table> | |
| 392 | + )} | |
| 393 | + </Panel> | |
| 394 | + <Panel title={<>Knowledge graph <span className="font-mono text-fg-subtle">{d.relations.length}</span></>} dense> | |
| 395 | + {d.relations.length === 0 ? ( | |
| 396 | + <Empty>No relation recorded.</Empty> | |
| 397 | + ) : ( | |
| 398 | + <ul className="divide-y divide-line"> | |
| 399 | + {d.relations.map((r, i) => ( | |
| 400 | + <li key={`${r.from_id}-${r.relation}-${r.to_id}-${i}`} className="flex flex-wrap items-center gap-x-1.5 px-3 py-1.5 text-[12.5px]"> | |
| 401 | + <Link href={`/entity/${r.from_id}`} className={r.from_id === d.entity.id ? "text-fg-muted" : "font-medium hover:underline"}>{r.from_name}</Link> | |
| 402 | + <span className="font-mono text-[10.5px] uppercase tracking-wider text-fg-subtle">{r.relation.replace(/_/g, " ")}</span> | |
| 403 | + <Link href={`/entity/${r.to_id}`} className={r.to_id === d.entity.id ? "text-fg-muted" : "font-medium hover:underline"}>{r.to_name}</Link> | |
| 404 | + <Chip className="ml-auto">{typeLabel(r.to_type)}</Chip> | |
| 405 | + </li> | |
| 406 | + ))} | |
| 407 | + </ul> | |
| 408 | + )} | |
| 409 | + </Panel> | |
| 410 | + {d.aliases.length > 0 && ( | |
| 411 | + <Panel title="Aliases"> | |
| 412 | + <div className="flex flex-wrap gap-1">{d.aliases.map((a) => <Chip key={a} className="font-mono">{a}</Chip>)}</div> | |
| 413 | + </Panel> | |
| 414 | + )} | |
| 415 | + </div> | |
| 416 | + </div> | |
| 417 | + ); | |
| 418 | +} | |
| 419 | + | |
| 420 | +function MetricsTab({ d, ins }: { d: EntityDetail; ins: EntityInsights }) { | |
| 421 | + const maxType = Math.max(1, ...d.by_type.map((t) => t.n)); | |
| 422 | + const total = d.by_type.reduce((n, t) => n + t.n, 0); | |
| 423 | + const anomalous = ins.anomaly.score >= 60; | |
| 424 | + const days7 = ins.heatmap.slice(-7).reduce((n, x) => n + x.events, 0); | |
| 425 | + const days35 = ins.heatmap.reduce((n, x) => n + x.events, 0); | |
| 426 | + const peak = ins.heatmap.reduce<{ day: string; events: number } | null>((best, x) => (!best || x.events > best.events ? { day: x.day, events: x.events } : best), null); | |
| 427 | + return ( | |
| 428 | + <div className="grid gap-4 xl:grid-cols-[1fr_1fr]"> | |
| 429 | + <div className="flex flex-col gap-4"> | |
| 430 | + <Panel title={<>Event types <span className="font-mono text-fg-subtle">{fmtInt(total)}</span></>}> | |
| 431 | + {d.by_type.length ? ( | |
| 432 | + <ul className="space-y-1.5"> | |
| 433 | + {d.by_type.map((t) => ( | |
| 434 | + <li key={t.event_type} className="grid grid-cols-[9rem_1fr_3.5rem] items-center gap-2 text-[12px] sm:grid-cols-[11rem_1fr_3.5rem]"> | |
| 435 | + <Link href={`/live?entity=${encodeURIComponent(d.entity.id)}&event_type=${t.event_type}`} className="truncate hover:underline">{typeLabel(t.event_type)}</Link> | |
| 436 | + <Bar value={t.n} max={maxType} tone="info" /> | |
| 437 | + <span className="text-right font-mono text-fg-subtle tabular">{t.n} <span className="text-[10.5px]">{total ? Math.round((t.n / total) * 100) : 0}%</span></span> | |
| 438 | + </li> | |
| 439 | + ))} | |
| 440 | + </ul> | |
| 441 | + ) : ( | |
| 442 | + <Empty>No events yet.</Empty> | |
| 443 | + )} | |
| 444 | + </Panel> | |
| 445 | + <SensorsPanel sensors={ins.most_active_sensors} /> | |
| 446 | + </div> | |
| 447 | + <div className="flex flex-col gap-4"> | |
| 448 | + <Panel title="Anomaly"> | |
| 449 | + <div className="flex items-baseline justify-between"> | |
| 450 | + <span className={`font-mono text-3xl font-semibold tabular ${anomalous ? "text-hot" : ins.anomaly.score >= 40 ? "text-warn" : "text-fg"}`}>{ins.anomaly.score > 0 ? fmtScore(ins.anomaly.score) : "0"}</span> | |
| 451 | + <span className={`label ${anomalous ? "!text-hot" : ""}`}>{anomalous ? "ANOMALOUS ACTIVITY" : ins.anomaly.score >= 40 ? "ELEVATED" : "NORMAL"}</span> | |
| 452 | + </div> | |
| 453 | + <div className="mt-2"><Bar value={ins.anomaly.score} tone={anomalous ? "hot" : ins.anomaly.score >= 40 ? "high" : "signal"} /></div> | |
| 454 | + <dl className="mt-3 grid grid-cols-2 gap-y-1 text-[12.5px]"> | |
| 455 | + <dt className="text-fg-subtle">30-day baseline</dt><dd className="text-right font-mono tabular">{ins.baseline_per_day.toFixed(1)} events/day</dd> | |
| 456 | + <dt className="text-fg-subtle">Last 24 h</dt><dd className="text-right font-mono tabular">{fmtInt(ins.events_24h)} events</dd> | |
| 457 | + <dt className="text-fg-subtle">Previous 24 h</dt><dd className="text-right font-mono tabular">{fmtInt(ins.events_prev_24h)} events</dd> | |
| 458 | + <dt className="text-fg-subtle">Velocity ratio</dt><dd className="text-right font-mono tabular">×{ins.velocity_ratio.toFixed(2)}</dd> | |
| 459 | + <dt className="text-fg-subtle">vs baseline</dt><dd className={`text-right font-mono tabular ${anomalous ? "font-semibold text-hot" : ""}`}>{ins.anomaly.pct > 0 ? "+" : ""}{Math.round(ins.anomaly.pct)}% (×{ins.anomaly.ratio.toFixed(2)})</dd> | |
| 460 | + </dl> | |
| 461 | + <p className="mt-3 text-[12px] leading-relaxed text-fg-muted"> | |
| 462 | + The anomaly score compares the last 24 h with this entity's own 30-day baseline, so a busy organization is not flagged for being busy. Scores ≥ 60 mark anomalous activity; the ratio above 1 means more events than usual. | |
| 463 | + </p> | |
| 464 | + </Panel> | |
| 465 | + <Panel title="35-day activity"> | |
| 466 | + <Heatmap days={ins.heatmap} /> | |
| 467 | + <dl className="mt-3 grid grid-cols-3 gap-2 text-[12.5px]"> | |
| 468 | + <div><dt className="label">7 d</dt><dd className="font-mono text-base font-semibold tabular">{fmtInt(days7)}</dd></div> | |
| 469 | + <div><dt className="label">35 d</dt><dd className="font-mono text-base font-semibold tabular">{fmtInt(days35)}</dd></div> | |
| 470 | + <div className="min-w-0"><dt className="label">Peak day</dt><dd className="truncate font-mono text-base font-semibold tabular">{peak && peak.events ? <>{peak.events} <span className="text-[11px] font-normal text-fg-subtle">{peak.day}</span></> : "—"}</dd></div> | |
| 471 | + </dl> | |
| 472 | + </Panel> | |
| 473 | + <div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-line bg-line"> | |
| 474 | + <div className="bg-panel"><Stat label="Total events" value={fmtInt(d.entity.event_count)} hint={d.entity.last_event_at ? `last ${relTime(d.entity.last_event_at)}` : "none yet"} /></div> | |
| 475 | + <div className="bg-panel"><Stat label="Entity importance" value={fmtScore(d.entity.importance)} hint="weight in scoring" /></div> | |
| 476 | + </div> | |
| 477 | + </div> | |
| 478 | + </div> | |
| 479 | + ); | |
| 480 | +} | |
modified
apps/web/src/app/error.tsx
+26 −7
@@ -1,14 +1,33 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | +import { AlertTriangle, RotateCcw } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useEffect } from "react"; | |
| 6 | +import { Kbd, Panel } from "@/components/ui"; | |
| 7 | + | |
| 3 | 8 | export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { |
| 9 | + useEffect(() => { | |
| 10 | + console.error(error); | |
| 11 | + }, [error]); | |
| 4 | 12 | return ( |
| 5 | − <div className="mx-auto max-w-lg py-20 text-center"> | |
| 6 | − <div className="label mb-2">Error</div> | |
| 7 | − <h1 className="text-xl font-semibold">Something went wrong while rendering</h1> | |
| 8 | − <p className="mt-2 font-mono text-[12px] text-fg-subtle">{error.digest ?? error.message}</p> | |
| 9 | − <button type="button" onClick={reset} className="mt-5 rounded-md border border-line bg-panel px-3 py-1.5 text-[13px] hover:border-line-strong"> | |
| 10 | − Try again | |
| 11 | − </button> | |
| 13 | + <div className="mx-auto max-w-lg py-12 sm:py-20"> | |
| 14 | + <Panel dense> | |
| 15 | + <div className="flex flex-col items-center gap-2 px-4 py-10 text-center"> | |
| 16 | + <span className="font-mono text-[11px] font-semibold tracking-wider text-danger">RENDER ERROR</span> | |
| 17 | + <AlertTriangle className="mt-1 size-6 text-danger" aria-hidden /> | |
| 18 | + <h1 className="mt-1 text-lg font-semibold leading-tight">Something went wrong while rendering</h1> | |
| 19 | + <p className="max-w-sm text-[13px] text-fg-muted">The sensors and the API are unaffected; only this view failed. Retrying usually works — the gateway may have been warming up.</p> | |
| 20 | + {(error.digest || error.message) && <code className="mt-1 max-w-full truncate rounded-sm border border-line bg-panel-2 px-2 py-0.5 font-mono text-[11px] text-fg-subtle" title={error.message}>{error.digest ?? error.message}</code>} | |
| 21 | + <div className="mt-4 flex flex-wrap justify-center gap-2 text-[12.5px]"> | |
| 22 | + <button type="button" onClick={reset} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-signal/40 bg-signal-soft px-3 text-signal hover:border-signal"><RotateCcw className="size-3.5" /> Try again</button> | |
| 23 | + <Link href="/live" className="inline-flex h-8 items-center rounded-md border border-line bg-panel px-3 hover:border-line-strong">Live feed</Link> | |
| 24 | + <Link href="/explore" className="inline-flex h-8 items-center rounded-md border border-line bg-panel px-3 hover:border-line-strong">Explore</Link> | |
| 25 | + </div> | |
| 26 | + <p className="mt-3 flex items-center gap-1.5 text-[11px] text-fg-subtle"> | |
| 27 | + <Kbd>⌘</Kbd><Kbd>K</Kbd> opens search from any page. | |
| 28 | + </p> | |
| 29 | + </div> | |
| 30 | + </Panel> | |
| 12 | 31 | </div> |
| 13 | 32 | ); |
| 14 | 33 | } |
added
apps/web/src/app/event/[slug]/loading.tsx
+57 −0
@@ -0,0 +1,57 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-4 flex flex-col gap-2"> | |
| 7 | + <div className="flex gap-2"> | |
| 8 | + <Skeleton className="h-4 w-24" /> | |
| 9 | + <Skeleton className="h-4 w-16" /> | |
| 10 | + <Skeleton className="h-4 w-20" /> | |
| 11 | + </div> | |
| 12 | + <Skeleton className="h-7 w-11/12 max-w-3xl" /> | |
| 13 | + <Skeleton className="h-7 w-2/3 max-w-xl" /> | |
| 14 | + </div> | |
| 15 | + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]"> | |
| 16 | + <div className="flex flex-col gap-4"> | |
| 17 | + <SkeletonPanel lines={4} /> | |
| 18 | + <div className="panel overflow-hidden"> | |
| 19 | + <div className="flex gap-1 border-b border-line px-2 py-1.5"> | |
| 20 | + <Skeleton className="h-6 w-16" /> | |
| 21 | + <Skeleton className="h-6 w-24" /> | |
| 22 | + <Skeleton className="h-6 w-20" /> | |
| 23 | + </div> | |
| 24 | + <div className="flex flex-col gap-1.5 p-3"> | |
| 25 | + {Array.from({ length: 8 }, (_, i) => ( | |
| 26 | + <Skeleton key={i} className={`h-3.5 ${i % 3 === 0 ? "w-11/12" : i % 3 === 1 ? "w-3/4" : "w-5/6"}`} /> | |
| 27 | + ))} | |
| 28 | + </div> | |
| 29 | + </div> | |
| 30 | + <SkeletonPanel lines={5} /> | |
| 31 | + <div className="panel"> | |
| 32 | + <Skeleton className="m-3 h-2.5 w-20" /> | |
| 33 | + <SkeletonRows rows={4} /> | |
| 34 | + </div> | |
| 35 | + </div> | |
| 36 | + <aside className="flex flex-col gap-4"> | |
| 37 | + <div className="panel p-3"> | |
| 38 | + <div className="flex items-center gap-3"> | |
| 39 | + <Skeleton className="h-9 w-14" /> | |
| 40 | + <Skeleton className="h-3 w-32" /> | |
| 41 | + </div> | |
| 42 | + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-3"> | |
| 43 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 44 | + <div key={i} className="flex flex-col gap-1.5"> | |
| 45 | + <Skeleton className="h-2.5 w-16" /> | |
| 46 | + <Skeleton className="h-1 w-full" /> | |
| 47 | + </div> | |
| 48 | + ))} | |
| 49 | + </div> | |
| 50 | + </div> | |
| 51 | + <SkeletonPanel lines={7} /> | |
| 52 | + <SkeletonPanel lines={3} /> | |
| 53 | + </aside> | |
| 54 | + </div> | |
| 55 | + </> | |
| 56 | + ); | |
| 57 | +} | |
added
apps/web/src/app/event/[slug]/opengraph-image.tsx
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +import { ImageResponse } from "next/og"; | |
| 2 | +import { api } from "@/lib/api"; | |
| 3 | +import { fmtScore, utcDateTime } from "@/lib/format"; | |
| 4 | + | |
| 5 | +export const runtime = "nodejs"; | |
| 6 | +export const alt = "WebSensor event"; | |
| 7 | +export const size = { width: 1200, height: 630 }; | |
| 8 | +export const contentType = "image/png"; | |
| 9 | + | |
| 10 | +const BG = "#0a0e15"; | |
| 11 | +const PANEL = "#10161f"; | |
| 12 | +const LINE = "#2d394e"; | |
| 13 | +const FG = "#e6e8ee"; | |
| 14 | +const MUTED = "#a3adc2"; | |
| 15 | +const SUBTLE = "#6b7591"; | |
| 16 | +const SIGNAL = "#22d3a5"; | |
| 17 | +const HOT = "#ff5c5c"; | |
| 18 | +const HIGH = "#ff9640"; | |
| 19 | +const MID = "#e4b53b"; | |
| 20 | +const SILENT = "#a78bfa"; | |
| 21 | + | |
| 22 | +function scoreColor(v: number): string { | |
| 23 | + return v >= 90 ? HOT : v >= 75 ? HIGH : v >= 50 ? MID : SUBTLE; | |
| 24 | +} | |
| 25 | + | |
| 26 | +function clip(s: string, max: number): string { | |
| 27 | + return s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export default async function OpenGraphImage({ params }: { params: Promise<{ slug: string }> }) { | |
| 31 | + const { slug } = await params; | |
| 32 | + const d = await api.event(slug); | |
| 33 | + const e = d?.event ?? null; | |
| 34 | + const signal = e ? Math.round(e.signal_score ?? e.importance) : 0; | |
| 35 | + const badges: { label: string; color: string }[] = []; | |
| 36 | + if (e?.cluster?.state === "breaking") badges.push({ label: "BREAKING", color: HOT }); | |
| 37 | + if (e?.cluster?.state === "developing") badges.push({ label: "DEVELOPING", color: HIGH }); | |
| 38 | + if (e?.silent_change) badges.push({ label: "SILENT", color: SILENT }); | |
| 39 | + if (e && e.first_party !== false) badges.push({ label: "FIRST PARTY", color: SIGNAL }); | |
| 40 | + if (e?.first_party === false) badges.push({ label: "EXTERNAL", color: SUBTLE }); | |
| 41 | + if (e?.evidence_label === "CONFIRMED") badges.push({ label: "CONFIRMED", color: SIGNAL }); | |
| 42 | + const title = e ? clip(e.title, 150) : "Event not found"; | |
| 43 | + const fontSize = title.length > 110 ? 40 : title.length > 70 ? 46 : 54; | |
| 44 | + | |
| 45 | + return new ImageResponse( | |
| 46 | + ( | |
| 47 | + <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "space-between", padding: 56, background: BG, color: FG, fontFamily: "Inter, system-ui, sans-serif" }}> | |
| 48 | + <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> | |
| 49 | + <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 30, fontWeight: 700 }}> | |
| 50 | + <div style={{ width: 16, height: 16, borderRadius: 16, background: SIGNAL, boxShadow: "0 0 0 8px rgba(34,211,165,0.22)" }} /> | |
| 51 | + <span> | |
| 52 | + Web<span style={{ color: SIGNAL }}>Sensor</span> | |
| 53 | + </span> | |
| 54 | + </div> | |
| 55 | + <div style={{ display: "flex", fontSize: 22, color: SUBTLE, fontFamily: "monospace", letterSpacing: 2 }}>{e ? (e.event_type ?? "").replace(/_/g, " ").toUpperCase() : ""}</div> | |
| 56 | + </div> | |
| 57 | + | |
| 58 | + <div style={{ display: "flex", flexDirection: "column", gap: 20, flex: 1, justifyContent: "center", paddingTop: 24, paddingBottom: 24 }}> | |
| 59 | + <div style={{ display: "flex", alignItems: "center", gap: 14, fontFamily: "monospace", fontSize: 26, color: MUTED, letterSpacing: 3, fontWeight: 700 }}> | |
| 60 | + <span>{(e?.source?.name ?? "").toUpperCase()}</span> | |
| 61 | + {e?.country ? <span style={{ color: SUBTLE, fontSize: 22 }}>· {e.country}</span> : null} | |
| 62 | + </div> | |
| 63 | + <div style={{ display: "flex", flexDirection: "row", gap: 32, alignItems: "flex-start" }}> | |
| 64 | + <div style={{ display: "flex", flex: 1, fontSize, fontWeight: 700, letterSpacing: -1.2, lineHeight: 1.12, color: FG, overflow: "hidden", maxHeight: fontSize * 1.12 * 3 + 6 }}>{title}</div> | |
| 65 | + <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: 200, height: 160, borderRadius: 12, border: `2px solid ${scoreColor(signal)}`, background: PANEL, flexShrink: 0 }}> | |
| 66 | + <div style={{ display: "flex", fontFamily: "monospace", fontSize: 88, fontWeight: 700, color: scoreColor(signal), lineHeight: 1 }}>{e ? fmtScore(signal) : "—"}</div> | |
| 67 | + <div style={{ display: "flex", fontSize: 16, letterSpacing: 3, color: SUBTLE, marginTop: 10, fontWeight: 600 }}>SIGNAL</div> | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | + {badges.length > 0 && ( | |
| 71 | + <div style={{ display: "flex", gap: 10 }}> | |
| 72 | + {badges.map((b) => ( | |
| 73 | + <div key={b.label} style={{ display: "flex", padding: "6px 12px", borderRadius: 6, border: `1.5px solid ${b.color}`, color: b.color, fontFamily: "monospace", fontSize: 20, fontWeight: 700, letterSpacing: 2 }}> | |
| 74 | + {b.label} | |
| 75 | + </div> | |
| 76 | + ))} | |
| 77 | + </div> | |
| 78 | + )} | |
| 79 | + </div> | |
| 80 | + | |
| 81 | + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderTop: `1px solid ${LINE}`, paddingTop: 20, fontSize: 22, color: SUBTLE, fontFamily: "monospace" }}> | |
| 82 | + <span>www.websensor.io</span> | |
| 83 | + <span>{e ? `DETECTED ${utcDateTime(e.detected_at)}` : "LIVE · UTC"}</span> | |
| 84 | + </div> | |
| 85 | + </div> | |
| 86 | + ), | |
| 87 | + { ...size }, | |
| 88 | + ); | |
| 89 | +} | |
modified
apps/web/src/app/event/[slug]/page.tsx
+248 −113
@@ -1,12 +1,16 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { notFound } from "next/navigation"; |
| 4 | +import type { ReactNode } from "react"; | |
| 5 | +import { IMPORTANCE_WEIGHTS } from "@websensor/core/client"; | |
| 4 | 6 | import { DiffViewer } from "@/components/diff-viewer"; |
| 7 | +import { BookmarkButton } from "@/components/event-drawer"; | |
| 5 | 8 | import { EventRow } from "@/components/event-row"; |
| 6 | −import { Chip, Empty, EvidenceTag, ExtLink, Gauge, HealthPill, PageHeader, Panel, Score, SilentBadge, Table, Td, TypeChip } from "@/components/ui"; | |
| 9 | +import { FieldChangeInline, FieldChanges } from "@/components/field-changes"; | |
| 10 | +import { Badge, Chip, Empty, EvidenceTag, ExtLink, Flag, Gauge, HealthPill, PageHeader, Panel, Score, StateBadge, Table, Td, TypeChip } from "@/components/ui"; | |
| 7 | 11 | import { ShareButton, WatchButton } from "@/components/watch-button"; |
| 8 | −import { api, SITE_URL } from "@/lib/api"; | |
| 9 | −import { fmtBytes, fmtMs, fmtPct, fmtScore, relTime, shortHash, typeLabel, utcDateTime } from "@/lib/format"; | |
| 12 | +import { api, SITE_URL, type SnapshotRow } from "@/lib/api"; | |
| 13 | +import { CLASS_LABELS, fmtBytes, fmtMs, fmtOffset, fmtPct, fmtScore, relTime, shortHash, typeLabel, utcDateTime } from "@/lib/format"; | |
| 10 | 14 | |
| 11 | 15 | export const dynamic = "force-dynamic"; |
| 12 | 16 | |
@@ -25,16 +29,23 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str | ||
| 25 | 29 | }; |
| 26 | 30 | } |
| 27 | 31 | |
| 28 | −const COMPONENT_WEIGHTS: [string, string, number][] = [ | |
| 29 | − ["severity", "Intrinsic event severity", 25], | |
| 30 | − ["source", "Source importance", 20], | |
| 31 | − ["entity", "Entity importance", 15], | |
| 32 | − ["novelty", "Novelty", 15], | |
| 33 | − ["magnitude", "Magnitude of change", 10], | |
| 34 | − ["confirmation", "Cross-source confirmation", 5], | |
| 35 | − ["userImpact", "User impact", 5], | |
| 36 | − ["unusualness", "Unusualness", 5], | |
| 37 | −]; | |
| 32 | +function Row({ label, value, tone = "" }: { label: string; value: ReactNode; tone?: string }) { | |
| 33 | + return ( | |
| 34 | + <> | |
| 35 | + <dt className="text-fg-subtle">{label}</dt> | |
| 36 | + <dd className={`text-right font-mono tabular ${tone}`}>{value}</dd> | |
| 37 | + </> | |
| 38 | + ); | |
| 39 | +} | |
| 40 | + | |
| 41 | +function Cell({ v, l }: { v: number | string; l: string }) { | |
| 42 | + return ( | |
| 43 | + <div className="rounded-md border border-line bg-panel-2 px-2 py-1.5 text-center"> | |
| 44 | + <div className="font-mono text-base font-semibold tabular">{v}</div> | |
| 45 | + <div className="label !text-[9.5px]">{l}</div> | |
| 46 | + </div> | |
| 47 | + ); | |
| 48 | +} | |
| 38 | 49 | |
| 39 | 50 | export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) { |
| 40 | 51 | const { slug } = await params; |
@@ -50,6 +61,16 @@ export default async function EventPage({ params }: { params: Promise<{ slug: st | ||
| 50 | 61 | const oldSnap = d.snapshots.find((s) => s.id === e.old_snapshot_id); |
| 51 | 62 | const newSnap = d.snapshots.find((s) => s.id === e.new_snapshot_id); |
| 52 | 63 | const subject = e.entities.find((x) => x.role === "subject") ?? e.entities[0]; |
| 64 | + const signal = e.signal_score ?? e.importance; | |
| 65 | + const cluster = e.cluster; | |
| 66 | + const clusterHref = cluster ? `/cluster/${cluster.slug ?? cluster.id}` : d.cluster ? `/cluster/${d.cluster.slug ?? d.cluster.id}` : null; | |
| 67 | + const leadTime = cluster?.lead_time_ms ?? d.cluster?.lead_time_ms ?? null; | |
| 68 | + const hasFields = (e.field_changes?.length ?? 0) > 0; | |
| 69 | + const diffKind = change?.change.kind ?? d.change?.kind ?? null; | |
| 70 | + const diffSummary = change?.change.diff ?? d.change?.diff ?? null; | |
| 71 | + const defaultTab = diffKind === "text" ? "split" : "semantic"; | |
| 72 | + const reasons = e.score_reasons ?? []; | |
| 73 | + const history = d.history ?? []; | |
| 53 | 74 | const jsonLd = { |
| 54 | 75 | "@context": "https://schema.org", |
| 55 | 76 | "@type": "NewsArticle", |
@@ -59,136 +80,267 @@ export default async function EventPage({ params }: { params: Promise<{ slug: st | ||
| 59 | 80 | dateModified: e.processed_at ?? e.detected_at, |
| 60 | 81 | url: `${SITE_URL}/event/${e.slug}`, |
| 61 | 82 | mainEntityOfPage: `${SITE_URL}/event/${e.slug}`, |
| 83 | + image: [`${SITE_URL}/event/${e.slug}/opengraph-image`], | |
| 62 | 84 | author: { "@type": "Organization", name: "WebSensor", url: SITE_URL }, |
| 63 | 85 | publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL }, |
| 64 | − about: e.entities.map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/company/${x.id}` })), | |
| 86 | + about: e.entities.map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/entity/${x.id}` })), | |
| 65 | 87 | isBasedOn: e.url, |
| 66 | 88 | keywords: [e.event_type, ...e.categories, ...e.keywords].join(", "), |
| 67 | 89 | }; |
| 90 | + const snapRows: [string, SnapshotRow | undefined][] = [ | |
| 91 | + ["Before", oldSnap], | |
| 92 | + ["After", newSnap], | |
| 93 | + ]; | |
| 68 | 94 | return ( |
| 69 | 95 | <> |
| 70 | 96 | <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> |
| 71 | 97 | <PageHeader |
| 72 | 98 | kicker={ |
| 73 | − <span className="flex flex-wrap items-center gap-2"> | |
| 74 | − <Link href={`/source/${e.source?.id ?? e.source_id}`} className="font-mono uppercase tracking-wide text-fg-muted hover:text-fg">{e.source?.name}</Link> | |
| 75 | − <TypeChip type={e.event_type} /> | |
| 76 | − {e.silent_change && <SilentBadge />} | |
| 99 | + <span className="flex flex-wrap items-center gap-1.5"> | |
| 100 | + <Link href={`/source/${e.source?.id ?? e.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{e.source?.name ?? e.source_id}</Link> | |
| 101 | + {e.country && <Flag code={e.country} className="text-[12px]" />} | |
| 102 | + <StateBadge state={cluster?.state} /> | |
| 103 | + {e.silent_change && <Badge kind="silent" />} | |
| 104 | + {e.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />} | |
| 77 | 105 | <EvidenceTag label={e.evidence_label} /> |
| 106 | + <TypeChip type={e.event_type} href={`/live?event_type=${e.event_type}`} /> | |
| 78 | 107 | {e.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)} |
| 79 | 108 | </span> |
| 80 | 109 | } |
| 81 | − title={e.title} | |
| 110 | + title={<span className="[overflow-wrap:anywhere]">{e.title}</span>} | |
| 82 | 111 | actions={ |
| 83 | 112 | <> |
| 84 | 113 | <ShareButton path={`/event/${e.slug}`} /> |
| 114 | + <BookmarkButton eventId={e.id} label /> | |
| 85 | 115 | {subject && <WatchButton kind="entity" value={subject.id} label={`Watch ${subject.name}`} />} |
| 86 | − <Link href="/alerts" className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Alert</Link> | |
| 116 | + <Link href={subject ? `/alerts?entity=${encodeURIComponent(subject.id)}` : "/alerts"} className="inline-flex h-7 items-center rounded-md border border-line bg-panel px-2.5 text-[12px] hover:border-line-strong">Alert</Link> | |
| 87 | 117 | </> |
| 88 | 118 | } |
| 89 | 119 | /> |
| 90 | − <div className="grid gap-4 lg:grid-cols-[1fr_340px]"> | |
| 91 | − <div className="flex flex-col gap-4"> | |
| 92 | − <Panel> | |
| 93 | − <p className="text-[15px] leading-relaxed">{e.summary}</p> | |
| 94 | − {e.why_it_matters && ( | |
| 95 | − <div className="mt-4"> | |
| 96 | − <div className="label mb-1">Why it matters</div> | |
| 97 | − <p className="text-[13.5px] leading-relaxed text-fg-muted">{e.why_it_matters}</p> | |
| 98 | − </div> | |
| 120 | + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]"> | |
| 121 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 122 | + {/* WHAT CHANGED */} | |
| 123 | + <Panel title={<span>What changed{e.change_class && <span className="ml-2 normal-case tracking-normal text-fg-subtle">· {CLASS_LABELS[e.change_class] ?? e.change_class}</span>}</span>} action={<span className="font-mono text-[11px] text-fg-subtle tabular">{utcDateTime(e.detected_at)}</span>}> | |
| 124 | + {hasFields ? ( | |
| 125 | + <> | |
| 126 | + <FieldChanges items={e.field_changes} /> | |
| 127 | + <p className="mt-3 text-[13px] leading-relaxed text-fg-muted [overflow-wrap:anywhere]">{e.summary}</p> | |
| 128 | + </> | |
| 129 | + ) : ( | |
| 130 | + <p className="text-[15px] leading-relaxed [overflow-wrap:anywhere]">{e.summary}</p> | |
| 99 | 131 | )} |
| 100 | − {who && ( | |
| 101 | − <div className="mt-3"> | |
| 102 | − <div className="label mb-1">Who it affects</div> | |
| 103 | − <p className="text-[13.5px] text-fg-muted">{who}</p> | |
| 104 | − </div> | |
| 132 | + </Panel> | |
| 133 | + | |
| 134 | + {/* BEFORE / AFTER */} | |
| 135 | + <div id="diff" className="scroll-mt-16"> | |
| 136 | + <div className="mb-2 flex flex-wrap items-center justify-between gap-2"> | |
| 137 | + <h2 className="label">Before / after{diffKind ? <span className="ml-2 normal-case tracking-normal text-fg-subtle">· {diffKind} diff</span> : null}</h2> | |
| 138 | + <span className="flex flex-wrap gap-3 text-[11px] text-fg-subtle"> | |
| 139 | + {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="hover:text-fg">open in compare →</Link>} | |
| 140 | + <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="hover:text-fg">URL history →</Link> | |
| 141 | + </span> | |
| 142 | + </div> | |
| 143 | + {change || diffSummary ? ( | |
| 144 | + <DiffViewer unified={change?.unified ?? null} summary={diffSummary} defaultTab={defaultTab} /> | |
| 145 | + ) : ( | |
| 146 | + <Panel dense> | |
| 147 | + <Empty>No diff is attached to this event{e.event_type === "page_removed" ? " — the page disappeared; its last snapshot is preserved below." : "."}</Empty> | |
| 148 | + </Panel> | |
| 105 | 149 | )} |
| 106 | − <div className="mt-4 grid gap-3 sm:grid-cols-2"> | |
| 150 | + </div> | |
| 151 | + | |
| 152 | + {/* WHY IT MATTERS */} | |
| 153 | + <Panel title={<span>Why it matters <span className="normal-case tracking-normal text-fg-subtle">· analysis</span></span>}> | |
| 154 | + {e.why_it_matters ? <p className="text-[13.5px] leading-relaxed">{e.why_it_matters}</p> : <p className="text-[13px] text-fg-subtle">No analysis was generated for this event; the observed change speaks for itself.</p>} | |
| 155 | + <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 107 | 156 | <div className="rounded-md border border-line bg-panel-2 p-3"> |
| 108 | 157 | <div className="label mb-1 !text-ok">Observed</div> |
| 109 | − <p className="text-[13px]">{observed ?? "Facts are limited to the diff below: the monitored endpoint changed between the two preserved snapshots."}</p> | |
| 158 | + <p className="text-[13px]">{observed ?? "Facts are limited to the diff above: the monitored endpoint changed between the two preserved snapshots."}</p> | |
| 110 | 159 | </div> |
| 111 | 160 | <div className="rounded-md border border-line bg-panel-2 p-3"> |
| 112 | 161 | <div className="label mb-1 !text-info">Inferred</div> |
| 113 | 162 | <p className="text-[13px] text-fg-muted">{inferred ?? "No inference beyond the observed change."}</p> |
| 114 | − <p className="mt-1 font-mono text-[11px] text-fg-subtle">confidence {fmtScore(e.confidence)}% · {model}</p> | |
| 163 | + <p className="mt-1 font-mono text-[11px] text-fg-subtle">confidence {fmtScore(e.confidence)} · {model}</p> | |
| 115 | 164 | </div> |
| 116 | 165 | </div> |
| 166 | + {who && ( | |
| 167 | + <div className="mt-3"> | |
| 168 | + <div className="label mb-1">Who it affects</div> | |
| 169 | + <p className="text-[13px] text-fg-muted">{who}</p> | |
| 170 | + </div> | |
| 171 | + )} | |
| 117 | 172 | </Panel> |
| 118 | 173 | |
| 119 | − <div> | |
| 120 | − <div className="mb-2 flex items-center justify-between"> | |
| 121 | − <h2 className="label">Diff · {change?.change.kind ?? d.change?.kind ?? "—"}</h2> | |
| 122 | − {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="text-[11px] text-fg-subtle hover:text-fg">open in compare →</Link>} | |
| 123 | − </div> | |
| 124 | − {change ? <DiffViewer unified={change.unified} summary={change.change.diff} defaultTab={change.change.kind === "text" ? "unified" : "semantic"} /> : <Panel dense><Empty>No diff is attached to this event{e.event_type === "page_removed" ? " — the page disappeared; its last snapshot is preserved below." : "."}</Empty></Panel>} | |
| 125 | − </div> | |
| 126 | − | |
| 174 | + {/* EVIDENCE */} | |
| 127 | 175 | <Panel title="Evidence" dense> |
| 128 | − <div className="px-3 py-2 text-[13px]"> | |
| 129 | − <div className="label mb-1">Monitored URL</div> | |
| 130 | − <ExtLink href={e.url} className="break-all font-mono text-[12.5px]">{e.url}</ExtLink> | |
| 131 | − <span className="ml-2 text-[11px] text-fg-subtle">· <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="hover:underline">URL history</Link> · <Link href={`/sensor/${e.sensor_id}`} className="hover:underline">sensor {e.sensor?.name}</Link></span> | |
| 176 | + <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 px-3 py-2 text-[13px]"> | |
| 177 | + <span className="label">Monitored URL</span> | |
| 178 | + <ExtLink href={e.url} className="min-w-0 break-all font-mono text-[12.5px]">{e.url}</ExtLink> | |
| 132 | 179 | </div> |
| 133 | 180 | <Table head={["Snapshot", "Captured", "HTTP", "Size", "Canonical hash", "Raw hash", ""]}> |
| 134 | − {[["Before", oldSnap], ["After", newSnap]].map(([label, s]) => { | |
| 135 | − const snap = s as typeof oldSnap; | |
| 136 | − return ( | |
| 137 | − <tr key={String(label)}> | |
| 138 | − <Td className="font-medium">{String(label)}</Td> | |
| 139 | − <Td mono className="text-fg-subtle">{snap ? utcDateTime(snap.captured_at) : "—"}</Td> | |
| 140 | − <Td mono>{snap?.http_status ?? "—"}</Td> | |
| 141 | − <Td mono>{snap ? fmtBytes(snap.content_length) : "—"}</Td> | |
| 142 | − <Td mono className="text-fg-subtle">{shortHash(snap?.canonical_hash)}</Td> | |
| 143 | − <Td mono className="text-fg-subtle">{shortHash(snap?.content_hash)}</Td> | |
| 144 | − <Td>{snap ? <a href={`/api/v1/snapshots/${snap.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a> : ""}</Td> | |
| 145 | − </tr> | |
| 146 | − ); | |
| 147 | − })} | |
| 181 | + {snapRows.map(([label, snap]) => ( | |
| 182 | + <tr key={label}> | |
| 183 | + <Td className="font-medium">{label}</Td> | |
| 184 | + <Td mono className="whitespace-nowrap text-fg-subtle">{snap ? utcDateTime(snap.captured_at) : "—"}</Td> | |
| 185 | + <Td mono>{snap?.http_status ?? "—"}</Td> | |
| 186 | + <Td mono className="whitespace-nowrap">{snap ? fmtBytes(snap.content_length) : "—"}</Td> | |
| 187 | + <Td mono className="text-fg-subtle"><span title={snap?.canonical_hash ?? undefined}>{shortHash(snap?.canonical_hash)}</span></Td> | |
| 188 | + <Td mono className="text-fg-subtle"><span title={snap?.content_hash ?? undefined}>{shortHash(snap?.content_hash)}</span></Td> | |
| 189 | + <Td className="whitespace-nowrap"> | |
| 190 | + {snap ? ( | |
| 191 | + snap.has_raw === false ? ( | |
| 192 | + <span className="text-fg-subtle" title="Raw body pruned by retention; canonical preserved">view raw · pruned</span> | |
| 193 | + ) : ( | |
| 194 | + <a href={`/api/v1/snapshots/${snap.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a> | |
| 195 | + ) | |
| 196 | + ) : ""} | |
| 197 | + </Td> | |
| 198 | + </tr> | |
| 199 | + ))} | |
| 148 | 200 | </Table> |
| 149 | − <div className="px-3 py-2 font-mono text-[11px] text-fg-subtle"> | |
| 150 | − event {e.id} · change {e.change_id ?? "—"} · {e.processing_version ?? ""} · trace: event → change → snapshots → sensor {e.sensor_id} → source {e.source_id} | |
| 201 | + {snapRows.some(([, s]) => s && s.has_raw === false) && <p className="px-3 pt-2 text-[11px] text-fg-subtle">Raw body pruned by retention; canonical preserved. Hashes and the canonical diff remain verifiable.</p>} | |
| 202 | + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2 text-[12px]"> | |
| 203 | + <dt className="text-fg-subtle">Evidence hash</dt> | |
| 204 | + <dd className="min-w-0 break-all font-mono text-fg-muted tabular">{newSnap?.canonical_hash ?? "—"}</dd> | |
| 205 | + <dt className="text-fg-subtle">Sensor</dt> | |
| 206 | + <dd className="min-w-0 truncate"><Link href={`/sensor/${e.sensor_id}`} className="text-info hover:underline">{e.sensor?.name ?? e.sensor_id}</Link> <span className="font-mono text-[11px] text-fg-subtle">{e.sensor?.type}{e.sensor?.connector ? ` · ${e.sensor.connector}` : ""}</span></dd> | |
| 207 | + <dt className="text-fg-subtle">Trace</dt> | |
| 208 | + <dd className="min-w-0 break-all font-mono text-[11px] text-fg-subtle">event {e.id} → change {e.change_id ?? "—"} → snapshots → sensor {e.sensor_id} → source {e.source_id}{e.processing_version ? ` · ${e.processing_version}` : ""}</dd> | |
| 209 | + </dl> | |
| 210 | + <div className="flex flex-wrap gap-3 border-t border-line px-3 py-2 text-[11.5px]"> | |
| 211 | + {oldSnap && newSnap && <Link href={`/compare?a=${oldSnap.id}&b=${newSnap.id}`} className="text-info hover:underline">Compare snapshots</Link>} | |
| 212 | + <Link href={`/sensor/${e.sensor_id}`} className="text-info hover:underline">Sensor</Link> | |
| 213 | + <Link href={`/url?u=${encodeURIComponent(e.url)}`} className="text-info hover:underline">URL history</Link> | |
| 214 | + <Link href={`/source/${e.source?.id ?? e.source_id}`} className="text-info hover:underline">Source</Link> | |
| 151 | 215 | </div> |
| 152 | 216 | </Panel> |
| 153 | 217 | |
| 154 | − <Panel title="Related events" dense> | |
| 155 | − {d.related.length ? d.related.map((r) => <EventRow key={r.id} ev={r} showDate />) : <Empty>No related events yet.</Empty>} | |
| 218 | + {/* HISTORICAL CONTEXT */} | |
| 219 | + <Panel title={<span>Previous changes on this page <span className="font-mono text-fg-subtle">{history.length}</span></span>} dense> | |
| 220 | + {history.length ? ( | |
| 221 | + <ul className="divide-y divide-line"> | |
| 222 | + {history.map((h) => ( | |
| 223 | + <li key={h.id} className="grid grid-cols-[5.5rem_1fr_auto] items-start gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 224 | + <span className="font-mono text-[11px] text-fg-subtle tabular" title={utcDateTime(h.detected_at)}>{relTime(h.detected_at)}</span> | |
| 225 | + <div className="min-w-0"> | |
| 226 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 227 | + <Link href={`/event/${h.slug}`} className="min-w-0 truncate hover:underline">{h.title}</Link> | |
| 228 | + {h.silent_change && <Badge kind="silent" compact />} | |
| 229 | + </div> | |
| 230 | + <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-fg-subtle"> | |
| 231 | + <span>{typeLabel(h.event_type)}</span> | |
| 232 | + {h.field_changes && h.field_changes.length > 0 && <FieldChangeInline items={h.field_changes} max={2} />} | |
| 233 | + </div> | |
| 234 | + </div> | |
| 235 | + <Score value={h.importance} size="sm" /> | |
| 236 | + </li> | |
| 237 | + ))} | |
| 238 | + </ul> | |
| 239 | + ) : ( | |
| 240 | + <Empty>First change observed on this URL.</Empty> | |
| 241 | + )} | |
| 242 | + </Panel> | |
| 243 | + | |
| 244 | + {/* RELATED SIGNALS */} | |
| 245 | + <Panel title={<span>Related signals <span className="font-mono text-fg-subtle">{d.related.length}</span></span>} dense> | |
| 246 | + {d.related.length ? d.related.map((r) => <EventRow key={r.id} ev={r} showDate />) : <Empty>No related signals yet.</Empty>} | |
| 156 | 247 | </Panel> |
| 248 | + | |
| 249 | + {/* CLUSTER / PROPAGATION */} | |
| 250 | + {(cluster || d.cluster) && ( | |
| 251 | + <Panel title="Event cluster · propagation" action={clusterHref && <Link href={clusterHref} className="text-[11px] text-info hover:underline">propagation timeline →</Link>}> | |
| 252 | + {d.cluster?.title && <div className="mb-2 text-[13px] font-medium [overflow-wrap:anywhere]">{d.cluster.title}</div>} | |
| 253 | + <div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> | |
| 254 | + <Cell v={cluster?.event_count ?? d.cluster?.event_count ?? 1} l="signals" /> | |
| 255 | + <Cell v={cluster?.first_party_count ?? d.cluster?.first_party_count ?? 0} l="first-party" /> | |
| 256 | + <Cell v={cluster?.external_count ?? d.cluster?.external_count ?? 0} l="external" /> | |
| 257 | + <Cell v={cluster?.source_count ?? d.cluster?.source_count ?? 1} l="sources" /> | |
| 258 | + </div> | |
| 259 | + {leadTime !== null && leadTime > 0 && ( | |
| 260 | + <p className="mt-3 rounded-md border border-signal/30 bg-signal-soft px-3 py-2 text-[12.5px]"> | |
| 261 | + <span className="font-semibold text-signal">WebSensor lead time {fmtOffset(leadTime).replace("+", "")}</span> <span className="text-fg-muted">— the first-party change was observed before the first external report.</span> | |
| 262 | + </p> | |
| 263 | + )} | |
| 264 | + <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-subtle"> | |
| 265 | + {(cluster?.state ?? d.cluster?.state) && <span>state <span className="font-mono uppercase text-fg-muted">{cluster?.state ?? d.cluster?.state}</span></span>} | |
| 266 | + {(cluster?.velocity ?? d.cluster?.velocity) !== undefined && <span>velocity <span className="font-mono text-fg-muted tabular">{Math.round(cluster?.velocity ?? d.cluster?.velocity ?? 0)}</span></span>} | |
| 267 | + {d.cluster && <span>{relTime(d.cluster.first_at)} → {relTime(d.cluster.last_at)}</span>} | |
| 268 | + </div> | |
| 269 | + {d.cluster && d.cluster.entity_ids.length > 0 && <div className="mt-2 flex flex-wrap gap-1">{d.cluster.entity_ids.slice(0, 8).map((id) => <Chip key={id} href={`/entity/${id}`}>{id.replace(/^(org|prd|ent)_/, "")}</Chip>)}</div>} | |
| 270 | + </Panel> | |
| 271 | + )} | |
| 157 | 272 | </div> |
| 158 | 273 | |
| 159 | − <aside className="flex flex-col gap-4"> | |
| 274 | + {/* RIGHT RAIL */} | |
| 275 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 160 | 276 | <Panel> |
| 161 | − <div className="mb-3 flex items-center gap-3"> | |
| 162 | − <Score value={e.importance} size="lg" /> | |
| 163 | − <div> | |
| 164 | − <div className="label">Importance</div> | |
| 165 | − <div className="text-[12px] text-fg-muted">{e.importance >= 90 ? "critical" : e.importance >= 75 ? "major" : e.importance >= 50 ? "notable" : "minor"}</div> | |
| 277 | + <div className="flex items-center gap-3"> | |
| 278 | + <Score value={signal} size="lg" kind="signal" /> | |
| 279 | + <div className="min-w-0"> | |
| 280 | + <div className="label">WebSensor signal score</div> | |
| 281 | + <div className="text-[12px] text-fg-muted">{signal >= 80 ? "attention now" : signal >= 60 ? "worth a look" : "informational"}</div> | |
| 166 | 282 | </div> |
| 167 | 283 | </div> |
| 168 | − <div className="flex flex-col gap-3"> | |
| 284 | + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-3"> | |
| 285 | + <Gauge label="Importance" value={e.importance} /> | |
| 169 | 286 | <Gauge label="Confidence" value={e.confidence} tone="info" /> |
| 170 | 287 | <Gauge label="Novelty" value={e.novelty} tone="signal" /> |
| 288 | + <Gauge label="Impact" value={e.impact_score} tone="high" /> | |
| 289 | + <Gauge label="Velocity" value={e.velocity_score} tone="silent" /> | |
| 290 | + <Gauge label="Anomaly" value={e.anomaly_score} tone="mid" /> | |
| 291 | + </div> | |
| 292 | + <div className="mt-4"> | |
| 293 | + <div className="label mb-1.5">Why this score</div> | |
| 294 | + {reasons.length ? ( | |
| 295 | + <ul className="divide-y divide-line rounded-md border border-line text-[12px]"> | |
| 296 | + {reasons.map((r, i) => ( | |
| 297 | + <li key={i} className="flex items-start gap-2 px-2 py-1"> | |
| 298 | + <span className={`w-2 shrink-0 font-mono font-semibold ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.sign}</span> | |
| 299 | + <span className="min-w-0 flex-1 text-fg-muted">{r.text}</span> | |
| 300 | + {r.points !== undefined && <span className={`shrink-0 font-mono tabular ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.points > 0 ? "+" : ""}{r.points}</span>} | |
| 301 | + </li> | |
| 302 | + ))} | |
| 303 | + </ul> | |
| 304 | + ) : ( | |
| 305 | + <p className="text-[12px] text-fg-subtle">Scored by heuristics only; no explicit adjustments were recorded for this event.</p> | |
| 306 | + )} | |
| 171 | 307 | </div> |
| 308 | + {e.change_class && <div className="mt-3 text-[11.5px] text-fg-subtle">Semantic class <span className="font-medium text-fg-muted">{CLASS_LABELS[e.change_class] ?? e.change_class}</span></div>} | |
| 309 | + </Panel> | |
| 310 | + | |
| 311 | + <Panel title="Importance components" dense> | |
| 312 | + <Table head={["Component", "Weight", "Score"]}> | |
| 313 | + {IMPORTANCE_WEIGHTS.map((w) => ( | |
| 314 | + <tr key={w.key}> | |
| 315 | + <Td>{w.label}</Td> | |
| 316 | + <Td mono className="text-fg-subtle">{w.weight}%</Td> | |
| 317 | + <Td mono>{e.importance_components?.[w.key] !== undefined ? fmtScore(e.importance_components[w.key]) : "—"}</Td> | |
| 318 | + </tr> | |
| 319 | + ))} | |
| 320 | + </Table> | |
| 172 | 321 | </Panel> |
| 322 | + | |
| 173 | 323 | <Panel title="Timing"> |
| 174 | 324 | <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]"> |
| 175 | − <dt className="text-fg-subtle">Published</dt><dd className="text-right font-mono tabular">{e.published_at ? utcDateTime(e.published_at) : "—"}</dd> | |
| 176 | − <dt className="text-fg-subtle">Observed from</dt><dd className="text-right font-mono tabular">{e.observed_from ? utcDateTime(e.observed_from) : "—"}</dd> | |
| 177 | − <dt className="text-fg-subtle">Detected</dt><dd className="text-right font-mono tabular">{utcDateTime(e.detected_at)}</dd> | |
| 178 | − <dt className="text-fg-subtle">Processed</dt><dd className="text-right font-mono tabular">{e.processed_at ? utcDateTime(e.processed_at) : "—"}</dd> | |
| 179 | − <dt className="text-fg-subtle">To feed</dt><dd className="text-right font-mono tabular">{e.published_to_feed_at ? utcDateTime(e.published_to_feed_at) : "—"}</dd> | |
| 180 | − <dt className="text-fg-subtle">Detection latency</dt><dd className="text-right font-mono tabular">{fmtMs(e.detection_latency_ms)}</dd> | |
| 181 | − <dt className="text-fg-subtle">Processing latency</dt><dd className="text-right font-mono tabular">{fmtMs(e.processing_latency_ms)}</dd> | |
| 325 | + <Row label="Published" value={e.published_at ? utcDateTime(e.published_at) : "—"} /> | |
| 326 | + <Row label="Observed from" value={e.observed_from ? utcDateTime(e.observed_from) : "—"} /> | |
| 327 | + <Row label="Detected" value={utcDateTime(e.detected_at)} /> | |
| 328 | + <Row label="Processed" value={e.processed_at ? utcDateTime(e.processed_at) : "—"} /> | |
| 329 | + <Row label="To feed" value={e.published_to_feed_at ? utcDateTime(e.published_to_feed_at) : "—"} /> | |
| 330 | + <Row label="Detection latency" value={fmtMs(e.detection_latency_ms)} /> | |
| 331 | + <Row label="Processing latency" value={fmtMs(e.processing_latency_ms)} /> | |
| 332 | + {leadTime !== null && leadTime > 0 && <Row label="WebSensor lead time" value={fmtOffset(leadTime).replace("+", "")} tone="text-signal font-semibold" />} | |
| 182 | 333 | </dl> |
| 183 | 334 | <p className="mt-2 text-[11px] text-fg-subtle">{relTime(e.detected_at)} · all times UTC</p> |
| 184 | 335 | </Panel> |
| 336 | + | |
| 185 | 337 | <Panel title="Entities" dense> |
| 186 | 338 | {e.entities.length ? ( |
| 187 | 339 | <ul className="divide-y divide-line"> |
| 188 | 340 | {e.entities.map((x) => ( |
| 189 | − <li key={x.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]"> | |
| 190 | − <Link href={`/company/${x.id}`} className="hover:underline">{x.name}</Link> | |
| 191 | − <span className="font-mono text-[11px] text-fg-subtle">{x.type}{x.role === "subject" ? " · subject" : ""}</span> | |
| 341 | + <li key={x.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[13px]"> | |
| 342 | + <Link href={`/entity/${x.id}`} className="min-w-0 truncate hover:underline">{x.name}</Link> | |
| 343 | + <span className="shrink-0 font-mono text-[11px] text-fg-subtle">{x.type}{x.role === "subject" ? " · subject" : ""}</span> | |
| 192 | 344 | </li> |
| 193 | 345 | ))} |
| 194 | 346 | </ul> |
@@ -196,57 +348,40 @@ export default async function EventPage({ params }: { params: Promise<{ slug: st | ||
| 196 | 348 | <Empty>No entity resolved.</Empty> |
| 197 | 349 | )} |
| 198 | 350 | </Panel> |
| 199 | − <Panel title="Importance components" dense> | |
| 200 | − <Table head={["Component", "Weight", "Score"]}> | |
| 201 | − {COMPONENT_WEIGHTS.map(([k, label, w]) => ( | |
| 202 | − <tr key={k}> | |
| 203 | − <Td>{label}</Td> | |
| 204 | − <Td mono className="text-fg-subtle">{w}%</Td> | |
| 205 | − <Td mono>{e.importance_components?.[k] !== undefined ? fmtScore(e.importance_components[k]) : "—"}</Td> | |
| 206 | − </tr> | |
| 207 | − ))} | |
| 208 | − </Table> | |
| 209 | − </Panel> | |
| 210 | − {d.cluster && ( | |
| 211 | − <Panel title="Event cluster"> | |
| 212 | − <div id="cluster" className="text-[13px]"> | |
| 213 | − <div className="font-medium">{d.cluster.title}</div> | |
| 214 | − <div className="mt-1 text-[11.5px] text-fg-subtle">{d.cluster.event_count} related observation{d.cluster.event_count === 1 ? "" : "s"} · max importance {fmtScore(d.cluster.max_importance)} · {relTime(d.cluster.first_at)} → {relTime(d.cluster.last_at)}</div> | |
| 215 | − {d.cluster.entity_ids.length > 0 && <div className="mt-2 flex flex-wrap gap-1">{d.cluster.entity_ids.slice(0, 8).map((id) => <Chip key={id} href={`/company/${id}`}>{id.replace(/^(org|prd)_/, "")}</Chip>)}</div>} | |
| 216 | − </div> | |
| 217 | − </Panel> | |
| 218 | − )} | |
| 351 | + | |
| 219 | 352 | <Panel title="Source reliability"> |
| 220 | 353 | <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]"> |
| 221 | 354 | <dt className="text-fg-subtle">Sensor health</dt><dd className="text-right"><HealthPill health={d.sensor_reliability?.health} /></dd> |
| 222 | − <dt className="text-fg-subtle">Connector success</dt><dd className="text-right font-mono tabular">{fmtPct(d.sensor_reliability?.success_rate ?? null)}</dd> | |
| 223 | − <dt className="text-fg-subtle">Latency</dt><dd className="text-right font-mono tabular">{fmtMs(d.sensor_reliability?.avg_latency_ms)}</dd> | |
| 224 | − <dt className="text-fg-subtle">Runs</dt><dd className="text-right font-mono tabular">{d.sensor_reliability?.total_runs ?? "—"}</dd> | |
| 225 | − <dt className="text-fg-subtle">Raw / meaningful</dt><dd className="text-right font-mono tabular">{d.sensor_reliability?.raw_changes ?? "—"} / {d.sensor_reliability?.meaningful_changes ?? "—"}</dd> | |
| 355 | + <Row label="Connector success" value={fmtPct(d.sensor_reliability?.success_rate ?? null)} /> | |
| 356 | + <Row label="Latency" value={fmtMs(d.sensor_reliability?.avg_latency_ms)} /> | |
| 357 | + <Row label="Runs" value={d.sensor_reliability?.total_runs ?? "—"} /> | |
| 358 | + <Row label="Raw / meaningful" value={`${d.sensor_reliability?.raw_changes ?? "—"} / ${d.sensor_reliability?.meaningful_changes ?? "—"}`} /> | |
| 226 | 359 | </dl> |
| 227 | − <p className="mt-2 text-[11px] text-fg-subtle">Sensor type {e.sensor?.type} via {e.sensor?.connector} connector · tier {e.sensor?.tier}</p> | |
| 360 | + <p className="mt-2 text-[11px] text-fg-subtle">Sensor type {e.sensor?.type} via {e.sensor?.connector ?? "—"} connector · tier {e.sensor?.tier ?? e.source?.tier ?? "—"}{e.first_party === false ? " · third-party report" : " · first-party channel"}</p> | |
| 228 | 361 | </Panel> |
| 362 | + | |
| 229 | 363 | <Panel title="Interpretation versions" dense> |
| 230 | 364 | {d.interpretations.length ? ( |
| 231 | 365 | <ul className="divide-y divide-line"> |
| 232 | 366 | {d.interpretations.map((v) => ( |
| 233 | − <li key={v.version} className="flex items-center justify-between px-3 py-1.5 text-[12.5px]"> | |
| 367 | + <li key={v.version} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 234 | 368 | <span>v{v.version} · <span className="font-mono">{v.model}</span></span> |
| 235 | 369 | <span className="font-mono text-[11px] text-fg-subtle">{utcDateTime(v.created_at)}</span> |
| 236 | 370 | </li> |
| 237 | 371 | ))} |
| 238 | 372 | </ul> |
| 239 | 373 | ) : ( |
| 240 | − <Empty>—</Empty> | |
| 374 | + <p className="px-3 py-2 text-[12px] text-fg-subtle">Heuristic interpretation only ({model}).</p> | |
| 241 | 375 | )} |
| 242 | − <p className="px-3 py-2 text-[11px] text-fg-subtle">Raw evidence is immutable; interpretations may be reprocessed and are versioned.</p> | |
| 376 | + <p className="border-t border-line px-3 py-2 text-[11px] text-fg-subtle">Raw evidence is immutable; interpretations may be reprocessed and are versioned.</p> | |
| 243 | 377 | </Panel> |
| 378 | + | |
| 244 | 379 | {e.keywords.length > 0 && ( |
| 245 | 380 | <Panel title="Keywords"> |
| 246 | 381 | <div className="flex flex-wrap gap-1">{e.keywords.map((k) => <Chip key={k} href={`/search?q=${encodeURIComponent(k)}`}>{k}</Chip>)}</div> |
| 247 | 382 | </Panel> |
| 248 | 383 | )} |
| 249 | − <p className="text-[11px] text-fg-subtle">{typeLabel(e.event_type)} · detected by WebSensor · AI-generated summaries never replace the original evidence above.</p> | |
| 384 | + <p className="text-[11px] text-fg-subtle">{typeLabel(e.event_type)} · detected by WebSensor · AI-generated analysis never replaces the original evidence above.</p> | |
| 250 | 385 | </aside> |
| 251 | 386 | </div> |
| 252 | 387 | </> |
added
apps/web/src/app/explore/loading.tsx
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +import { Skeleton, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3"> | |
| 7 | + <Skeleton className="mb-1 h-2.5 w-16" /> | |
| 8 | + <Skeleton className="h-5 w-32" /> | |
| 9 | + <Skeleton className="mt-2 h-3 w-full max-w-xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="mb-3 flex gap-2 border-b border-line pb-2"> | |
| 12 | + {Array.from({ length: 8 }, (_, i) => ( | |
| 13 | + <Skeleton key={i} className="h-3 w-16" /> | |
| 14 | + ))} | |
| 15 | + </div> | |
| 16 | + <div className="mb-3 flex gap-1"> | |
| 17 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 18 | + <Skeleton key={i} className="h-4 w-20" /> | |
| 19 | + ))} | |
| 20 | + </div> | |
| 21 | + <div className="panel"> | |
| 22 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-40" /></div> | |
| 23 | + <SkeletonRows rows={12} /> | |
| 24 | + </div> | |
| 25 | + </> | |
| 26 | + ); | |
| 27 | +} | |
modified
apps/web/src/app/explore/page.tsx
+373 −98
@@ -1,112 +1,387 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { EventRow } from "@/components/event-row"; |
| 4 | −import { Bar, Empty, PageHeader, Panel, Score } from "@/components/ui"; | |
| 5 | −import { api } from "@/lib/api"; | |
| 6 | −import { fmtScore, relTime, typeLabel } from "@/lib/format"; | |
| 4 | +import { Badge, Bar, Chip, Empty, Flag, PageHeader, Panel, Score, StateBadge, Table, Td, Tabs, TierBadge } from "@/components/ui"; | |
| 5 | +import { api, type Cluster, type EventItem } from "@/lib/api"; | |
| 6 | +import { CHANNELS, SAVED_VIEWS, feedHref, fmtInt, fmtOffset, fmtScore, relTime, typeLabel } from "@/lib/format"; | |
| 7 | 7 | |
| 8 | 8 | export const dynamic = "force-dynamic"; |
| 9 | −export const metadata: Metadata = { title: "Explore", description: "Most active sources, biggest changes, silent changes, unusual activity and event clusters." }; | |
| 10 | 9 | |
| 11 | −export default async function ExplorePage() { | |
| 12 | − const x = await api.explore(); | |
| 13 | − const maxType = Math.max(1, ...(x?.by_type ?? []).map((t) => t.n)); | |
| 14 | − const maxCat = Math.max(1, ...(x?.by_category ?? []).map((t) => t.n)); | |
| 10 | +const TABS = [ | |
| 11 | + { key: "trending", label: "Trending" }, | |
| 12 | + { key: "breaking", label: "Breaking" }, | |
| 13 | + { key: "silent", label: "Silent" }, | |
| 14 | + { key: "newly", label: "Newly detected" }, | |
| 15 | + { key: "active", label: "Active sources" }, | |
| 16 | + { key: "unusual", label: "Unusual activity" }, | |
| 17 | + { key: "clusters", label: "Clusters" }, | |
| 18 | + { key: "entities", label: "Entities" }, | |
| 19 | + { key: "sources", label: "Sources" }, | |
| 20 | + { key: "categories", label: "Categories" }, | |
| 21 | + { key: "countries", label: "Countries" }, | |
| 22 | +] as const; | |
| 23 | +type TabKey = (typeof TABS)[number]["key"]; | |
| 24 | + | |
| 25 | +function tabOf(v: string | undefined): TabKey { | |
| 26 | + return (TABS.find((t) => t.key === v)?.key ?? "trending") as TabKey; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<{ tab?: string }> }): Promise<Metadata> { | |
| 30 | + const { tab } = await searchParams; | |
| 31 | + const t = TABS.find((x) => x.key === tabOf(tab))!; | |
| 32 | + return { title: `Discover · ${t.label}`, description: "Trending entities, breaking clusters, silent changes, newly detected signals, active and anomalous sources, clusters, entities, sources, categories and countries.", alternates: { canonical: t.key === "trending" ? "/explore" : `/explore?tab=${t.key}` } }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** Discover page (spec §28): URL-driven tabs over the derived views of the event store. */ | |
| 36 | +export default async function ExplorePage({ searchParams }: { searchParams: Promise<{ tab?: string }> }) { | |
| 37 | + const sp = await searchParams; | |
| 38 | + const tab = tabOf(sp.tab); | |
| 39 | + const [x, trending, desk, rank, sources, countries] = await Promise.all([ | |
| 40 | + api.explore(), | |
| 41 | + tab === "trending" ? api.trending(24, 30) : Promise.resolve({ items: [] }), | |
| 42 | + tab === "breaking" ? api.breakingDesk() : Promise.resolve(null), | |
| 43 | + tab === "entities" ? api.rank(50) : Promise.resolve({ items: [] }), | |
| 44 | + tab === "sources" ? api.sources({}) : Promise.resolve({ items: [] }), | |
| 45 | + tab === "countries" ? api.countries() : Promise.resolve({ items: [] }), | |
| 46 | + ]); | |
| 47 | + const counts: Partial<Record<TabKey, number>> = { | |
| 48 | + trending: tab === "trending" ? trending.items.length : undefined, | |
| 49 | + breaking: desk ? desk.breaking_now.length + desk.developing.length + desk.recently_confirmed.length : undefined, | |
| 50 | + silent: x?.silent_changes?.length, | |
| 51 | + newly: x?.newly_detected?.length, | |
| 52 | + active: x?.most_active_sources?.length, | |
| 53 | + unusual: x?.unusual_activity?.length, | |
| 54 | + clusters: x?.clusters?.length, | |
| 55 | + entities: tab === "entities" ? rank.items.length : undefined, | |
| 56 | + sources: tab === "sources" ? sources.items.length : undefined, | |
| 57 | + categories: x?.by_category?.length, | |
| 58 | + countries: tab === "countries" ? countries.items.length : undefined, | |
| 59 | + }; | |
| 15 | 60 | return ( |
| 16 | 61 | <> |
| 17 | − <PageHeader kicker="Discover" title="Explore" description="Derived views over the event store: who is changing the most, what mattered most, what changed silently, and where activity is abnormal." /> | |
| 18 | − <div className="grid gap-4 lg:grid-cols-3"> | |
| 19 | − <Panel title="Most active sources · 24 h" dense> | |
| 20 | − {x?.most_active_sources?.length ? ( | |
| 21 | − <ul className="divide-y divide-line"> | |
| 22 | − {x.most_active_sources.map((s) => ( | |
| 23 | − <li key={s.id} className="flex items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 24 | − <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link> | |
| 25 | − <span className="font-mono text-[12px] text-fg-subtle tabular">{s.events_24h} ev</span> | |
| 26 | − <Score value={s.max_importance} size="sm" /> | |
| 27 | − </li> | |
| 28 | − ))} | |
| 29 | − </ul> | |
| 30 | − ) : ( | |
| 31 | − <Empty /> | |
| 32 | − )} | |
| 33 | − </Panel> | |
| 34 | − <Panel title="Unusual activity" dense> | |
| 35 | − {x?.unusual_activity?.length ? ( | |
| 36 | − <ul className="divide-y divide-line"> | |
| 37 | − {x.unusual_activity.map((s) => ( | |
| 38 | − <li key={s.id} className="px-3 py-1.5 text-[13px]"> | |
| 39 | − <div className="flex items-center gap-2"> | |
| 40 | − <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link> | |
| 41 | − <span className={`font-mono text-[12px] font-semibold tabular ${s.activity_score >= 70 ? "text-hot" : s.activity_score >= 45 ? "text-high" : "text-fg-muted"}`}>{fmtScore(s.activity_score)}</span> | |
| 42 | − </div> | |
| 43 | − <div className="text-[11px] text-fg-subtle">{s.changes_2h} changes in 2 h · baseline {s.baseline_per_day}/day</div> | |
| 44 | − <div className="mt-1"><Bar value={s.activity_score} tone={s.activity_score >= 70 ? "hot" : s.activity_score >= 45 ? "high" : "signal"} /></div> | |
| 45 | − </li> | |
| 46 | − ))} | |
| 47 | − </ul> | |
| 48 | − ) : ( | |
| 49 | − <Empty>Activity anomalies compare the last 2 h of raw changes with a 14-day baseline.</Empty> | |
| 50 | − )} | |
| 51 | − </Panel> | |
| 52 | − <Panel title="Event clusters · 48 h" dense> | |
| 53 | − {x?.clusters?.length ? ( | |
| 54 | − <ul className="divide-y divide-line"> | |
| 55 | − {x.clusters.map((c) => ( | |
| 56 | − <li key={c.id} className="flex items-start gap-2 px-3 py-1.5 text-[13px]"> | |
| 57 | − <Score value={c.max_importance} size="sm" /> | |
| 58 | − <div className="min-w-0"> | |
| 59 | − <div className="line-clamp-2 font-medium">{c.title}</div> | |
| 60 | − <div className="text-[11px] text-fg-subtle">{c.event_count} observations · {c.source?.name ?? ""} · {relTime(c.last_at)}</div> | |
| 61 | − </div> | |
| 62 | − </li> | |
| 63 | − ))} | |
| 64 | − </ul> | |
| 65 | − ) : ( | |
| 66 | − <Empty>No multi-observation clusters yet.</Empty> | |
| 67 | − )} | |
| 68 | − </Panel> | |
| 62 | + <PageHeader compact kicker="Discover" title="Explore" description="Derived views over the event store: who is changing the most, what mattered most, what changed silently, and where activity is abnormal." /> | |
| 63 | + <Tabs className="mb-3" current={tab} items={TABS.map((t) => ({ key: t.key, label: t.label, href: t.key === "trending" ? "/explore" : `/explore?tab=${t.key}`, count: counts[t.key] }))} /> | |
| 64 | + <div className="mb-3 flex flex-wrap items-center gap-1"> | |
| 65 | + <span className="mr-1 text-[11px] text-fg-subtle">Saved views</span> | |
| 66 | + {SAVED_VIEWS.map((v) => ( | |
| 67 | + <Chip key={v.key} href={feedHref(v.query, "/live")}>{v.label}</Chip> | |
| 68 | + ))} | |
| 69 | 69 | </div> |
| 70 | − <div className="mt-4 grid gap-4 lg:grid-cols-2"> | |
| 71 | − <Panel title="Biggest changes · 48 h" dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">breaking →</Link>}> | |
| 72 | − {x?.biggest_changes?.length ? x.biggest_changes.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty />} | |
| 73 | − </Panel> | |
| 74 | − <Panel title="Silent changes" dense action={<Link href="/silent" className="text-[11px] text-fg-subtle hover:text-fg">all silent →</Link>}> | |
| 75 | − {x?.silent_changes?.length ? x.silent_changes.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No silent changes yet.</Empty>} | |
| 76 | − </Panel> | |
| 77 | − </div> | |
| 78 | − <div className="mt-4 grid gap-4 lg:grid-cols-2"> | |
| 79 | − <Panel title="Events by type · 7 d"> | |
| 80 | − {x?.by_type?.length ? ( | |
| 81 | − <ul className="space-y-1.5"> | |
| 82 | − {x.by_type.slice(0, 16).map((t) => ( | |
| 83 | − <li key={t.event_type} className="grid grid-cols-[10rem_1fr_3rem] items-center gap-2 text-[12.5px]"> | |
| 84 | − <span className="truncate">{typeLabel(t.event_type)}</span> | |
| 85 | − <Bar value={t.n} max={maxType} tone="info" /> | |
| 86 | − <span className="text-right font-mono text-fg-subtle tabular">{t.n}</span> | |
| 87 | − </li> | |
| 88 | − ))} | |
| 89 | − </ul> | |
| 90 | − ) : ( | |
| 91 | − <Empty /> | |
| 92 | − )} | |
| 70 | + | |
| 71 | + {tab === "trending" && <TrendingTab items={trending.items} />} | |
| 72 | + {tab === "breaking" && <BreakingTab desk={desk} />} | |
| 73 | + {tab === "silent" && ( | |
| 74 | + <Panel title="Silent changes · 48 h" dense action={<Link href="/silent" className="text-[11px] text-fg-subtle hover:text-fg">all silent →</Link>}> | |
| 75 | + {x?.silent_changes?.length ? x.silent_changes.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No silent change detected in this window.</Empty>} | |
| 93 | 76 | </Panel> |
| 94 | − <Panel title="Events by category · 7 d"> | |
| 95 | − {x?.by_category?.length ? ( | |
| 96 | − <ul className="space-y-1.5"> | |
| 97 | − {x.by_category.slice(0, 16).map((t) => ( | |
| 98 | − <li key={t.category} className="grid grid-cols-[10rem_1fr_3rem] items-center gap-2 text-[12.5px]"> | |
| 99 | − <Link href={`/category/${t.category}`} className="truncate hover:underline">{t.category}</Link> | |
| 100 | − <Bar value={t.n} max={maxCat} tone="signal" /> | |
| 101 | − <span className="text-right font-mono text-fg-subtle tabular">{t.n}</span> | |
| 102 | − </li> | |
| 103 | − ))} | |
| 104 | − </ul> | |
| 105 | − ) : ( | |
| 106 | − <Empty /> | |
| 107 | − )} | |
| 77 | + )} | |
| 78 | + {tab === "newly" && ( | |
| 79 | + <Panel title="Newly detected · first observation of a page, feed or entity" dense action={<Link href="/live" className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}> | |
| 80 | + {x?.newly_detected?.length ? x.newly_detected.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>Nothing newly detected in this window.</Empty>} | |
| 108 | 81 | </Panel> |
| 109 | − </div> | |
| 82 | + )} | |
| 83 | + {tab === "active" && <ActiveTab items={x?.most_active_sources ?? []} />} | |
| 84 | + {tab === "unusual" && <UnusualTab items={x?.unusual_activity ?? []} />} | |
| 85 | + {tab === "clusters" && <ClustersTab items={x?.clusters ?? []} />} | |
| 86 | + {tab === "entities" && <EntitiesTab items={rank.items} />} | |
| 87 | + {tab === "sources" && <SourcesTab items={sources.items} />} | |
| 88 | + {tab === "categories" && <CategoriesTab byCategory={x?.by_category ?? []} byType={x?.by_type ?? []} labels={x?.event_types ?? {}} />} | |
| 89 | + {tab === "countries" && <CountriesTab items={countries.items} />} | |
| 110 | 90 | </> |
| 111 | 91 | ); |
| 112 | 92 | } |
| 93 | + | |
| 94 | +// --------------------------------------------------------------------------------------- | |
| 95 | + | |
| 96 | +function Dir({ d }: { d?: "up" | "down" | "flat" }) { | |
| 97 | + const c = d === "up" ? "text-signal" : d === "down" ? "text-fg-subtle" : "text-fg-muted"; | |
| 98 | + return <span className={`font-mono ${c}`} title={d === "up" ? "Rising vs. previous window" : d === "down" ? "Falling vs. previous window" : "Flat vs. previous window"}>{d === "up" ? "↑" : d === "down" ? "↓" : "→"}</span>; | |
| 99 | +} | |
| 100 | + | |
| 101 | +function TrendingTab({ items }: { items: Awaited<ReturnType<typeof api.trending>>["items"] }) { | |
| 102 | + return ( | |
| 103 | + <Panel title="Trending entities · 24 h" dense action={<span className="text-[11px] text-fg-subtle">score = volume × acceleration × signal, vs. the previous 24 h</span>}> | |
| 104 | + {items.length === 0 ? ( | |
| 105 | + <Empty>Trending is computed from the last 24 h of events.</Empty> | |
| 106 | + ) : ( | |
| 107 | + <Table head={["#", "Entity", "", "Events", "Sources", "1st-party", "Silent", "Avg signal", "Score"]}> | |
| 108 | + {items.map((t, i) => ( | |
| 109 | + <tr key={t.id} className="hover:bg-panel-2/60"> | |
| 110 | + <Td mono className="text-fg-subtle">{i + 1}</Td> | |
| 111 | + <Td> | |
| 112 | + <Link href={`/entity/${t.id}`} className="font-medium hover:underline">{t.name}</Link> | |
| 113 | + <div className="truncate font-mono text-[10.5px] text-fg-subtle">{t.type}{t.domain ? ` · ${t.domain}` : ""}</div> | |
| 114 | + </Td> | |
| 115 | + <Td mono><Dir d={t.direction} /></Td> | |
| 116 | + <Td mono className="whitespace-nowrap">{fmtInt(t.events)}<span className="text-fg-subtle"> / {fmtInt(t.prev_events)}</span></Td> | |
| 117 | + <Td mono>{t.sources}</Td> | |
| 118 | + <Td mono className={t.first_party ? "text-signal" : "text-fg-subtle"}>{t.first_party ?? 0}</Td> | |
| 119 | + <Td mono className={t.silent ? "text-silent" : "text-fg-subtle"}>{t.silent}</Td> | |
| 120 | + <Td mono>{fmtScore(t.avg_signal)}</Td> | |
| 121 | + <Td> | |
| 122 | + <div className="flex w-28 items-center gap-2"> | |
| 123 | + <Bar value={t.score} tone={t.score >= 80 ? "hot" : t.score >= 60 ? "high" : "signal"} /> | |
| 124 | + <span className="w-8 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(t.score)}</span> | |
| 125 | + </div> | |
| 126 | + </Td> | |
| 127 | + </tr> | |
| 128 | + ))} | |
| 129 | + </Table> | |
| 130 | + )} | |
| 131 | + </Panel> | |
| 132 | + ); | |
| 133 | +} | |
| 134 | + | |
| 135 | +function ClusterRows({ items, empty }: { items: Cluster[]; empty: string }) { | |
| 136 | + if (!items.length) return <Empty>{empty}</Empty>; | |
| 137 | + return ( | |
| 138 | + <ul className="divide-y divide-line"> | |
| 139 | + {items.map((c) => { | |
| 140 | + const e = c.event as EventItem | null | undefined; | |
| 141 | + return ( | |
| 142 | + <li key={c.id} className="grid grid-cols-[auto_1fr] items-start gap-x-3 px-3 py-2 text-[13px]"> | |
| 143 | + <Score value={e?.signal_score ?? c.max_importance} kind="signal" size="sm" /> | |
| 144 | + <div className="min-w-0"> | |
| 145 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-fg-subtle"> | |
| 146 | + <StateBadge state={c.state} /> | |
| 147 | + {(e?.source ?? c.source) && <span className="truncate font-mono uppercase text-fg-muted">{(e?.source ?? c.source)?.name}</span>} | |
| 148 | + {e?.country && <Flag code={e.country} />} | |
| 149 | + <span className="ml-auto whitespace-nowrap font-mono tabular">{relTime(c.last_at)}</span> | |
| 150 | + </div> | |
| 151 | + <Link href={`/cluster/${c.primary_slug ?? c.slug ?? c.id}`} className="mt-0.5 block font-medium leading-snug hover:underline">{c.title}</Link> | |
| 152 | + <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-fg-subtle"> | |
| 153 | + <span>{c.event_count} signal{c.event_count === 1 ? "" : "s"} · {c.source_count ?? 1} source{(c.source_count ?? 1) === 1 ? "" : "s"}</span> | |
| 154 | + {(c.first_party_count ?? 0) > 0 && <span className="text-signal">{c.first_party_count} first-party</span>} | |
| 155 | + {(c.external_count ?? 0) > 0 && <span>{c.external_count} external</span>} | |
| 156 | + {c.lead_time_ms && c.lead_time_ms > 0 ? <span title="WebSensor lead time before the first external report">lead {fmtOffset(c.lead_time_ms).replace("+", "")}</span> : null} | |
| 157 | + {c.categories?.slice(0, 2).map((k) => <Chip key={k} href={`/category/${k}`}>{k}</Chip>)} | |
| 158 | + </div> | |
| 159 | + </div> | |
| 160 | + </li> | |
| 161 | + ); | |
| 162 | + })} | |
| 163 | + </ul> | |
| 164 | + ); | |
| 165 | +} | |
| 166 | + | |
| 167 | +function BreakingTab({ desk }: { desk: Awaited<ReturnType<typeof api.breakingDesk>> }) { | |
| 168 | + const d = desk ?? { breaking_now: [], developing: [], recently_confirmed: [], watching: [] }; | |
| 169 | + return ( | |
| 170 | + <div className="grid gap-4 xl:grid-cols-2"> | |
| 171 | + <Panel title={<span className="text-hot">Breaking now <span className="font-mono text-fg-subtle">{d.breaking_now.length}</span></span>} dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">desk →</Link>}> | |
| 172 | + <ClusterRows items={d.breaking_now} empty="Nothing is breaking right now. Breaking requires strong signal, freshness and confirmation — not just recency." /> | |
| 173 | + </Panel> | |
| 174 | + <Panel title={<span className="text-high">Developing <span className="font-mono text-fg-subtle">{d.developing.length}</span></span>} dense> | |
| 175 | + <ClusterRows items={d.developing} empty="No developing story: signals are not accumulating fast enough anywhere." /> | |
| 176 | + </Panel> | |
| 177 | + <Panel title={<span className="text-ok">Recently confirmed <span className="font-mono text-fg-subtle">{d.recently_confirmed.length}</span></span>} dense> | |
| 178 | + <ClusterRows items={d.recently_confirmed} empty="No cluster reached independent confirmation in the last 48 h." /> | |
| 179 | + </Panel> | |
| 180 | + <Panel title="Watching · high signal, not yet breaking" dense> | |
| 181 | + {d.watching.length ? d.watching.slice(0, 12).map((e) => <EventRow key={e.id} ev={e} />) : <Empty>Nothing to watch.</Empty>} | |
| 182 | + </Panel> | |
| 183 | + </div> | |
| 184 | + ); | |
| 185 | +} | |
| 186 | + | |
| 187 | +function ActiveTab({ items }: { items: NonNullable<Awaited<ReturnType<typeof api.explore>>>["most_active_sources"] }) { | |
| 188 | + const max = Math.max(1, ...items.map((s) => s.events_24h)); | |
| 189 | + return ( | |
| 190 | + <Panel title="Most active sources · 24 h" dense action={<Link href="/sources" className="text-[11px] text-fg-subtle hover:text-fg">all sources →</Link>}> | |
| 191 | + {items.length === 0 ? ( | |
| 192 | + <Empty /> | |
| 193 | + ) : ( | |
| 194 | + <Table head={["#", "Source", "Domain", "Kind", "Events 24 h", "", "Max importance"]}> | |
| 195 | + {items.map((s, i) => ( | |
| 196 | + <tr key={s.id} className="hover:bg-panel-2/60"> | |
| 197 | + <Td mono className="text-fg-subtle">{i + 1}</Td> | |
| 198 | + <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td> | |
| 199 | + <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td> | |
| 200 | + <Td>{(s as { first_party?: boolean }).first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td> | |
| 201 | + <Td mono>{fmtInt(s.events_24h)}</Td> | |
| 202 | + <Td><div className="w-24 sm:w-40"><Bar value={s.events_24h} max={max} tone="signal" /></div></Td> | |
| 203 | + <Td><Score value={s.max_importance} size="sm" /></Td> | |
| 204 | + </tr> | |
| 205 | + ))} | |
| 206 | + </Table> | |
| 207 | + )} | |
| 208 | + </Panel> | |
| 209 | + ); | |
| 210 | +} | |
| 211 | + | |
| 212 | +function UnusualTab({ items }: { items: NonNullable<Awaited<ReturnType<typeof api.explore>>>["unusual_activity"] }) { | |
| 213 | + return ( | |
| 214 | + <Panel title="Unusual activity · last 2 h vs. 14-day baseline" dense action={<Link href="/radar" className="text-[11px] text-fg-subtle hover:text-fg">radar →</Link>}> | |
| 215 | + {items.length === 0 ? ( | |
| 216 | + <Empty>Activity anomalies compare the last 2 h of raw changes with a 14-day baseline. Nothing is far above its baseline right now.</Empty> | |
| 217 | + ) : ( | |
| 218 | + <Table head={["Source", "Domain", "Changes 2 h", "Baseline / day", "vs. baseline", "Activity score"]}> | |
| 219 | + {items.map((s) => { | |
| 220 | + const tone = s.activity_score >= 70 ? "hot" : s.activity_score >= 45 ? "high" : "signal"; | |
| 221 | + return ( | |
| 222 | + <tr key={s.id} className="hover:bg-panel-2/60"> | |
| 223 | + <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td> | |
| 224 | + <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td> | |
| 225 | + <Td mono>{s.changes_2h}</Td> | |
| 226 | + <Td mono className="text-fg-subtle">{s.baseline_per_day}</Td> | |
| 227 | + <Td mono className={`font-semibold ${tone === "hot" ? "text-hot" : tone === "high" ? "text-high" : "text-fg-muted"}`}>{s.pct_vs_baseline !== null && s.pct_vs_baseline !== undefined ? `+${Math.min(9999, Math.round(s.pct_vs_baseline)).toLocaleString("en-US")}%` : "—"}</Td> | |
| 228 | + <Td> | |
| 229 | + <div className="flex w-32 items-center gap-2"> | |
| 230 | + <Bar value={s.activity_score} tone={tone} /> | |
| 231 | + <span className="w-8 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(s.activity_score)}</span> | |
| 232 | + </div> | |
| 233 | + </Td> | |
| 234 | + </tr> | |
| 235 | + ); | |
| 236 | + })} | |
| 237 | + </Table> | |
| 238 | + )} | |
| 239 | + </Panel> | |
| 240 | + ); | |
| 241 | +} | |
| 242 | + | |
| 243 | +function ClustersTab({ items }: { items: Cluster[] }) { | |
| 244 | + return ( | |
| 245 | + <Panel title="Event clusters · 48 h" dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">breaking desk →</Link>}> | |
| 246 | + <ClusterRows items={items} empty="No multi-observation clusters yet. Related observations are grouped into clusters as they arrive." /> | |
| 247 | + </Panel> | |
| 248 | + ); | |
| 249 | +} | |
| 250 | + | |
| 251 | +function EntitiesTab({ items }: { items: Awaited<ReturnType<typeof api.rank>>["items"] }) { | |
| 252 | + return ( | |
| 253 | + <Panel title="Entity rankings · 7 d" dense action={<Link href="/entities" className="text-[11px] text-fg-subtle hover:text-fg">all entities →</Link>}> | |
| 254 | + {items.length === 0 ? ( | |
| 255 | + <Empty>Rankings appear once entities accumulate events.</Empty> | |
| 256 | + ) : ( | |
| 257 | + <Table head={["#", "Entity", "Type", "24 h", "7 d", "Baseline / d", "Sources", "Silent", "Breaking", "Avg signal", "Confirmed", "Last", "Rank score"]}> | |
| 258 | + {items.map((e) => ( | |
| 259 | + <tr key={e.id} className="hover:bg-panel-2/60"> | |
| 260 | + <Td mono className="text-fg-subtle">{e.rank}</Td> | |
| 261 | + <Td> | |
| 262 | + <Link href={`/entity/${e.id}`} className="font-medium hover:underline">{e.name}</Link> | |
| 263 | + {e.domain && <div className="truncate font-mono text-[10.5px] text-fg-subtle">{e.domain}</div>} | |
| 264 | + </Td> | |
| 265 | + <Td><Chip>{e.type.replace(/_/g, " ")}</Chip></Td> | |
| 266 | + <Td mono>{fmtInt(e.events_24h)}</Td> | |
| 267 | + <Td mono>{fmtInt(e.events_7d)}</Td> | |
| 268 | + <Td mono className="text-fg-subtle">{e.baseline_per_day.toFixed(1)}</Td> | |
| 269 | + <Td mono>{e.sources}</Td> | |
| 270 | + <Td mono className={e.silent_24h ? "text-silent" : "text-fg-subtle"}>{e.silent_24h}</Td> | |
| 271 | + <Td mono className={e.breaking_24h ? "text-hot" : "text-fg-subtle"}>{e.breaking_24h}</Td> | |
| 272 | + <Td mono>{fmtScore(e.avg_signal)}</Td> | |
| 273 | + <Td mono className="text-fg-subtle">{Math.round(e.confirmed_ratio * 100)}%</Td> | |
| 274 | + <Td mono className="text-fg-subtle">{relTime(e.last_at)}</Td> | |
| 275 | + <Td> | |
| 276 | + <div className="flex w-24 items-center gap-2"> | |
| 277 | + <Bar value={e.rank_score} tone={e.rank_score >= 80 ? "hot" : e.rank_score >= 60 ? "high" : "signal"} /> | |
| 278 | + <span className="w-7 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(e.rank_score)}</span> | |
| 279 | + </div> | |
| 280 | + </Td> | |
| 281 | + </tr> | |
| 282 | + ))} | |
| 283 | + </Table> | |
| 284 | + )} | |
| 285 | + </Panel> | |
| 286 | + ); | |
| 287 | +} | |
| 288 | + | |
| 289 | +function SourcesTab({ items }: { items: Awaited<ReturnType<typeof api.sources>>["items"] }) { | |
| 290 | + const top = [...items].sort((a, b) => (b.events_24h ?? 0) - (a.events_24h ?? 0)).slice(0, 50); | |
| 291 | + const max = Math.max(1, ...top.map((s) => s.events_24h ?? 0)); | |
| 292 | + return ( | |
| 293 | + <Panel title="Sources · top 50 by events in 24 h" dense action={<Link href="/sources" className="text-[11px] text-fg-subtle hover:text-fg">all sources →</Link>}> | |
| 294 | + {top.length === 0 ? ( | |
| 295 | + <Empty /> | |
| 296 | + ) : ( | |
| 297 | + <Table head={["Tier", "Source", "Country", "Kind", "Categories", "Sensors", "Events 24 h", "", "Total", "Last event"]}> | |
| 298 | + {top.map((s) => ( | |
| 299 | + <tr key={s.id} className="hover:bg-panel-2/60"> | |
| 300 | + <Td><TierBadge tier={s.tier} /></Td> | |
| 301 | + <Td> | |
| 302 | + <Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link> | |
| 303 | + <div className="truncate font-mono text-[10.5px] text-fg-subtle">{s.domain}</div> | |
| 304 | + </Td> | |
| 305 | + <Td><Flag code={s.country} /></Td> | |
| 306 | + <Td>{s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td> | |
| 307 | + <Td><div className="flex flex-wrap gap-1">{s.categories.slice(0, 3).map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}</div></Td> | |
| 308 | + <Td mono>{s.sensor_count ?? 0}</Td> | |
| 309 | + <Td mono>{fmtInt(s.events_24h ?? 0)}</Td> | |
| 310 | + <Td><div className="w-20 sm:w-32"><Bar value={s.events_24h ?? 0} max={max} /></div></Td> | |
| 311 | + <Td mono className="text-fg-subtle">{fmtInt(s.event_count)}</Td> | |
| 312 | + <Td mono className="text-fg-subtle">{s.last_event_at ? relTime(s.last_event_at) : "—"}</Td> | |
| 313 | + </tr> | |
| 314 | + ))} | |
| 315 | + </Table> | |
| 316 | + )} | |
| 317 | + </Panel> | |
| 318 | + ); | |
| 319 | +} | |
| 320 | + | |
| 321 | +function CategoriesTab({ byCategory, byType, labels }: { byCategory: { category: string; n: number }[]; byType: { event_type: string; n: number }[]; labels: Record<string, string> }) { | |
| 322 | + const maxCat = Math.max(1, ...byCategory.map((t) => t.n)); | |
| 323 | + const maxType = Math.max(1, ...byType.map((t) => t.n)); | |
| 324 | + const channelOf = (c: string): string | undefined => CHANNELS.find((ch) => ch.query.category === c)?.label; | |
| 325 | + return ( | |
| 326 | + <div className="grid gap-4 lg:grid-cols-2"> | |
| 327 | + <Panel title="Events by category · 7 d" dense> | |
| 328 | + {byCategory.length ? ( | |
| 329 | + <ul className="divide-y divide-line"> | |
| 330 | + {byCategory.slice(0, 30).map((t) => ( | |
| 331 | + <li key={t.category} className="grid grid-cols-[8rem_1fr_3.5rem] items-center gap-2 px-3 py-1.5 text-[12.5px] sm:grid-cols-[11rem_1fr_3.5rem]"> | |
| 332 | + <Link href={`/category/${t.category}`} className="truncate hover:underline" title={channelOf(t.category) ? `${channelOf(t.category)} desk` : t.category}>{t.category}</Link> | |
| 333 | + <Bar value={t.n} max={maxCat} tone="signal" /> | |
| 334 | + <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span> | |
| 335 | + </li> | |
| 336 | + ))} | |
| 337 | + </ul> | |
| 338 | + ) : ( | |
| 339 | + <Empty /> | |
| 340 | + )} | |
| 341 | + </Panel> | |
| 342 | + <Panel title="Events by type · 7 d" dense> | |
| 343 | + {byType.length ? ( | |
| 344 | + <ul className="divide-y divide-line"> | |
| 345 | + {byType.slice(0, 30).map((t) => ( | |
| 346 | + <li key={t.event_type} className="grid grid-cols-[8rem_1fr_3.5rem] items-center gap-2 px-3 py-1.5 text-[12.5px] sm:grid-cols-[11rem_1fr_3.5rem]"> | |
| 347 | + <Link href={`/live?event_type=${t.event_type}`} className="truncate hover:underline" title={labels[t.event_type] ?? typeLabel(t.event_type)}>{typeLabel(t.event_type)}</Link> | |
| 348 | + <Bar value={t.n} max={maxType} tone="info" /> | |
| 349 | + <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span> | |
| 350 | + </li> | |
| 351 | + ))} | |
| 352 | + </ul> | |
| 353 | + ) : ( | |
| 354 | + <Empty /> | |
| 355 | + )} | |
| 356 | + </Panel> | |
| 357 | + </div> | |
| 358 | + ); | |
| 359 | +} | |
| 360 | + | |
| 361 | +function CountriesTab({ items }: { items: Awaited<ReturnType<typeof api.countries>>["items"] }) { | |
| 362 | + return ( | |
| 363 | + <Panel title={`Countries · ${items.length}`} dense action={<Link href="/country" className="text-[11px] text-fg-subtle hover:text-fg">country desks →</Link>}> | |
| 364 | + {items.length === 0 ? ( | |
| 365 | + <Empty>Country desks appear once sources carry a country.</Empty> | |
| 366 | + ) : ( | |
| 367 | + <ul className="grid gap-px bg-line sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> | |
| 368 | + {items.map((c) => ( | |
| 369 | + <li key={c.country} className="bg-panel"> | |
| 370 | + <Link href={`/country/${c.slug}`} className="flex items-center gap-3 px-3 py-2 hover:bg-panel-2/60"> | |
| 371 | + <span className="w-6 text-center text-lg leading-none" aria-hidden>{c.flag || <Flag code={c.country} />}</span> | |
| 372 | + <span className="min-w-0 flex-1"> | |
| 373 | + <span className="block truncate text-[13px] font-medium">{c.name}</span> | |
| 374 | + <span className="block font-mono text-[10.5px] text-fg-subtle">{c.country} · {c.sources} source{c.sources === 1 ? "" : "s"}</span> | |
| 375 | + </span> | |
| 376 | + <span className="flex flex-col items-end font-mono text-[12px] tabular"> | |
| 377 | + <span>{fmtInt(c.events_24h)} <span className="text-[10px] text-fg-subtle">24 h</span></span> | |
| 378 | + <span className={c.breaking_24h ? "text-hot" : "text-fg-subtle"}>{fmtInt(c.breaking_24h)} <span className="text-[10px] text-fg-subtle">brk</span></span> | |
| 379 | + </span> | |
| 380 | + </Link> | |
| 381 | + </li> | |
| 382 | + ))} | |
| 383 | + </ul> | |
| 384 | + )} | |
| 385 | + </Panel> | |
| 386 | + ); | |
| 387 | +} | |
modified
apps/web/src/app/globals.css
+103 −10
@@ -9,6 +9,7 @@ | ||
| 9 | 9 | --color-bg: var(--bg); |
| 10 | 10 | --color-panel: var(--panel); |
| 11 | 11 | --color-panel-2: var(--panel-2); |
| 12 | + --color-panel-3: var(--panel-3); | |
| 12 | 13 | --color-fg: var(--fg); |
| 13 | 14 | --color-fg-muted: var(--fg-muted); |
| 14 | 15 | --color-fg-subtle: var(--fg-subtle); |
@@ -34,6 +35,10 @@ | ||
| 34 | 35 | --animate-pulse-dot: pulse-dot 1.6s ease-in-out infinite; |
| 35 | 36 | --animate-flash: flash 1.4s ease-out both; |
| 36 | 37 | --animate-fade-in: fade-in 0.2s ease-out both; |
| 38 | + --animate-slide-in: slide-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1) both; | |
| 39 | + --animate-sheet-up: sheet-up 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) both; | |
| 40 | + --animate-row-in: row-in 0.35s ease-out both; | |
| 41 | + --animate-tick: tick 0.6s ease-out both; | |
| 37 | 42 | |
| 38 | 43 | @keyframes pulse-dot { |
| 39 | 44 | 0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent); } |
@@ -47,18 +52,35 @@ | ||
| 47 | 52 | from { opacity: 0; transform: translateY(-2px); } |
| 48 | 53 | to { opacity: 1; transform: translateY(0); } |
| 49 | 54 | } |
| 55 | + @keyframes slide-in { | |
| 56 | + from { opacity: 0; transform: translateX(16px); } | |
| 57 | + to { opacity: 1; transform: translateX(0); } | |
| 58 | + } | |
| 59 | + @keyframes sheet-up { | |
| 60 | + from { opacity: 0; transform: translateY(24px); } | |
| 61 | + to { opacity: 1; transform: translateY(0); } | |
| 62 | + } | |
| 63 | + @keyframes row-in { | |
| 64 | + from { opacity: 0; transform: translateY(-6px); background-color: color-mix(in oklab, var(--signal) 18%, transparent); } | |
| 65 | + to { opacity: 1; transform: translateY(0); background-color: transparent; } | |
| 66 | + } | |
| 67 | + @keyframes tick { | |
| 68 | + 0% { color: var(--signal); } | |
| 69 | + 100% { color: inherit; } | |
| 70 | + } | |
| 50 | 71 | } |
| 51 | 72 | |
| 52 | 73 | :root { |
| 53 | 74 | color-scheme: light; |
| 54 | − --bg: #f7f8fb; | |
| 75 | + --bg: #f6f7fa; | |
| 55 | 76 | --panel: #ffffff; |
| 56 | − --panel-2: #f0f2f6; | |
| 77 | + --panel-2: #eff1f5; | |
| 78 | + --panel-3: #e6e9ef; | |
| 57 | 79 | --fg: #0d1220; |
| 58 | 80 | --fg-muted: #4a5468; |
| 59 | 81 | --fg-subtle: #7f889b; |
| 60 | − --line: #e2e6ee; | |
| 61 | − --line-strong: #c6cdd9; | |
| 82 | + --line: #e1e5ec; | |
| 83 | + --line-strong: #c3cad6; | |
| 62 | 84 | --signal: #0f9f7d; |
| 63 | 85 | --signal-soft: #dcf5ee; |
| 64 | 86 | --hot: #d12b2b; |
@@ -71,18 +93,25 @@ | ||
| 71 | 93 | --warn: #c57a0a; |
| 72 | 94 | --ok: #0f9f7d; |
| 73 | 95 | --info: #2b63d9; |
| 96 | + | |
| 97 | + /* density (spec §32) — overridden by [data-density] */ | |
| 98 | + --row-py: 7px; | |
| 99 | + --row-gap: 4px; | |
| 100 | + --fs-row: 13.5px; | |
| 101 | + --fs-meta: 11.5px; | |
| 74 | 102 | } |
| 75 | 103 | |
| 76 | 104 | .dark { |
| 77 | 105 | color-scheme: dark; |
| 78 | − --bg: #0b0f17; | |
| 79 | − --panel: #111726; | |
| 80 | − --panel-2: #161d2e; | |
| 81 | − --fg: #e5e7eb; | |
| 106 | + --bg: #0a0e15; | |
| 107 | + --panel: #10161f; | |
| 108 | + --panel-2: #151c28; | |
| 109 | + --panel-3: #1b2331; | |
| 110 | + --fg: #e6e8ee; | |
| 82 | 111 | --fg-muted: #a3adc2; |
| 83 | 112 | --fg-subtle: #6b7591; |
| 84 | − --line: #1f2937; | |
| 85 | − --line-strong: #2f3b52; | |
| 113 | + --line: #1c2533; | |
| 114 | + --line-strong: #2d394e; | |
| 86 | 115 | --signal: #22d3a5; |
| 87 | 116 | --signal-soft: #0f2a24; |
| 88 | 117 | --hot: #ff5c5c; |
@@ -97,6 +126,19 @@ | ||
| 97 | 126 | --info: #6ea0ff; |
| 98 | 127 | } |
| 99 | 128 | |
| 129 | +[data-density="compact"] { | |
| 130 | + --row-py: 3px; | |
| 131 | + --row-gap: 2px; | |
| 132 | + --fs-row: 12.5px; | |
| 133 | + --fs-meta: 10.5px; | |
| 134 | +} | |
| 135 | +[data-density="comfortable"] { | |
| 136 | + --row-py: 11px; | |
| 137 | + --row-gap: 6px; | |
| 138 | + --fs-row: 14.5px; | |
| 139 | + --fs-meta: 12px; | |
| 140 | +} | |
| 141 | + | |
| 100 | 142 | @layer base { |
| 101 | 143 | * { |
| 102 | 144 | border-color: var(--line); |
@@ -104,6 +146,7 @@ | ||
| 104 | 146 | html { |
| 105 | 147 | -webkit-text-size-adjust: 100%; |
| 106 | 148 | text-rendering: optimizeLegibility; |
| 149 | + scrollbar-gutter: stable; | |
| 107 | 150 | } |
| 108 | 151 | body { |
| 109 | 152 | @apply bg-bg text-fg font-sans antialiased; |
@@ -134,6 +177,12 @@ | ||
| 134 | 177 | border-radius: 6px; |
| 135 | 178 | border: 2px solid var(--bg); |
| 136 | 179 | } |
| 180 | + @media (prefers-reduced-motion: reduce) { | |
| 181 | + *, *::before, *::after { | |
| 182 | + animation-duration: 0.01ms !important; | |
| 183 | + transition-duration: 0.01ms !important; | |
| 184 | + } | |
| 185 | + } | |
| 137 | 186 | } |
| 138 | 187 | |
| 139 | 188 | @utility hairline { |
@@ -154,8 +203,52 @@ | ||
| 154 | 203 | color: var(--fg-subtle); |
| 155 | 204 | font-weight: 600; |
| 156 | 205 | } |
| 206 | +@utility row-dense { | |
| 207 | + padding-top: var(--row-py); | |
| 208 | + padding-bottom: var(--row-py); | |
| 209 | + font-size: var(--fs-row); | |
| 210 | +} | |
| 211 | +@utility meta { | |
| 212 | + font-size: var(--fs-meta); | |
| 213 | +} | |
| 214 | +@utility offscreen-ok { | |
| 215 | + content-visibility: auto; | |
| 216 | + contain-intrinsic-size: auto 64px; | |
| 217 | +} | |
| 218 | +@utility no-scrollbar { | |
| 219 | + scrollbar-width: none; | |
| 220 | + &::-webkit-scrollbar { display: none; } | |
| 221 | +} | |
| 157 | 222 | |
| 158 | 223 | .diff-line-add { background: color-mix(in oklab, var(--ok) 14%, transparent); color: var(--fg); } |
| 159 | 224 | .diff-line-del { background: color-mix(in oklab, var(--danger) 14%, transparent); color: var(--fg); } |
| 160 | 225 | .diff-line-meta { color: var(--fg-subtle); } |
| 161 | 226 | .diff-line-hunk { color: var(--info); background: color-mix(in oklab, var(--info) 8%, transparent); } |
| 227 | + | |
| 228 | +/* skeletons (spec §113) */ | |
| 229 | +.skeleton { | |
| 230 | + position: relative; | |
| 231 | + overflow: hidden; | |
| 232 | + background: color-mix(in oklab, var(--panel-2) 80%, var(--panel-3)); | |
| 233 | + border-radius: 4px; | |
| 234 | +} | |
| 235 | +.skeleton::after { | |
| 236 | + content: ""; | |
| 237 | + position: absolute; | |
| 238 | + inset: 0; | |
| 239 | + transform: translateX(-100%); | |
| 240 | + background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--fg) 6%, transparent), transparent); | |
| 241 | + animation: shimmer 1.4s infinite; | |
| 242 | +} | |
| 243 | +@keyframes shimmer { 100% { transform: translateX(100%); } } | |
| 244 | + | |
| 245 | +/* heatmap cells (spec §102) */ | |
| 246 | +.heat-0 { background: var(--panel-2); } | |
| 247 | +.heat-1 { background: color-mix(in oklab, var(--signal) 22%, var(--panel-2)); } | |
| 248 | +.heat-2 { background: color-mix(in oklab, var(--signal) 42%, var(--panel-2)); } | |
| 249 | +.heat-3 { background: color-mix(in oklab, var(--signal) 65%, var(--panel-2)); } | |
| 250 | +.heat-4 { background: var(--signal); } | |
| 251 | +.heat-hot { background: var(--hot); } | |
| 252 | + | |
| 253 | +/* sheet / drawer scrim */ | |
| 254 | +.scrim { background: color-mix(in oklab, #000 45%, transparent); backdrop-filter: blur(2px); } | |
added
apps/web/src/app/health/loading.tsx
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3 flex flex-col gap-2"> | |
| 7 | + <Skeleton className="h-3 w-28" /> | |
| 8 | + <Skeleton className="h-6 w-48" /> | |
| 9 | + <Skeleton className="h-3 w-full max-w-2xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 xl:grid-cols-8 xl:divide-y-0"> | |
| 12 | + {Array.from({ length: 8 }, (_, i) => ( | |
| 13 | + <div key={i} className="flex flex-col gap-1.5 px-3 py-2"> | |
| 14 | + <Skeleton className="h-2.5 w-16" /> | |
| 15 | + <Skeleton className="h-5 w-12" /> | |
| 16 | + <Skeleton className="h-2.5 w-20" /> | |
| 17 | + </div> | |
| 18 | + ))} | |
| 19 | + </div> | |
| 20 | + <Skeleton className="mb-4 h-8 w-full" /> | |
| 21 | + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 22 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 23 | + <SkeletonPanel key={i} lines={6} /> | |
| 24 | + ))} | |
| 25 | + </div> | |
| 26 | + <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"> | |
| 27 | + <div className="panel"> | |
| 28 | + <Skeleton className="m-3 h-2.5 w-32" /> | |
| 29 | + <SkeletonRows rows={4} /> | |
| 30 | + </div> | |
| 31 | + <div className="panel"> | |
| 32 | + <Skeleton className="m-3 h-2.5 w-32" /> | |
| 33 | + <SkeletonRows rows={4} /> | |
| 34 | + </div> | |
| 35 | + </div> | |
| 36 | + </> | |
| 37 | + ); | |
| 38 | +} | |
modified
apps/web/src/app/health/page.tsx
+122 −14
@@ -2,26 +2,62 @@ import Link from "next/link"; | ||
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { Chip, Empty, HealthPill, PageHeader, Panel, Stat, Table, Td } from "@/components/ui"; |
| 4 | 4 | import { api } from "@/lib/api"; |
| 5 | −import { fmtBytes, fmtInt, fmtMs, fmtPct, relTime } from "@/lib/format"; | |
| 5 | +import { fmtBytes, fmtInt, fmtMs, fmtPct, relTime, utcDateTime, utcTime } from "@/lib/format"; | |
| 6 | 6 | |
| 7 | 7 | export const dynamic = "force-dynamic"; |
| 8 | −export const metadata: Metadata = { title: "Connector health", description: "Live health of every connector family and sensor: success rate, latency, HTTP codes, noise ratios." }; | |
| 8 | +export const metadata: Metadata = { title: "Connector health", description: "Live health of every connector family and sensor: success rate, latency, HTTP codes, noise ratios, queue depth and engine heartbeat." }; | |
| 9 | + | |
| 10 | +/** A heartbeat older than two minutes means the scheduler stopped publishing. */ | |
| 11 | +function heartbeatStale(at: string | undefined): boolean { | |
| 12 | + return Boolean(at) && Date.now() - new Date(at as string).getTime() > 120_000; | |
| 13 | +} | |
| 9 | 14 | |
| 10 | 15 | export default async function HealthPage() { |
| 11 | 16 | const h = await api.health(); |
| 12 | 17 | const byHealth = Object.fromEntries((h?.sensors_by_health ?? []).map((x) => [x.health, x.n])); |
| 18 | + const tp = h?.throughput; | |
| 19 | + const eng = h?.engine ?? null; | |
| 20 | + const engStale = heartbeatStale(eng?.at); | |
| 21 | + const healthy = byHealth.UP ?? 0; | |
| 22 | + const degraded = (byHealth.DEGRADED ?? 0) + (byHealth.RATE_LIMITED ?? 0); | |
| 23 | + const failed = byHealth.ERROR ?? 0; | |
| 13 | 24 | return ( |
| 14 | 25 | <> |
| 15 | − <PageHeader kicker="Internal observability" title="Connector health" description="Every connector exposes UP / DEGRADED / ERROR / RATE_LIMITED with 24 h success rate, latency, changes and events. Noisy sensors point to canonicalizer work, not more LLM calls." /> | |
| 16 | − <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0"> | |
| 17 | − <Stat label="Sensors UP" value={fmtInt(byHealth.UP ?? 0)} /> | |
| 18 | − <Stat label="Degraded" value={fmtInt(byHealth.DEGRADED ?? 0)} /> | |
| 19 | − <Stat label="Error" value={fmtInt(byHealth.ERROR ?? 0)} /> | |
| 20 | − <Stat label="Rate limited" value={fmtInt(byHealth.RATE_LIMITED ?? 0)} /> | |
| 21 | − <Stat label="WS clients" value={fmtInt(h?.live.clients ?? 0)} hint={`${fmtInt(h?.live.published ?? 0)} published since start`} /> | |
| 22 | − <Stat label="Connectors" value={fmtInt(h?.connectors.length ?? 0)} /> | |
| 26 | + <PageHeader compact kicker="Public observability" title="Connector health" description="Every connector exposes UP / DEGRADED / ERROR / RATE_LIMITED with 24 h success rate, latency, changes and events. Noisy sensors point to canonicalizer work, not more LLM calls." /> | |
| 27 | + | |
| 28 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 xl:grid-cols-8 xl:divide-y-0"> | |
| 29 | + <Stat label="Healthy" value={fmtInt(healthy)} tone="signal" hint="sensors UP" /> | |
| 30 | + <Stat label="Degraded" value={fmtInt(degraded)} tone={degraded ? "warn" : undefined} hint={`${fmtInt(byHealth.RATE_LIMITED ?? 0)} rate-limited`} /> | |
| 31 | + <Stat label="Failed" value={fmtInt(failed)} tone={failed ? "hot" : undefined} hint="consecutive errors" /> | |
| 32 | + <Stat label="Checks / min" value={tp ? tp.checks_per_min.toFixed(1) : "—"} hint={tp ? `${fmtInt(tp.checks_5m)} in 5 min` : undefined} /> | |
| 33 | + <Stat label="Events / min" value={tp ? tp.events_per_min.toFixed(2) : "—"} tone="signal" hint={tp ? `${fmtInt(tp.events_5m)} in 5 min · ${fmtInt(tp.changes_5m)} raw` : undefined} /> | |
| 34 | + <Stat label="304 ratio" value={fmtPct(tp?.not_modified_ratio ?? null)} hint="conditional GET hits · 5 min" /> | |
| 35 | + <Stat label="Queue depth" value={fmtInt(tp?.queue_due ?? 0)} tone={(tp?.queue_due ?? 0) > 500 ? "warn" : undefined} hint="sensors due now" /> | |
| 36 | + <Stat label="Avg latency" value={fmtMs(tp?.avg_latency_5m_ms)} hint={`${fmtInt(tp?.noise_filtered_24h ?? 0)} noise filtered · 24 h`} /> | |
| 23 | 37 | </div> |
| 24 | − <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 38 | + | |
| 39 | + <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-line bg-panel px-3 py-2 font-mono text-[11.5px] text-fg-muted tabular"> | |
| 40 | + <span className="flex items-center gap-1.5"> | |
| 41 | + <span className={`inline-block size-2 rounded-full ${eng && !engStale ? "bg-signal animate-pulse-dot" : "bg-warn"}`} /> | |
| 42 | + <span className="label !text-fg-subtle">engine</span> | |
| 43 | + <span className={eng && !engStale ? "text-signal" : "text-warn"}>{eng ? (engStale ? "STALE" : "LIVE") : "NO HEARTBEAT"}</span> | |
| 44 | + </span> | |
| 45 | + {eng ? ( | |
| 46 | + <> | |
| 47 | + <span>in flight {eng.inflight ?? 0} / {eng.concurrency ?? "—"}</span> | |
| 48 | + <span>due {fmtInt(eng.due)}</span> | |
| 49 | + <span>busy hosts {eng.busyHosts?.length ?? 0}</span> | |
| 50 | + <span className={eng.circuitOpen?.length ? "text-warn" : ""}>circuit open {eng.circuitOpen?.length ?? 0}</span> | |
| 51 | + {eng.version && <span>v{eng.version}</span>} | |
| 52 | + {eng.at && <span className="text-fg-subtle" title={utcDateTime(eng.at)}>heartbeat {utcTime(eng.at)} UTC · {relTime(eng.at)}</span>} | |
| 53 | + </> | |
| 54 | + ) : ( | |
| 55 | + <span className="text-fg-subtle">the scheduler publishes a heartbeat to Redis every few seconds; none is visible right now.</span> | |
| 56 | + )} | |
| 57 | + <span className="ml-auto text-fg-subtle">WS clients {fmtInt(h?.live.clients ?? 0)} · {fmtInt(h?.live.published ?? 0)} published since start</span> | |
| 58 | + </div> | |
| 59 | + | |
| 60 | + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 25 | 61 | {(h?.connectors ?? []).map((c) => ( |
| 26 | 62 | <Panel key={c.connector} title={<span className="flex items-center gap-2 normal-case tracking-normal"><span className="font-mono text-[13px] text-fg">{c.connector}</span><HealthPill health={c.status} /></span>}> |
| 27 | 63 | <dl className="grid grid-cols-2 gap-y-1 text-[12.5px]"> |
@@ -42,7 +78,43 @@ export default async function HealthPage() { | ||
| 42 | 78 | ))} |
| 43 | 79 | {!h?.connectors.length && <Panel dense><Empty>Health rollups appear after the first minute of engine activity.</Empty></Panel>} |
| 44 | 80 | </div> |
| 45 | − <div className="mt-4 grid gap-4 lg:grid-cols-2"> | |
| 81 | + | |
| 82 | + <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> | |
| 83 | + <Panel title="Sensors by connector" dense> | |
| 84 | + {h?.sensors_by_connector?.length ? ( | |
| 85 | + <Table head={["Connector", "Sensors", "Up", "Up ratio", "Avg latency"]}> | |
| 86 | + {h.sensors_by_connector.map((c) => ( | |
| 87 | + <tr key={c.connector}> | |
| 88 | + <Td mono>{c.connector}</Td> | |
| 89 | + <Td mono>{fmtInt(c.sensors)}</Td> | |
| 90 | + <Td mono>{fmtInt(c.up)}</Td> | |
| 91 | + <Td mono className={c.sensors && c.up / c.sensors < 0.9 ? "text-warn" : ""}>{fmtPct(c.sensors ? c.up / c.sensors : null)}</Td> | |
| 92 | + <Td mono>{fmtMs(c.avg_latency_ms)}</Td> | |
| 93 | + </tr> | |
| 94 | + ))} | |
| 95 | + </Table> | |
| 96 | + ) : ( | |
| 97 | + <Empty /> | |
| 98 | + )} | |
| 99 | + </Panel> | |
| 100 | + <Panel title="Sensors by status" dense> | |
| 101 | + {h?.sensors_by_status?.length ? ( | |
| 102 | + <Table head={["Status", "Sensors"]}> | |
| 103 | + {h.sensors_by_status.map((s) => ( | |
| 104 | + <tr key={s.status}> | |
| 105 | + <Td><HealthPill health={s.status} /></Td> | |
| 106 | + <Td mono>{fmtInt(s.n)}</Td> | |
| 107 | + </tr> | |
| 108 | + ))} | |
| 109 | + </Table> | |
| 110 | + ) : ( | |
| 111 | + <Empty /> | |
| 112 | + )} | |
| 113 | + <p className="border-t border-line px-3 py-2 text-[11px] text-fg-subtle">PENDING → VALIDATED → ACTIVE is the sensor lifecycle; DISABLED sensors are kept for evidence but never scheduled.</p> | |
| 114 | + </Panel> | |
| 115 | + </div> | |
| 116 | + | |
| 117 | + <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"> | |
| 46 | 118 | <Panel title="Degraded sensors" dense> |
| 47 | 119 | {h?.degraded_sensors.length ? ( |
| 48 | 120 | <Table head={["Sensor", "Source", "Health", "Errors", "HTTP", "Last error", "Last check"]}> |
@@ -53,8 +125,8 @@ export default async function HealthPage() { | ||
| 53 | 125 | <Td><HealthPill health={s.health} /></Td> |
| 54 | 126 | <Td mono>{s.consecutive_errors}</Td> |
| 55 | 127 | <Td mono>{s.last_status ?? "—"}</Td> |
| 56 | − <Td className="max-w-[16rem] truncate text-danger" >{s.last_error}</Td> | |
| 57 | − <Td mono className="text-fg-subtle">{s.last_check_at ? relTime(s.last_check_at) : "—"}</Td> | |
| 128 | + <Td className="max-w-[16rem] truncate text-danger"><span title={s.last_error ?? ""}>{s.last_error}</span></Td> | |
| 129 | + <Td mono className="whitespace-nowrap text-fg-subtle">{s.last_check_at ? relTime(s.last_check_at) : "—"}</Td> | |
| 58 | 130 | </tr> |
| 59 | 131 | ))} |
| 60 | 132 | </Table> |
@@ -80,6 +152,41 @@ export default async function HealthPage() { | ||
| 80 | 152 | )} |
| 81 | 153 | </Panel> |
| 82 | 154 | </div> |
| 155 | + | |
| 156 | + <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"> | |
| 157 | + <Panel title="Top failing domains" dense> | |
| 158 | + {h?.top_failing_domains?.length ? ( | |
| 159 | + <Table head={["Host", "Failures", "Last error"]}> | |
| 160 | + {h.top_failing_domains.map((f) => ( | |
| 161 | + <tr key={f.host}> | |
| 162 | + <Td mono><Link href={`/domain/${f.host}`} className="hover:underline">{f.host}</Link></Td> | |
| 163 | + <Td mono className="text-danger">{fmtInt(f.failures)}</Td> | |
| 164 | + <Td className="max-w-[20rem] truncate font-mono text-[11px] text-fg-muted"><span title={f.last_error ?? ""}>{f.last_error ?? "—"}</span></Td> | |
| 165 | + </tr> | |
| 166 | + ))} | |
| 167 | + </Table> | |
| 168 | + ) : ( | |
| 169 | + <Empty>No failing domains right now.</Empty> | |
| 170 | + )} | |
| 171 | + </Panel> | |
| 172 | + <Panel title="Slowest sensors" dense> | |
| 173 | + {h?.slowest_sensors?.length ? ( | |
| 174 | + <Table head={["Sensor", "Source", "Connector", "Avg latency"]}> | |
| 175 | + {h.slowest_sensors.map((s) => ( | |
| 176 | + <tr key={s.id}> | |
| 177 | + <Td><Link href={`/sensor/${s.id}`} className="hover:underline">{s.name}</Link></Td> | |
| 178 | + <Td><Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_id}</Link></Td> | |
| 179 | + <Td mono>{s.connector}</Td> | |
| 180 | + <Td mono className={s.avg_latency_ms > 10_000 ? "text-warn" : ""}>{fmtMs(s.avg_latency_ms)}</Td> | |
| 181 | + </tr> | |
| 182 | + ))} | |
| 183 | + </Table> | |
| 184 | + ) : ( | |
| 185 | + <Empty /> | |
| 186 | + )} | |
| 187 | + </Panel> | |
| 188 | + </div> | |
| 189 | + | |
| 83 | 190 | <Panel title="Daily metrics" dense className="mt-4"> |
| 84 | 191 | {h?.daily.length ? ( |
| 85 | 192 | <Table head={["Day", "Checks", "304", "Bytes", "Raw changes", "Events", "Silent", "Errors", "LLM calls", "LLM tokens in/out"]}> |
@@ -102,6 +209,7 @@ export default async function HealthPage() { | ||
| 102 | 209 | <Empty /> |
| 103 | 210 | )} |
| 104 | 211 | </Panel> |
| 212 | + <p className="mt-3 text-[11px] text-fg-subtle">Rollups refresh every minute from sensor runs; raw counters are in <code>/api/v1/health/connectors</code> and <code>/api/metrics</code> (Prometheus). Operators: <Link href="/ops" className="hover:text-fg">/ops</Link>.</p> | |
| 105 | 213 | </> |
| 106 | 214 | ); |
| 107 | 215 | } |
modified
apps/web/src/app/layout.tsx
+18 −9
@@ -1,8 +1,11 @@ | ||
| 1 | 1 | import type { Metadata, Viewport } from "next"; |
| 2 | 2 | import { Inter, JetBrains_Mono } from "next/font/google"; |
| 3 | 3 | import type { ReactNode } from "react"; |
| 4 | +import { CommandPalette } from "@/components/command-palette"; | |
| 5 | +import { EventDrawerProvider } from "@/components/event-drawer"; | |
| 4 | 6 | import { Footer } from "@/components/footer"; |
| 5 | 7 | import { MobileNav, TopNav } from "@/components/nav"; |
| 8 | +import { PrefsProvider } from "@/components/prefs"; | |
| 6 | 9 | import { Providers } from "@/components/theme"; |
| 7 | 10 | import { SITE_URL } from "@/lib/api"; |
| 8 | 11 | import "./globals.css"; |
@@ -12,26 +15,32 @@ const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains", | ||
| 12 | 15 | |
| 13 | 16 | export const metadata: Metadata = { |
| 14 | 17 | metadataBase: new URL(SITE_URL), |
| 15 | − title: { default: "WebSensor — Detect What Changed. Know Why It Matters.", template: "%s · WebSensor" }, | |
| 16 | − description: "Real-time intelligence from the changing Web. WebSensor monitors official public sources, detects meaningful changes, scores their importance and publishes them live.", | |
| 18 | + title: { default: "WebSensor — The Web is changing. We are watching.", template: "%s · WebSensor" }, | |
| 19 | + description: "WebSensor continuously watches the public web for meaningful changes, preserving evidence and connecting signals into real-world events.", | |
| 17 | 20 | applicationName: "WebSensor", |
| 18 | − openGraph: { type: "website", siteName: "WebSensor", url: SITE_URL, title: "WebSensor — The Web, Live.", description: "A global sensor network for the changing Web." }, | |
| 19 | − twitter: { card: "summary_large_image", title: "WebSensor — The Web, Live.", description: "Detect What Changed. Know Why It Matters." }, | |
| 21 | + openGraph: { type: "website", siteName: "WebSensor", url: SITE_URL, title: "WebSensor — The Web is changing. We are watching.", description: "Real-time Internet change intelligence: what changed, where, when, whether it was silent, how important it is — with immutable evidence." }, | |
| 22 | + twitter: { card: "summary_large_image", title: "WebSensor — The Web is changing. We are watching.", description: "Detect what changed. Know why it matters." }, | |
| 20 | 23 | alternates: { canonical: "/", types: { "application/rss+xml": `${SITE_URL}/api/v1/feed.rss` } }, |
| 21 | 24 | robots: { index: true, follow: true }, |
| 22 | 25 | }; |
| 23 | 26 | |
| 24 | −export const viewport: Viewport = { themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#0b0f17" }, { media: "(prefers-color-scheme: light)", color: "#f7f8fb" }], width: "device-width", initialScale: 1 }; | |
| 27 | +export const viewport: Viewport = { themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#0a0e15" }, { media: "(prefers-color-scheme: light)", color: "#f6f7fa" }], width: "device-width", initialScale: 1, viewportFit: "cover" }; | |
| 25 | 28 | |
| 26 | 29 | export default function RootLayout({ children }: { children: ReactNode }) { |
| 27 | 30 | return ( |
| 28 | 31 | <html lang="en" className={`${inter.variable} ${mono.variable} dark`} suppressHydrationWarning> |
| 29 | 32 | <body className="min-h-dvh"> |
| 30 | 33 | <Providers> |
| 31 | − <TopNav /> | |
| 32 | − <main className="mx-auto w-full max-w-[1500px] px-3 py-4 sm:px-4">{children}</main> | |
| 33 | − <Footer /> | |
| 34 | − <MobileNav /> | |
| 34 | + <PrefsProvider> | |
| 35 | + <EventDrawerProvider> | |
| 36 | + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-[70] focus:rounded-md focus:bg-panel focus:px-3 focus:py-2">Skip to content</a> | |
| 37 | + <TopNav /> | |
| 38 | + <main id="main" className="mx-auto w-full max-w-[1600px] px-3 py-4 pb-24 sm:px-4 lg:pb-6">{children}</main> | |
| 39 | + <Footer /> | |
| 40 | + <MobileNav /> | |
| 41 | + <CommandPalette /> | |
| 42 | + </EventDrawerProvider> | |
| 43 | + </PrefsProvider> | |
| 35 | 44 | </Providers> |
| 36 | 45 | </body> |
| 37 | 46 | </html> |
added
apps/web/src/app/live/loading.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +import { Skeleton, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-4 flex flex-col gap-2"> | |
| 7 | + <Skeleton className="h-3 w-40" /> | |
| 8 | + <Skeleton className="h-7 w-56" /> | |
| 9 | + <div className="flex flex-wrap gap-1"> | |
| 10 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 11 | + <Skeleton key={i} className="h-5 w-20" /> | |
| 12 | + ))} | |
| 13 | + </div> | |
| 14 | + </div> | |
| 15 | + <div className="panel overflow-hidden"> | |
| 16 | + <div className="flex items-center gap-3 border-b border-line px-3 py-2"> | |
| 17 | + <Skeleton className="h-3 w-24" /> | |
| 18 | + <Skeleton className="h-3 w-12" /> | |
| 19 | + <Skeleton className="ml-auto h-6 w-28" /> | |
| 20 | + </div> | |
| 21 | + <div className="flex gap-1 border-b border-line px-2 py-1.5"> | |
| 22 | + {Array.from({ length: 8 }, (_, i) => ( | |
| 23 | + <Skeleton key={i} className="h-5 w-16" /> | |
| 24 | + ))} | |
| 25 | + </div> | |
| 26 | + <SkeletonRows rows={14} /> | |
| 27 | + </div> | |
| 28 | + </> | |
| 29 | + ); | |
| 30 | +} | |
added
apps/web/src/app/live/page.tsx
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { LiveFeed } from "@/components/live-feed"; | |
| 4 | +import { filtersFromParams } from "@/lib/feed-filters"; | |
| 5 | +import { PageHeader } from "@/components/ui"; | |
| 6 | +import { api, type EventQuery } from "@/lib/api"; | |
| 7 | +import { SAVED_VIEWS, feedHref } from "@/lib/format"; | |
| 8 | + | |
| 9 | +export const dynamic = "force-dynamic"; | |
| 10 | +export const metadata: Metadata = { title: "Live", description: "Every meaningful change detected on the public web, live. Filters persist in the URL and can be shared." }; | |
| 11 | + | |
| 12 | +/** Full-width live feed with URL-persisted filters (spec §99) and saved views (spec §100). */ | |
| 13 | +export default async function LivePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { | |
| 14 | + const sp = await searchParams; | |
| 15 | + const f = filtersFromParams(sp); | |
| 16 | + const q: EventQuery = { ...f, limit: 60 }; | |
| 17 | + const events = await api.events(q); | |
| 18 | + const activeView = SAVED_VIEWS.find((v) => Object.entries(v.query).every(([k, val]) => String(sp[k] ?? "") === String(val)) && Object.keys(sp).length === Object.keys(v.query).length); | |
| 19 | + return ( | |
| 20 | + <> | |
| 21 | + <PageHeader | |
| 22 | + compact | |
| 23 | + kicker="Live feed" | |
| 24 | + title={activeView ? activeView.label : "Live"} | |
| 25 | + description="Everything WebSensor detects, as it happens. Filters live in the URL — share the link to share the view." | |
| 26 | + actions={ | |
| 27 | + <div className="flex flex-wrap gap-1"> | |
| 28 | + {SAVED_VIEWS.map((v) => ( | |
| 29 | + <Link key={v.key} href={feedHref(v.query, "/live")} className={`rounded-md border px-2 py-1 text-[11.5px] ${activeView?.key === v.key ? "border-signal/50 bg-signal-soft text-signal" : "border-line text-fg-muted hover:text-fg"}`}> | |
| 30 | + {v.label} | |
| 31 | + </Link> | |
| 32 | + ))} | |
| 33 | + </div> | |
| 34 | + } | |
| 35 | + /> | |
| 36 | + <LiveFeed key={JSON.stringify(f)} initial={events.items} initialCursor={events.nextCursor} initialFilters={f} syncUrl showTabs={!f.category} title="LIVE WEB" /> | |
| 37 | + </> | |
| 38 | + ); | |
| 39 | +} | |
added
apps/web/src/app/monitors/monitors.tsx
+310 −0
@@ -0,0 +1,310 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { Check, Plus, RefreshCw, Trash2 } from "lucide-react"; | |
| 4 | +import { useCallback, useEffect, useState } from "react"; | |
| 5 | +import { EventRow } from "@/components/event-row"; | |
| 6 | +import { FieldChangeInline } from "@/components/field-changes"; | |
| 7 | +import { Chip, Empty, HealthPill, Panel, SkeletonRows, Table, Td } from "@/components/ui"; | |
| 8 | +import { clientApiBase, type ChangeRow, type EventItem, type Monitor } from "@/lib/api"; | |
| 9 | +import { CLASS_LABELS, fmtBytes, hostOf, relTime, untilTime, utcDateTime } from "@/lib/format"; | |
| 10 | +import { ownerToken } from "@/lib/owner"; | |
| 11 | + | |
| 12 | +interface ApiError { | |
| 13 | + status: number; | |
| 14 | + error: string; | |
| 15 | + detail?: string; | |
| 16 | + limit?: number; | |
| 17 | +} | |
| 18 | + | |
| 19 | +/** Owner-scoped request that keeps the API's error shape (`{error, detail?}`) instead of flattening it. */ | |
| 20 | +async function request<T>(path: string, init: RequestInit = {}): Promise<T> { | |
| 21 | + const res = await fetch(`${clientApiBase()}${path}`, { ...init, headers: { "content-type": "application/json", accept: "application/json", "x-websensor-owner": ownerToken(), ...(init.headers ?? {}) } }); | |
| 22 | + if (!res.ok) { | |
| 23 | + const body = (await res.json().catch(() => ({}))) as Partial<ApiError> & { issues?: { path?: (string | number)[]; message?: string }[] }; | |
| 24 | + const detail = body.detail ?? body.issues?.map((i) => `${(i.path ?? []).join(".")}: ${i.message ?? ""}`).join("; "); | |
| 25 | + throw Object.assign(new Error(body.error ?? `HTTP ${res.status}`), { status: res.status, error: body.error ?? `http_${res.status}`, detail, limit: body.limit } satisfies ApiError); | |
| 26 | + } | |
| 27 | + return (await res.json()) as T; | |
| 28 | +} | |
| 29 | + | |
| 30 | +const ERROR_HELP: Record<string, string> = { | |
| 31 | + url_rejected: "Private, loopback, metadata and internal addresses are refused by the SSRF policy.", | |
| 32 | + scheme_not_allowed: "Only http:// and https:// URLs can be monitored.", | |
| 33 | + fetch_failed: "The page could not be fetched. Check the address, TLS certificate and that the host answers public requests.", | |
| 34 | + http_error: "The page answered with an error status. Monitors need a 2xx page to take a baseline.", | |
| 35 | + unparseable: "The response could not be normalized as HTML/text.", | |
| 36 | + thin_content: "Almost no server-rendered text was found (client-side app?). Try a more specific URL or a CSS selector.", | |
| 37 | + monitor_limit_reached: "Monitor limit reached for this browser. Delete one to add another.", | |
| 38 | + not_found: "Monitor not found — it may have been deleted.", | |
| 39 | +}; | |
| 40 | + | |
| 41 | +interface Form { | |
| 42 | + url: string; | |
| 43 | + name: string; | |
| 44 | + frequency: "hourly" | "daily"; | |
| 45 | + sensitivity: "low" | "normal" | "high"; | |
| 46 | + selector: string; | |
| 47 | + keywords: string; | |
| 48 | +} | |
| 49 | +const EMPTY: Form = { url: "", name: "", frequency: "hourly", sensitivity: "normal", selector: "", keywords: "" }; | |
| 50 | + | |
| 51 | +interface CreateResult { | |
| 52 | + id: string; | |
| 53 | + url: string; | |
| 54 | + tier: string; | |
| 55 | + status: string; | |
| 56 | + test?: { http_status: number; content_type?: string | null; bytes?: number | null; title?: string | null; mode?: string; extraction_confidence?: number | null }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function Monitors() { | |
| 60 | + const [items, setItems] = useState<Monitor[] | null>(null); | |
| 61 | + const [limit, setLimit] = useState(5); | |
| 62 | + const [form, setForm] = useState<Form>(EMPTY); | |
| 63 | + const [err, setErr] = useState<ApiError | null>(null); | |
| 64 | + const [busy, setBusy] = useState(false); | |
| 65 | + const [created, setCreated] = useState<CreateResult | null>(null); | |
| 66 | + const [selected, setSelected] = useState<string | null>(null); | |
| 67 | + /** Keyed by monitor id so switching monitors shows a loading state without a synchronous reset. */ | |
| 68 | + const [loaded, setLoaded] = useState<{ id: string; events: EventItem[]; changes: ChangeRow[]; error: string | null } | null>(null); | |
| 69 | + const detail = loaded && loaded.id === selected ? loaded : null; | |
| 70 | + | |
| 71 | + const load = useCallback( | |
| 72 | + () => | |
| 73 | + request<{ items: Monitor[]; limit: number }>("/api/v1/monitors") | |
| 74 | + .then((r) => { | |
| 75 | + setItems(r.items); | |
| 76 | + setLimit(r.limit); | |
| 77 | + setSelected((cur) => cur ?? r.items[0]?.id ?? null); | |
| 78 | + }) | |
| 79 | + .catch((e: ApiError) => { | |
| 80 | + setErr(e); | |
| 81 | + setItems([]); | |
| 82 | + }), | |
| 83 | + [], | |
| 84 | + ); | |
| 85 | + useEffect(() => { | |
| 86 | + void load(); | |
| 87 | + const t = setInterval(() => void load(), 30_000); | |
| 88 | + return () => clearInterval(t); | |
| 89 | + }, [load]); | |
| 90 | + | |
| 91 | + useEffect(() => { | |
| 92 | + if (!selected) return; | |
| 93 | + const id = selected; | |
| 94 | + let cancelled = false; | |
| 95 | + request<{ events: EventItem[]; changes: ChangeRow[] }>(`/api/v1/monitors/${encodeURIComponent(id)}/events?limit=50`) | |
| 96 | + .then((r) => { | |
| 97 | + if (!cancelled) setLoaded({ id, events: r.events, changes: r.changes, error: null }); | |
| 98 | + }) | |
| 99 | + .catch((e: ApiError) => { | |
| 100 | + if (!cancelled) setLoaded({ id, events: [], changes: [], error: `${e.error}${e.detail ? ` — ${e.detail}` : ""}` }); | |
| 101 | + }); | |
| 102 | + return () => { | |
| 103 | + cancelled = true; | |
| 104 | + }; | |
| 105 | + }, [selected, items]); | |
| 106 | + | |
| 107 | + const create = async (): Promise<void> => { | |
| 108 | + setErr(null); | |
| 109 | + setCreated(null); | |
| 110 | + const url = form.url.trim(); | |
| 111 | + if (!/^https?:\/\//i.test(url)) { | |
| 112 | + setErr({ status: 0, error: "invalid_url", detail: "Enter a full address starting with http:// or https://." }); | |
| 113 | + return; | |
| 114 | + } | |
| 115 | + const keywords = form.keywords.split(/[,\n]/).map((k) => k.trim()).filter(Boolean).slice(0, 20); | |
| 116 | + setBusy(true); | |
| 117 | + try { | |
| 118 | + const r = await request<CreateResult>("/api/v1/monitors", { method: "POST", body: JSON.stringify({ url, name: form.name.trim() || undefined, frequency: form.frequency, sensitivity: form.sensitivity, selector: form.selector.trim() || undefined, keywords: keywords.length ? keywords : undefined }) }); | |
| 119 | + setCreated(r); | |
| 120 | + setForm(EMPTY); | |
| 121 | + await load(); | |
| 122 | + setSelected(r.id); | |
| 123 | + } catch (e) { | |
| 124 | + setErr(e as ApiError); | |
| 125 | + } finally { | |
| 126 | + setBusy(false); | |
| 127 | + } | |
| 128 | + }; | |
| 129 | + | |
| 130 | + const remove = async (m: Monitor): Promise<void> => { | |
| 131 | + setItems((prev) => prev?.filter((x) => x.id !== m.id) ?? prev); | |
| 132 | + if (selected === m.id) setSelected(null); | |
| 133 | + try { | |
| 134 | + await request(`/api/v1/monitors/${encodeURIComponent(m.id)}`, { method: "DELETE" }); | |
| 135 | + } catch (e) { | |
| 136 | + setErr(e as ApiError); | |
| 137 | + } | |
| 138 | + await load(); | |
| 139 | + }; | |
| 140 | + | |
| 141 | + const active = items?.filter((m) => m.enabled).length ?? 0; | |
| 142 | + const current = items?.find((m) => m.id === selected) ?? null; | |
| 143 | + const input = "h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]"; | |
| 144 | + | |
| 145 | + return ( | |
| 146 | + <div className="grid gap-4 lg:grid-cols-[380px_minmax(0,1fr)]"> | |
| 147 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 148 | + <Panel title={<span>New monitor <span className="normal-case tracking-normal text-fg-subtle">· {active} / {limit} used</span></span>}> | |
| 149 | + <form | |
| 150 | + className="flex flex-col gap-3 text-[12.5px]" | |
| 151 | + onSubmit={(e) => { | |
| 152 | + e.preventDefault(); | |
| 153 | + void create(); | |
| 154 | + }} | |
| 155 | + > | |
| 156 | + <label className="flex flex-col gap-1"> | |
| 157 | + <span className="label">URL</span> | |
| 158 | + <input type="url" required value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/pricing" className={`${input} font-mono text-[12px]`} /> | |
| 159 | + </label> | |
| 160 | + <label className="flex flex-col gap-1"> | |
| 161 | + <span className="label">Name <span className="normal-case tracking-normal">· optional</span></span> | |
| 162 | + <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder={form.url ? hostOf(form.url) : "Defaults to the host name"} maxLength={80} className={input} /> | |
| 163 | + </label> | |
| 164 | + <div className="grid grid-cols-2 gap-3"> | |
| 165 | + <div> | |
| 166 | + <div className="label mb-1">Frequency</div> | |
| 167 | + <div className="flex gap-1" role="radiogroup" aria-label="Frequency"> | |
| 168 | + {(["hourly", "daily"] as const).map((f) => ( | |
| 169 | + <button key={f} type="button" role="radio" aria-checked={form.frequency === f} onClick={() => setForm({ ...form, frequency: f })} className={`h-8 flex-1 rounded-md border text-[12px] ${form.frequency === f ? "border-signal/50 bg-signal-soft text-signal" : "border-line bg-panel text-fg-muted hover:text-fg"}`}> | |
| 170 | + {f} | |
| 171 | + </button> | |
| 172 | + ))} | |
| 173 | + </div> | |
| 174 | + </div> | |
| 175 | + <div> | |
| 176 | + <div className="label mb-1">Sensitivity</div> | |
| 177 | + <div className="flex gap-1" role="radiogroup" aria-label="Sensitivity"> | |
| 178 | + {(["low", "normal", "high"] as const).map((s) => ( | |
| 179 | + <button key={s} type="button" role="radio" aria-checked={form.sensitivity === s} onClick={() => setForm({ ...form, sensitivity: s })} title={s === "low" ? "Only big changes" : s === "high" ? "Every meaningful change" : "Balanced"} className={`h-8 flex-1 rounded-md border text-[12px] ${form.sensitivity === s ? "border-signal/50 bg-signal-soft text-signal" : "border-line bg-panel text-fg-muted hover:text-fg"}`}> | |
| 180 | + {s} | |
| 181 | + </button> | |
| 182 | + ))} | |
| 183 | + </div> | |
| 184 | + </div> | |
| 185 | + </div> | |
| 186 | + <label className="flex flex-col gap-1"> | |
| 187 | + <span className="label">CSS selector <span className="normal-case tracking-normal">· optional, narrows the watched region</span></span> | |
| 188 | + <input value={form.selector} onChange={(e) => setForm({ ...form, selector: e.target.value })} placeholder="main .pricing-table" maxLength={200} className={`${input} font-mono text-[12px]`} /> | |
| 189 | + </label> | |
| 190 | + <label className="flex flex-col gap-1"> | |
| 191 | + <span className="label">Keywords <span className="normal-case tracking-normal">· optional, comma-separated</span></span> | |
| 192 | + <input value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} placeholder="price, deprecated, discontinued" className={input} /> | |
| 193 | + </label> | |
| 194 | + <button type="submit" disabled={busy || active >= limit} className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-line bg-panel-2 px-3 text-[12.5px] hover:border-line-strong disabled:opacity-60"> | |
| 195 | + {busy ? <RefreshCw className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />} {busy ? "Fetching & testing…" : active >= limit ? "Limit reached" : "Create monitor"} | |
| 196 | + </button> | |
| 197 | + {err && ( | |
| 198 | + <div className="rounded-md border border-danger/40 bg-danger/10 px-2 py-1.5 text-[12px]"> | |
| 199 | + <div className="font-mono font-semibold text-danger">{err.error}{err.status ? ` · HTTP ${err.status}` : ""}</div> | |
| 200 | + {err.detail && <div className="mt-0.5 break-words text-fg">{err.detail}</div>} | |
| 201 | + {ERROR_HELP[err.error] && <div className="mt-0.5 text-fg-muted">{ERROR_HELP[err.error]}</div>} | |
| 202 | + </div> | |
| 203 | + )} | |
| 204 | + {created && ( | |
| 205 | + <div className="rounded-md border border-ok/40 bg-ok/10 px-2 py-1.5 text-[12px]"> | |
| 206 | + <div className="flex items-center gap-1 font-semibold text-ok"><Check className="size-3.5" /> Monitor created · baseline test passed</div> | |
| 207 | + {created.test && ( | |
| 208 | + <div className="mt-0.5 font-mono text-[11px] text-fg-muted tabular"> | |
| 209 | + HTTP {created.test.http_status} · {created.test.content_type ?? "—"} · {fmtBytes(created.test.bytes)} · {created.test.mode ?? "text"} · confidence {created.test.extraction_confidence !== null && created.test.extraction_confidence !== undefined ? Math.round(created.test.extraction_confidence * 100) : "—"}% | |
| 210 | + {created.test.title && <div className="truncate text-fg">“{created.test.title}”</div>} | |
| 211 | + </div> | |
| 212 | + )} | |
| 213 | + </div> | |
| 214 | + )} | |
| 215 | + </form> | |
| 216 | + </Panel> | |
| 217 | + <Panel title="Limits & rules"> | |
| 218 | + <ul className="list-disc space-y-1 pl-4 text-[12.5px] text-fg-muted"> | |
| 219 | + <li><span className="text-fg">{limit} monitors per browser</span>, scoped to this browser's anonymous owner token.</li> | |
| 220 | + <li><span className="text-fg">Public URLs only.</span> Private networks, loopback and cloud-metadata addresses are rejected; redirects are checked hop by hop.</li> | |
| 221 | + <li><span className="text-fg">Respects robots and rate limits.</span> Conditional GET, one request per interval, per-host concurrency caps, circuit breaker on failing hosts.</li> | |
| 222 | + <li><span className="text-fg">No authentication bypass.</span> Pages behind logins, paywalls or bot walls are not fetched with credentials or headless browsers.</li> | |
| 223 | + <li>Hourly = tier C, daily = tier D. The baseline is taken on the first engine run; the first event needs a second snapshot.</li> | |
| 224 | + <li>Custom monitors never enter the public feed, rankings or clusters.</li> | |
| 225 | + </ul> | |
| 226 | + </Panel> | |
| 227 | + </aside> | |
| 228 | + | |
| 229 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 230 | + <Panel title={<span>Your monitors <span className="font-mono text-fg-subtle">{items?.length ?? "…"}</span></span>} dense> | |
| 231 | + {items === null ? ( | |
| 232 | + <SkeletonRows rows={3} /> | |
| 233 | + ) : items.length ? ( | |
| 234 | + <Table head={["Monitor", "Status", "Schedule", "Last check", "HTTP", "Changes", ""]}> | |
| 235 | + {items.map((m) => { | |
| 236 | + const sens = typeof m.config.sensitivity === "string" ? m.config.sensitivity : "normal"; | |
| 237 | + const sel = m.id === selected; | |
| 238 | + return ( | |
| 239 | + <tr key={m.id} className={sel ? "bg-panel-2/60" : ""}> | |
| 240 | + <Td> | |
| 241 | + <button type="button" aria-pressed={sel} onClick={() => setSelected(m.id)} className="block max-w-[18rem] truncate text-left font-medium hover:underline">{m.name}</button> | |
| 242 | + <a href={m.url} target="_blank" rel="noopener noreferrer nofollow" className="block max-w-[18rem] truncate font-mono text-[10.5px] text-fg-subtle hover:text-info" title={m.url}>{m.url}</a> | |
| 243 | + </Td> | |
| 244 | + <Td> | |
| 245 | + <div className="flex flex-wrap gap-1"> | |
| 246 | + <HealthPill health={m.enabled ? m.health : "DISABLED"} /> | |
| 247 | + <Chip className="font-mono">{m.status}</Chip> | |
| 248 | + </div> | |
| 249 | + </Td> | |
| 250 | + <Td> | |
| 251 | + <div className="flex flex-wrap gap-1"> | |
| 252 | + <Chip>{m.tier === "D" ? "daily" : "hourly"}</Chip> | |
| 253 | + <Chip tone={sens === "high" ? "high" : sens === "low" ? "default" : "info"}>{sens}</Chip> | |
| 254 | + </div> | |
| 255 | + <div className="mt-0.5 font-mono text-[10.5px] text-fg-subtle tabular">next {untilTime(m.next_check_at)}</div> | |
| 256 | + </Td> | |
| 257 | + <Td mono className="whitespace-nowrap text-fg-subtle">{m.last_check_at ? <span title={utcDateTime(m.last_check_at)}>{relTime(m.last_check_at)}</span> : "pending"}</Td> | |
| 258 | + <Td mono className={m.last_status && m.last_status >= 400 ? "text-danger" : ""}>{m.last_status ?? "—"}</Td> | |
| 259 | + <Td mono className="whitespace-nowrap" ><span title="raw / meaningful">{m.raw_changes ?? 0} / {m.meaningful_changes ?? 0}</span></Td> | |
| 260 | + <Td> | |
| 261 | + <button type="button" aria-label={`Delete monitor ${m.name}`} onClick={() => remove(m)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button> | |
| 262 | + </Td> | |
| 263 | + </tr> | |
| 264 | + ); | |
| 265 | + })} | |
| 266 | + </Table> | |
| 267 | + ) : ( | |
| 268 | + <Empty>No monitors yet — add a public URL on the left. The first check takes a baseline; changes are reported from the second check on.</Empty> | |
| 269 | + )} | |
| 270 | + {items?.some((m) => m.last_error) && ( | |
| 271 | + <ul className="border-t border-line px-3 py-2 text-[11px]"> | |
| 272 | + {items.filter((m) => m.last_error).map((m) => ( | |
| 273 | + <li key={m.id} className="truncate font-mono text-danger" title={m.last_error ?? ""}>{m.name}: {m.last_error}</li> | |
| 274 | + ))} | |
| 275 | + </ul> | |
| 276 | + )} | |
| 277 | + </Panel> | |
| 278 | + | |
| 279 | + <Panel title={<span>Events {current && <span className="normal-case tracking-normal text-fg-subtle">· {current.name}</span>}</span>} dense> | |
| 280 | + {!current ? ( | |
| 281 | + <Empty>Select a monitor to see its events and raw changes.</Empty> | |
| 282 | + ) : detail === null ? ( | |
| 283 | + <SkeletonRows rows={3} /> | |
| 284 | + ) : detail.events.length ? ( | |
| 285 | + detail.events.map((e) => <EventRow key={e.id} ev={e} showDate />) | |
| 286 | + ) : ( | |
| 287 | + <Empty>{detail.error ?? (current.total_runs ? "No meaningful change detected yet." : "Waiting for the first check to take a baseline.")}</Empty> | |
| 288 | + )} | |
| 289 | + </Panel> | |
| 290 | + | |
| 291 | + {current && detail && detail.changes.length > 0 && ( | |
| 292 | + <Panel title={<span>Raw changes <span className="font-mono text-fg-subtle">{detail.changes.length}</span> <span className="normal-case tracking-normal text-fg-subtle">· before noise filtering</span></span>} dense> | |
| 293 | + <Table head={["Detected", "Kind", "Class", "Signal", "Fields", "Event"]}> | |
| 294 | + {detail.changes.map((c) => ( | |
| 295 | + <tr key={c.id}> | |
| 296 | + <Td mono className="whitespace-nowrap text-fg-subtle"><span title={utcDateTime(c.detected_at)}>{relTime(c.detected_at)}</span></Td> | |
| 297 | + <Td mono>{c.kind}</Td> | |
| 298 | + <Td>{c.change_class ? <Chip tone={c.meaningful ? "signal" : "default"}>{CLASS_LABELS[c.change_class] ?? c.change_class}</Chip> : <span className="text-fg-subtle">—</span>}</Td> | |
| 299 | + <Td mono>{Math.round(Number(c.signal))}</Td> | |
| 300 | + <Td>{c.field_changes?.length ? <FieldChangeInline items={c.field_changes} max={2} /> : <span className="text-fg-subtle">—</span>}</Td> | |
| 301 | + <Td>{c.event_id ? <Chip tone="ok">event</Chip> : <span className="text-[11px] text-fg-subtle">filtered</span>}</Td> | |
| 302 | + </tr> | |
| 303 | + ))} | |
| 304 | + </Table> | |
| 305 | + </Panel> | |
| 306 | + )} | |
| 307 | + </div> | |
| 308 | + </div> | |
| 309 | + ); | |
| 310 | +} | |
added
apps/web/src/app/monitors/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { PageHeader } from "@/components/ui"; | |
| 3 | +import { Monitors } from "./monitors"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { title: "Custom monitors", description: "Watch any public URL with WebSensor's change engine: canonical extraction, semantic diff, noise filtering and evidence, scoped to your browser.", robots: { index: false } }; | |
| 6 | + | |
| 7 | +export default function MonitorsPage() { | |
| 8 | + return ( | |
| 9 | + <> | |
| 10 | + <PageHeader compact kicker="Stored in this browser (no account yet)" title="Custom monitors" description="Point WebSensor at a public page. It is fetched, canonicalized and diffed like every registry sensor; meaningful changes become events visible only to this browser." /> | |
| 11 | + <Monitors /> | |
| 12 | + </> | |
| 13 | + ); | |
| 14 | +} | |
modified
apps/web/src/app/not-found.tsx
+19 −8
@@ -1,15 +1,26 @@ | ||
| 1 | +import { Radar, Search } from "lucide-react"; | |
| 1 | 2 | import Link from "next/link"; |
| 3 | +import { Kbd, Panel } from "@/components/ui"; | |
| 2 | 4 | |
| 3 | 5 | export default function NotFound() { |
| 4 | 6 | return ( |
| 5 | − <div className="mx-auto max-w-lg py-20 text-center"> | |
| 6 | − <div className="label mb-2">404</div> | |
| 7 | − <h1 className="text-xl font-semibold">Nothing observed at this address</h1> | |
| 8 | − <p className="mt-2 text-[13px] text-fg-muted">The page may have moved, or the event, source or entity does not exist.</p> | |
| 9 | − <div className="mt-5 flex justify-center gap-3 text-[13px]"> | |
| 10 | − <Link href="/" className="rounded-md border border-line bg-panel px-3 py-1.5 hover:border-line-strong">Live feed</Link> | |
| 11 | − <Link href="/search" className="rounded-md border border-line bg-panel px-3 py-1.5 hover:border-line-strong">Search</Link> | |
| 12 | − </div> | |
| 7 | + <div className="mx-auto max-w-lg py-12 sm:py-20"> | |
| 8 | + <Panel dense> | |
| 9 | + <div className="flex flex-col items-center gap-2 px-4 py-10 text-center"> | |
| 10 | + <span className="font-mono text-[11px] font-semibold tracking-wider text-fg-subtle">HTTP 404 · NOT OBSERVED</span> | |
| 11 | + <Radar className="mt-1 size-6 text-fg-subtle" aria-hidden /> | |
| 12 | + <h1 className="mt-1 text-lg font-semibold leading-tight">Nothing observed at this address</h1> | |
| 13 | + <p className="max-w-sm text-[13px] text-fg-muted">The page may have moved, or the event, source, entity or cluster does not exist. Sensors keep watching; this URL is not one of them.</p> | |
| 14 | + <div className="mt-4 flex flex-wrap justify-center gap-2 text-[12.5px]"> | |
| 15 | + <Link href="/live" className="inline-flex h-8 items-center rounded-md border border-line bg-panel px-3 hover:border-line-strong">Live feed</Link> | |
| 16 | + <Link href="/explore" className="inline-flex h-8 items-center rounded-md border border-line bg-panel px-3 hover:border-line-strong">Explore</Link> | |
| 17 | + <Link href="/search" className="inline-flex h-8 items-center gap-1.5 rounded-md border border-line bg-panel px-3 hover:border-line-strong"><Search className="size-3.5" /> Search</Link> | |
| 18 | + </div> | |
| 19 | + <p className="mt-3 flex items-center gap-1.5 text-[11px] text-fg-subtle"> | |
| 20 | + Press <Kbd>⌘</Kbd><Kbd>K</Kbd> anywhere to search entities, events and URLs. | |
| 21 | + </p> | |
| 22 | + </div> | |
| 23 | + </Panel> | |
| 13 | 24 | </div> |
| 14 | 25 | ); |
| 15 | 26 | } |
added
apps/web/src/app/ops/ops.tsx
+706 −0
@@ -0,0 +1,706 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { KeyRound, LogOut, Play, RefreshCw, ShieldAlert } from "lucide-react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 6 | +import { SENSOR_TYPES, TIERS } from "@websensor/core/client"; | |
| 7 | +import { useMounted } from "@/components/theme"; | |
| 8 | +import { Chip, Empty, HealthPill, Panel, Skeleton, Sparkline, Stat, Table, Td } from "@/components/ui"; | |
| 9 | +import { clientApiBase } from "@/lib/api"; | |
| 10 | +import { fmtBytes, fmtInt, fmtMs, fmtPct, relTime, untilTime, utcDateTime, utcTime } from "@/lib/format"; | |
| 11 | + | |
| 12 | +const KEY = "ws_admin"; | |
| 13 | + | |
| 14 | +// --------------------------------------------------------------------------------------- | |
| 15 | +// Types for GET /api/v1/admin/ops (kept local: internal shape, not part of the public client) | |
| 16 | +// --------------------------------------------------------------------------------------- | |
| 17 | + | |
| 18 | +interface Ops { | |
| 19 | + db: { latency_ms: number }; | |
| 20 | + queue: { due: number; overdue_10m: number; p0: number; p0_due: number; oldest_due: string | null }; | |
| 21 | + engine: { inflight?: number; concurrency?: number; due?: number; busyHosts?: { host: string; inflight: number }[]; circuitOpen?: { host: string; failures: number; until: string }[]; at?: string; version?: string; [k: string]: unknown } | null; | |
| 22 | + outcomes_1h: { outcome: string; n: number }[]; | |
| 23 | + top_failing_domains: { host: string; failures: number; sensors: number; last_error: string | null; last_at: string }[]; | |
| 24 | + slowest_sensors: { id: string; name: string; source_id: string; url: string; connector: string; avg_latency_ms: number; total_runs: number }[]; | |
| 25 | + throughput_24h: { t: string; checks: number; not_modified: number; errors: number; events: number; avg_ms: number | null }[]; | |
| 26 | + storage: { db_size: string; db_bytes: string | number; snapshots: number; snapshots_with_raw: number; raw_bytes_uncompressed: string | number; changes: number; events: number; runs: number; blobs: { files: number; bytes: number } | null }; | |
| 27 | + llm_24h: { model: string; calls: number; input_tokens: string | number; output_tokens: string | number; failures: number }[]; | |
| 28 | + sensors_by_status: { status: string; health: string; n: number }[]; | |
| 29 | + recent_errors: { started_at: string; sensor_id: string; source_id: string; connector: string; http_status: number | null; outcome: string; error: string | null }[]; | |
| 30 | + cache: { entries: number; keys: string[] }; | |
| 31 | + live: { clients: number; published: number }; | |
| 32 | + connectors: { key: string; name: string; version?: string; sensorTypes?: string[]; description?: string }[]; | |
| 33 | + generated_at: string; | |
| 34 | +} | |
| 35 | + | |
| 36 | +class AdminError extends Error { | |
| 37 | + status: number; | |
| 38 | + body: Record<string, unknown>; | |
| 39 | + constructor(status: number, body: Record<string, unknown>) { | |
| 40 | + super(typeof body.error === "string" ? body.error : `HTTP ${status}`); | |
| 41 | + this.status = status; | |
| 42 | + this.body = body; | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +function readToken(): string { | |
| 47 | + if (typeof window === "undefined") return ""; | |
| 48 | + return window.sessionStorage.getItem(KEY) ?? ""; | |
| 49 | +} | |
| 50 | + | |
| 51 | +async function adminFetch<T>(path: string, init: RequestInit = {}, token = readToken()): Promise<T> { | |
| 52 | + const headers: Record<string, string> = { accept: "application/json", "x-websensor-admin": token, ...((init.headers as Record<string, string>) ?? {}) }; | |
| 53 | + if (init.body !== undefined && !headers["content-type"]) headers["content-type"] = "application/json"; | |
| 54 | + const res = await fetch(`${clientApiBase()}${path}`, { ...init, headers }); | |
| 55 | + const body = (await res.json().catch(() => ({}))) as Record<string, unknown>; | |
| 56 | + if (!res.ok) throw new AdminError(res.status, body); | |
| 57 | + return body as T; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** A heartbeat older than two minutes means the scheduler stopped publishing. */ | |
| 61 | +function heartbeatStale(at: string | undefined): boolean { | |
| 62 | + return Boolean(at) && Date.now() - new Date(at as string).getTime() > 120_000; | |
| 63 | +} | |
| 64 | + | |
| 65 | +function gateMessage(e: unknown): string { | |
| 66 | + if (e instanceof AdminError) { | |
| 67 | + if (e.status === 401) return "invalid token"; | |
| 68 | + if (e.status === 404) return "admin API disabled (WS_ADMIN_TOKEN not set)"; | |
| 69 | + return `${e.message}${typeof e.body.detail === "string" ? ` — ${e.body.detail}` : ""}`; | |
| 70 | + } | |
| 71 | + return (e as Error).message; | |
| 72 | +} | |
| 73 | + | |
| 74 | +// --------------------------------------------------------------------------------------- | |
| 75 | + | |
| 76 | +export function Ops() { | |
| 77 | + const mounted = useMounted(); | |
| 78 | + /** null = use the token stored in sessionStorage; "" = signed out; otherwise the token entered in this session. */ | |
| 79 | + const [override, setOverride] = useState<string | null>(null); | |
| 80 | + const token: string | null = !mounted ? null : override ?? readToken(); | |
| 81 | + const [gate, setGate] = useState<string | null>(null); | |
| 82 | + const [data, setData] = useState<Ops | null>(null); | |
| 83 | + const [err, setErr] = useState<string | null>(null); | |
| 84 | + const [refreshing, setRefreshing] = useState(false); | |
| 85 | + const [input, setInput] = useState(""); | |
| 86 | + const [tick, setTick] = useState(0); | |
| 87 | + | |
| 88 | + // Poll /admin/ops every 15 s while a token is present (and on manual refresh via `tick`). | |
| 89 | + useEffect(() => { | |
| 90 | + if (!token) return; | |
| 91 | + const t = token; | |
| 92 | + let cancelled = false; | |
| 93 | + const pull = (): void => { | |
| 94 | + adminFetch<Ops>("/api/v1/admin/ops", {}, t) | |
| 95 | + .then((d) => { | |
| 96 | + if (cancelled) return; | |
| 97 | + setData(d); | |
| 98 | + setErr(null); | |
| 99 | + setGate(null); | |
| 100 | + }) | |
| 101 | + .catch((e: unknown) => { | |
| 102 | + if (cancelled) return; | |
| 103 | + const msg = gateMessage(e); | |
| 104 | + if (e instanceof AdminError && (e.status === 401 || e.status === 404)) { | |
| 105 | + setGate(msg); | |
| 106 | + setData(null); | |
| 107 | + } else setErr(msg); | |
| 108 | + }) | |
| 109 | + .finally(() => { | |
| 110 | + if (!cancelled) setRefreshing(false); | |
| 111 | + }); | |
| 112 | + }; | |
| 113 | + pull(); | |
| 114 | + const iv = setInterval(pull, 15_000); | |
| 115 | + return () => { | |
| 116 | + cancelled = true; | |
| 117 | + clearInterval(iv); | |
| 118 | + }; | |
| 119 | + }, [token, tick]); | |
| 120 | + | |
| 121 | + const refresh = (): void => { | |
| 122 | + setRefreshing(true); | |
| 123 | + setTick((x) => x + 1); | |
| 124 | + }; | |
| 125 | + const submitToken = async (): Promise<void> => { | |
| 126 | + const t = input.trim(); | |
| 127 | + if (!t) return; | |
| 128 | + try { | |
| 129 | + const d = await adminFetch<Ops>("/api/v1/admin/ops", {}, t); | |
| 130 | + window.sessionStorage.setItem(KEY, t); | |
| 131 | + setData(d); | |
| 132 | + setGate(null); | |
| 133 | + setErr(null); | |
| 134 | + setOverride(t); | |
| 135 | + setInput(""); | |
| 136 | + } catch (e) { | |
| 137 | + window.sessionStorage.removeItem(KEY); | |
| 138 | + setGate(gateMessage(e)); | |
| 139 | + } | |
| 140 | + }; | |
| 141 | + const signOut = (): void => { | |
| 142 | + window.sessionStorage.removeItem(KEY); | |
| 143 | + setOverride(""); | |
| 144 | + setData(null); | |
| 145 | + setGate(null); | |
| 146 | + }; | |
| 147 | + | |
| 148 | + if (token === null) return <Skeleton className="h-24 w-full" />; | |
| 149 | + if (!token || (gate && !data)) { | |
| 150 | + return ( | |
| 151 | + <Panel> | |
| 152 | + <form | |
| 153 | + className="mx-auto flex max-w-md flex-col gap-2 py-6 text-[13px]" | |
| 154 | + onSubmit={(e) => { | |
| 155 | + e.preventDefault(); | |
| 156 | + void submitToken(); | |
| 157 | + }} | |
| 158 | + > | |
| 159 | + <p className="leading-relaxed text-fg-muted"> | |
| 160 | + <KeyRound className="mr-1.5 inline size-4 align-text-bottom" /> | |
| 161 | + Enter the admin token (<code>WS_ADMIN_TOKEN</code>). It is kept in <code>sessionStorage</code> for this tab only and sent as <code>X-WebSensor-Admin</code>. | |
| 162 | + </p> | |
| 163 | + <div className="flex gap-1"> | |
| 164 | + <input type="password" value={input} onChange={(e) => setInput(e.target.value)} placeholder="admin token" autoComplete="off" aria-label="Admin token" className="h-8 min-w-0 flex-1 rounded-md border border-line bg-panel px-2 font-mono text-[12.5px]" /> | |
| 165 | + <button type="submit" className="inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-3 text-[12.5px] hover:border-line-strong">Unlock</button> | |
| 166 | + </div> | |
| 167 | + {gate && <p className="flex items-center gap-1 text-[12px] text-danger"><ShieldAlert className="size-3.5" /> {gate}</p>} | |
| 168 | + <p className="text-[11px] text-fg-subtle">Public health is at <Link href="/health" className="text-info hover:underline">/health</Link>.</p> | |
| 169 | + </form> | |
| 170 | + </Panel> | |
| 171 | + ); | |
| 172 | + } | |
| 173 | + | |
| 174 | + return ( | |
| 175 | + <div className="flex flex-col gap-4"> | |
| 176 | + <div className="flex flex-wrap items-center justify-between gap-2 text-[11.5px] text-fg-subtle"> | |
| 177 | + <span className="flex items-center gap-2"> | |
| 178 | + <span className={`inline-block size-2 rounded-full ${err ? "bg-danger" : "bg-signal animate-pulse-dot"}`} /> | |
| 179 | + {data ? `generated ${utcTime(data.generated_at)} UTC · db ${data.db.latency_ms} ms · ${data.live.clients} WS clients` : "loading…"} | |
| 180 | + {err && <span className="text-danger">· {err}</span>} | |
| 181 | + </span> | |
| 182 | + <span className="flex items-center gap-2"> | |
| 183 | + <button type="button" onClick={refresh} disabled={refreshing} className="inline-flex h-7 items-center gap-1 rounded-md border border-line bg-panel px-2 hover:border-line-strong disabled:opacity-60"><RefreshCw className={`size-3 ${refreshing ? "animate-spin" : ""}`} /> refresh</button> | |
| 184 | + <button type="button" onClick={signOut} className="inline-flex h-7 items-center gap-1 rounded-md border border-line bg-panel px-2 hover:border-line-strong"><LogOut className="size-3" /> forget token</button> | |
| 185 | + </span> | |
| 186 | + </div> | |
| 187 | + | |
| 188 | + {data ? <Dashboard d={data} /> : <Skeleton className="h-64 w-full" />} | |
| 189 | + | |
| 190 | + <Tools connectors={data?.connectors ?? []} onDone={refresh} /> | |
| 191 | + </div> | |
| 192 | + ); | |
| 193 | +} | |
| 194 | + | |
| 195 | +// --------------------------------------------------------------------------------------- | |
| 196 | +// Dashboard panels | |
| 197 | +// --------------------------------------------------------------------------------------- | |
| 198 | + | |
| 199 | +function Dashboard({ d }: { d: Ops }) { | |
| 200 | + const eng = d.engine; | |
| 201 | + const stale = heartbeatStale(eng?.at); | |
| 202 | + const tp = d.throughput_24h; | |
| 203 | + const sum = (k: "checks" | "events" | "errors" | "not_modified"): number => tp.reduce((a, r) => a + Number(r[k] ?? 0), 0); | |
| 204 | + const outcomes = d.outcomes_1h; | |
| 205 | + const oTotal = outcomes.reduce((a, o) => a + o.n, 0); | |
| 206 | + const byStatus = useMemo(() => { | |
| 207 | + const m = new Map<string, { n: number; health: Record<string, number> }>(); | |
| 208 | + for (const r of d.sensors_by_status) { | |
| 209 | + const cur = m.get(r.status) ?? { n: 0, health: {} }; | |
| 210 | + cur.n += r.n; | |
| 211 | + cur.health[r.health] = (cur.health[r.health] ?? 0) + r.n; | |
| 212 | + m.set(r.status, cur); | |
| 213 | + } | |
| 214 | + return [...m.entries()].sort((a, b) => b[1].n - a[1].n); | |
| 215 | + }, [d.sensors_by_status]); | |
| 216 | + const raw = Number(d.storage.raw_bytes_uncompressed); | |
| 217 | + const blobBytes = d.storage.blobs?.bytes ?? 0; | |
| 218 | + | |
| 219 | + return ( | |
| 220 | + <> | |
| 221 | + <div className="panel grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 xl:grid-cols-8 xl:divide-y-0"> | |
| 222 | + <Stat label="Engine" value={eng ? (stale ? "STALE" : "LIVE") : "NO HEARTBEAT"} tone={eng && !stale ? "signal" : "warn"} hint={eng?.at ? `${relTime(eng.at)} · ${eng.version ?? "?"}` : "Redis ws:engine:status empty"} /> | |
| 223 | + <Stat label="In flight" value={eng ? `${eng.inflight ?? 0} / ${eng.concurrency ?? "—"}` : "—"} hint="workers / concurrency" /> | |
| 224 | + <Stat label="Queue due" value={fmtInt(d.queue.due)} tone={d.queue.overdue_10m > 0 ? "warn" : undefined} hint={`${fmtInt(d.queue.overdue_10m)} overdue > 10 min`} /> | |
| 225 | + <Stat label="P0 · due" value={`${fmtInt(d.queue.p0)} · ${fmtInt(d.queue.p0_due)}`} tone={d.queue.p0_due > 0 ? "hot" : undefined} hint={d.queue.oldest_due ? `oldest ${untilTime(d.queue.oldest_due)}` : "—"} /> | |
| 226 | + <Stat label="Checks 24 h" value={fmtInt(sum("checks"))} hint={`${fmtPct(sum("checks") ? sum("not_modified") / sum("checks") : null)} 304`} /> | |
| 227 | + <Stat label="Events 24 h" value={fmtInt(sum("events"))} tone="signal" hint={`${fmtInt(sum("errors"))} errors`} /> | |
| 228 | + <Stat label="Circuit open" value={fmtInt(eng?.circuitOpen?.length ?? 0)} tone={(eng?.circuitOpen?.length ?? 0) > 0 ? "warn" : undefined} hint={`${eng?.busyHosts?.length ?? 0} busy hosts`} /> | |
| 229 | + <Stat label="Cache" value={fmtInt(d.cache.entries)} hint="TTL entries" /> | |
| 230 | + </div> | |
| 231 | + | |
| 232 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-3"> | |
| 233 | + <Panel title="Engine heartbeat"> | |
| 234 | + {eng ? ( | |
| 235 | + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]"> | |
| 236 | + <Row k="At" v={utcDateTime(eng.at)} /> | |
| 237 | + <Row k="Version" v={eng.version ?? "—"} /> | |
| 238 | + <Row k="In flight" v={`${eng.inflight ?? 0} / ${eng.concurrency ?? "—"}`} /> | |
| 239 | + <Row k="Due (engine view)" v={fmtInt(eng.due)} /> | |
| 240 | + <Row k="Busy hosts" v={eng.busyHosts?.length ? eng.busyHosts.slice(0, 8).map((h) => `${h.host} ×${h.inflight}`).join(", ") : "none"} /> | |
| 241 | + <Row k="Circuit open" v={eng.circuitOpen?.length ? eng.circuitOpen.slice(0, 8).map((h) => `${h.host} (${h.failures}, until ${utcTime(h.until, false)})`).join(", ") : "none"} tone={eng.circuitOpen?.length ? "text-warn" : ""} /> | |
| 242 | + </dl> | |
| 243 | + ) : ( | |
| 244 | + <Empty>No heartbeat in Redis — the engine is not running or cannot reach Redis.</Empty> | |
| 245 | + )} | |
| 246 | + </Panel> | |
| 247 | + <Panel title="Outcomes · 1 h"> | |
| 248 | + {outcomes.length ? ( | |
| 249 | + <ul className="flex flex-col gap-1.5 text-[12.5px]"> | |
| 250 | + {outcomes.map((o) => ( | |
| 251 | + <li key={o.outcome} className="grid grid-cols-[7rem_1fr_3.5rem] items-center gap-2"> | |
| 252 | + <span className="truncate font-mono text-[11.5px]">{o.outcome}</span> | |
| 253 | + <div className="h-1.5 overflow-hidden rounded-full bg-panel-2"><div className={`h-full ${o.outcome === "error" || o.outcome === "parse_error" || o.outcome === "rate_limited" ? "bg-danger" : o.outcome === "event" ? "bg-signal" : o.outcome === "not_modified" ? "bg-info" : "bg-low"}`} style={{ width: `${oTotal ? (o.n / oTotal) * 100 : 0}%` }} /></div> | |
| 254 | + <span className="text-right font-mono tabular">{fmtInt(o.n)}</span> | |
| 255 | + </li> | |
| 256 | + ))} | |
| 257 | + </ul> | |
| 258 | + ) : ( | |
| 259 | + <Empty>No runs in the last hour.</Empty> | |
| 260 | + )} | |
| 261 | + </Panel> | |
| 262 | + <Panel title="Storage"> | |
| 263 | + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]"> | |
| 264 | + <Row k="Database" v={d.storage.db_size} /> | |
| 265 | + <Row k="Snapshots" v={`${fmtInt(d.storage.snapshots)} · ${fmtInt(d.storage.snapshots_with_raw)} with raw`} /> | |
| 266 | + <Row k="Raw (uncompressed)" v={fmtBytes(raw)} /> | |
| 267 | + <Row k="Blob store" v={d.storage.blobs ? `${fmtInt(d.storage.blobs.files)} files · ${fmtBytes(blobBytes)}` : "—"} /> | |
| 268 | + <Row k="Compression" v={raw && blobBytes ? `${(raw / blobBytes).toFixed(1)}×` : "—"} /> | |
| 269 | + <Row k="Changes / events" v={`${fmtInt(d.storage.changes)} / ${fmtInt(d.storage.events)}`} /> | |
| 270 | + <Row k="Runs" v={fmtInt(d.storage.runs)} /> | |
| 271 | + </dl> | |
| 272 | + </Panel> | |
| 273 | + </div> | |
| 274 | + | |
| 275 | + <Panel title="Throughput · 24 h · hourly" dense> | |
| 276 | + {tp.length ? ( | |
| 277 | + <div className="grid grid-cols-1 gap-3 p-3 sm:grid-cols-3"> | |
| 278 | + <Spark label="Checks" values={tp.map((r) => r.checks)} tone="info" /> | |
| 279 | + <Spark label="Events" values={tp.map((r) => r.events)} tone="signal" /> | |
| 280 | + <Spark label="Errors" values={tp.map((r) => r.errors)} tone="hot" /> | |
| 281 | + </div> | |
| 282 | + ) : ( | |
| 283 | + <Empty>No sensor runs recorded in the last 24 h.</Empty> | |
| 284 | + )} | |
| 285 | + {tp.length > 0 && ( | |
| 286 | + <div className="max-h-56 overflow-auto border-t border-line"> | |
| 287 | + <Table head={["Hour (UTC)", "Checks", "304", "Errors", "Events", "Avg ms"]}> | |
| 288 | + {[...tp].reverse().map((r) => ( | |
| 289 | + <tr key={r.t}> | |
| 290 | + <Td mono>{r.t.slice(5, 16).replace("T", " ")}</Td> | |
| 291 | + <Td mono>{fmtInt(r.checks)}</Td> | |
| 292 | + <Td mono className="text-fg-subtle">{fmtInt(r.not_modified)}</Td> | |
| 293 | + <Td mono className={r.errors > 0 ? "text-danger" : ""}>{fmtInt(r.errors)}</Td> | |
| 294 | + <Td mono className="text-signal">{fmtInt(r.events)}</Td> | |
| 295 | + <Td mono>{fmtMs(r.avg_ms)}</Td> | |
| 296 | + </tr> | |
| 297 | + ))} | |
| 298 | + </Table> | |
| 299 | + </div> | |
| 300 | + )} | |
| 301 | + </Panel> | |
| 302 | + | |
| 303 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-3"> | |
| 304 | + <Panel title="Sensors by status / health" dense> | |
| 305 | + {byStatus.length ? ( | |
| 306 | + <Table head={["Status", "Sensors", "Health"]}> | |
| 307 | + {byStatus.map(([status, v]) => ( | |
| 308 | + <tr key={status}> | |
| 309 | + <Td mono>{status}</Td> | |
| 310 | + <Td mono>{fmtInt(v.n)}</Td> | |
| 311 | + <Td> | |
| 312 | + <div className="flex flex-wrap gap-1"> | |
| 313 | + {Object.entries(v.health).sort((a, b) => b[1] - a[1]).map(([h, n]) => ( | |
| 314 | + <span key={h} className="inline-flex items-center gap-1"><HealthPill health={h} /><span className="font-mono text-[11px] text-fg-subtle tabular">{fmtInt(n)}</span></span> | |
| 315 | + ))} | |
| 316 | + </div> | |
| 317 | + </Td> | |
| 318 | + </tr> | |
| 319 | + ))} | |
| 320 | + </Table> | |
| 321 | + ) : ( | |
| 322 | + <Empty /> | |
| 323 | + )} | |
| 324 | + </Panel> | |
| 325 | + <Panel title="LLM · 24 h" dense> | |
| 326 | + {d.llm_24h.length ? ( | |
| 327 | + <Table head={["Model", "Calls", "In", "Out", "Fail"]}> | |
| 328 | + {d.llm_24h.map((m) => ( | |
| 329 | + <tr key={m.model}> | |
| 330 | + <Td mono className="whitespace-nowrap">{m.model}</Td> | |
| 331 | + <Td mono>{fmtInt(m.calls)}</Td> | |
| 332 | + <Td mono>{fmtInt(Number(m.input_tokens))}</Td> | |
| 333 | + <Td mono>{fmtInt(Number(m.output_tokens))}</Td> | |
| 334 | + <Td mono className={m.failures ? "text-danger" : "text-fg-subtle"}>{fmtInt(m.failures)}</Td> | |
| 335 | + </tr> | |
| 336 | + ))} | |
| 337 | + </Table> | |
| 338 | + ) : ( | |
| 339 | + <Empty>No LLM calls in 24 h (heuristics only).</Empty> | |
| 340 | + )} | |
| 341 | + </Panel> | |
| 342 | + <Panel title="Live & cache" dense> | |
| 343 | + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2 text-[12.5px]"> | |
| 344 | + <Row k="WS clients" v={fmtInt(d.live.clients)} /> | |
| 345 | + <Row k="Published since start" v={fmtInt(d.live.published)} /> | |
| 346 | + <Row k="Cache entries" v={fmtInt(d.cache.entries)} /> | |
| 347 | + </dl> | |
| 348 | + <div className="max-h-32 overflow-auto border-t border-line px-3 py-2"> | |
| 349 | + <div className="flex flex-wrap gap-1">{d.cache.keys.slice(0, 40).map((k) => <Chip key={k} className="font-mono">{k}</Chip>)}</div> | |
| 350 | + </div> | |
| 351 | + </Panel> | |
| 352 | + </div> | |
| 353 | + | |
| 354 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-2"> | |
| 355 | + <Panel title="Top failing domains · 6 h" dense> | |
| 356 | + {d.top_failing_domains.length ? ( | |
| 357 | + <Table head={["Host", "Failures", "Sensors", "Last error", "Last"]}> | |
| 358 | + {d.top_failing_domains.map((f) => ( | |
| 359 | + <tr key={f.host}> | |
| 360 | + <Td mono><Link href={`/domain/${f.host}`} className="hover:underline">{f.host}</Link></Td> | |
| 361 | + <Td mono className="text-danger">{fmtInt(f.failures)}</Td> | |
| 362 | + <Td mono>{fmtInt(f.sensors)}</Td> | |
| 363 | + <Td className="max-w-[18rem] truncate font-mono text-[11px] text-fg-muted" ><span title={f.last_error ?? ""}>{f.last_error ?? "—"}</span></Td> | |
| 364 | + <Td mono className="whitespace-nowrap text-fg-subtle">{relTime(f.last_at)}</Td> | |
| 365 | + </tr> | |
| 366 | + ))} | |
| 367 | + </Table> | |
| 368 | + ) : ( | |
| 369 | + <Empty>No failures in the last 6 h.</Empty> | |
| 370 | + )} | |
| 371 | + </Panel> | |
| 372 | + <Panel title="Slowest sensors" dense> | |
| 373 | + {d.slowest_sensors.length ? ( | |
| 374 | + <Table head={["Sensor", "Source", "Connector", "Avg latency", "Runs"]}> | |
| 375 | + {d.slowest_sensors.map((s) => ( | |
| 376 | + <tr key={s.id}> | |
| 377 | + <Td><Link href={`/sensor/${s.id}`} className="hover:underline">{s.name}</Link><div className="max-w-[14rem] truncate font-mono text-[10.5px] text-fg-subtle">{s.url}</div></Td> | |
| 378 | + <Td><Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_id}</Link></Td> | |
| 379 | + <Td mono>{s.connector}</Td> | |
| 380 | + <Td mono className={s.avg_latency_ms > 10_000 ? "text-warn" : ""}>{fmtMs(s.avg_latency_ms)}</Td> | |
| 381 | + <Td mono>{fmtInt(s.total_runs)}</Td> | |
| 382 | + </tr> | |
| 383 | + ))} | |
| 384 | + </Table> | |
| 385 | + ) : ( | |
| 386 | + <Empty /> | |
| 387 | + )} | |
| 388 | + </Panel> | |
| 389 | + </div> | |
| 390 | + | |
| 391 | + <Panel title="Recent errors · 1 h" dense> | |
| 392 | + {d.recent_errors.length ? ( | |
| 393 | + <Table head={["At", "Sensor", "Source", "Connector", "HTTP", "Outcome", "Error"]}> | |
| 394 | + {d.recent_errors.map((r, i) => ( | |
| 395 | + <tr key={`${r.sensor_id}-${i}`}> | |
| 396 | + <Td mono className="whitespace-nowrap text-fg-subtle">{utcTime(r.started_at)}</Td> | |
| 397 | + <Td><Link href={`/sensor/${r.sensor_id}`} className="font-mono text-[11.5px] hover:underline">{r.sensor_id}</Link></Td> | |
| 398 | + <Td><Link href={`/source/${r.source_id}`} className="hover:underline">{r.source_id}</Link></Td> | |
| 399 | + <Td mono>{r.connector}</Td> | |
| 400 | + <Td mono>{r.http_status ?? "—"}</Td> | |
| 401 | + <Td mono className="text-danger">{r.outcome}</Td> | |
| 402 | + <Td className="max-w-[24rem] truncate font-mono text-[11px] text-fg-muted"><span title={r.error ?? ""}>{r.error ?? "—"}</span></Td> | |
| 403 | + </tr> | |
| 404 | + ))} | |
| 405 | + </Table> | |
| 406 | + ) : ( | |
| 407 | + <Empty>No errors in the last hour.</Empty> | |
| 408 | + )} | |
| 409 | + </Panel> | |
| 410 | + </> | |
| 411 | + ); | |
| 412 | +} | |
| 413 | + | |
| 414 | +function Row({ k, v, tone = "" }: { k: string; v: ReactNode; tone?: string }) { | |
| 415 | + return ( | |
| 416 | + <> | |
| 417 | + <dt className="text-fg-subtle">{k}</dt> | |
| 418 | + <dd className={`min-w-0 break-words text-right font-mono text-[12px] tabular ${tone}`}>{v}</dd> | |
| 419 | + </> | |
| 420 | + ); | |
| 421 | +} | |
| 422 | + | |
| 423 | +function Spark({ label, values, tone }: { label: string; values: number[]; tone: "signal" | "hot" | "info" }) { | |
| 424 | + const last = values[values.length - 1] ?? 0; | |
| 425 | + const max = Math.max(...values, 0); | |
| 426 | + return ( | |
| 427 | + <div className="rounded-md border border-line bg-panel-2 p-2"> | |
| 428 | + <div className="flex items-baseline justify-between"> | |
| 429 | + <span className="label">{label}</span> | |
| 430 | + <span className="font-mono text-[11px] text-fg-subtle tabular">last h {fmtInt(last)} · max {fmtInt(max)}</span> | |
| 431 | + </div> | |
| 432 | + <div className="mt-1 w-full overflow-hidden"> | |
| 433 | + <Sparkline values={values} width={360} height={40} tone={tone} /> | |
| 434 | + </div> | |
| 435 | + </div> | |
| 436 | + ); | |
| 437 | +} | |
| 438 | + | |
| 439 | +// --------------------------------------------------------------------------------------- | |
| 440 | +// Tools | |
| 441 | +// --------------------------------------------------------------------------------------- | |
| 442 | + | |
| 443 | +type Json = Record<string, unknown>; | |
| 444 | + | |
| 445 | +function Result({ r }: { r: { ok: boolean; body: Json | null; error?: string } | null }) { | |
| 446 | + if (!r) return null; | |
| 447 | + return ( | |
| 448 | + <div className={`rounded-md border px-2 py-1.5 text-[12px] ${r.ok ? "border-ok/40 bg-ok/10" : "border-danger/40 bg-danger/10"}`}> | |
| 449 | + {r.error && <div className="font-mono font-semibold text-danger">{r.error}</div>} | |
| 450 | + {r.body && <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-4 text-fg-muted">{JSON.stringify(r.body, null, 2)}</pre>} | |
| 451 | + </div> | |
| 452 | + ); | |
| 453 | +} | |
| 454 | + | |
| 455 | +function useAction() { | |
| 456 | + const [busy, setBusy] = useState(false); | |
| 457 | + const [res, setRes] = useState<{ ok: boolean; body: Json | null; error?: string } | null>(null); | |
| 458 | + const run = async (fn: () => Promise<Json>): Promise<Json | null> => { | |
| 459 | + setBusy(true); | |
| 460 | + setRes(null); | |
| 461 | + try { | |
| 462 | + const body = await fn(); | |
| 463 | + setRes({ ok: true, body }); | |
| 464 | + return body; | |
| 465 | + } catch (e) { | |
| 466 | + setRes({ ok: false, body: e instanceof AdminError ? e.body : null, error: gateMessage(e) }); | |
| 467 | + return null; | |
| 468 | + } finally { | |
| 469 | + setBusy(false); | |
| 470 | + } | |
| 471 | + }; | |
| 472 | + return { busy, res, run }; | |
| 473 | +} | |
| 474 | + | |
| 475 | +const input = "h-8 min-w-0 rounded-md border border-line bg-panel px-2 font-mono text-[12px]"; | |
| 476 | +const btn = "inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-2.5 text-[12px] hover:border-line-strong disabled:opacity-50"; | |
| 477 | + | |
| 478 | +function Tools({ connectors, onDone }: { connectors: Ops["connectors"]; onDone: () => void }) { | |
| 479 | + return ( | |
| 480 | + <> | |
| 481 | + <h2 className="label mt-2">Tools</h2> | |
| 482 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-2"> | |
| 483 | + <TestConnector connectors={connectors} /> | |
| 484 | + <SensorActions onDone={onDone} /> | |
| 485 | + <SourceAndBulk onDone={onDone} /> | |
| 486 | + <Import onDone={onDone} /> | |
| 487 | + </div> | |
| 488 | + </> | |
| 489 | + ); | |
| 490 | +} | |
| 491 | + | |
| 492 | +function TestConnector({ connectors }: { connectors: Ops["connectors"] }) { | |
| 493 | + const [f, setF] = useState({ url: "", sensor_id: "", connector: "http", type: "HTML", config: "{}" }); | |
| 494 | + const { busy, res, run } = useAction(); | |
| 495 | + const preview = res?.body?.normalized as Json | undefined; | |
| 496 | + const meta = res?.body?.meta as Json | undefined; | |
| 497 | + const submit = (): void => { | |
| 498 | + let config: Json = {}; | |
| 499 | + try { | |
| 500 | + config = f.config.trim() ? (JSON.parse(f.config) as Json) : {}; | |
| 501 | + } catch { | |
| 502 | + void run(() => Promise.reject(new Error("config is not valid JSON"))); | |
| 503 | + return; | |
| 504 | + } | |
| 505 | + void run(() => adminFetch<Json>("/api/v1/admin/sensors/test", { method: "POST", body: JSON.stringify({ ...(f.sensor_id.trim() ? { sensor_id: f.sensor_id.trim() } : { url: f.url.trim() }), connector: f.connector, type: f.type, config }) })); | |
| 506 | + }; | |
| 507 | + return ( | |
| 508 | + <Panel title="Test connector · dry run, nothing persisted"> | |
| 509 | + <form | |
| 510 | + className="flex flex-col gap-2 text-[12.5px]" | |
| 511 | + onSubmit={(e) => { | |
| 512 | + e.preventDefault(); | |
| 513 | + submit(); | |
| 514 | + }} | |
| 515 | + > | |
| 516 | + <input value={f.url} onChange={(e) => setF({ ...f, url: e.target.value })} placeholder="https://example.com/feed.xml" aria-label="URL" className={input} /> | |
| 517 | + <input value={f.sensor_id} onChange={(e) => setF({ ...f, sensor_id: e.target.value })} placeholder="… or an existing sensor id (overrides URL)" aria-label="Sensor id" className={input} /> | |
| 518 | + <div className="grid grid-cols-2 gap-2"> | |
| 519 | + <select value={f.connector} onChange={(e) => setF({ ...f, connector: e.target.value })} aria-label="Connector" className={input}> | |
| 520 | + {(connectors.length ? connectors.map((c) => c.key) : ["http"]).map((k) => ( | |
| 521 | + <option key={k} value={k}>{k}</option> | |
| 522 | + ))} | |
| 523 | + </select> | |
| 524 | + <select value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} aria-label="Sensor type" className={input}> | |
| 525 | + {SENSOR_TYPES.map((t) => ( | |
| 526 | + <option key={t} value={t}>{t}</option> | |
| 527 | + ))} | |
| 528 | + </select> | |
| 529 | + </div> | |
| 530 | + <textarea value={f.config} onChange={(e) => setF({ ...f, config: e.target.value })} rows={2} aria-label="Config JSON" placeholder='{"selector": "main"}' className="rounded-md border border-line bg-panel px-2 py-1 font-mono text-[12px]" /> | |
| 531 | + <button type="submit" disabled={busy || (!f.url.trim() && !f.sensor_id.trim())} className={btn}><Play className="size-3.5" /> {busy ? "Fetching…" : "Run test"}</button> | |
| 532 | + </form> | |
| 533 | + {res && ( | |
| 534 | + <div className="mt-2 flex flex-col gap-2 text-[12px]"> | |
| 535 | + <div className="flex flex-wrap items-center gap-2"> | |
| 536 | + <Chip tone={res.ok && res.body?.ok ? "ok" : "danger"} className="font-mono">{res.ok && res.body?.ok ? "OK" : `FAILED${typeof res.body?.stage === "string" ? ` · ${res.body.stage}` : ""}`}</Chip> | |
| 537 | + {meta && <span className="font-mono text-[11px] text-fg-subtle tabular">HTTP {String(meta.status ?? "—")} · {String(meta.contentType ?? "—")} · {fmtBytes(Number(meta.contentLength ?? 0))} · {String(res.body?.fetch_ms ?? "—")} ms · {String(meta.redirects ?? 0)} redirects</span>} | |
| 538 | + </div> | |
| 539 | + {preview && ( | |
| 540 | + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-md border border-line bg-panel-2 p-2 text-[12px]"> | |
| 541 | + <Row k="Mode" v={String(preview.mode ?? "—")} /> | |
| 542 | + <Row k="Title" v={String(preview.title ?? "—")} /> | |
| 543 | + <Row k="Items" v={preview.items === null || preview.items === undefined ? "—" : String(preview.items)} /> | |
| 544 | + <Row k="Extraction confidence" v={typeof preview.extractionConfidence === "number" ? `${Math.round(preview.extractionConfidence * 100)}%` : "—"} /> | |
| 545 | + <Row k="Canonical hash" v={String(preview.canonicalHash ?? "").slice(0, 16)} /> | |
| 546 | + <Row k="Published" v={String(preview.publishedAt ?? "—")} /> | |
| 547 | + </dl> | |
| 548 | + )} | |
| 549 | + {preview && typeof preview.text_preview === "string" && <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-line bg-panel-2 p-2 font-mono text-[11px] leading-4 text-fg-muted">{preview.text_preview}</pre>} | |
| 550 | + {preview && typeof preview.json_preview === "string" && <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-line bg-panel-2 p-2 font-mono text-[11px] leading-4 text-fg-muted">{preview.json_preview}</pre>} | |
| 551 | + {preview && Array.isArray(preview.sample_items) && preview.sample_items.length > 0 && <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-line bg-panel-2 p-2 font-mono text-[11px] leading-4 text-fg-muted">{JSON.stringify(preview.sample_items, null, 1)}</pre>} | |
| 552 | + {(!res.ok || !res.body?.ok) && <Result r={{ ok: false, body: (res.body?.error as Json | undefined) ?? res.body, error: res.error }} />} | |
| 553 | + </div> | |
| 554 | + )} | |
| 555 | + </Panel> | |
| 556 | + ); | |
| 557 | +} | |
| 558 | + | |
| 559 | +function SensorActions({ onDone }: { onDone: () => void }) { | |
| 560 | + const [id, setId] = useState(""); | |
| 561 | + const [tier, setTier] = useState(""); | |
| 562 | + const [priority, setPriority] = useState(""); | |
| 563 | + const [interval, setInterval_] = useState(""); | |
| 564 | + const { busy, res, run } = useAction(); | |
| 565 | + const act = (a: "run-now" | "enable" | "disable"): void => { | |
| 566 | + void run(() => adminFetch<Json>(`/api/v1/admin/sensors/${encodeURIComponent(id.trim())}/${a}`, { method: "POST" })).then(onDone); | |
| 567 | + }; | |
| 568 | + const patch = (): void => { | |
| 569 | + const body: Json = {}; | |
| 570 | + if (tier) body.tier = tier; | |
| 571 | + if (priority !== "") body.priority = Number(priority); | |
| 572 | + if (interval !== "") body.base_interval_seconds = Number(interval); | |
| 573 | + void run(() => adminFetch<Json>(`/api/v1/admin/sensors/${encodeURIComponent(id.trim())}`, { method: "PATCH", body: JSON.stringify(body) })).then(onDone); | |
| 574 | + }; | |
| 575 | + return ( | |
| 576 | + <Panel title="Sensor actions"> | |
| 577 | + <div className="flex flex-col gap-2 text-[12.5px]"> | |
| 578 | + <input value={id} onChange={(e) => setId(e.target.value)} placeholder="sensor id (e.g. openai_pricing)" aria-label="Sensor id" className={input} /> | |
| 579 | + <div className="flex flex-wrap gap-1"> | |
| 580 | + <button type="button" disabled={busy || !id.trim()} onClick={() => act("run-now")} className={btn}><Play className="size-3.5" /> run now</button> | |
| 581 | + <button type="button" disabled={busy || !id.trim()} onClick={() => act("enable")} className={btn}>enable</button> | |
| 582 | + <button type="button" disabled={busy || !id.trim()} onClick={() => act("disable")} className={`${btn} text-danger`}>disable</button> | |
| 583 | + <Link href={id.trim() ? `/sensor/${encodeURIComponent(id.trim())}` : "#"} className={`${btn} ${id.trim() ? "" : "pointer-events-none opacity-50"}`}>open →</Link> | |
| 584 | + </div> | |
| 585 | + <div className="grid grid-cols-3 gap-2"> | |
| 586 | + <select value={tier} onChange={(e) => setTier(e.target.value)} aria-label="Tier" className={input}> | |
| 587 | + <option value="">tier …</option> | |
| 588 | + {TIERS.map((t) => ( | |
| 589 | + <option key={t} value={t}>tier {t}</option> | |
| 590 | + ))} | |
| 591 | + </select> | |
| 592 | + <select value={priority} onChange={(e) => setPriority(e.target.value)} aria-label="Priority" className={input}> | |
| 593 | + <option value="">priority …</option> | |
| 594 | + {[0, 1, 2, 3].map((p) => ( | |
| 595 | + <option key={p} value={p}>P{p}</option> | |
| 596 | + ))} | |
| 597 | + </select> | |
| 598 | + <input value={interval} onChange={(e) => setInterval_(e.target.value)} inputMode="numeric" placeholder="interval s" aria-label="Base interval seconds" className={input} /> | |
| 599 | + </div> | |
| 600 | + <button type="button" disabled={busy || !id.trim() || (!tier && priority === "" && interval === "")} onClick={patch} className={btn}>apply patch</button> | |
| 601 | + <Result r={res} /> | |
| 602 | + </div> | |
| 603 | + </Panel> | |
| 604 | + ); | |
| 605 | +} | |
| 606 | + | |
| 607 | +function SourceAndBulk({ onDone }: { onDone: () => void }) { | |
| 608 | + const [sid, setSid] = useState(""); | |
| 609 | + const [ids, setIds] = useState(""); | |
| 610 | + const [action, setAction] = useState<"enable" | "disable" | "run-now">("run-now"); | |
| 611 | + const src = useAction(); | |
| 612 | + const bulk = useAction(); | |
| 613 | + const list = ids.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean); | |
| 614 | + return ( | |
| 615 | + <Panel title="Source actions & bulk"> | |
| 616 | + <div className="flex flex-col gap-2 text-[12.5px]"> | |
| 617 | + <input value={sid} onChange={(e) => setSid(e.target.value)} placeholder="source id (e.g. openai)" aria-label="Source id" className={input} /> | |
| 618 | + <div className="flex flex-wrap gap-1"> | |
| 619 | + {(["run-now", "enable", "disable"] as const).map((a) => ( | |
| 620 | + <button key={a} type="button" disabled={src.busy || !sid.trim()} onClick={() => void src.run(() => adminFetch<Json>(`/api/v1/admin/sources/${encodeURIComponent(sid.trim())}/${a}`, { method: "POST" })).then(onDone)} className={`${btn} ${a === "disable" ? "text-danger" : ""}`}> | |
| 621 | + {a === "run-now" && <Play className="size-3.5" />} {a} | |
| 622 | + </button> | |
| 623 | + ))} | |
| 624 | + <Link href={sid.trim() ? `/source/${encodeURIComponent(sid.trim())}` : "#"} className={`${btn} ${sid.trim() ? "" : "pointer-events-none opacity-50"}`}>open →</Link> | |
| 625 | + </div> | |
| 626 | + <Result r={src.res} /> | |
| 627 | + <div className="label mt-2">Bulk sensor action</div> | |
| 628 | + <textarea value={ids} onChange={(e) => setIds(e.target.value)} rows={3} placeholder="sensor ids, one per line or comma-separated" aria-label="Sensor ids" className="rounded-md border border-line bg-panel px-2 py-1 font-mono text-[12px]" /> | |
| 629 | + <div className="flex flex-wrap items-center gap-1"> | |
| 630 | + <select value={action} onChange={(e) => setAction(e.target.value as typeof action)} aria-label="Bulk action" className={input}> | |
| 631 | + <option value="run-now">run now</option> | |
| 632 | + <option value="enable">enable</option> | |
| 633 | + <option value="disable">disable</option> | |
| 634 | + </select> | |
| 635 | + <button type="button" disabled={bulk.busy || !list.length} onClick={() => void bulk.run(() => adminFetch<Json>("/api/v1/admin/sensors/bulk", { method: "POST", body: JSON.stringify({ ids: list, action }) })).then(onDone)} className={btn}> | |
| 636 | + apply to <span className="font-mono tabular">{list.length}</span> | |
| 637 | + </button> | |
| 638 | + </div> | |
| 639 | + <Result r={bulk.res} /> | |
| 640 | + </div> | |
| 641 | + </Panel> | |
| 642 | + ); | |
| 643 | +} | |
| 644 | + | |
| 645 | +interface ImportReport { | |
| 646 | + dry_run: boolean; | |
| 647 | + valid?: number; | |
| 648 | + invalid: number; | |
| 649 | + imported_sources?: number; | |
| 650 | + extended_sources?: number; | |
| 651 | + sensors?: number; | |
| 652 | + report: { id?: string; ok: boolean; extend: boolean; issues?: string[]; sensors?: number }[]; | |
| 653 | +} | |
| 654 | + | |
| 655 | +function Import({ onDone }: { onDone: () => void }) { | |
| 656 | + const [doc, setDoc] = useState(""); | |
| 657 | + const [report, setReport] = useState<ImportReport | null>(null); | |
| 658 | + const [checkedDoc, setCheckedDoc] = useState<string | null>(null); | |
| 659 | + const { busy, res, run } = useAction(); | |
| 660 | + const isJson = /^\s*[[{]/.test(doc); | |
| 661 | + const send = async (dry: boolean): Promise<void> => { | |
| 662 | + const body = await run(() => adminFetch<Json>(`/api/v1/admin/sources/import${dry ? "?dry_run=1" : ""}`, { method: "POST", headers: { "content-type": isJson ? "application/json" : "text/yaml" }, body: doc })); | |
| 663 | + if (body) { | |
| 664 | + setReport(body as unknown as ImportReport); | |
| 665 | + setCheckedDoc(dry ? doc : null); | |
| 666 | + if (!dry) onDone(); | |
| 667 | + } else setReport(null); | |
| 668 | + }; | |
| 669 | + const canImport = report?.dry_run === true && checkedDoc === doc && (report.valid ?? 0) > 0; | |
| 670 | + return ( | |
| 671 | + <Panel title="Import sources · YAML or JSON"> | |
| 672 | + <div className="flex flex-col gap-2 text-[12.5px]"> | |
| 673 | + <textarea value={doc} onChange={(e) => setDoc(e.target.value)} rows={7} spellCheck={false} aria-label="Registry document" placeholder={"sources:\n - id: example\n name: Example\n domain: example.com\n tier: C\n categories: [web]\n sensors:\n - name: home\n type: HTML\n url: https://example.com/"} className="rounded-md border border-line bg-panel px-2 py-1 font-mono text-[12px] leading-4" /> | |
| 674 | + <div className="flex flex-wrap items-center gap-1"> | |
| 675 | + <Chip className="font-mono">{isJson ? "application/json" : "text/yaml"}</Chip> | |
| 676 | + <button type="button" disabled={busy || !doc.trim()} onClick={() => void send(true)} className={btn}>dry run</button> | |
| 677 | + <button type="button" disabled={busy || !canImport} onClick={() => void send(false)} className={`${btn} ${canImport ? "border-signal/50 text-signal" : ""}`} title={canImport ? "Write the validated document" : "Run a successful dry run first"}>import</button> | |
| 678 | + <span className="text-[11px] text-fg-subtle">Same schema as <code>config/sources.d/*.yaml</code>; <code>extend: true</code> adds to an existing source.</span> | |
| 679 | + </div> | |
| 680 | + {res && !res.ok && <Result r={res} />} | |
| 681 | + {report && ( | |
| 682 | + <div> | |
| 683 | + <div className="mb-1 flex flex-wrap gap-1 text-[11.5px]"> | |
| 684 | + <Chip tone={report.dry_run ? "info" : "ok"} className="font-mono">{report.dry_run ? "DRY RUN" : "IMPORTED"}</Chip> | |
| 685 | + {report.valid !== undefined && <Chip tone="ok">{report.valid} valid</Chip>} | |
| 686 | + {report.invalid > 0 && <Chip tone="danger">{report.invalid} invalid</Chip>} | |
| 687 | + {report.imported_sources !== undefined && <Chip>{report.imported_sources} sources · {report.extended_sources ?? 0} extended · {report.sensors ?? 0} sensors</Chip>} | |
| 688 | + </div> | |
| 689 | + <div className="overflow-hidden rounded-md border border-line"> | |
| 690 | + <Table head={["Source", "Mode", "Result", "Sensors / issues"]}> | |
| 691 | + {report.report.map((r, i) => ( | |
| 692 | + <tr key={`${r.id ?? "?"}-${i}`}> | |
| 693 | + <Td mono>{r.id ?? <span className="text-fg-subtle">(no id)</span>}</Td> | |
| 694 | + <Td mono className="text-fg-subtle">{r.extend ? "extend" : "new"}</Td> | |
| 695 | + <Td>{r.ok ? <Chip tone="ok">OK</Chip> : <Chip tone="danger">FAIL</Chip>}</Td> | |
| 696 | + <Td className="text-[12px]">{r.ok ? <span className="font-mono">{r.sensors ?? 0} sensors</span> : <ul className="list-disc pl-4 text-danger">{(r.issues ?? []).map((x, j) => <li key={j} className="font-mono text-[11px]">{x}</li>)}</ul>}</Td> | |
| 697 | + </tr> | |
| 698 | + ))} | |
| 699 | + </Table> | |
| 700 | + </div> | |
| 701 | + </div> | |
| 702 | + )} | |
| 703 | + </div> | |
| 704 | + </Panel> | |
| 705 | + ); | |
| 706 | +} | |
added
apps/web/src/app/ops/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { PageHeader } from "@/components/ui"; | |
| 3 | +import { Ops } from "./ops"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { title: "Operations", description: "Internal WebSensor operations dashboard.", robots: { index: false, follow: false } }; | |
| 6 | + | |
| 7 | +export default function OpsPage() { | |
| 8 | + return ( | |
| 9 | + <> | |
| 10 | + <PageHeader compact kicker="Internal · requires the admin token" title="Operations" description="Engine heartbeat, queue, throughput, storage, LLM usage, failing domains and connector tools. Refreshes every 15 s. Not linked from public navigation." /> | |
| 11 | + <Ops /> | |
| 12 | + </> | |
| 13 | + ); | |
| 14 | +} | |
modified
apps/web/src/app/page.tsx
+35 −10
@@ -1,28 +1,53 @@ | ||
| 1 | +import Link from "next/link"; | |
| 1 | 2 | import { LiveFeed } from "@/components/live-feed"; |
| 2 | −import { ClustersPanel, TrendingPanel } from "@/components/rail"; | |
| 3 | −import { StatsStrip } from "@/components/stats-strip"; | |
| 3 | +import { LiveStrip } from "@/components/live-strip"; | |
| 4 | +import { AnomalyRail, BreakingRail, ClustersPanel, SilentRail, TrendingPanel } from "@/components/rail"; | |
| 5 | +import { EventRow } from "@/components/event-row"; | |
| 6 | +import { Empty, Panel } from "@/components/ui"; | |
| 4 | 7 | import { api } from "@/lib/api"; |
| 8 | +import { agoIso } from "@/lib/format"; | |
| 5 | 9 | |
| 6 | 10 | export const dynamic = "force-dynamic"; |
| 7 | 11 | |
| 8 | −export default async function LivePage() { | |
| 9 | − const [stats, events, trending, clusters] = await Promise.all([api.stats(), api.events({ limit: 60 }), api.trending(24, 10), api.clusters(20, 48)]); | |
| 12 | +/** | |
| 13 | + * Homepage (spec §64, §94): hero line · live system strip · live feed · right rail (breaking now, | |
| 14 | + * trending, anomalous) · lower area (clusters, silent changes, infrastructure pulse). One coherent | |
| 15 | + * data surface; the product sells the product. | |
| 16 | + */ | |
| 17 | +export default async function HomePage() { | |
| 18 | + const [stats, events, trending, clusters, breaking, explore, infra] = await Promise.all([ | |
| 19 | + api.stats(), | |
| 20 | + api.events({ limit: 60 }), | |
| 21 | + api.trending(24, 10), | |
| 22 | + api.clusters(30, 48), | |
| 23 | + api.breakingDesk(), | |
| 24 | + api.explore(), | |
| 25 | + api.events({ limit: 8, group: "reliability", after: agoIso(12 * 3600e3) }), | |
| 26 | + ]); | |
| 10 | 27 | return ( |
| 11 | 28 | <> |
| 12 | 29 | <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"> |
| 13 | − <h1 className="text-[15px] font-semibold tracking-tight"> | |
| 30 | + <h1 className="text-[16px] font-semibold tracking-tight"> | |
| 14 | 31 | The Web is changing. <span className="text-fg-muted">We are watching.</span> |
| 15 | 32 | </h1> |
| 16 | − <p className="text-[12px] text-fg-subtle">Official sources · conditional fetches · immutable evidence · importance-scored events</p> | |
| 33 | + <p className="text-[12px] text-fg-subtle">WebSensor continuously watches the public web for meaningful changes, preserving evidence and connecting signals into real-world events.</p> | |
| 17 | 34 | </div> |
| 18 | − <StatsStrip stats={stats} /> | |
| 19 | − <div className="grid gap-4 lg:grid-cols-[1fr_320px]"> | |
| 35 | + <LiveStrip initial={stats} /> | |
| 36 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_340px] [&>*]:min-w-0"> | |
| 20 | 37 | <LiveFeed initial={events.items} initialCursor={events.nextCursor} /> |
| 21 | − <aside className="hidden flex-col gap-4 lg:flex"> | |
| 38 | + <aside className="flex flex-col gap-4"> | |
| 39 | + <BreakingRail items={breaking?.breaking_now ?? []} developing={breaking?.developing ?? []} /> | |
| 22 | 40 | <TrendingPanel items={trending.items} /> |
| 23 | − <ClustersPanel items={clusters.items} /> | |
| 41 | + <AnomalyRail items={explore?.unusual_activity ?? []} /> | |
| 24 | 42 | </aside> |
| 25 | 43 | </div> |
| 44 | + <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3 [&>*]:min-w-0"> | |
| 45 | + <ClustersPanel items={clusters.items} title="Event clusters · 48 h" /> | |
| 46 | + <SilentRail items={explore?.silent_changes ?? []} /> | |
| 47 | + <Panel title="Infrastructure pulse · 12 h" dense action={<Link href="/live?group=reliability" className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}> | |
| 48 | + {infra.items.length ? infra.items.slice(0, 6).map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No outage, incident or maintenance signal in the last 12 h.</Empty>} | |
| 49 | + </Panel> | |
| 50 | + </div> | |
| 26 | 51 | </> |
| 27 | 52 | ); |
| 28 | 53 | } |
added
apps/web/src/app/pulse/loading.tsx
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3 flex flex-col gap-2"> | |
| 7 | + <Skeleton className="h-3 w-20" /> | |
| 8 | + <Skeleton className="h-6 w-24" /> | |
| 9 | + <Skeleton className="h-3 w-full max-w-2xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0"> | |
| 12 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 13 | + <div key={i} className="flex flex-col gap-1.5 px-3 py-2"> | |
| 14 | + <Skeleton className="h-2.5 w-20" /> | |
| 15 | + <Skeleton className="h-5 w-12" /> | |
| 16 | + <Skeleton className="h-2.5 w-16" /> | |
| 17 | + </div> | |
| 18 | + ))} | |
| 19 | + </div> | |
| 20 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_340px]"> | |
| 21 | + <div className="flex flex-col gap-4"> | |
| 22 | + <div className="panel p-3"> | |
| 23 | + <Skeleton className="mb-3 h-2.5 w-56" /> | |
| 24 | + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> | |
| 25 | + <Skeleton className="h-16 w-full" /> | |
| 26 | + <Skeleton className="h-16 w-full" /> | |
| 27 | + </div> | |
| 28 | + </div> | |
| 29 | + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> | |
| 30 | + {Array.from({ length: 4 }, (_, i) => ( | |
| 31 | + <div key={i} className="panel"> | |
| 32 | + <div className="border-b border-line px-3 py-2"> | |
| 33 | + <Skeleton className="h-2.5 w-32" /> | |
| 34 | + </div> | |
| 35 | + <SkeletonRows rows={4} /> | |
| 36 | + </div> | |
| 37 | + ))} | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + <aside className="flex flex-col gap-4"> | |
| 41 | + <SkeletonPanel lines={8} /> | |
| 42 | + <SkeletonPanel lines={6} /> | |
| 43 | + <SkeletonPanel lines={6} /> | |
| 44 | + </aside> | |
| 45 | + </div> | |
| 46 | + </> | |
| 47 | + ); | |
| 48 | +} | |
added
apps/web/src/app/pulse/page.tsx
+115 −0
@@ -0,0 +1,115 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { EventRow } from "@/components/event-row"; | |
| 4 | +import { BreakingRail } from "@/components/rail"; | |
| 5 | +import { Empty, PageHeader, Panel, Sparkline, Stat } from "@/components/ui"; | |
| 6 | +import { api } from "@/lib/api"; | |
| 7 | +import { fmtInt, GROUP_LABELS, relTime, CHANNELS } from "@/lib/format"; | |
| 8 | + | |
| 9 | +export const dynamic = "force-dynamic"; | |
| 10 | +export const metadata: Metadata = { title: "Pulse", description: "What is changing on the Internet right now: global activity, breaking events, fastest-rising entities, cyber incidents, AI developments, market and government events, infrastructure incidents, silent changes." }; | |
| 11 | + | |
| 12 | +/** Real-time summary of the Internet (spec §39). */ | |
| 13 | +export default async function PulsePage() { | |
| 14 | + const p = await api.pulse(); | |
| 15 | + if (!p) return <Empty>Pulse is warming up.</Empty>; | |
| 16 | + const activity = p.activity ?? []; | |
| 17 | + const evSeries = activity.map((a) => Number(a.events)); | |
| 18 | + const chSeries = activity.map((a) => Number(a.changes)); | |
| 19 | + const groups = Object.entries(p.by_group_24h ?? {}).sort((a, b) => b[1] - a[1]); | |
| 20 | + const maxG = Math.max(1, ...groups.map(([, n]) => n)); | |
| 21 | + const deskLabel = (d: string): string => CHANNELS.find((c) => c.key === d)?.label ?? d; | |
| 22 | + return ( | |
| 23 | + <> | |
| 24 | + <PageHeader compact kicker="Right now" title="Pulse" description="A live summary of what is changing on the public web — generated from real observations, refreshed every ten seconds." /> | |
| 25 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0"> | |
| 26 | + <Stat label="Events · 1 h" value={fmtInt(p.totals?.events_1h)} hint="meaningful signals" tone="signal" /> | |
| 27 | + <Stat label="Raw changes · 1 h" value={fmtInt(p.totals?.changes_1h)} hint="before noise filtering" /> | |
| 28 | + <Stat label="Checks · 5 min" value={fmtInt(p.totals?.checks_5m)} hint={`${Math.round(Number(p.totals?.checks_5m ?? 0) / 5)}/min`} /> | |
| 29 | + <Stat label="Active sources · 24 h" value={fmtInt(p.totals?.active_sources_24h)} /> | |
| 30 | + <Stat label="Countries · 24 h" value={fmtInt(p.totals?.active_countries_24h)} /> | |
| 31 | + <Stat label="Breaking / developing" value={`${p.breaking.filter((c) => c.state === "breaking").length} / ${p.breaking.filter((c) => c.state === "developing").length}`} tone="hot" /> | |
| 32 | + </div> | |
| 33 | + <div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_340px] [&>*]:min-w-0"> | |
| 34 | + <div className="flex flex-col gap-4"> | |
| 35 | + <Panel title="Global activity · last 6 h · 15-minute buckets"> | |
| 36 | + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 [&>*]:min-w-0"> | |
| 37 | + <div> | |
| 38 | + <div className="label mb-1">Events</div> | |
| 39 | + <Sparkline values={evSeries} width={420} height={56} tone="signal" responsive /> | |
| 40 | + </div> | |
| 41 | + <div> | |
| 42 | + <div className="label mb-1">Raw changes</div> | |
| 43 | + <Sparkline values={chSeries} width={420} height={56} tone="muted" responsive /> | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + <p className="mt-2 text-[11px] text-fg-subtle">The gap between raw changes and events is the noise WebSensor filtered out: timestamps, navigation churn, cookie banners, advertising, cosmetic edits.</p> | |
| 47 | + </Panel> | |
| 48 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 49 | + {(p.desks ?? []).filter((d) => d.items.length).map((d) => ( | |
| 50 | + <Panel key={d.desk} title={`${deskLabel(d.desk)} · 12 h`} dense action={<Link href={`/category/${d.desk}`} className="text-[11px] text-fg-subtle hover:text-fg">desk →</Link>}> | |
| 51 | + {d.items.map((e) => ( | |
| 52 | + <EventRow key={e.id} ev={e} /> | |
| 53 | + ))} | |
| 54 | + </Panel> | |
| 55 | + ))} | |
| 56 | + </div> | |
| 57 | + <Panel title="Infrastructure incidents · 6 h" dense action={<Link href="/live?group=reliability" className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}> | |
| 58 | + {p.infrastructure.length ? p.infrastructure.map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No outage, incident or maintenance signal in the last 6 h.</Empty>} | |
| 59 | + </Panel> | |
| 60 | + <Panel title="Silent changes · 24 h" dense action={<Link href="/silent" className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 61 | + {p.silent_changes.length ? p.silent_changes.map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No silent change in the last 24 h.</Empty>} | |
| 62 | + </Panel> | |
| 63 | + </div> | |
| 64 | + <aside className="flex flex-col gap-4"> | |
| 65 | + <BreakingRail items={p.breaking.filter((c) => c.state === "breaking")} developing={p.breaking.filter((c) => c.state === "developing")} /> | |
| 66 | + <Panel title="Fastest-rising entities · 3 h" dense> | |
| 67 | + {p.rising_entities.length ? ( | |
| 68 | + <ol className="divide-y divide-line"> | |
| 69 | + {p.rising_entities.map((r, i) => ( | |
| 70 | + <li key={r.id} className="grid grid-cols-[1.25rem_1fr_auto] items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 71 | + <span className="font-mono text-[11px] text-fg-subtle tabular">{i + 1}</span> | |
| 72 | + <div className="min-w-0"> | |
| 73 | + <Link href={`/entity/${r.id}`} className="block truncate font-medium hover:underline">{r.name}</Link> | |
| 74 | + <div className="text-[11px] text-fg-subtle">{r.events_3h} signals in 3 h · {r.events_prev_24h} in the prior 24 h</div> | |
| 75 | + </div> | |
| 76 | + <span className="font-mono text-[12px] font-semibold text-signal tabular">×{r.acceleration}</span> | |
| 77 | + </li> | |
| 78 | + ))} | |
| 79 | + </ol> | |
| 80 | + ) : ( | |
| 81 | + <Empty>No entity is accelerating right now.</Empty> | |
| 82 | + )} | |
| 83 | + </Panel> | |
| 84 | + <Panel title="Anomalous sources · 2 h" dense> | |
| 85 | + {p.anomalies.length ? ( | |
| 86 | + <ul className="divide-y divide-line"> | |
| 87 | + {p.anomalies.map((s) => ( | |
| 88 | + <li key={s.id} className="flex items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 89 | + <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate hover:underline">{s.name}</Link> | |
| 90 | + <span className="text-[11px] text-fg-subtle">{s.changes_2h} vs {s.baseline_per_day}/d</span> | |
| 91 | + <span className={`font-mono text-[12px] font-semibold tabular ${s.activity_score >= 70 ? "text-hot" : "text-high"}`}>{s.pct_vs_baseline !== null && s.pct_vs_baseline !== undefined ? `+${Math.min(9999, s.pct_vs_baseline)}%` : Math.round(s.activity_score)}</span> | |
| 92 | + </li> | |
| 93 | + ))} | |
| 94 | + </ul> | |
| 95 | + ) : ( | |
| 96 | + <Empty>All sources within baseline.</Empty> | |
| 97 | + )} | |
| 98 | + </Panel> | |
| 99 | + <Panel title="Events by group · 24 h"> | |
| 100 | + <ul className="space-y-1.5"> | |
| 101 | + {groups.map(([g, n]) => ( | |
| 102 | + <li key={g} className="grid grid-cols-[8rem_1fr_3rem] items-center gap-2 text-[12.5px]"> | |
| 103 | + <Link href={`/live?group=${g}`} className="truncate hover:underline">{GROUP_LABELS[g] ?? g}</Link> | |
| 104 | + <div className="h-1 w-full overflow-hidden rounded-full bg-panel-2"><div className="h-full rounded-full bg-info" style={{ width: `${(n / maxG) * 100}%` }} /></div> | |
| 105 | + <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(n)}</span> | |
| 106 | + </li> | |
| 107 | + ))} | |
| 108 | + </ul> | |
| 109 | + </Panel> | |
| 110 | + <p className="text-[11px] text-fg-subtle">Generated {relTime(p.generated_at)} · all values come from stored observations; nothing is estimated.</p> | |
| 111 | + </aside> | |
| 112 | + </div> | |
| 113 | + </> | |
| 114 | + ); | |
| 115 | +} | |
added
apps/web/src/app/radar/loading.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +import { Skeleton, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3 flex flex-col gap-2"> | |
| 7 | + <Skeleton className="h-4 w-32" /> | |
| 8 | + <Skeleton className="h-6 w-24" /> | |
| 9 | + <Skeleton className="h-3 w-full max-w-3xl" /> | |
| 10 | + <Skeleton className="h-3 w-2/3 max-w-2xl" /> | |
| 11 | + </div> | |
| 12 | + <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3"> | |
| 13 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 14 | + <div key={i} className="panel"> | |
| 15 | + <div className="border-b border-line px-3 py-2"> | |
| 16 | + <Skeleton className="h-2.5 w-40" /> | |
| 17 | + </div> | |
| 18 | + <SkeletonRows rows={4} /> | |
| 19 | + </div> | |
| 20 | + ))} | |
| 21 | + </div> | |
| 22 | + <div className="mt-4 panel"> | |
| 23 | + <div className="border-b border-line px-3 py-2"> | |
| 24 | + <Skeleton className="h-2.5 w-64" /> | |
| 25 | + </div> | |
| 26 | + <SkeletonRows rows={5} /> | |
| 27 | + </div> | |
| 28 | + </> | |
| 29 | + ); | |
| 30 | +} | |
added
apps/web/src/app/radar/page.tsx
+127 −0
@@ -0,0 +1,127 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { EventRow } from "@/components/event-row"; | |
| 4 | +import { Chip, Empty, PageHeader, Panel, Score, StateLabelText } from "@/components/ui"; | |
| 5 | +import { api } from "@/lib/api"; | |
| 6 | +import { relTime, typeLabel } from "@/lib/format"; | |
| 7 | + | |
| 8 | +export const dynamic = "force-dynamic"; | |
| 9 | +export const metadata: Metadata = { title: "Radar", description: "Weak signals that are not yet breaking: unusual source activity, clusters of silent changes, documentation and repository bursts, fresh status changes." }; | |
| 10 | + | |
| 11 | +/** Radar (spec §101): indicators, not facts. */ | |
| 12 | +export default async function RadarPage() { | |
| 13 | + const r = await api.radar(); | |
| 14 | + if (!r) return <Empty>Radar is warming up.</Empty>; | |
| 15 | + return ( | |
| 16 | + <> | |
| 17 | + <PageHeader compact kicker={<Chip tone="warn">Indicators, not facts</Chip>} title="Radar" description="What might become important next. Each item is a pattern in raw observations — a source far above its baseline, several silent changes around one entity, a burst of documentation or repository edits, a fresh status change — that has not (yet) produced a high-importance event." /> | |
| 18 | + <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 [&>*]:min-w-0"> | |
| 19 | + <Panel title="Unusual source activity · 3 h" dense> | |
| 20 | + {r.unusual_source_activity.length ? ( | |
| 21 | + <ul className="divide-y divide-line"> | |
| 22 | + {r.unusual_source_activity.map((s) => ( | |
| 23 | + <li key={s.id} className="px-3 py-2 text-[13px]"> | |
| 24 | + <div className="flex items-center gap-2"> | |
| 25 | + <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link> | |
| 26 | + <span className="font-mono text-[12px] font-semibold text-high tabular">×{s.ratio}</span> | |
| 27 | + </div> | |
| 28 | + <div className="text-[11px] text-fg-subtle">{s.changes_3h} raw changes in 3 h · baseline {s.baseline_per_day}/day · no high-importance event yet</div> | |
| 29 | + <div className="mt-1 flex flex-wrap gap-1">{(s.categories ?? []).slice(0, 3).map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}</div> | |
| 30 | + </li> | |
| 31 | + ))} | |
| 32 | + </ul> | |
| 33 | + ) : ( | |
| 34 | + <Empty>No source is quietly bursting above its baseline.</Empty> | |
| 35 | + )} | |
| 36 | + </Panel> | |
| 37 | + <Panel title="Silent-change clusters · 24 h" dense> | |
| 38 | + {r.silent_clusters.length ? ( | |
| 39 | + <ul className="divide-y divide-line"> | |
| 40 | + {r.silent_clusters.map((e) => ( | |
| 41 | + <li key={e.id} className="px-3 py-2 text-[13px]"> | |
| 42 | + <div className="flex items-center gap-2"> | |
| 43 | + <Link href={`/entity/${e.id}?tab=silent`} className="min-w-0 flex-1 truncate font-medium hover:underline">{e.name}</Link> | |
| 44 | + <span className="font-mono text-[12px] font-semibold text-silent tabular">{e.silent_24h} silent</span> | |
| 45 | + </div> | |
| 46 | + <div className="mt-1 flex flex-wrap gap-1">{e.types.slice(0, 4).map((t) => <Chip key={t} tone="silent">{typeLabel(t)}</Chip>)}<span className="text-[11px] text-fg-subtle">{relTime(e.last_at)}</span></div> | |
| 47 | + </li> | |
| 48 | + ))} | |
| 49 | + </ul> | |
| 50 | + ) : ( | |
| 51 | + <Empty>No entity accumulated several silent changes.</Empty> | |
| 52 | + )} | |
| 53 | + </Panel> | |
| 54 | + <Panel title="Developing clusters" dense> | |
| 55 | + {r.developing.length ? ( | |
| 56 | + <ul className="divide-y divide-line"> | |
| 57 | + {r.developing.map((c) => ( | |
| 58 | + <li key={c.id} className="flex items-start gap-2 px-3 py-2 text-[13px]"> | |
| 59 | + <Score value={c.max_importance} size="sm" /> | |
| 60 | + <div className="min-w-0"> | |
| 61 | + <Link href={`/cluster/${c.slug ?? c.id}`} className="line-clamp-2 font-medium hover:underline">{c.title}</Link> | |
| 62 | + <div className="flex flex-wrap gap-x-2 text-[11px] text-fg-subtle"><StateLabelText state={c.state} /><span>{c.event_count} signals · {c.source_count} sources · velocity {Math.round(c.velocity ?? 0)}</span><span>{relTime(c.last_at)}</span></div> | |
| 63 | + </div> | |
| 64 | + </li> | |
| 65 | + ))} | |
| 66 | + </ul> | |
| 67 | + ) : ( | |
| 68 | + <Empty>No developing cluster.</Empty> | |
| 69 | + )} | |
| 70 | + </Panel> | |
| 71 | + <Panel title="Documentation & API bursts · 6 h" dense> | |
| 72 | + {r.documentation_bursts.length ? ( | |
| 73 | + <ul className="divide-y divide-line"> | |
| 74 | + {r.documentation_bursts.map((s) => ( | |
| 75 | + <li key={s.id} className="px-3 py-2 text-[13px]"> | |
| 76 | + <div className="flex items-center gap-2"> | |
| 77 | + <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link> | |
| 78 | + <span className="font-mono text-[12px] tabular">{s.doc_changes_6h} edits</span> | |
| 79 | + </div> | |
| 80 | + <div className="mt-1 flex flex-wrap gap-1">{s.types.map((t) => <Chip key={t}>{typeLabel(t)}</Chip>)}<span className="text-[11px] text-fg-subtle">{relTime(s.last_at)}</span></div> | |
| 81 | + </li> | |
| 82 | + ))} | |
| 83 | + </ul> | |
| 84 | + ) : ( | |
| 85 | + <Empty>No documentation burst. A launch often starts with several doc/API edits in a short window.</Empty> | |
| 86 | + )} | |
| 87 | + </Panel> | |
| 88 | + <Panel title="Repository bursts · 6 h" dense> | |
| 89 | + {r.repository_bursts.length ? ( | |
| 90 | + <ul className="divide-y divide-line"> | |
| 91 | + {r.repository_bursts.map((s) => ( | |
| 92 | + <li key={s.id} className="flex items-center gap-2 px-3 py-2 text-[13px]"> | |
| 93 | + <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link> | |
| 94 | + <span className="font-mono text-[12px] tabular">{s.repo_events_6h} releases/commits</span> | |
| 95 | + <span className="text-[11px] text-fg-subtle">{relTime(s.last_at)}</span> | |
| 96 | + </li> | |
| 97 | + ))} | |
| 98 | + </ul> | |
| 99 | + ) : ( | |
| 100 | + <Empty>No repository burst.</Empty> | |
| 101 | + )} | |
| 102 | + </Panel> | |
| 103 | + <Panel title="New coverage lighting up · 24 h" dense> | |
| 104 | + {r.new_coverage.length ? ( | |
| 105 | + <ul className="divide-y divide-line"> | |
| 106 | + {r.new_coverage.map((s) => ( | |
| 107 | + <li key={s.id} className="flex items-center gap-2 px-3 py-2 text-[13px]"> | |
| 108 | + <div className="min-w-0 flex-1"> | |
| 109 | + <Link href={`/sensor/${s.id}`} className="block truncate font-medium hover:underline">{s.name}</Link> | |
| 110 | + <div className="truncate text-[11px] text-fg-subtle">{s.source_name} · first event {relTime(s.first_event_at)}</div> | |
| 111 | + </div> | |
| 112 | + <span className="font-mono text-[12px] tabular">{s.events}</span> | |
| 113 | + </li> | |
| 114 | + ))} | |
| 115 | + </ul> | |
| 116 | + ) : ( | |
| 117 | + <Empty>No sensor produced its first event today.</Empty> | |
| 118 | + )} | |
| 119 | + </Panel> | |
| 120 | + </div> | |
| 121 | + <Panel title="Fresh status changes below the breaking bar · 3 h" dense className="mt-4"> | |
| 122 | + {r.status_changes.length ? r.status_changes.map((e) => <EventRow key={e.id} ev={e} />) : <Empty>No fresh status-page change.</Empty>} | |
| 123 | + </Panel> | |
| 124 | + <p className="mt-3 text-[11px] text-fg-subtle">{r.disclaimer} Generated {relTime(r.generated_at)}.</p> | |
| 125 | + </> | |
| 126 | + ); | |
| 127 | +} | |
modified
apps/web/src/app/search/page.tsx
+134 −56
@@ -1,74 +1,152 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { EventRow } from "@/components/event-row"; |
| 4 | −import { Chip, Empty, PageHeader, Panel } from "@/components/ui"; | |
| 4 | +import { Badge, Chip, Empty, Flag, Kbd, PageHeader, Panel, Score, StateBadge, TierBadge } from "@/components/ui"; | |
| 5 | 5 | import { api } from "@/lib/api"; |
| 6 | −import { relTime } from "@/lib/format"; | |
| 6 | +import { relTime, typeLabel, utcDateTime } from "@/lib/format"; | |
| 7 | 7 | |
| 8 | 8 | export const dynamic = "force-dynamic"; |
| 9 | 9 | export const metadata: Metadata = { title: "Search", robots: { index: false } }; |
| 10 | 10 | |
| 11 | +const SYNTAX: { key: string; example: string; help: string }[] = [ | |
| 12 | + { key: "entity", example: "entity:openai", help: "events linked to an entity" }, | |
| 13 | + { key: "type", example: "type:pricing_change", help: "event type" }, | |
| 14 | + { key: "after", example: "after:7d", help: "detected after (7d, 24h, 2026-09-01)" }, | |
| 15 | + { key: "silent", example: "silent:true", help: "silent changes only" }, | |
| 16 | + { key: "importance", example: "importance:>70", help: "minimum importance" }, | |
| 17 | + { key: "country", example: "country:CA", help: "source country" }, | |
| 18 | + { key: "first_party", example: "first_party:true", help: "first-party evidence only" }, | |
| 19 | +]; | |
| 20 | + | |
| 21 | +function fmtFilter(k: string, v: unknown): string { | |
| 22 | + if (v === null || v === undefined) return ""; | |
| 23 | + if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}T/.test(v)) return utcDateTime(v); | |
| 24 | + if (Array.isArray(v)) return v.join(", "); | |
| 25 | + return String(v); | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** Search (spec §30): full-text + structured filters, results across events, clusters, entities, sources and URLs. */ | |
| 11 | 29 | export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) { |
| 12 | 30 | const { q = "" } = await searchParams; |
| 13 | − const r = q.trim().length >= 2 ? await api.search(q.trim()) : null; | |
| 31 | + const query = q.trim(); | |
| 32 | + const r = query.length >= 2 ? await api.search(query) : null; | |
| 33 | + const filters = Object.entries(r?.parsed?.filters ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== ""); | |
| 34 | + const text = r?.parsed?.text?.trim() ?? ""; | |
| 35 | + const clusters = r?.clusters ?? []; | |
| 36 | + const total = r ? r.events.length + clusters.length + r.entities.length + r.sources.length + r.urls.length : 0; | |
| 14 | 37 | return ( |
| 15 | 38 | <> |
| 16 | − <PageHeader kicker="Search everything" title={q ? <span>Results for “{q}”</span> : "Search"} description="Events, entities, sources, URLs and domains. Full-text search over titles, summaries and keywords." /> | |
| 17 | − <form action="/search" className="mb-4 flex gap-2"> | |
| 18 | − <input name="q" defaultValue={q} autoFocus placeholder="OpenAI, CVE-2026, pricing, status.…" className="h-9 w-full max-w-xl rounded-md border border-line bg-panel px-3 text-[13.5px] placeholder:text-fg-subtle" /> | |
| 19 | − <button type="submit" className="h-9 rounded-md border border-line bg-panel-2 px-3 text-[13px]">Search</button> | |
| 39 | + <PageHeader compact kicker="Search everything" title={query ? <span>Results for “{query}”</span> : "Search"} description="Events, clusters, entities, sources and URLs. Full-text over titles, summaries and keywords; structured filters narrow the event set." /> | |
| 40 | + <form action="/search" className="mb-2 flex gap-2"> | |
| 41 | + <input name="q" defaultValue={q} autoFocus placeholder="OpenAI, CVE-2026, pricing, status.…" aria-label="Search" className="h-9 w-full min-w-0 max-w-2xl rounded-md border border-line bg-panel px-3 font-mono text-[13px] placeholder:text-fg-subtle" /> | |
| 42 | + <button type="submit" className="h-9 rounded-md border border-line bg-panel-2 px-3 text-[13px] hover:border-line-strong">Search</button> | |
| 20 | 43 | </form> |
| 44 | + <div className="mb-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-fg-subtle"> | |
| 45 | + <span>Syntax</span> | |
| 46 | + {SYNTAX.map((s) => ( | |
| 47 | + <Link key={s.key} href={`/search?q=${encodeURIComponent(`${query ? query + " " : ""}${s.example}`)}`} title={s.help} className="rounded-sm border border-line bg-panel-2 px-1.5 py-px font-mono text-[10.5px] text-fg-muted hover:text-fg">{s.example}</Link> | |
| 48 | + ))} | |
| 49 | + <span className="hidden items-center gap-1 sm:inline-flex"><Kbd>⌘K</Kbd> anywhere</span> | |
| 50 | + </div> | |
| 51 | + | |
| 21 | 52 | {r && ( |
| 22 | − <div className="grid gap-4 lg:grid-cols-[1fr_320px]"> | |
| 23 | − <Panel title={`Events · ${r.events.length}`} dense> | |
| 24 | − {r.events.length ? r.events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No events match.</Empty>} | |
| 25 | − </Panel> | |
| 26 | − <aside className="flex flex-col gap-4"> | |
| 27 | − <Panel title={`Entities · ${r.entities.length}`} dense> | |
| 28 | − {r.entities.length ? ( | |
| 29 | − <ul className="divide-y divide-line"> | |
| 30 | − {r.entities.map((e) => ( | |
| 31 | − <li key={e.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]"> | |
| 32 | − <Link href={`/company/${e.id}`} className="hover:underline">{e.name}</Link> | |
| 33 | − <Chip>{e.type}</Chip> | |
| 34 | − </li> | |
| 35 | − ))} | |
| 36 | − </ul> | |
| 37 | − ) : ( | |
| 38 | − <Empty>—</Empty> | |
| 39 | − )} | |
| 40 | − </Panel> | |
| 41 | − <Panel title={`Sources · ${r.sources.length}`} dense> | |
| 42 | − {r.sources.length ? ( | |
| 43 | − <ul className="divide-y divide-line"> | |
| 44 | − {r.sources.map((s) => ( | |
| 45 | − <li key={s.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]"> | |
| 46 | − <Link href={`/source/${s.id}`} className="hover:underline">{s.name}</Link> | |
| 47 | − <Link href={`/domain/${s.domain}`} className="font-mono text-[11px] text-fg-subtle hover:underline">{s.domain}</Link> | |
| 48 | − </li> | |
| 49 | − ))} | |
| 50 | − </ul> | |
| 51 | − ) : ( | |
| 52 | − <Empty>—</Empty> | |
| 53 | − )} | |
| 54 | − </Panel> | |
| 55 | − <Panel title={`URLs · ${r.urls.length}`} dense> | |
| 56 | − {r.urls.length ? ( | |
| 57 | − <ul className="divide-y divide-line"> | |
| 58 | − {r.urls.map((u) => ( | |
| 59 | − <li key={u.url} className="px-3 py-1.5 text-[12px]"> | |
| 60 | − <Link href={`/url?u=${encodeURIComponent(u.url)}`} className="block truncate font-mono hover:underline">{u.url}</Link> | |
| 61 | − <span className="text-[11px] text-fg-subtle">{u.change_count} changes · {relTime(u.last_seen_at)}</span> | |
| 62 | − </li> | |
| 63 | − ))} | |
| 64 | − </ul> | |
| 65 | − ) : ( | |
| 66 | − <Empty>—</Empty> | |
| 67 | − )} | |
| 68 | − </Panel> | |
| 69 | − </aside> | |
| 70 | − </div> | |
| 53 | + <> | |
| 54 | + <div className="mb-4 flex flex-wrap items-center gap-1.5 text-[12px]"> | |
| 55 | + <span className="text-fg-subtle">{total} result{total === 1 ? "" : "s"}</span> | |
| 56 | + {text && <Chip tone="default" title="Full-text term">“{text}”</Chip>} | |
| 57 | + {filters.map(([k, v]) => ( | |
| 58 | + <Chip key={k} tone="info" className="font-mono" title={`Filter ${k}`}>{k}: {fmtFilter(k, v)}</Chip> | |
| 59 | + ))} | |
| 60 | + {filters.length > 0 && <Link href={`/search?q=${encodeURIComponent(text)}`} className="text-[11px] text-fg-subtle hover:text-fg">clear filters</Link>} | |
| 61 | + </div> | |
| 62 | + <div className="grid gap-4 xl:grid-cols-[1fr_360px]"> | |
| 63 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 64 | + <Panel title={`Events · ${r.events.length}`} dense action={r.events.length ? <Link href={`/live?q=${encodeURIComponent(text || query)}`} className="text-[11px] text-fg-subtle hover:text-fg">live →</Link> : undefined}> | |
| 65 | + {r.events.length ? r.events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No event matches{filters.length ? " these filters" : ""}. Try a broader term, remove a filter, or extend the window with <span className="font-mono">after:30d</span>.</Empty>} | |
| 66 | + </Panel> | |
| 67 | + <Panel title={`Clusters · ${clusters.length}`} dense> | |
| 68 | + {clusters.length ? ( | |
| 69 | + <ul className="divide-y divide-line"> | |
| 70 | + {clusters.map((c) => ( | |
| 71 | + <li key={c.id} className="grid grid-cols-[auto_1fr] items-start gap-x-3 px-3 py-2 text-[13px]"> | |
| 72 | + <Score value={c.max_importance} size="sm" /> | |
| 73 | + <div className="min-w-0"> | |
| 74 | + <div className="flex flex-wrap items-center gap-x-2 text-[11px] text-fg-subtle"> | |
| 75 | + <StateBadge state={c.state} /> | |
| 76 | + <span>{c.event_count} signal{c.event_count === 1 ? "" : "s"} · {c.source_count} source{c.source_count === 1 ? "" : "s"}</span> | |
| 77 | + <span className="ml-auto font-mono tabular">{relTime(c.last_at)}</span> | |
| 78 | + </div> | |
| 79 | + <Link href={`/cluster/${c.slug ?? c.id}`} className="mt-0.5 block font-medium leading-snug hover:underline">{c.title}</Link> | |
| 80 | + </div> | |
| 81 | + </li> | |
| 82 | + ))} | |
| 83 | + </ul> | |
| 84 | + ) : ( | |
| 85 | + <Empty>No cluster matches. Clusters group related signals across sources.</Empty> | |
| 86 | + )} | |
| 87 | + </Panel> | |
| 88 | + </div> | |
| 89 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 90 | + <Panel title={`Entities · ${r.entities.length}`} dense> | |
| 91 | + {r.entities.length ? ( | |
| 92 | + <ul className="divide-y divide-line"> | |
| 93 | + {r.entities.map((e) => ( | |
| 94 | + <li key={e.id} className="flex items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 95 | + <div className="min-w-0 flex-1"> | |
| 96 | + <Link href={`/entity/${e.id}`} className="block truncate font-medium hover:underline">{e.name}</Link> | |
| 97 | + <div className="truncate font-mono text-[10.5px] text-fg-subtle">{e.domain ?? e.id}{e.event_count ? ` · ${e.event_count} ev` : ""}</div> | |
| 98 | + </div> | |
| 99 | + <Chip>{e.type.replace(/_/g, " ")}</Chip> | |
| 100 | + </li> | |
| 101 | + ))} | |
| 102 | + </ul> | |
| 103 | + ) : ( | |
| 104 | + <Empty>No entity matches.</Empty> | |
| 105 | + )} | |
| 106 | + </Panel> | |
| 107 | + <Panel title={`Sources · ${r.sources.length}`} dense> | |
| 108 | + {r.sources.length ? ( | |
| 109 | + <ul className="divide-y divide-line"> | |
| 110 | + {r.sources.map((s) => ( | |
| 111 | + <li key={s.id} className="flex items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 112 | + <TierBadge tier={s.tier} /> | |
| 113 | + <div className="min-w-0 flex-1"> | |
| 114 | + <Link href={`/source/${s.id}`} className="block truncate font-medium hover:underline">{s.name}</Link> | |
| 115 | + <Link href={`/domain/${s.domain}`} className="block truncate font-mono text-[10.5px] text-fg-subtle hover:underline">{s.domain}</Link> | |
| 116 | + </div> | |
| 117 | + {s.country && <Flag code={s.country} className="text-[12px]" />} | |
| 118 | + {s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />} | |
| 119 | + </li> | |
| 120 | + ))} | |
| 121 | + </ul> | |
| 122 | + ) : ( | |
| 123 | + <Empty>No source matches.</Empty> | |
| 124 | + )} | |
| 125 | + </Panel> | |
| 126 | + <Panel title={`URLs · ${r.urls.length}`} dense> | |
| 127 | + {r.urls.length ? ( | |
| 128 | + <ul className="divide-y divide-line"> | |
| 129 | + {r.urls.map((u) => ( | |
| 130 | + <li key={u.url} className="px-3 py-1.5 text-[12px]"> | |
| 131 | + <Link href={`/url?u=${encodeURIComponent(u.url)}`} className="block truncate font-mono hover:underline" title={u.url}>{u.url}</Link> | |
| 132 | + <span className="flex items-center gap-2 text-[11px] text-fg-subtle"> | |
| 133 | + <span>{u.change_count} change{u.change_count === 1 ? "" : "s"}</span> | |
| 134 | + <span>·</span> | |
| 135 | + <span>{relTime(u.last_seen_at)}</span> | |
| 136 | + {u.status && u.status !== "active" && <Chip tone="warn" className="font-mono">{typeLabel(u.status)}</Chip>} | |
| 137 | + </span> | |
| 138 | + </li> | |
| 139 | + ))} | |
| 140 | + </ul> | |
| 141 | + ) : ( | |
| 142 | + <Empty>No monitored URL matches.</Empty> | |
| 143 | + )} | |
| 144 | + </Panel> | |
| 145 | + </aside> | |
| 146 | + </div> | |
| 147 | + </> | |
| 71 | 148 | )} |
| 149 | + {!r && query.length > 0 && query.length < 2 && <Empty>Type at least two characters.</Empty>} | |
| 72 | 150 | </> |
| 73 | 151 | ); |
| 74 | 152 | } |
added
apps/web/src/app/sensor/[id]/loading.tsx
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3"> | |
| 7 | + <Skeleton className="mb-1 h-2.5 w-64" /> | |
| 8 | + <Skeleton className="h-5 w-40" /> | |
| 9 | + <Skeleton className="mt-2 h-3 w-full max-w-lg" /> | |
| 10 | + </div> | |
| 11 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-line sm:grid-cols-4 lg:grid-cols-8"> | |
| 12 | + {Array.from({ length: 8 }, (_, i) => ( | |
| 13 | + <div key={i} className="flex flex-col gap-1.5 px-3 py-2"> | |
| 14 | + <Skeleton className="h-2.5 w-16" /> | |
| 15 | + <Skeleton className="h-5 w-12" /> | |
| 16 | + <Skeleton className="h-2.5 w-20" /> | |
| 17 | + </div> | |
| 18 | + ))} | |
| 19 | + </div> | |
| 20 | + <div className="grid gap-4 xl:grid-cols-[1fr_380px]"> | |
| 21 | + <div className="flex flex-col gap-4"> | |
| 22 | + <div className="panel"> | |
| 23 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-56" /></div> | |
| 24 | + <SkeletonRows rows={10} /> | |
| 25 | + </div> | |
| 26 | + <div className="panel"> | |
| 27 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-24" /></div> | |
| 28 | + <SkeletonRows rows={5} /> | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + <div className="flex flex-col gap-4"> | |
| 32 | + <div className="panel"> | |
| 33 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-20" /></div> | |
| 34 | + <SkeletonRows rows={6} /> | |
| 35 | + </div> | |
| 36 | + <SkeletonPanel lines={5} /> | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + </> | |
| 40 | + ); | |
| 41 | +} | |
modified
apps/web/src/app/sensor/[id]/page.tsx
+165 −73
@@ -1,9 +1,12 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { notFound } from "next/navigation"; |
| 4 | +import { PRIORITY_LABELS } from "@websensor/core/client"; | |
| 5 | +import { EventRow } from "@/components/event-row"; | |
| 6 | +import { FieldChangeInline } from "@/components/field-changes"; | |
| 4 | 7 | import { Chip, Empty, ExtLink, HealthPill, PageHeader, Panel, Stat, Table, Td, TierBadge } from "@/components/ui"; |
| 5 | −import { api } from "@/lib/api"; | |
| 6 | −import { fmtBytes, fmtDuration, fmtInt, fmtMs, relTime, shortHash, untilTime, utcDateTime } from "@/lib/format"; | |
| 8 | +import { api, type SnapshotRow } from "@/lib/api"; | |
| 9 | +import { CLASS_LABELS, dayHeader, fmtBytes, fmtDuration, fmtInt, fmtMs, relTime, shortHash, untilTime, utcDate, utcDateTime, utcTime } from "@/lib/format"; | |
| 7 | 10 | |
| 8 | 11 | export const dynamic = "force-dynamic"; |
| 9 | 12 | |
@@ -13,93 +16,182 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin | ||
| 13 | 16 | return { title: d ? `${d.sensor.source_name} · ${d.sensor.name} — sensor` : "Sensor not found", robots: { index: false } }; |
| 14 | 17 | } |
| 15 | 18 | |
| 19 | +/** Sensor page with historical memory (spec §79): how this page looked over time. */ | |
| 16 | 20 | export default async function SensorPage({ params }: { params: Promise<{ id: string }> }) { |
| 17 | 21 | const { id } = await params; |
| 18 | 22 | const d = await api.sensor(id); |
| 19 | 23 | if (!d) notFound(); |
| 20 | 24 | const s = d.sensor; |
| 25 | + const snaps = await api.sensorSnapshots(s.id, 200); | |
| 26 | + const history = snaps.items.length ? snaps.items : d.snapshots; | |
| 21 | 27 | const runs = s.total_runs ?? 0; |
| 28 | + const checks24 = s.checks_24h ?? 0; | |
| 29 | + const nm24 = checks24 ? Math.round(((s.not_modified_24h ?? 0) / checks24) * 100) : null; | |
| 30 | + const nmAll = runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : null; | |
| 31 | + const events = d.events ?? []; | |
| 32 | + const prio = s.priority ?? null; | |
| 33 | + // Group snapshots by UTC day (newest first, API order). | |
| 34 | + const days: { day: string; rows: SnapshotRow[] }[] = []; | |
| 35 | + for (const sn of history) { | |
| 36 | + const day = utcDate(sn.captured_at); | |
| 37 | + const last = days[days.length - 1]; | |
| 38 | + if (last && last.day === day) last.rows.push(sn); | |
| 39 | + else days.push({ day, rows: [sn] }); | |
| 40 | + } | |
| 41 | + const changeCount = history.filter((x) => x.has_change).length; | |
| 22 | 42 | return ( |
| 23 | 43 | <> |
| 24 | 44 | <PageHeader |
| 25 | − kicker={<span className="flex items-center gap-2"><Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_name}</Link> · <span className="font-mono">{s.type}</span> · {s.connector} · <TierBadge tier={s.tier} /></span>} | |
| 45 | + compact | |
| 46 | + kicker={ | |
| 47 | + <span className="flex flex-wrap items-center gap-2"> | |
| 48 | + <Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_name}</Link> | |
| 49 | + <span className="text-fg-subtle">/</span> | |
| 50 | + <span className="font-mono">{s.type}</span> | |
| 51 | + <span className="text-fg-subtle">·</span> | |
| 52 | + <span className="font-mono text-fg-muted">{s.connector}</span> | |
| 53 | + <TierBadge tier={s.tier} /> | |
| 54 | + {prio !== null && <Chip tone={prio === 0 ? "hot" : prio === 1 ? "high" : "default"} className="font-mono" title={PRIORITY_LABELS[prio] ?? `Priority ${prio}`}>P{prio}</Chip>} | |
| 55 | + {s.status && <HealthPill health={s.enabled ? s.status : "DISABLED"} />} | |
| 56 | + <HealthPill health={s.enabled ? s.health : "DISABLED"} /> | |
| 57 | + </span> | |
| 58 | + } | |
| 26 | 59 | title={s.name} |
| 27 | 60 | description={<ExtLink href={s.url} className="font-mono text-[12px] break-all">{s.url}</ExtLink>} |
| 28 | − actions={<><HealthPill health={s.enabled ? s.health : "DISABLED"} /><Link href={`/url?u=${encodeURIComponent(s.url)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">URL history →</Link></>} | |
| 61 | + actions={ | |
| 62 | + <> | |
| 63 | + <Link href={`/url?u=${encodeURIComponent(s.url)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">URL history →</Link> | |
| 64 | + {s.domain && <Link href={`/domain/${s.domain}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Domain →</Link>} | |
| 65 | + </> | |
| 66 | + } | |
| 29 | 67 | /> |
| 68 | + | |
| 30 | 69 | <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 lg:grid-cols-8 lg:divide-y-0"> |
| 31 | − <Stat label="Runs" value={fmtInt(runs)} hint={`${runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : 0}% 304`} /> | |
| 32 | − <Stat label="Raw changes" value={fmtInt(s.raw_changes)} /> | |
| 33 | − <Stat label="Meaningful" value={fmtInt(s.meaningful_changes)} hint={s.raw_changes ? `noise ${Math.round((1 - (s.meaningful_changes ?? 0) / Math.max(1, s.raw_changes)) * 100)}%` : undefined} /> | |
| 34 | − <Stat label="Latency" value={fmtMs(s.avg_latency_ms)} /> | |
| 70 | + <Stat label="Checks · 24 h" value={fmtInt(checks24)} hint={`${fmtInt(runs)} total`} /> | |
| 71 | + <Stat label="304 · 24 h" value={nm24 !== null ? `${nm24}%` : "—"} hint={nmAll !== null ? `${nmAll}% all-time` : s.has_etag || s.has_last_modified ? "validators exposed" : "no validators"} /> | |
| 72 | + <Stat label="Errors · 24 h" value={fmtInt(s.errors_24h ?? 0)} tone={(s.errors_24h ?? 0) > 0 ? "warn" : undefined} hint={s.consecutive_errors ? <span className="text-danger">{s.consecutive_errors} consecutive</span> : "0 consecutive"} /> | |
| 73 | + <Stat label="Avg latency" value={fmtMs(s.avg_ms_24h ?? s.avg_latency_ms)} hint={s.avg_ms_24h !== null && s.avg_ms_24h !== undefined ? "last 24 h" : "all-time"} /> | |
| 74 | + <Stat label="Raw / meaningful" value={<span>{fmtInt(s.raw_changes)}<span className="text-fg-subtle"> / </span><span className="text-signal">{fmtInt(s.meaningful_changes)}</span></span>} hint={s.raw_changes ? `noise ${Math.round((1 - (s.meaningful_changes ?? 0) / Math.max(1, s.raw_changes)) * 100)}%` : undefined} /> | |
| 75 | + <Stat label="Snapshots" value={fmtInt(history.length)} hint={`${changeCount} with a change`} /> | |
| 35 | 76 | <Stat label="Last check" value={s.last_check_at ? relTime(s.last_check_at) : "never"} hint={s.last_status ? `HTTP ${s.last_status}` : undefined} /> |
| 36 | − <Stat label="Next check" value={untilTime(s.next_check_at)} hint={s.base_interval_seconds ? `base ${fmtDuration(s.base_interval_seconds)}` : "adaptive"} /> | |
| 37 | − <Stat label="Last change" value={s.last_change_at ? relTime(s.last_change_at) : "—"} /> | |
| 38 | − <Stat label="Errors" value={fmtInt(s.consecutive_errors)} hint={s.last_error ? <span className="text-danger">{s.last_error}</span> : "consecutive"} /> | |
| 77 | + <Stat label="Next check" value={untilTime(s.next_check_at)} hint={s.current_interval_seconds ? `every ${fmtDuration(s.current_interval_seconds)}` : s.base_interval_seconds ? `base ${fmtDuration(s.base_interval_seconds)}` : "adaptive"} /> | |
| 39 | 78 | </div> |
| 40 | − <div className="grid gap-4 lg:grid-cols-2"> | |
| 41 | − <Panel title={`Changes · ${d.changes.length}`} dense> | |
| 42 | − {d.changes.length ? ( | |
| 43 | − <Table head={["Detected", "Kind", "Signal", "Noise", "Magnitude", "Heuristic", "Event", "Compare"]}> | |
| 44 | − {d.changes.map((c) => ( | |
| 45 | − <tr key={c.id}> | |
| 46 | − <Td mono className="text-fg-subtle">{utcDateTime(c.detected_at)}</Td> | |
| 47 | − <Td mono>{c.kind}</Td> | |
| 48 | − <Td mono className={c.signal >= 0.32 ? "text-signal" : "text-fg-subtle"}>{c.signal.toFixed(2)}</Td> | |
| 49 | − <Td mono>{c.noise_ratio !== undefined ? `${Math.round(c.noise_ratio * 100)}%` : "—"}</Td> | |
| 50 | − <Td mono>{c.magnitude ?? "—"}</Td> | |
| 51 | − <Td>{c.heuristic_type ? <Chip>{c.heuristic_type.replace(/_/g, " ")}</Chip> : "—"}</Td> | |
| 52 | − <Td>{c.event_id ? <Link href={`/event/${c.event_id}`} className="text-info hover:underline">event</Link> : <span className="text-fg-subtle">{c.meaningful ? "—" : "filtered"}</span>}</Td> | |
| 53 | − <Td>{c.old_snapshot_id && c.new_snapshot_id ? <Link href={`/compare?a=${c.old_snapshot_id}&b=${c.new_snapshot_id}`} className="text-info hover:underline">diff</Link> : "—"}</Td> | |
| 54 | − </tr> | |
| 55 | − ))} | |
| 56 | − </Table> | |
| 57 | − ) : ( | |
| 58 | − <Empty>No changes detected yet.</Empty> | |
| 59 | − )} | |
| 60 | − </Panel> | |
| 61 | − <Panel title={`Snapshots · ${d.snapshots.length}`} dense> | |
| 62 | − {d.snapshots.length ? ( | |
| 63 | − <Table head={["Captured", "HTTP", "Type", "Size", "Canonical hash", "Confidence", ""]}> | |
| 64 | − {d.snapshots.map((sn, i) => ( | |
| 65 | − <tr key={sn.id}> | |
| 66 | − <Td mono className="text-fg-subtle">{utcDateTime(sn.captured_at)}</Td> | |
| 67 | − <Td mono>{sn.http_status ?? "—"}</Td> | |
| 68 | − <Td mono className="text-fg-subtle">{(sn.content_type ?? sn.mode ?? "").split(";")[0]}</Td> | |
| 69 | − <Td mono>{fmtBytes(sn.content_length)}</Td> | |
| 70 | − <Td mono className="text-fg-subtle">{shortHash(sn.canonical_hash)}</Td> | |
| 71 | − <Td mono>{sn.extraction_confidence !== null && sn.extraction_confidence !== undefined ? sn.extraction_confidence.toFixed(2) : "—"}</Td> | |
| 72 | − <Td> | |
| 73 | − <a href={`/api/v1/snapshots/${sn.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">raw</a> | |
| 74 | − {d.snapshots[i + 1] && <> · <Link href={`/compare?a=${d.snapshots[i + 1]!.id}&b=${sn.id}`} className="text-info hover:underline">vs previous</Link></>} | |
| 75 | − </Td> | |
| 76 | − </tr> | |
| 77 | − ))} | |
| 78 | − </Table> | |
| 79 | − ) : ( | |
| 80 | − <Empty>No snapshots yet.</Empty> | |
| 81 | − )} | |
| 82 | − </Panel> | |
| 79 | + {s.last_error && ( | |
| 80 | + <div className="mb-4 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 font-mono text-[12px] text-danger break-words"> | |
| 81 | + last error · {s.last_error} | |
| 82 | + </div> | |
| 83 | + )} | |
| 84 | + | |
| 85 | + <div className="grid gap-4 xl:grid-cols-[1fr_380px]"> | |
| 86 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 87 | + <Panel title={`How this page looked over time · ${history.length} snapshot${history.length === 1 ? "" : "s"}`} dense action={<span className="hidden text-[11px] text-fg-subtle sm:inline">raw bodies may be pruned by retention · canonical form and hashes are kept</span>}> | |
| 88 | + {history.length === 0 ? ( | |
| 89 | + <Empty>No snapshot yet — the first successful check stores the initial state of this page.</Empty> | |
| 90 | + ) : ( | |
| 91 | + <div className="overflow-x-auto"> | |
| 92 | + <table className="w-full text-[12.5px]"> | |
| 93 | + <thead> | |
| 94 | + <tr className="border-b border-line text-left"> | |
| 95 | + {["Captured", "HTTP", "Size", "Canonical hash", "Change", "Event", "Raw", "Compare"].map((h) => ( | |
| 96 | + <th key={h} className="label whitespace-nowrap px-3 py-2 font-semibold">{h}</th> | |
| 97 | + ))} | |
| 98 | + </tr> | |
| 99 | + </thead> | |
| 100 | + {days.map((g) => ( | |
| 101 | + <tbody key={g.day} className="divide-y divide-line border-b border-line"> | |
| 102 | + <tr className="bg-panel-2/50"> | |
| 103 | + <td colSpan={8} className="px-3 py-1 font-mono text-[10.5px] font-semibold tracking-wider text-fg-subtle">{dayHeader(g.rows[0]!.captured_at)} <span className="font-normal">· {g.rows.length} capture{g.rows.length === 1 ? "" : "s"}</span></td> | |
| 104 | + </tr> | |
| 105 | + {g.rows.map((sn) => { | |
| 106 | + const idx = history.indexOf(sn); | |
| 107 | + const prev = history[idx + 1]; | |
| 108 | + return ( | |
| 109 | + <tr key={sn.id} className={`hover:bg-panel-2/60 ${sn.has_change ? "border-l-2 border-l-signal/60" : "border-l-2 border-l-transparent"}`}> | |
| 110 | + <Td mono className="whitespace-nowrap"><span title={utcDateTime(sn.captured_at)}>{utcTime(sn.captured_at)}</span> <span className="text-fg-subtle">{relTime(sn.captured_at)}</span></Td> | |
| 111 | + <Td mono className={sn.http_status && sn.http_status >= 400 ? "text-warn" : "text-fg-muted"}>{sn.http_status ?? "—"}</Td> | |
| 112 | + <Td mono className="whitespace-nowrap">{fmtBytes(sn.content_length)}</Td> | |
| 113 | + <Td mono className="text-fg-subtle"><span title={sn.canonical_hash ?? undefined}>{shortHash(sn.canonical_hash)}</span>{sn.mode ? <span className="ml-1.5 text-[10.5px]">{sn.mode}</span> : null}</Td> | |
| 114 | + <Td>{sn.has_change ? <Chip tone="signal" className="font-mono">CHANGED</Chip> : <span className="text-fg-subtle">—</span>}</Td> | |
| 115 | + <Td>{sn.event_slug ? <Link href={`/event/${sn.event_slug}`} className="text-info hover:underline">event →</Link> : <span className="text-fg-subtle">—</span>}</Td> | |
| 116 | + <Td> | |
| 117 | + {sn.has_raw === false ? ( | |
| 118 | + <span className="cursor-not-allowed text-fg-subtle line-through" title="raw body pruned by retention">view raw</span> | |
| 119 | + ) : ( | |
| 120 | + <a href={`/api/v1/snapshots/${sn.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="text-info hover:underline">view raw</a> | |
| 121 | + )} | |
| 122 | + </Td> | |
| 123 | + <Td>{prev ? <Link href={`/compare?a=${prev.id}&b=${sn.id}`} className="whitespace-nowrap text-info hover:underline">compare with previous</Link> : <span className="text-fg-subtle">first capture</span>}</Td> | |
| 124 | + </tr> | |
| 125 | + ); | |
| 126 | + })} | |
| 127 | + </tbody> | |
| 128 | + ))} | |
| 129 | + </table> | |
| 130 | + </div> | |
| 131 | + )} | |
| 132 | + </Panel> | |
| 133 | + | |
| 134 | + <Panel title={`Changes · ${d.changes.length}`} dense> | |
| 135 | + {d.changes.length ? ( | |
| 136 | + <Table head={["Detected", "Kind", "Class", "Signal", "Meaningful", "What changed", "Links"]}> | |
| 137 | + {d.changes.map((c) => ( | |
| 138 | + <tr key={c.id} className="hover:bg-panel-2/60"> | |
| 139 | + <Td mono className="whitespace-nowrap text-fg-subtle"><span title={utcDateTime(c.detected_at)}>{utcDate(c.detected_at)} {utcTime(c.detected_at, false)}</span></Td> | |
| 140 | + <Td mono>{c.kind}</Td> | |
| 141 | + <Td>{c.change_class ? <Chip tone={c.change_class === "pricing" || c.change_class === "policy" ? "high" : c.change_class === "meaningful" || c.change_class === "product" || c.change_class === "personnel" ? "signal" : "default"}>{CLASS_LABELS[c.change_class] ?? c.change_class}</Chip> : c.heuristic_type ? <Chip>{c.heuristic_type.replace(/_/g, " ")}</Chip> : <span className="text-fg-subtle">—</span>}</Td> | |
| 142 | + <Td mono className={c.signal >= 0.32 ? "text-signal" : "text-fg-subtle"}>{c.signal.toFixed(2)}{c.noise_ratio !== undefined && c.noise_ratio > 0 ? <span className="ml-1 text-[10.5px] text-fg-subtle">noise {Math.round(c.noise_ratio * 100)}%</span> : null}</Td> | |
| 143 | + <Td>{c.meaningful ? <Chip tone="ok" className="font-mono">YES</Chip> : <span className="font-mono text-[11px] text-fg-subtle">filtered</span>}</Td> | |
| 144 | + <Td className="min-w-[14rem]">{c.field_changes?.length ? <FieldChangeInline items={c.field_changes} max={2} /> : c.magnitude !== undefined && c.magnitude !== null ? <span className="font-mono text-[11px] text-fg-subtle">magnitude {c.magnitude}</span> : <span className="text-fg-subtle">—</span>}</Td> | |
| 145 | + <Td className="whitespace-nowrap"> | |
| 146 | + {c.event_id && <Link href={`/event/${c.event_id}`} className="text-info hover:underline">event</Link>} | |
| 147 | + {c.event_id && c.old_snapshot_id && c.new_snapshot_id && <span className="text-fg-subtle"> · </span>} | |
| 148 | + {c.old_snapshot_id && c.new_snapshot_id && <Link href={`/compare?a=${c.old_snapshot_id}&b=${c.new_snapshot_id}`} className="text-info hover:underline">diff</Link>} | |
| 149 | + {!c.event_id && !(c.old_snapshot_id && c.new_snapshot_id) && <span className="text-fg-subtle">—</span>} | |
| 150 | + </Td> | |
| 151 | + </tr> | |
| 152 | + ))} | |
| 153 | + </Table> | |
| 154 | + ) : ( | |
| 155 | + <Empty>No change detected yet.</Empty> | |
| 156 | + )} | |
| 157 | + </Panel> | |
| 158 | + | |
| 159 | + <Panel title={`Runs · ${d.runs.length}`} dense> | |
| 160 | + {d.runs.length ? ( | |
| 161 | + <Table head={["Started", "Outcome", "HTTP", "Duration", "Bytes", "Method", "Error"]}> | |
| 162 | + {d.runs.map((r) => ( | |
| 163 | + <tr key={r.id} className="hover:bg-panel-2/60"> | |
| 164 | + <Td mono className="whitespace-nowrap text-fg-subtle">{utcDateTime(r.started_at)}</Td> | |
| 165 | + <Td><Chip tone={r.outcome === "event" ? "signal" : r.outcome === "changed" ? "info" : r.outcome === "error" || r.outcome === "parse_error" ? "danger" : r.outcome === "rate_limited" || r.outcome === "missing" ? "warn" : "default"}>{r.outcome}</Chip></Td> | |
| 166 | + <Td mono>{r.http_status ?? "—"}</Td> | |
| 167 | + <Td mono>{fmtMs(r.duration_ms)}</Td> | |
| 168 | + <Td mono>{fmtBytes(r.bytes)}</Td> | |
| 169 | + <Td mono className="text-fg-subtle">{r.fetch_method ?? "—"}</Td> | |
| 170 | + <Td className="max-w-[24rem] truncate text-danger" >{r.error ?? ""}</Td> | |
| 171 | + </tr> | |
| 172 | + ))} | |
| 173 | + </Table> | |
| 174 | + ) : ( | |
| 175 | + <Empty>No run recorded in the retained window.</Empty> | |
| 176 | + )} | |
| 177 | + </Panel> | |
| 178 | + </div> | |
| 179 | + | |
| 180 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 181 | + <Panel title={`Events · ${events.length}`} dense action={<Link href={`/source/${s.source_id}`} className="text-[11px] text-fg-subtle hover:text-fg">source →</Link>}> | |
| 182 | + {events.length ? events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No meaningful event from this sensor yet — raw changes that are noise never become events.</Empty>} | |
| 183 | + </Panel> | |
| 184 | + <Panel title="Conditional requests"> | |
| 185 | + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]"> | |
| 186 | + <dt className="text-fg-subtle">ETag</dt><dd className="min-w-0 truncate font-mono text-[11.5px]" title={s.etag ?? undefined}>{s.etag ?? <span className="text-fg-subtle">not exposed</span>}</dd> | |
| 187 | + <dt className="text-fg-subtle">Last-Modified</dt><dd className="min-w-0 truncate font-mono text-[11.5px]" title={s.last_modified ?? undefined}>{s.last_modified ?? <span className="text-fg-subtle">not exposed</span>}</dd> | |
| 188 | + <dt className="text-fg-subtle">Last change</dt><dd className="font-mono text-[11.5px]">{s.last_change_at ? relTime(s.last_change_at) : "—"}</dd> | |
| 189 | + <dt className="text-fg-subtle">Last event</dt><dd className="font-mono text-[11.5px]">{s.last_event_at ? relTime(s.last_event_at) : "—"}</dd> | |
| 190 | + <dt className="text-fg-subtle">Validated</dt><dd className="font-mono text-[11.5px]">{s.validated_at ? relTime(s.validated_at) : "—"}</dd> | |
| 191 | + </dl> | |
| 192 | + </Panel> | |
| 193 | + </aside> | |
| 83 | 194 | </div> |
| 84 | − <Panel title={`Runs · ${d.runs.length}`} dense className="mt-4"> | |
| 85 | − {d.runs.length ? ( | |
| 86 | − <Table head={["Started", "Outcome", "HTTP", "Duration", "Bytes", "Method", "Error"]}> | |
| 87 | − {d.runs.map((r) => ( | |
| 88 | − <tr key={r.id}> | |
| 89 | − <Td mono className="text-fg-subtle">{utcDateTime(r.started_at)}</Td> | |
| 90 | − <Td><Chip tone={r.outcome === "event" ? "signal" : r.outcome === "changed" ? "info" : r.outcome === "error" || r.outcome === "parse_error" ? "danger" : r.outcome === "rate_limited" || r.outcome === "missing" ? "warn" : "default"}>{r.outcome}</Chip></Td> | |
| 91 | − <Td mono>{r.http_status ?? "—"}</Td> | |
| 92 | − <Td mono>{fmtMs(r.duration_ms)}</Td> | |
| 93 | − <Td mono>{fmtBytes(r.bytes)}</Td> | |
| 94 | − <Td mono className="text-fg-subtle">{r.fetch_method ?? "—"}</Td> | |
| 95 | − <Td className="max-w-[24rem] truncate text-danger" >{r.error ?? ""}</Td> | |
| 96 | − </tr> | |
| 97 | − ))} | |
| 98 | − </Table> | |
| 99 | − ) : ( | |
| 100 | − <Empty>No runs recorded yet.</Empty> | |
| 101 | − )} | |
| 102 | − </Panel> | |
| 103 | 195 | </> |
| 104 | 196 | ); |
| 105 | 197 | } |
added
apps/web/src/app/silent/loading.tsx
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +import { Skeleton, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-4 flex flex-col gap-2"> | |
| 7 | + <Skeleton className="h-3 w-48" /> | |
| 8 | + <Skeleton className="h-7 w-52" /> | |
| 9 | + <Skeleton className="h-3 w-full max-w-2xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="panel mb-4"> | |
| 12 | + <div className="border-b border-line px-3 py-2"> | |
| 13 | + <Skeleton className="h-2.5 w-36" /> | |
| 14 | + </div> | |
| 15 | + <ul className="grid grid-cols-1 gap-px bg-line md:grid-cols-2 xl:grid-cols-3"> | |
| 16 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 17 | + <li key={i} className="flex flex-col gap-1.5 bg-panel px-3 py-2.5"> | |
| 18 | + <div className="flex items-center gap-2"> | |
| 19 | + <Skeleton className="h-3 w-20" /> | |
| 20 | + <Skeleton className="h-4 w-12" /> | |
| 21 | + <Skeleton className="ml-auto h-3 w-10" /> | |
| 22 | + </div> | |
| 23 | + <Skeleton className={`h-3.5 ${i % 2 ? "w-4/5" : "w-11/12"}`} /> | |
| 24 | + <Skeleton className="h-2.5 w-2/3" /> | |
| 25 | + </li> | |
| 26 | + ))} | |
| 27 | + </ul> | |
| 28 | + </div> | |
| 29 | + <div className="panel overflow-hidden"> | |
| 30 | + <div className="flex items-center gap-3 border-b border-line px-3 py-2"> | |
| 31 | + <Skeleton className="h-3 w-44" /> | |
| 32 | + <Skeleton className="h-3 w-12" /> | |
| 33 | + </div> | |
| 34 | + <SkeletonRows rows={10} /> | |
| 35 | + </div> | |
| 36 | + </> | |
| 37 | + ); | |
| 38 | +} | |
modified
apps/web/src/app/silent/page.tsx
+44 −5
@@ -1,17 +1,56 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | +import Link from "next/link"; | |
| 3 | +import { FieldChanges } from "@/components/field-changes"; | |
| 2 | 4 | import { LiveFeed } from "@/components/live-feed"; |
| 3 | −import { PageHeader } from "@/components/ui"; | |
| 5 | +import { Badge, Chip, Empty, Flag, PageHeader, Panel, Score } from "@/components/ui"; | |
| 4 | 6 | import { api } from "@/lib/api"; |
| 7 | +import { agoIso, relTime, typeLabel, utcDateTime } from "@/lib/format"; | |
| 5 | 8 | |
| 6 | 9 | export const dynamic = "force-dynamic"; |
| 7 | −export const metadata: Metadata = { title: "Silent changes", description: "Important changes detected without a corresponding public announcement." }; | |
| 10 | +export const metadata: Metadata = { title: "Silent changes", description: "Pricing, terms, API limits, documentation and product pages that changed quietly — no announcement matched. Previous state, current state, exact diff." }; | |
| 8 | 11 | |
| 12 | +/** Dedicated silent-change experience (spec §23). */ | |
| 9 | 13 | export default async function SilentPage() { |
| 10 | − const events = await api.events({ silent_change: true, limit: 60 }); | |
| 14 | + const [top, recent, count24] = await Promise.all([api.events({ silent_change: true, limit: 12, order: "signal", after: agoIso(48 * 3600e3) }), api.events({ silent_change: true, limit: 60 }), api.eventCount({ silent_change: true, after: agoIso(24 * 3600e3) })]); | |
| 15 | + const byType = new Map<string, number>(); | |
| 16 | + for (const e of recent.items) byType.set(e.event_type, (byType.get(e.event_type) ?? 0) + 1); | |
| 11 | 17 | return ( |
| 12 | 18 | <> |
| 13 | − <PageHeader kicker="Flagship signal" title={<span>⚠ Silent changes</span>} description="Pricing, terms, API limits, documentation or product pages that changed quietly — no announcement matched within the observation window. Each item links to the exact diff and both preserved snapshots." /> | |
| 14 | − <LiveFeed initial={events.items} initialCursor={events.nextCursor} fixed="silent" title="SILENT CHANGES" /> | |
| 19 | + <PageHeader | |
| 20 | + kicker={<span className="flex items-center gap-2"><Badge kind="silent" /> <span className="text-fg-subtle">{count24.count} in the last 24 h · first-party pages only · importance ≥ 45 · no matching announcement within 12 h</span></span>} | |
| 21 | + title="Silent changes" | |
| 22 | + description="Something was modified without an obvious public announcement: a price quietly changed, documentation rewritten, a feature removed, terms edited, an API limit adjusted, a product page gone. Each item shows the previous state, the current state and the exact diff." | |
| 23 | + actions={<div className="flex flex-wrap gap-1">{[...byType.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([t, n]) => <Chip key={t} href={`/live?silent_change=true&event_type=${t}`}>{typeLabel(t)} <span className="font-mono text-fg-subtle">{n}</span></Chip>)}</div>} | |
| 24 | + /> | |
| 25 | + <Panel title="Most significant · 48 h" dense className="mb-4"> | |
| 26 | + {top.items.length === 0 ? ( | |
| 27 | + <Empty>No silent change detected in this period.</Empty> | |
| 28 | + ) : ( | |
| 29 | + <ul className="grid gap-px bg-line md:grid-cols-2 xl:grid-cols-3"> | |
| 30 | + {top.items.map((e) => ( | |
| 31 | + <li key={e.id} className="flex flex-col gap-2 bg-panel p-3"> | |
| 32 | + <div className="flex items-center gap-2 text-[11px] text-fg-subtle"> | |
| 33 | + <Link href={`/source/${e.source?.id ?? e.source_id}`} className="truncate font-mono uppercase text-fg-muted hover:text-fg">{e.source?.name}</Link> | |
| 34 | + {e.country && <Flag code={e.country} />} | |
| 35 | + <Chip>{typeLabel(e.event_type)}</Chip> | |
| 36 | + <span className="ml-auto whitespace-nowrap font-mono tabular" title={utcDateTime(e.detected_at)}>{relTime(e.detected_at)}</span> | |
| 37 | + </div> | |
| 38 | + <div className="flex items-start gap-2"> | |
| 39 | + <Score value={e.signal_score ?? e.importance} kind="signal" size="sm" /> | |
| 40 | + <Link href={`/event/${e.slug}`} className="font-medium leading-snug hover:underline">{e.title}</Link> | |
| 41 | + </div> | |
| 42 | + {e.field_changes?.length ? <FieldChanges items={e.field_changes} compact max={3} /> : <p className="line-clamp-3 text-[12px] text-fg-muted">{e.summary}</p>} | |
| 43 | + <div className="mt-auto flex items-center gap-2 text-[11px]"> | |
| 44 | + <Link href={`/event/${e.slug}#diff`} className="text-info hover:underline">exact diff</Link> | |
| 45 | + {e.old_snapshot_id && e.new_snapshot_id && <Link href={`/compare?a=${e.old_snapshot_id}&b=${e.new_snapshot_id}`} className="text-info hover:underline">before / after</Link>} | |
| 46 | + <span className="ml-auto text-fg-subtle">{e.evidence_label}</span> | |
| 47 | + </div> | |
| 48 | + </li> | |
| 49 | + ))} | |
| 50 | + </ul> | |
| 51 | + )} | |
| 52 | + </Panel> | |
| 53 | + <LiveFeed initial={recent.items} initialCursor={recent.nextCursor} fixed="silent" title="SILENT CHANGES · LIVE" showFilters={false} /> | |
| 15 | 54 | </> |
| 16 | 55 | ); |
| 17 | 56 | } |
modified
apps/web/src/app/sitemap.ts
+27 −6
@@ -1,19 +1,40 @@ | ||
| 1 | 1 | import type { MetadataRoute } from "next"; |
| 2 | 2 | import { api, SITE_URL } from "@/lib/api"; |
| 3 | +import { CHANNEL_KEYS } from "@/lib/format"; | |
| 3 | 4 | |
| 4 | 5 | export const dynamic = "force-dynamic"; |
| 5 | 6 | |
| 7 | +/** Public, crawlable surface only — user tools (/watchlists, /alerts, /bookmarks, /monitors, /ops) are noindex. */ | |
| 6 | 8 | export default async function sitemap(): Promise<MetadataRoute.Sitemap> { |
| 7 | 9 | const now = new Date(); |
| 8 | − const statics: MetadataRoute.Sitemap = ["", "/breaking", "/explore", "/sources", "/entities", "/silent", "/api", "/health", "/bot", "/watchlists", "/alerts", ...["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure", "news"].map((c) => `/category/${c}`)].map((p) => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency: p === "" ? "always" : "hourly", priority: p === "" ? 1 : 0.7 })); | |
| 9 | − const [events, sources, entities] = await Promise.all([api.events({ limit: 200 }), api.sources(), api.entities({ limit: 500 })]); | |
| 10 | + const statics: MetadataRoute.Sitemap = [ | |
| 11 | + { url: `${SITE_URL}/`, lastModified: now, changeFrequency: "always", priority: 1 }, | |
| 12 | + { url: `${SITE_URL}/live`, lastModified: now, changeFrequency: "always", priority: 0.9 }, | |
| 13 | + { url: `${SITE_URL}/breaking`, lastModified: now, changeFrequency: "always", priority: 0.9 }, | |
| 14 | + { url: `${SITE_URL}/pulse`, lastModified: now, changeFrequency: "always", priority: 0.8 }, | |
| 15 | + { url: `${SITE_URL}/radar`, lastModified: now, changeFrequency: "hourly", priority: 0.7 }, | |
| 16 | + { url: `${SITE_URL}/silent`, lastModified: now, changeFrequency: "hourly", priority: 0.8 }, | |
| 17 | + { url: `${SITE_URL}/explore`, lastModified: now, changeFrequency: "hourly", priority: 0.7 }, | |
| 18 | + { url: `${SITE_URL}/sources`, lastModified: now, changeFrequency: "daily", priority: 0.6 }, | |
| 19 | + { url: `${SITE_URL}/entities`, lastModified: now, changeFrequency: "hourly", priority: 0.6 }, | |
| 20 | + { url: `${SITE_URL}/country`, lastModified: now, changeFrequency: "hourly", priority: 0.6 }, | |
| 21 | + { url: `${SITE_URL}/api`, lastModified: now, changeFrequency: "weekly", priority: 0.5 }, | |
| 22 | + { url: `${SITE_URL}/health`, lastModified: now, changeFrequency: "hourly", priority: 0.3 }, | |
| 23 | + { url: `${SITE_URL}/bot`, lastModified: now, changeFrequency: "monthly", priority: 0.3 }, | |
| 24 | + ...CHANNEL_KEYS.map((c) => ({ url: `${SITE_URL}/category/${c}`, lastModified: now, changeFrequency: "hourly" as const, priority: 0.7 })), | |
| 25 | + ]; | |
| 26 | + const [events, sources, entities, countries, clusters] = await Promise.all([api.events({ limit: 200 }), api.sources(), api.entities({ limit: 500 }), api.countries(), api.clusters(200, 168)]); | |
| 10 | 27 | const more = await (events.nextCursor ? api.events({ limit: 200, cursor: events.nextCursor }) : Promise.resolve({ items: [], nextCursor: null })); |
| 11 | 28 | const third = await (more.nextCursor ? api.events({ limit: 100, cursor: more.nextCursor }) : Promise.resolve({ items: [], nextCursor: null })); |
| 12 | − return [ | |
| 29 | + const seen = new Set<string>(); | |
| 30 | + const unique = <T extends { url: string }>(rows: T[]): T[] => rows.filter((r) => (seen.has(r.url) ? false : (seen.add(r.url), true))); | |
| 31 | + return unique([ | |
| 13 | 32 | ...statics, |
| 14 | − ...[...events.items, ...more.items, ...third.items].map((e) => ({ url: `${SITE_URL}/event/${e.slug}`, lastModified: new Date(e.detected_at), changeFrequency: "daily" as const, priority: Math.min(0.9, 0.4 + e.importance / 200) })), | |
| 33 | + ...[...events.items, ...more.items, ...third.items].map((e) => ({ url: `${SITE_URL}/event/${e.slug}`, lastModified: new Date(e.detected_at), changeFrequency: "daily" as const, priority: Math.min(0.9, 0.4 + (e.signal_score ?? e.importance) / 200) })), | |
| 34 | + ...clusters.items.filter((c) => c.slug).map((c) => ({ url: `${SITE_URL}/cluster/${c.slug}`, lastModified: new Date(c.last_at), changeFrequency: "hourly" as const, priority: c.state === "breaking" ? 0.9 : 0.6 })), | |
| 35 | + ...countries.items.map((c) => ({ url: `${SITE_URL}/country/${c.slug}`, lastModified: now, changeFrequency: "hourly" as const, priority: 0.6 })), | |
| 15 | 36 | ...sources.items.map((s) => ({ url: `${SITE_URL}/source/${s.id}`, lastModified: s.last_event_at ? new Date(s.last_event_at) : now, changeFrequency: "hourly" as const, priority: 0.6 })), |
| 16 | 37 | ...sources.items.map((s) => ({ url: `${SITE_URL}/domain/${s.domain}`, lastModified: now, changeFrequency: "daily" as const, priority: 0.4 })), |
| 17 | − ...entities.items.map((e) => ({ url: `${SITE_URL}/company/${e.id}`, lastModified: e.last_event_at ? new Date(e.last_event_at) : now, changeFrequency: "hourly" as const, priority: 0.6 })), | |
| 18 | − ]; | |
| 38 | + ...entities.items.map((e) => ({ url: `${SITE_URL}/entity/${e.id}`, lastModified: e.last_event_at ? new Date(e.last_event_at) : now, changeFrequency: "hourly" as const, priority: 0.6 })), | |
| 39 | + ]); | |
| 19 | 40 | } |
added
apps/web/src/app/source/[id]/loading.tsx
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +import { Skeleton, SkeletonPanel, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3"> | |
| 7 | + <Skeleton className="mb-1 h-2.5 w-48" /> | |
| 8 | + <Skeleton className="h-5 w-40" /> | |
| 9 | + <Skeleton className="mt-2 h-3 w-full max-w-2xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-line sm:grid-cols-3 lg:grid-cols-6"> | |
| 12 | + {Array.from({ length: 6 }, (_, i) => ( | |
| 13 | + <div key={i} className="flex flex-col gap-1.5 px-3 py-2"> | |
| 14 | + <Skeleton className="h-2.5 w-16" /> | |
| 15 | + <Skeleton className="h-5 w-12" /> | |
| 16 | + <Skeleton className="h-2.5 w-20" /> | |
| 17 | + </div> | |
| 18 | + ))} | |
| 19 | + </div> | |
| 20 | + <div className="grid gap-4 xl:grid-cols-[1fr_320px]"> | |
| 21 | + <div className="flex flex-col gap-4"> | |
| 22 | + <SkeletonPanel lines={3} /> | |
| 23 | + <div className="panel"> | |
| 24 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-24" /></div> | |
| 25 | + <SkeletonRows rows={8} /> | |
| 26 | + </div> | |
| 27 | + <div className="panel"> | |
| 28 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-28" /></div> | |
| 29 | + <SkeletonRows rows={8} /> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + <div className="flex flex-col gap-4"> | |
| 33 | + <SkeletonPanel lines={6} /> | |
| 34 | + <SkeletonPanel lines={6} /> | |
| 35 | + <SkeletonPanel lines={4} /> | |
| 36 | + <SkeletonPanel lines={5} /> | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + </> | |
| 40 | + ); | |
| 41 | +} | |
modified
apps/web/src/app/source/[id]/page.tsx
+190 −51
@@ -1,10 +1,11 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | 3 | import { notFound } from "next/navigation"; |
| 4 | +import { PRIORITY_LABELS } from "@websensor/core/client"; | |
| 4 | 5 | import { EventRow } from "@/components/event-row"; |
| 5 | −import { Bar, Chip, Empty, ExtLink, HealthPill, PageHeader, Panel, Stat, Table, Td, TierBadge } from "@/components/ui"; | |
| 6 | −import { api } from "@/lib/api"; | |
| 7 | −import { fmtDuration, fmtInt, fmtMs, fmtScore, relTime, untilTime } from "@/lib/format"; | |
| 6 | +import { Badge, Bar, Chip, Empty, ExtLink, Flag, HealthPill, PageHeader, Panel, Sparkline, Stat, Table, Td, TierBadge } from "@/components/ui"; | |
| 7 | +import { api, type SensorRow } from "@/lib/api"; | |
| 8 | +import { fmtDuration, fmtInt, fmtMs, fmtPct, fmtScore, relTime, typeLabel, untilTime, utcDateTime } from "@/lib/format"; | |
| 8 | 9 | |
| 9 | 10 | export const dynamic = "force-dynamic"; |
| 10 | 11 | |
@@ -12,83 +13,137 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin | ||
| 12 | 13 | const { id } = await params; |
| 13 | 14 | const d = await api.source(id); |
| 14 | 15 | if (!d) return { title: "Source not found" }; |
| 15 | − return { title: `${d.source.name} — source`, description: d.source.description ?? `Sensors, activity and events for ${d.source.name} (${d.source.domain}).`, alternates: { canonical: `/source/${d.source.id}` } }; | |
| 16 | + return { title: `${d.source.name} — source intelligence`, description: d.source.description ?? `Sensors, quality, activity and events for ${d.source.name} (${d.source.domain}).`, alternates: { canonical: `/source/${d.source.id}` } }; | |
| 16 | 17 | } |
| 17 | 18 | |
| 19 | +function qualityTone(v: number): "signal" | "warn" | "hot" | undefined { | |
| 20 | + return v >= 80 ? "signal" : v >= 60 ? undefined : v >= 40 ? "warn" : "hot"; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Source intelligence page (spec §27, §48). */ | |
| 18 | 24 | export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) { |
| 19 | 25 | const { id } = await params; |
| 20 | 26 | const d = await api.source(id); |
| 21 | 27 | if (!d) notFound(); |
| 22 | 28 | const events = await api.events({ source: d.source.id, limit: 40 }); |
| 29 | + const src = d.source; | |
| 23 | 30 | const a = d.activity ?? {}; |
| 31 | + const q = d.quality; | |
| 24 | 32 | const score = a.activity_score ?? 0; |
| 25 | 33 | const entityId = d.entities.find((e) => e.type === "organization" || e.id.startsWith("org_"))?.id; |
| 34 | + const daily = [...(d.daily ?? [])].sort((x, y) => x.day.localeCompare(y.day)).slice(-30); | |
| 35 | + const sensorsUp = d.sensors.filter((s) => s.enabled && s.health === "UP").length; | |
| 36 | + const rawTotal = q?.raw_changes ?? d.sensors.reduce((n, s) => n + (s.raw_changes ?? 0), 0); | |
| 37 | + const meaningfulTotal = q?.meaningful_changes ?? d.sensors.reduce((n, s) => n + (s.meaningful_changes ?? 0), 0); | |
| 38 | + const maxType = Math.max(1, ...(d.by_type ?? []).map((t) => t.n)); | |
| 39 | + const extra = src as typeof src & { llm_enabled?: boolean; discover?: Record<string, boolean>; fallback?: Record<string, boolean>; rate_limit_per_min?: number | null; terms_reviewed_at?: string | null }; | |
| 26 | 40 | return ( |
| 27 | 41 | <> |
| 28 | 42 | <PageHeader |
| 29 | − kicker={<span className="flex items-center gap-2"><TierBadge tier={d.source.tier} /> Tier {d.source.tier} · <Link href={`/domain/${d.source.domain}`} className="font-mono hover:underline">{d.source.domain}</Link></span>} | |
| 30 | − title={d.source.name} | |
| 31 | − description={d.source.description} | |
| 43 | + compact | |
| 44 | + kicker={ | |
| 45 | + <span className="flex flex-wrap items-center gap-2"> | |
| 46 | + <TierBadge tier={src.tier} /> | |
| 47 | + <span>Tier {src.tier}</span> | |
| 48 | + <span className="text-fg-subtle">·</span> | |
| 49 | + <Link href={`/domain/${src.domain}`} className="font-mono hover:underline">{src.domain}</Link> | |
| 50 | + {src.country && ( | |
| 51 | + <> | |
| 52 | + <span className="text-fg-subtle">·</span> | |
| 53 | + <Link href={`/country/${src.country.toLowerCase()}`} className="inline-flex items-center gap-1 font-mono hover:text-fg"><Flag code={src.country} /> {src.country}</Link> | |
| 54 | + </> | |
| 55 | + )} | |
| 56 | + {src.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />} | |
| 57 | + {src.enabled === false && <Chip tone="warn" className="font-mono">DISABLED</Chip>} | |
| 58 | + </span> | |
| 59 | + } | |
| 60 | + title={src.name} | |
| 61 | + description={src.description ?? (src.first_party === false ? `Media / aggregator source: its reports are third-party evidence and never count as first-party confirmation.` : `Official channels of ${src.name}: every sensor below is an endpoint the organization itself publishes.`)} | |
| 32 | 62 | actions={ |
| 33 | 63 | <> |
| 34 | − {d.source.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)} | |
| 35 | − {entityId && <Link href={`/company/${entityId}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Entity & timeline →</Link>} | |
| 36 | − <ExtLink href={d.source.homepage ?? `https://${d.source.domain}`} className="text-[12px]">Website ↗</ExtLink> | |
| 64 | + {src.categories.map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)} | |
| 65 | + {entityId && <Link href={`/entity/${entityId}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Entity & timeline →</Link>} | |
| 66 | + <Link href={`/live?q=${encodeURIComponent(src.name)}`} className="hidden rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong sm:inline-block">Live →</Link> | |
| 67 | + <ExtLink href={src.homepage ?? `https://${src.domain}`} className="text-[12px]">Website ↗</ExtLink> | |
| 37 | 68 | </> |
| 38 | 69 | } |
| 39 | 70 | /> |
| 40 | − <div className="grid gap-4 lg:grid-cols-[1fr_320px]"> | |
| 41 | − <div className="flex flex-col gap-4"> | |
| 42 | − <Panel title={`Sensors · ${d.sensors.length}`} dense> | |
| 43 | − {d.sensors.length === 0 ? ( | |
| 44 | − <Empty>No sensors yet — discovery validates feeds, sitemaps and status pages before creating sensors.</Empty> | |
| 45 | − ) : ( | |
| 46 | − <Table head={["Sensor", "Type", "Tier", "Health", "Last check", "Next", "Latency", "Raw / meaningful", "304 %", "Status"]}> | |
| 47 | − {d.sensors.map((s) => { | |
| 48 | − const runs = s.total_runs ?? 0; | |
| 49 | − const nm = runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : 0; | |
| 71 | + | |
| 72 | + <div className="panel mb-4 grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-3 lg:grid-cols-6 lg:divide-y-0"> | |
| 73 | + <Stat label="Quality score" value={q ? fmtScore(q.quality_score) : "—"} tone={q ? qualityTone(q.quality_score) : undefined} hint={q ? `${q.sensors_up ?? sensorsUp} / ${q.sensors ?? d.sensors.length} sensors up` : "not computed yet"} /> | |
| 74 | + <Stat label="Success rate" value={q ? fmtPct(q.success_rate) : "—"} tone={q && q.success_rate < 0.9 ? "warn" : undefined} hint={q?.checks_7d ? `${fmtInt(q.checks_7d)} checks · ${fmtInt(q.errors_7d)} errors · 7 d` : `${fmtInt(q?.total_runs)} checks total`} /> | |
| 75 | + <Stat label="Avg latency" value={fmtMs(q?.avg_latency_ms)} hint="across sensors" /> | |
| 76 | + <Stat label="Structured" value={q ? fmtPct(q.structured_share) : "—"} hint={q ? `${q.structured_sensors ?? 0} feeds / APIs / status` : undefined} /> | |
| 77 | + <Stat label="Usefulness" value={q ? fmtPct(q.usefulness) : "—"} hint={`${fmtInt(meaningfulTotal)} meaningful / ${fmtInt(rawTotal)} raw`} /> | |
| 78 | + <Stat label="Sensors" value={<span>{sensorsUp}<span className="text-fg-subtle"> / {d.sensors.length}</span></span>} hint={`${d.sensors.filter((s) => s.health !== "UP").length} not UP`} tone={sensorsUp < d.sensors.length ? "warn" : undefined} /> | |
| 79 | + </div> | |
| 80 | + | |
| 81 | + <div className="grid gap-4 xl:grid-cols-[1fr_320px]"> | |
| 82 | + <div className="flex min-w-0 flex-col gap-4"> | |
| 83 | + <Panel title="30 days · daily activity" action={daily.length ? <span className="text-[11px] text-fg-subtle">{daily[0]!.day} → {daily[daily.length - 1]!.day}</span> : undefined}> | |
| 84 | + {daily.length ? ( | |
| 85 | + <div className="grid gap-4 sm:grid-cols-3"> | |
| 86 | + {( | |
| 87 | + [ | |
| 88 | + { label: "Checks", key: "checks", tone: "muted" }, | |
| 89 | + { label: "Raw changes", key: "raw_changes", tone: "info" }, | |
| 90 | + { label: "Events", key: "events", tone: "signal" }, | |
| 91 | + ] as const | |
| 92 | + ).map((s) => { | |
| 93 | + const values = daily.map((r) => Number(r[s.key] ?? 0)); | |
| 94 | + const sum = values.reduce((x, y) => x + y, 0); | |
| 50 | 95 | return ( |
| 51 | − <tr key={s.id} className="hover:bg-panel-2/60"> | |
| 52 | − <Td> | |
| 53 | − <Link href={`/sensor/${s.id}`} className="font-medium hover:underline">{s.name}</Link> | |
| 54 | − <div className="max-w-[28rem] truncate font-mono text-[11px] text-fg-subtle">{s.url}</div> | |
| 55 | − </Td> | |
| 56 | − <Td mono><span className="text-fg-muted">{s.type}</span><div className="text-[10.5px] text-fg-subtle">{s.connector}</div></Td> | |
| 57 | − <Td><TierBadge tier={s.tier} /></Td> | |
| 58 | − <Td><HealthPill health={s.enabled ? s.health : "DISABLED"} /></Td> | |
| 59 | − <Td mono className="text-fg-subtle">{s.last_check_at ? relTime(s.last_check_at) : "never"}</Td> | |
| 60 | − <Td mono className="text-fg-subtle">{untilTime(s.next_check_at)}{s.base_interval_seconds ? <div className="text-[10.5px]">base {fmtDuration(s.base_interval_seconds)}</div> : null}</Td> | |
| 61 | − <Td mono>{fmtMs(s.avg_latency_ms)}</Td> | |
| 62 | − <Td mono>{s.raw_changes ?? 0} / <span className="text-signal">{s.meaningful_changes ?? 0}</span></Td> | |
| 63 | − <Td mono>{runs ? `${nm}%` : "—"}<div className="text-[10.5px] text-fg-subtle">{s.has_etag ? "etag" : ""}{s.has_etag && s.has_last_modified ? "+" : ""}{s.has_last_modified ? "lm" : ""}</div></Td> | |
| 64 | − <Td mono>{s.last_status ?? "—"}{s.last_error ? <div className="max-w-[14rem] truncate text-[10.5px] text-danger" title={s.last_error}>{s.last_error}</div> : null}</Td> | |
| 65 | − </tr> | |
| 96 | + <div key={s.key} className="min-w-0"> | |
| 97 | + <div className="flex items-baseline justify-between"> | |
| 98 | + <span className="label">{s.label}</span> | |
| 99 | + <span className="font-mono text-[12px] tabular">{fmtInt(sum)}</span> | |
| 100 | + </div> | |
| 101 | + <div className="mt-1 w-full overflow-hidden"> | |
| 102 | + <Sparkline values={values} width={300} height={40} tone={s.tone} /> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 66 | 105 | ); |
| 67 | 106 | })} |
| 107 | + </div> | |
| 108 | + ) : ( | |
| 109 | + <Empty>Daily counters accumulate from the first check onward — nothing recorded for the last 30 days yet.</Empty> | |
| 110 | + )} | |
| 111 | + </Panel> | |
| 112 | + | |
| 113 | + <Panel title={`Sensors · ${d.sensors.length}`} dense action={<span className="hidden text-[11px] text-fg-subtle sm:inline">P0 critical · P1 major · P2 specialized · P3 low urgency</span>}> | |
| 114 | + {d.sensors.length === 0 ? ( | |
| 115 | + <Empty>No sensors yet — discovery validates feeds, sitemaps and status pages before creating sensors.</Empty> | |
| 116 | + ) : ( | |
| 117 | + <Table head={["Sensor", "Type", "Tier", "Prio", "Status", "Health", "Polling", "Last / next", "HTTP", "Cond.", "24 h", "Snap.", "304 %", "Raw / mean.", ""]}> | |
| 118 | + {d.sensors.map((s) => <SensorTr key={s.id} s={s} />)} | |
| 68 | 119 | </Table> |
| 69 | 120 | )} |
| 70 | 121 | </Panel> |
| 71 | − <Panel title="Recent events" dense> | |
| 122 | + | |
| 123 | + <Panel title="Recent events" dense action={<Link href={`/live?q=${encodeURIComponent(src.name)}`} className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}> | |
| 72 | 124 | {events.items.length ? events.items.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No meaningful events yet for this source.</Empty>} |
| 73 | 125 | </Panel> |
| 126 | + | |
| 74 | 127 | {d.discovery.length > 0 && ( |
| 75 | − <Panel title="Discovered endpoints" dense> | |
| 76 | − <Table head={["Kind", "URL", "Evidence", "Items", "Value", "Status"]}> | |
| 128 | + <Panel title={`Discovered endpoints · ${d.discovery.length}`} dense> | |
| 129 | + <Table head={["Kind", "URL", "Evidence", "Items", "Value", "Status", "Found"]}> | |
| 77 | 130 | {d.discovery.map((c) => ( |
| 78 | − <tr key={c.url}> | |
| 131 | + <tr key={c.url} className="hover:bg-panel-2/60"> | |
| 79 | 132 | <Td mono>{c.kind}</Td> |
| 80 | − <Td><span className="block max-w-[32rem] truncate font-mono text-[11.5px]">{c.url}</span></Td> | |
| 133 | + <Td><span className="block max-w-[28rem] truncate font-mono text-[11.5px]" title={c.url}>{c.url}</span></Td> | |
| 81 | 134 | <Td className="text-fg-subtle">{c.evidence}</Td> |
| 82 | 135 | <Td mono>{c.score?.itemCount ?? "—"}</Td> |
| 83 | 136 | <Td mono>{c.score?.value !== undefined ? fmtScore((c.score.value ?? 0) * 100) : "—"}</Td> |
| 84 | 137 | <Td><Chip tone={c.status === "promoted" ? "ok" : c.status === "rejected" ? "danger" : "default"}>{c.status}</Chip></Td> |
| 138 | + <Td mono className="whitespace-nowrap text-fg-subtle">{relTime(c.found_at)}</Td> | |
| 85 | 139 | </tr> |
| 86 | 140 | ))} |
| 87 | 141 | </Table> |
| 88 | 142 | </Panel> |
| 89 | 143 | )} |
| 90 | 144 | </div> |
| 91 | − <aside className="flex flex-col gap-4"> | |
| 145 | + | |
| 146 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 92 | 147 | <Panel title="Activity anomaly"> |
| 93 | 148 | <div className="flex items-baseline justify-between"> |
| 94 | 149 | <span className={`font-mono text-3xl font-semibold tabular ${score >= 70 ? "text-hot" : score >= 45 ? "text-high" : "text-fg"}`}>{fmtScore(score)}</span> |
@@ -100,19 +155,34 @@ export default async function SourcePage({ params }: { params: Promise<{ id: str | ||
| 100 | 155 | <dt className="text-fg-subtle">Current</dt><dd className="text-right font-mono tabular">{a.changes_2h ?? 0} in 2 h</dd> |
| 101 | 156 | <dt className="text-fg-subtle">Events 24 h</dt><dd className="text-right font-mono tabular">{a.events_24h ?? 0}</dd> |
| 102 | 157 | <dt className="text-fg-subtle">Events 14 d</dt><dd className="text-right font-mono tabular">{a.events_14d ?? 0}</dd> |
| 158 | + <dt className="text-fg-subtle">Silent 24 h</dt><dd className={`text-right font-mono tabular ${a.silent_24h ? "text-silent" : ""}`}>{a.silent_24h ?? 0}</dd> | |
| 159 | + <dt className="text-fg-subtle">Breaking 24 h</dt><dd className={`text-right font-mono tabular ${a.breaking_24h ? "text-hot" : ""}`}>{a.breaking_24h ?? 0}</dd> | |
| 103 | 160 | </dl> |
| 104 | 161 | </Panel> |
| 105 | − <div className="panel grid grid-cols-2 divide-x divide-line"> | |
| 106 | − <Stat label="Sensors" value={fmtInt(d.sensors.length)} hint={`${d.sensors.filter((s) => s.health === "UP").length} UP`} /> | |
| 107 | − <Stat label="Raw changes" value={fmtInt(d.sensors.reduce((n, s) => n + (s.raw_changes ?? 0), 0))} hint={`${fmtInt(d.sensors.reduce((n, s) => n + (s.meaningful_changes ?? 0), 0))} meaningful`} /> | |
| 108 | − </div> | |
| 109 | − <Panel title="Entities" dense> | |
| 162 | + | |
| 163 | + <Panel title="Events by type" dense> | |
| 164 | + {d.by_type?.length ? ( | |
| 165 | + <ul className="divide-y divide-line"> | |
| 166 | + {d.by_type.map((t) => ( | |
| 167 | + <li key={t.event_type} className="grid grid-cols-[8.5rem_1fr_3rem] items-center gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 168 | + <Link href={`/live?event_type=${t.event_type}&q=${encodeURIComponent(src.name)}`} className="truncate hover:underline">{typeLabel(t.event_type)}</Link> | |
| 169 | + <Bar value={t.n} max={maxType} tone="info" /> | |
| 170 | + <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span> | |
| 171 | + </li> | |
| 172 | + ))} | |
| 173 | + </ul> | |
| 174 | + ) : ( | |
| 175 | + <Empty>No events yet.</Empty> | |
| 176 | + )} | |
| 177 | + </Panel> | |
| 178 | + | |
| 179 | + <Panel title={`Entities · ${d.entities.length}`} dense> | |
| 110 | 180 | {d.entities.length ? ( |
| 111 | 181 | <ul className="divide-y divide-line"> |
| 112 | 182 | {d.entities.map((e) => ( |
| 113 | − <li key={e.id} className="flex items-center justify-between px-3 py-1.5 text-[13px]"> | |
| 114 | − <Link href={`/company/${e.id}`} className="hover:underline">{e.name}</Link> | |
| 115 | − <span className="font-mono text-[11px] text-fg-subtle">{e.type} · {e.event_count} ev</span> | |
| 183 | + <li key={e.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[13px]"> | |
| 184 | + <Link href={`/entity/${e.id}`} className="min-w-0 truncate hover:underline">{e.name}</Link> | |
| 185 | + <span className="whitespace-nowrap font-mono text-[11px] text-fg-subtle">{e.type.replace(/_/g, " ")} · {e.event_count} ev</span> | |
| 116 | 186 | </li> |
| 117 | 187 | ))} |
| 118 | 188 | </ul> |
@@ -120,15 +190,84 @@ export default async function SourcePage({ params }: { params: Promise<{ id: str | ||
| 120 | 190 | <Empty>No entities linked.</Empty> |
| 121 | 191 | )} |
| 122 | 192 | </Panel> |
| 193 | + | |
| 123 | 194 | <Panel title="Source policy"> |
| 124 | 195 | <dl className="grid grid-cols-2 gap-y-1 text-[12.5px]"> |
| 125 | − <dt className="text-fg-subtle">robots.txt checked</dt><dd className="text-right font-mono tabular">{d.source.robots_checked_at ? relTime(d.source.robots_checked_at) : "—"}</dd> | |
| 126 | − <dt className="text-fg-subtle">Importance weight</dt><dd className="text-right font-mono tabular">{d.source.importance_weight ?? 1}</dd> | |
| 196 | + <dt className="text-fg-subtle">Provenance</dt><dd className="text-right">{src.first_party === false ? "third-party media" : "first-party channels"}</dd> | |
| 197 | + <dt className="text-fg-subtle">robots.txt checked</dt><dd className="text-right font-mono tabular" title={src.robots_checked_at ? utcDateTime(src.robots_checked_at) : undefined}>{src.robots_checked_at ? relTime(src.robots_checked_at) : "—"}</dd> | |
| 198 | + <dt className="text-fg-subtle">Importance weight</dt><dd className="text-right font-mono tabular">{src.importance_weight ?? 1}</dd> | |
| 199 | + {extra.llm_enabled !== undefined && ( | |
| 200 | + <> | |
| 201 | + <dt className="text-fg-subtle">LLM interpretation</dt><dd className="text-right font-mono">{extra.llm_enabled ? "on" : "off · heuristics only"}</dd> | |
| 202 | + </> | |
| 203 | + )} | |
| 204 | + {extra.rate_limit_per_min ? ( | |
| 205 | + <> | |
| 206 | + <dt className="text-fg-subtle">Rate limit</dt><dd className="text-right font-mono tabular">{extra.rate_limit_per_min}/min</dd> | |
| 207 | + </> | |
| 208 | + ) : null} | |
| 209 | + {extra.discover && Object.values(extra.discover).some(Boolean) && ( | |
| 210 | + <> | |
| 211 | + <dt className="text-fg-subtle">Discovery</dt><dd className="text-right font-mono">{Object.entries(extra.discover).filter(([, v]) => v).map(([k]) => k).join(" · ")}</dd> | |
| 212 | + </> | |
| 213 | + )} | |
| 214 | + {extra.fallback && Object.values(extra.fallback).some(Boolean) && ( | |
| 215 | + <> | |
| 216 | + <dt className="text-fg-subtle">Fallback</dt><dd className="text-right font-mono">{Object.entries(extra.fallback).filter(([, v]) => v).map(([k]) => k).join(" · ")}</dd> | |
| 217 | + </> | |
| 218 | + )} | |
| 127 | 219 | <dt className="text-fg-subtle">Acquisition</dt><dd className="text-right">official feeds first</dd> |
| 128 | 220 | </dl> |
| 221 | + {src.notes && <p className="mt-3 border-t border-line pt-2 text-[11.5px] leading-snug text-fg-muted break-words">{src.notes}</p>} | |
| 129 | 222 | </Panel> |
| 130 | 223 | </aside> |
| 131 | 224 | </div> |
| 132 | 225 | </> |
| 133 | 226 | ); |
| 134 | 227 | } |
| 228 | + | |
| 229 | +function SensorTr({ s }: { s: SensorRow }) { | |
| 230 | + const runs = s.total_runs ?? 0; | |
| 231 | + const nm = runs ? Math.round(((s.total_not_modified ?? 0) / runs) * 100) : null; | |
| 232 | + const prio = s.priority ?? null; | |
| 233 | + const cond = [s.has_etag || s.etag ? "etag" : null, s.has_last_modified || s.last_modified ? "lm" : null].filter(Boolean); | |
| 234 | + const condTitle = [s.etag ? `ETag: ${s.etag}` : null, s.last_modified ? `Last-Modified: ${s.last_modified}` : null].filter(Boolean).join("\n") || "No conditional-request validators exposed"; | |
| 235 | + return ( | |
| 236 | + <tr className="hover:bg-panel-2/60"> | |
| 237 | + <Td> | |
| 238 | + <Link href={`/sensor/${s.id}`} className="font-medium hover:underline">{s.name}</Link> | |
| 239 | + <div className="max-w-[16rem] truncate font-mono text-[11px] text-fg-subtle" title={s.url}>{s.url}</div> | |
| 240 | + </Td> | |
| 241 | + <Td mono><span className="text-fg-muted">{s.type}</span><div className="text-[10.5px] text-fg-subtle">{s.connector}</div></Td> | |
| 242 | + <Td><TierBadge tier={s.tier} /></Td> | |
| 243 | + <Td>{prio !== null ? <Chip tone={prio === 0 ? "hot" : prio === 1 ? "high" : "default"} className="font-mono" title={PRIORITY_LABELS[prio] ?? `Priority ${prio}`}>P{prio}</Chip> : <span className="text-fg-subtle">—</span>}</Td> | |
| 244 | + <Td>{s.status ? <HealthPill health={s.enabled ? s.status : "DISABLED"} /> : <span className="text-fg-subtle">—</span>}</Td> | |
| 245 | + <Td><HealthPill health={s.enabled ? s.health : "DISABLED"} /></Td> | |
| 246 | + <Td mono className="whitespace-nowrap"> | |
| 247 | + {s.current_interval_seconds ? fmtDuration(s.current_interval_seconds) : <span className="text-fg-subtle">adaptive</span>} | |
| 248 | + <div className="text-[10.5px] text-fg-subtle">{s.base_interval_seconds ? `base ${fmtDuration(s.base_interval_seconds)}` : "no base"}</div> | |
| 249 | + </Td> | |
| 250 | + <Td mono className="whitespace-nowrap text-fg-subtle"> | |
| 251 | + <div title={s.last_check_at ? utcDateTime(s.last_check_at) : undefined}>{s.last_check_at ? relTime(s.last_check_at) : "never"}</div> | |
| 252 | + <div className="text-[10.5px]" title={s.next_check_at ? utcDateTime(s.next_check_at) : undefined}>{untilTime(s.next_check_at)}</div> | |
| 253 | + </Td> | |
| 254 | + <Td mono> | |
| 255 | + {s.last_status ? <span className={s.last_status >= 500 ? "text-danger" : s.last_status >= 400 ? "text-warn" : s.last_status === 304 ? "text-fg-subtle" : ""}>{s.last_status}</span> : <span className="text-fg-subtle">—</span>} | |
| 256 | + {s.last_error ? <div className="max-w-[10rem] truncate text-[10.5px] text-danger" title={s.last_error}>{s.last_error}</div> : null} | |
| 257 | + </Td> | |
| 258 | + <Td> | |
| 259 | + <span className="flex gap-1" title={condTitle}> | |
| 260 | + {cond.length ? cond.map((c) => <Chip key={c} tone="ok" className="font-mono">{c}</Chip>) : <span className="text-fg-subtle">—</span>} | |
| 261 | + </span> | |
| 262 | + </Td> | |
| 263 | + <Td mono className="whitespace-nowrap"> | |
| 264 | + {fmtInt(s.checks_24h ?? 0)} <span className="text-fg-subtle">chk</span> | |
| 265 | + <div className="text-[10.5px]">{s.changes_24h ?? 0} <span className="text-fg-subtle">chg</span></div> | |
| 266 | + </Td> | |
| 267 | + <Td mono>{fmtInt(s.snapshot_count ?? 0)}</Td> | |
| 268 | + <Td mono>{nm !== null ? `${nm}%` : <span className="text-fg-subtle">—</span>}</Td> | |
| 269 | + <Td mono className="whitespace-nowrap">{s.raw_changes ?? 0} / <span className="text-signal">{s.meaningful_changes ?? 0}</span></Td> | |
| 270 | + <Td><Link href={`/sensor/${s.id}`} className="whitespace-nowrap text-[11px] text-info hover:underline">history →</Link></Td> | |
| 271 | + </tr> | |
| 272 | + ); | |
| 273 | +} | |
added
apps/web/src/app/sources/loading.tsx
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +import { Skeleton, SkeletonRows } from "@/components/ui"; | |
| 2 | + | |
| 3 | +export default function Loading() { | |
| 4 | + return ( | |
| 5 | + <> | |
| 6 | + <div className="mb-3"> | |
| 7 | + <Skeleton className="mb-1 h-2.5 w-48" /> | |
| 8 | + <Skeleton className="h-5 w-28" /> | |
| 9 | + <Skeleton className="mt-2 h-3 w-full max-w-2xl" /> | |
| 10 | + </div> | |
| 11 | + <div className="mb-2 flex gap-2"> | |
| 12 | + <Skeleton className="h-8 w-64" /> | |
| 13 | + <Skeleton className="h-8 w-40" /> | |
| 14 | + <Skeleton className="h-8 w-16" /> | |
| 15 | + </div> | |
| 16 | + <div className="mb-2 flex gap-1"> | |
| 17 | + {Array.from({ length: 9 }, (_, i) => ( | |
| 18 | + <Skeleton key={i} className="h-4 w-10" /> | |
| 19 | + ))} | |
| 20 | + </div> | |
| 21 | + <div className="mb-3 flex flex-wrap gap-1"> | |
| 22 | + {Array.from({ length: 16 }, (_, i) => ( | |
| 23 | + <Skeleton key={i} className="h-4 w-16" /> | |
| 24 | + ))} | |
| 25 | + </div> | |
| 26 | + <div className="panel"> | |
| 27 | + <div className="border-b border-line px-3 py-2"><Skeleton className="h-2.5 w-80" /></div> | |
| 28 | + <SkeletonRows rows={16} /> | |
| 29 | + </div> | |
| 30 | + </> | |
| 31 | + ); | |
| 32 | +} | |
modified
apps/web/src/app/sources/page.tsx
+58 −21
@@ -1,48 +1,85 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | −import { Chip, Empty, PageHeader, Panel, Table, Td, TierBadge } from "@/components/ui"; | |
| 3 | +import { Badge, Chip, Empty, Flag, PageHeader, Panel, Table, Td, TierBadge } from "@/components/ui"; | |
| 4 | 4 | import { api } from "@/lib/api"; |
| 5 | 5 | import { fmtInt, relTime } from "@/lib/format"; |
| 6 | 6 | |
| 7 | 7 | export const dynamic = "force-dynamic"; |
| 8 | −export const metadata: Metadata = { title: "Sources", description: "Every organization monitored by WebSensor, with its sensors and recent activity." }; | |
| 8 | +export const metadata: Metadata = { title: "Sources", description: "Every organization monitored by WebSensor, with its sensors and recent activity. Filter by country, tier, category and provenance." }; | |
| 9 | 9 | |
| 10 | −const CATS = ["ai", "cloud", "developer", "cyber", "consumer-tech", "semiconductors", "finance", "government", "statistics", "health", "pharma", "science", "space", "automotive", "commerce", "payments", "crypto", "enterprise", "internet", "standards"]; | |
| 10 | +const CATS = ["ai", "cloud", "developer", "cyber", "consumer-tech", "semiconductors", "finance", "government", "statistics", "health", "pharma", "science", "space", "automotive", "commerce", "payments", "crypto", "enterprise", "internet", "standards", "news", "media", "sports", "transport", "energy"]; | |
| 11 | +const TIERS = ["S", "A", "B", "C", "D"]; | |
| 11 | 12 | |
| 12 | −export default async function SourcesPage({ searchParams }: { searchParams: Promise<{ q?: string; category?: string }> }) { | |
| 13 | − const sp = await searchParams; | |
| 14 | − const { items } = await api.sources({ q: sp.q, category: sp.category }); | |
| 13 | +type Params = { q?: string; category?: string; country?: string; tier?: string; first_party?: string }; | |
| 14 | + | |
| 15 | +function href(p: Params, patch: Partial<Params>): string { | |
| 16 | + const n: Record<string, string | undefined> = { ...p, ...patch }; | |
| 17 | + const sp = new URLSearchParams(); | |
| 18 | + for (const k of ["q", "category", "country", "tier", "first_party"]) if (n[k]) sp.set(k, n[k]!); | |
| 19 | + const s = sp.toString(); | |
| 20 | + return s ? `/sources?${s}` : "/sources"; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export default async function SourcesPage({ searchParams }: { searchParams: Promise<Params> }) { | |
| 24 | + const raw = await searchParams; | |
| 25 | + const sp: Params = { q: raw.q?.trim() || undefined, category: raw.category || undefined, country: raw.country?.toUpperCase() || undefined, tier: TIERS.includes((raw.tier ?? "").toUpperCase()) ? raw.tier!.toUpperCase() : undefined, first_party: raw.first_party === "true" || raw.first_party === "false" ? raw.first_party : undefined }; | |
| 26 | + const [{ items }, countries] = await Promise.all([api.sources({ q: sp.q, category: sp.category, country: sp.country, tier: sp.tier, first_party: sp.first_party }), api.countries()]); | |
| 27 | + const active = [sp.q, sp.category, sp.country, sp.tier, sp.first_party].filter(Boolean).length; | |
| 28 | + const firstParty = items.filter((s) => s.first_party !== false).length; | |
| 15 | 29 | return ( |
| 16 | 30 | <> |
| 17 | − <PageHeader kicker={`${fmtInt(items.length)} organizations`} title="Sources" description="WebSensor monitors sensors, not merely domains: each organization exposes several official endpoints (feeds, status pages, sitemaps, pricing and documentation pages)." /> | |
| 18 | − <form className="mb-3 flex flex-wrap items-center gap-2" action="/sources"> | |
| 19 | − <input name="q" defaultValue={sp.q ?? ""} placeholder="Filter by name or domain…" className="h-8 w-64 rounded-md border border-line bg-panel px-2.5 text-[13px] placeholder:text-fg-subtle" /> | |
| 31 | + <PageHeader compact kicker={`${fmtInt(items.length)} organizations · ${fmtInt(firstParty)} first-party · ${fmtInt(items.length - firstParty)} media`} title="Sources" description="WebSensor monitors sensors, not merely domains: each organization exposes several official endpoints (feeds, status pages, sitemaps, pricing and documentation pages). Media sources are marked EXTERNAL and never count as first-party evidence." /> | |
| 32 | + <form className="mb-2 flex flex-wrap items-center gap-2" action="/sources"> | |
| 33 | + <input name="q" defaultValue={sp.q ?? ""} placeholder="Filter by name or domain…" aria-label="Filter by name or domain" className="h-8 w-full max-w-64 min-w-0 rounded-md border border-line bg-panel px-2.5 text-[13px] placeholder:text-fg-subtle sm:w-64" /> | |
| 34 | + <select name="country" defaultValue={sp.country ?? ""} aria-label="Country" className="h-8 max-w-[14rem] rounded-md border border-line bg-panel px-2 text-[12.5px]"> | |
| 35 | + <option value="">all countries</option> | |
| 36 | + {countries.items.map((c) => ( | |
| 37 | + <option key={c.country} value={c.country}>{c.name} · {c.sources}</option> | |
| 38 | + ))} | |
| 39 | + </select> | |
| 20 | 40 | {sp.category && <input type="hidden" name="category" value={sp.category} />} |
| 21 | − <button type="submit" className="h-8 rounded-md border border-line bg-panel-2 px-3 text-[12.5px]">Filter</button> | |
| 41 | + {sp.tier && <input type="hidden" name="tier" value={sp.tier} />} | |
| 42 | + {sp.first_party && <input type="hidden" name="first_party" value={sp.first_party} />} | |
| 43 | + <button type="submit" className="h-8 rounded-md border border-line bg-panel-2 px-3 text-[12.5px] hover:border-line-strong">Filter</button> | |
| 44 | + {active > 0 && <Link href="/sources" className="text-[12px] text-fg-subtle hover:text-fg">clear {active} filter{active === 1 ? "" : "s"}</Link>} | |
| 22 | 45 | </form> |
| 23 | − <div className="mb-3 flex flex-wrap gap-1"> | |
| 24 | − <Chip href="/sources" tone={!sp.category ? "signal" : "default"}>all</Chip> | |
| 46 | + <div className="mb-2 flex flex-wrap items-center gap-1"> | |
| 47 | + <span className="label mr-1">tier</span> | |
| 48 | + <Chip href={href(sp, { tier: undefined })} tone={!sp.tier ? "signal" : "default"} className="font-mono">all</Chip> | |
| 49 | + {TIERS.map((t) => ( | |
| 50 | + <Chip key={t} href={href(sp, { tier: t })} tone={sp.tier === t ? "signal" : "default"} className="font-mono">{t}</Chip> | |
| 51 | + ))} | |
| 52 | + <span className="label mx-1">·</span> | |
| 53 | + <Chip href={href(sp, { first_party: undefined })} tone={!sp.first_party ? "signal" : "default"}>all kinds</Chip> | |
| 54 | + <Chip href={href(sp, { first_party: "true" })} tone={sp.first_party === "true" ? "signal" : "default"}>first-party</Chip> | |
| 55 | + <Chip href={href(sp, { first_party: "false" })} tone={sp.first_party === "false" ? "signal" : "default"}>media / external</Chip> | |
| 56 | + </div> | |
| 57 | + <div className="mb-3 flex flex-wrap items-center gap-1"> | |
| 58 | + <span className="label mr-1">category</span> | |
| 59 | + <Chip href={href(sp, { category: undefined })} tone={!sp.category ? "signal" : "default"}>all</Chip> | |
| 25 | 60 | {CATS.map((c) => ( |
| 26 | − <Chip key={c} href={`/sources?category=${c}${sp.q ? `&q=${encodeURIComponent(sp.q)}` : ""}`} tone={sp.category === c ? "signal" : "default"}>{c}</Chip> | |
| 61 | + <Chip key={c} href={href(sp, { category: c })} tone={sp.category === c ? "signal" : "default"}>{c}</Chip> | |
| 27 | 62 | ))} |
| 63 | + {sp.category && !CATS.includes(sp.category) && <Chip tone="signal">{sp.category}</Chip>} | |
| 28 | 64 | </div> |
| 29 | 65 | <Panel dense> |
| 30 | 66 | {items.length === 0 ? ( |
| 31 | − <Empty>No sources match.</Empty> | |
| 67 | + <Empty>No source matches these filters.</Empty> | |
| 32 | 68 | ) : ( |
| 33 | − <Table head={["Tier", "Source", "Domain", "Categories", "Sensors", "Events 24h", "Total", "Last event", "Last check", "Health"]}> | |
| 69 | + <Table head={["Tier", "Source", "Domain", "Country", "Kind", "Categories", "Sensors", "Events 24 h", "Total", "Last event", "Health"]}> | |
| 34 | 70 | {items.map((s) => ( |
| 35 | 71 | <tr key={s.id} className="hover:bg-panel-2/60"> |
| 36 | 72 | <Td><TierBadge tier={s.tier} /></Td> |
| 37 | 73 | <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td> |
| 38 | 74 | <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td> |
| 39 | − <Td><div className="flex flex-wrap gap-1">{s.categories.slice(0, 3).map((c) => <Chip key={c} href={`/sources?category=${c}`}>{c}</Chip>)}</div></Td> | |
| 75 | + <Td>{s.country ? <Link href={href(sp, { country: s.country })} className="inline-flex items-center gap-1 font-mono text-[11px] text-fg-muted hover:text-fg"><Flag code={s.country} /> {s.country}</Link> : <span className="text-fg-subtle">—</span>}</Td> | |
| 76 | + <Td>{s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td> | |
| 77 | + <Td><div className="flex flex-wrap gap-1">{s.categories.slice(0, 3).map((c) => <Chip key={c} href={href(sp, { category: c })}>{c}</Chip>)}{s.categories.length > 3 && <span className="font-mono text-[10.5px] text-fg-subtle">+{s.categories.length - 3}</span>}</div></Td> | |
| 40 | 78 | <Td mono>{s.sensor_count ?? 0}</Td> |
| 41 | − <Td mono>{s.events_24h ?? 0}</Td> | |
| 42 | − <Td mono>{fmtInt(s.event_count)}</Td> | |
| 43 | − <Td mono className="text-fg-subtle">{s.last_event_at ? relTime(s.last_event_at) : "—"}</Td> | |
| 44 | − <Td mono className="text-fg-subtle">{s.last_check_at ? relTime(s.last_check_at) : "—"}</Td> | |
| 45 | − <Td>{(s.sensors_degraded ?? 0) > 0 ? <Chip tone="warn">{s.sensors_degraded} degraded</Chip> : <Chip tone="ok">UP</Chip>}</Td> | |
| 79 | + <Td mono>{fmtInt(s.events_24h ?? 0)}</Td> | |
| 80 | + <Td mono className="text-fg-subtle">{fmtInt(s.event_count)}</Td> | |
| 81 | + <Td mono className="whitespace-nowrap text-fg-subtle">{s.last_event_at ? relTime(s.last_event_at) : "—"}</Td> | |
| 82 | + <Td>{(s.sensors_degraded ?? 0) > 0 ? <Chip tone="warn" className="font-mono">{s.sensors_degraded} DEGRADED</Chip> : <Chip tone="ok" className="font-mono">UP</Chip>}</Td> | |
| 46 | 83 | </tr> |
| 47 | 84 | ))} |
| 48 | 85 | </Table> |
modified
apps/web/src/app/timeline/[id]/page.tsx
+7 −28
@@ -1,33 +1,12 @@ | ||
| 1 | −import Link from "next/link"; | |
| 2 | −import type { Metadata } from "next"; | |
| 3 | −import { notFound } from "next/navigation"; | |
| 4 | −import { Timeline } from "@/components/timeline"; | |
| 5 | −import { PageHeader, Panel } from "@/components/ui"; | |
| 6 | −import { api } from "@/lib/api"; | |
| 1 | +import { permanentRedirect } from "next/navigation"; | |
| 7 | 2 | |
| 8 | 3 | export const dynamic = "force-dynamic"; |
| 9 | 4 | |
| 10 | −export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { | |
| 5 | +/** `/timeline/:id` lives on the entity page now — permanently redirected to `/entity/:id?tab=timeline`. */ | |
| 6 | +export default async function TimelinePage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ cursor?: string; range?: string }> }) { | |
| 11 | 7 | const { id } = await params; |
| 12 | − const d = await api.entity(id); | |
| 13 | − return { title: d ? `${d.entity.name} — full timeline` : "Timeline", alternates: { canonical: `/timeline/${id}` } }; | |
| 14 | −} | |
| 15 | − | |
| 16 | −export default async function TimelinePage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ cursor?: string }> }) { | |
| 17 | − const { id } = await params; | |
| 18 | − const { cursor } = await searchParams; | |
| 19 | − const d = await api.entity(id); | |
| 20 | − if (!d) notFound(); | |
| 21 | − const page = await api.entityTimeline(d.entity.id, cursor, 100); | |
| 22 | − return ( | |
| 23 | − <> | |
| 24 | − <PageHeader kicker={<Link href={`/company/${d.entity.id}`} className="hover:underline">← {d.entity.name}</Link>} title={`${d.entity.name} · timeline`} description="Every meaningful event, newest first. Git history for this organization's public Web." /> | |
| 25 | − <Panel dense> | |
| 26 | − <Timeline events={page.items} showSource /> | |
| 27 | − <div className="flex justify-end px-3 py-2 text-[12px]"> | |
| 28 | − {page.nextCursor && <Link href={`/timeline/${d.entity.id}?cursor=${encodeURIComponent(page.nextCursor)}`} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 hover:border-line-strong">Older →</Link>} | |
| 29 | − </div> | |
| 30 | − </Panel> | |
| 31 | − </> | |
| 32 | − ); | |
| 8 | + const sp = await searchParams; | |
| 9 | + const q = new URLSearchParams({ tab: "timeline", range: sp.range ?? "all" }); | |
| 10 | + if (sp.cursor) q.set("cursor", sp.cursor); | |
| 11 | + permanentRedirect(`/entity/${encodeURIComponent(id)}?${q.toString()}`); | |
| 33 | 12 | } |
modified
apps/web/src/app/watchlists/page.tsx
+2 −2
@@ -2,12 +2,12 @@ import type { Metadata } from "next"; | ||
| 2 | 2 | import { PageHeader } from "@/components/ui"; |
| 3 | 3 | import { Watchlists } from "./watchlists"; |
| 4 | 4 | |
| 5 | −export const metadata: Metadata = { title: "Watchlists", description: "Follow organizations, sources, keywords and categories. Live updates via WebSocket.", robots: { index: false } }; | |
| 5 | +export const metadata: Metadata = { title: "Watchlists", description: "Follow organizations, sources, keywords, categories, URLs, event types, countries and groups. Live updates via WebSocket.", robots: { index: false } }; | |
| 6 | 6 | |
| 7 | 7 | export default function WatchlistsPage() { |
| 8 | 8 | return ( |
| 9 | 9 | <> |
| 10 | − <PageHeader kicker="Stored in this browser (no account yet)" title="Watchlists" description="Follow entities, sources, keywords or categories. Matching events stream in live. Accounts, email and webhook delivery are planned." /> | |
| 10 | + <PageHeader compact kicker="Stored in this browser (no account yet)" title="Watchlists" description="Follow entities, sources, keywords, categories, exact URLs, event types, countries or groups. Matching events stream in live over the watchlist channel; turn a watchlist into deliveries with an alert rule." /> | |
| 11 | 11 | <Watchlists /> |
| 12 | 12 | </> |
| 13 | 13 | ); |
modified
apps/web/src/app/watchlists/watchlists.tsx
+210 −45
@@ -2,24 +2,51 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { Plus, Trash2, X } from "lucide-react"; |
| 4 | 4 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 5 | +import { COUNTRIES, EVENT_TYPES } from "@websensor/core/client"; | |
| 5 | 6 | import { EventRow } from "@/components/event-row"; |
| 6 | 7 | import { LiveDot } from "@/components/live-feed"; |
| 7 | −import { Chip, Empty, Panel } from "@/components/ui"; | |
| 8 | +import { Chip, Empty, Flag, Panel, SkeletonRows, type Tone } from "@/components/ui"; | |
| 8 | 9 | import { liveToEvent, type EventItem, type LiveEvent, type SearchResult, type Watchlist } from "@/lib/api"; |
| 9 | −import { CHANNEL_KEYS } from "@/lib/format"; | |
| 10 | +import { CHANNEL_KEYS, GROUP_LABELS, hostOf, typeLabel } from "@/lib/format"; | |
| 10 | 11 | import { ownerFetch, publicFetch } from "@/lib/owner"; |
| 11 | 12 | import { useLive } from "@/lib/use-live"; |
| 12 | 13 | |
| 13 | −type Item = { kind: "entity" | "source" | "keyword" | "category"; value: string; label?: string }; | |
| 14 | +type Kind = "entity" | "source" | "keyword" | "category" | "url" | "event_type" | "country" | "group"; | |
| 15 | +type Item = { kind: Kind; value: string }; | |
| 16 | + | |
| 17 | +const KIND_TONE: Record<Kind, Tone> = { entity: "signal", source: "info", category: "ok", keyword: "default", url: "warn", event_type: "high", country: "default", group: "silent" }; | |
| 18 | +const KIND_LABEL: Record<Kind, string> = { entity: "entity", source: "source", keyword: "keyword", category: "category", url: "url", event_type: "type", country: "country", group: "group" }; | |
| 19 | + | |
| 20 | +function itemLabel(i: { kind: string; value: string }): string { | |
| 21 | + switch (i.kind) { | |
| 22 | + case "entity": | |
| 23 | + return i.value.replace(/^(org|prd|ent)_/, ""); | |
| 24 | + case "url": | |
| 25 | + return i.value.length > 48 ? `${hostOf(i.value)}…${i.value.slice(-18)}` : i.value; | |
| 26 | + case "event_type": | |
| 27 | + return EVENT_TYPES[i.value]?.label ?? typeLabel(i.value); | |
| 28 | + case "group": | |
| 29 | + return GROUP_LABELS[i.value] ?? i.value; | |
| 30 | + case "country": | |
| 31 | + return COUNTRIES[i.value]?.name ?? i.value; | |
| 32 | + default: | |
| 33 | + return i.value; | |
| 34 | + } | |
| 35 | +} | |
| 14 | 36 | |
| 15 | 37 | export function Watchlists() { |
| 16 | 38 | const [lists, setLists] = useState<Watchlist[] | null>(null); |
| 17 | 39 | const [active, setActive] = useState<string | null>(null); |
| 18 | − const [events, setEvents] = useState<EventItem[]>([]); | |
| 40 | + /** Keyed by watchlist id: switching lists shows a loading state without a synchronous reset. */ | |
| 41 | + const [feed, setFeed] = useState<{ id: string; items: EventItem[] } | null>(null); | |
| 42 | + const events = feed && feed.id === active ? feed.items : null; | |
| 19 | 43 | const [error, setError] = useState<string | null>(null); |
| 20 | 44 | const [name, setName] = useState(""); |
| 21 | 45 | const [q, setQ] = useState(""); |
| 46 | + const [url, setUrl] = useState(""); | |
| 22 | 47 | const [results, setResults] = useState<SearchResult | null>(null); |
| 48 | + const [typeSel, setTypeSel] = useState(""); | |
| 49 | + const [countrySel, setCountrySel] = useState(""); | |
| 23 | 50 | |
| 24 | 51 | const load = useCallback( |
| 25 | 52 | () => |
@@ -40,7 +67,18 @@ export function Watchlists() { | ||
| 40 | 67 | |
| 41 | 68 | useEffect(() => { |
| 42 | 69 | if (!active) return; |
| 43 | − ownerFetch<{ items: EventItem[] }>(`/api/v1/watchlists/${active}/events?limit=60`).then((r) => setEvents(r.items)).catch(() => setEvents([])); | |
| 70 | + const id = active; | |
| 71 | + let cancelled = false; | |
| 72 | + ownerFetch<{ items: EventItem[] }>(`/api/v1/watchlists/${id}/events?limit=60`) | |
| 73 | + .then((r) => { | |
| 74 | + if (!cancelled) setFeed({ id, items: r.items }); | |
| 75 | + }) | |
| 76 | + .catch(() => { | |
| 77 | + if (!cancelled) setFeed({ id, items: [] }); | |
| 78 | + }); | |
| 79 | + return () => { | |
| 80 | + cancelled = true; | |
| 81 | + }; | |
| 44 | 82 | }, [active, lists]); |
| 45 | 83 | |
| 46 | 84 | useEffect(() => { |
@@ -55,52 +93,76 @@ export function Watchlists() { | ||
| 55 | 93 | }, [q]); |
| 56 | 94 | |
| 57 | 95 | const channels = useMemo(() => (active ? [`watchlist:${active}`] : []), [active]); |
| 58 | − const status = useLive(channels, (e: LiveEvent) => setEvents((prev) => (prev.some((x) => x.id === e.id) ? prev : [liveToEvent(e), ...prev].slice(0, 200)))); | |
| 96 | + const status = useLive(channels, (e: LiveEvent) => setFeed((prev) => (!prev || prev.id !== active || prev.items.some((x) => x.id === e.id) ? prev : { id: prev.id, items: [liveToEvent(e), ...prev.items].slice(0, 200) }))); | |
| 59 | 97 | |
| 60 | 98 | const current = lists?.find((l) => l.id === active) ?? null; |
| 61 | 99 | |
| 62 | 100 | const create = async (): Promise<void> => { |
| 63 | − const wl = await ownerFetch<Watchlist>("/api/v1/watchlists", { method: "POST", body: JSON.stringify({ name: name.trim() || "My watchlist", items: [] }) }); | |
| 64 | − setName(""); | |
| 65 | − await load(); | |
| 66 | − setActive(wl.id); | |
| 101 | + try { | |
| 102 | + const wl = await ownerFetch<Watchlist>("/api/v1/watchlists", { method: "POST", body: JSON.stringify({ name: name.trim() || "My watchlist", items: [] }) }); | |
| 103 | + setName(""); | |
| 104 | + await load(); | |
| 105 | + setActive(wl.id); | |
| 106 | + } catch (e) { | |
| 107 | + setError((e as Error).message); | |
| 108 | + } | |
| 67 | 109 | }; |
| 68 | 110 | const remove = async (id: string): Promise<void> => { |
| 69 | − await ownerFetch(`/api/v1/watchlists/${id}`, { method: "DELETE" }); | |
| 111 | + await ownerFetch(`/api/v1/watchlists/${id}`, { method: "DELETE" }).catch((e: Error) => setError(e.message)); | |
| 70 | 112 | setActive(null); |
| 71 | 113 | await load(); |
| 72 | 114 | }; |
| 73 | 115 | const setItems = async (items: Item[]): Promise<void> => { |
| 74 | 116 | if (!current) return; |
| 75 | − await ownerFetch(`/api/v1/watchlists/${current.id}`, { method: "PUT", body: JSON.stringify({ items: items.map((i) => ({ kind: i.kind, value: i.value })) }) }); | |
| 117 | + try { | |
| 118 | + await ownerFetch(`/api/v1/watchlists/${current.id}`, { method: "PUT", body: JSON.stringify({ items: items.map((i) => ({ kind: i.kind, value: i.value })) }) }); | |
| 119 | + setError(null); | |
| 120 | + } catch (e) { | |
| 121 | + setError((e as Error).message); | |
| 122 | + } | |
| 76 | 123 | await load(); |
| 77 | 124 | }; |
| 125 | + const currentItems = (): Item[] => (current?.items ?? []).map((i) => ({ kind: i.kind as Kind, value: i.value })); | |
| 78 | 126 | const add = (it: Item): void => { |
| 79 | 127 | if (!current) return; |
| 80 | − const items = current.items.map((i) => ({ kind: i.kind as Item["kind"], value: i.value })); | |
| 128 | + const items = currentItems(); | |
| 81 | 129 | if (!items.some((i) => i.kind === it.kind && i.value === it.value)) void setItems([...items, it]); |
| 82 | 130 | setQ(""); |
| 83 | 131 | }; |
| 84 | 132 | const del = (it: { kind: string; value: string }): void => { |
| 85 | 133 | if (!current) return; |
| 86 | − void setItems(current.items.filter((i) => !(i.kind === it.kind && i.value === it.value)).map((i) => ({ kind: i.kind as Item["kind"], value: i.value }))); | |
| 134 | + void setItems(currentItems().filter((i) => !(i.kind === it.kind && i.value === it.value))); | |
| 135 | + }; | |
| 136 | + const addUrl = (): void => { | |
| 137 | + const v = url.trim(); | |
| 138 | + if (!/^https?:\/\//i.test(v)) { | |
| 139 | + setError("URL must start with http:// or https://"); | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + add({ kind: "url", value: v }); | |
| 143 | + setUrl(""); | |
| 87 | 144 | }; |
| 88 | 145 | |
| 146 | + const selectCls = "h-8 min-w-0 flex-1 rounded-md border border-line bg-panel px-2 text-[12.5px]"; | |
| 147 | + const addBtn = "inline-flex h-8 shrink-0 items-center gap-1 rounded-md border border-line bg-panel-2 px-2 text-[12px] hover:border-line-strong disabled:opacity-50"; | |
| 148 | + | |
| 89 | 149 | return ( |
| 90 | − <div className="grid gap-4 lg:grid-cols-[300px_1fr]"> | |
| 91 | − <aside className="flex flex-col gap-4"> | |
| 150 | + <div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]"> | |
| 151 | + <aside className="flex min-w-0 flex-col gap-4"> | |
| 92 | 152 | <Panel title="Your watchlists" dense> |
| 93 | 153 | {lists === null ? ( |
| 94 | − <Empty>Loading…</Empty> | |
| 154 | + <SkeletonRows rows={2} /> | |
| 95 | 155 | ) : ( |
| 96 | 156 | <ul className="divide-y divide-line"> |
| 97 | 157 | {lists.map((l) => ( |
| 98 | − <li key={l.id} className={`flex items-center justify-between px-3 py-1.5 text-[13px] ${l.id === active ? "bg-panel-2" : ""}`}> | |
| 99 | − <button type="button" onClick={() => setActive(l.id)} className="min-w-0 flex-1 truncate text-left hover:underline">{l.name} <span className="text-fg-subtle">· {l.items.length}</span></button> | |
| 100 | − <button type="button" aria-label="Delete" onClick={() => remove(l.id)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button> | |
| 158 | + <li key={l.id} className={`flex items-center justify-between gap-2 px-3 py-1.5 text-[13px] ${l.id === active ? "bg-panel-2" : ""}`}> | |
| 159 | + <button type="button" aria-pressed={l.id === active} onClick={() => setActive(l.id)} className="min-w-0 flex-1 truncate text-left hover:underline"> | |
| 160 | + {l.name} <span className="font-mono text-[11px] text-fg-subtle">· {l.items.length}</span> | |
| 161 | + </button> | |
| 162 | + <button type="button" aria-label={`Delete watchlist ${l.name}`} onClick={() => remove(l.id)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button> | |
| 101 | 163 | </li> |
| 102 | 164 | ))} |
| 103 | − {lists.length === 0 && <Empty>No watchlist yet.</Empty>} | |
| 165 | + {lists.length === 0 && <Empty>No watchlist yet — create one below.</Empty>} | |
| 104 | 166 | </ul> |
| 105 | 167 | )} |
| 106 | 168 | <form |
@@ -110,44 +172,147 @@ export function Watchlists() { | ||
| 110 | 172 | void create(); |
| 111 | 173 | }} |
| 112 | 174 | > |
| 113 | − <input value={name} onChange={(e) => setName(e.target.value)} placeholder="New watchlist name" className="h-8 min-w-0 flex-1 rounded-md border border-line bg-panel px-2 text-[12.5px]" /> | |
| 114 | − <button type="submit" className="inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-2 text-[12px]"><Plus className="size-3.5" /> Add</button> | |
| 175 | + <input value={name} onChange={(e) => setName(e.target.value)} placeholder="New watchlist name" aria-label="New watchlist name" className={selectCls} /> | |
| 176 | + <button type="submit" className={addBtn}><Plus className="size-3.5" /> Add</button> | |
| 115 | 177 | </form> |
| 116 | 178 | {error && <p className="px-3 pb-2 text-[11px] text-danger">{error}</p>} |
| 117 | 179 | </Panel> |
| 180 | + | |
| 118 | 181 | {current && ( |
| 119 | − <Panel title={`Items · ${current.items.length}`}> | |
| 120 | − <div className="mb-2 flex flex-wrap gap-1"> | |
| 182 | + <Panel title={<span>Items <span className="font-mono text-fg-subtle">{current.items.length}</span></span>}> | |
| 183 | + <div className="mb-3 flex flex-wrap gap-1"> | |
| 121 | 184 | {current.items.map((i) => ( |
| 122 | − <Chip key={`${i.kind}:${i.value}`} tone={i.kind === "entity" ? "signal" : i.kind === "source" ? "info" : i.kind === "category" ? "ok" : "default"}> | |
| 123 | − <span className="text-fg-subtle">{i.kind}</span> {i.value.replace(/^(org|prd)_/, "")} | |
| 124 | − <button type="button" aria-label="Remove" onClick={() => del(i)} className="ml-0.5 hover:text-danger"><X className="size-3" /></button> | |
| 185 | + <Chip key={`${i.kind}:${i.value}`} tone={KIND_TONE[i.kind as Kind] ?? "default"} title={`${i.kind}: ${i.value}`}> | |
| 186 | + <span className="font-mono text-[9.5px] uppercase tracking-wider opacity-70">{KIND_LABEL[i.kind as Kind] ?? i.kind}</span> | |
| 187 | + {i.kind === "country" && <Flag code={i.value} />} | |
| 188 | + <span className="max-w-[14rem] truncate">{itemLabel(i)}</span> | |
| 189 | + <button type="button" aria-label={`Remove ${i.kind} ${i.value}`} onClick={() => del(i)} className="ml-0.5 hover:text-danger"><X className="size-3" /></button> | |
| 125 | 190 | </Chip> |
| 126 | 191 | ))} |
| 127 | − {current.items.length === 0 && <span className="text-[12px] text-fg-subtle">Empty — add entities, sources, keywords or categories.</span>} | |
| 192 | + {current.items.length === 0 && <span className="text-[12px] text-fg-subtle">Empty — add entities, sources, keywords, categories, URLs, event types, countries or groups.</span>} | |
| 128 | 193 | </div> |
| 129 | − <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search entities / sources, or type a keyword…" className="h-8 w-full rounded-md border border-line bg-panel px-2 text-[12.5px]" /> | |
| 130 | − {q.trim().length >= 2 && ( | |
| 131 | − <div className="mt-1 max-h-64 overflow-auto rounded-md border border-line bg-panel text-[12.5px]"> | |
| 132 | − <button type="button" onClick={() => add({ kind: "keyword", value: q.trim().toLowerCase() })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2">keyword “{q.trim()}”</button> | |
| 133 | − {results?.entities.map((e) => ( | |
| 134 | − <button key={e.id} type="button" onClick={() => add({ kind: "entity", value: e.id })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2">entity · {e.name} <span className="text-fg-subtle">{e.type}</span></button> | |
| 135 | − ))} | |
| 136 | − {results?.sources.map((s) => ( | |
| 137 | − <button key={s.id} type="button" onClick={() => add({ kind: "source", value: s.id })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2">source · {s.name} <span className="text-fg-subtle">{s.domain}</span></button> | |
| 138 | − ))} | |
| 194 | + | |
| 195 | + <div className="flex flex-col gap-3 text-[12.5px]"> | |
| 196 | + <div> | |
| 197 | + <div className="label mb-1">Entity · source · keyword</div> | |
| 198 | + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search entities / sources, or type a keyword…" aria-label="Search entities, sources or keyword" className="h-8 w-full rounded-md border border-line bg-panel px-2 text-[12.5px]" /> | |
| 199 | + {q.trim().length >= 2 && ( | |
| 200 | + <div className="mt-1 max-h-64 overflow-auto rounded-md border border-line bg-panel text-[12.5px]"> | |
| 201 | + <button type="button" onClick={() => add({ kind: "keyword", value: q.trim().toLowerCase() })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2"><span className="label mr-1 !text-[9.5px]">keyword</span> “{q.trim()}”</button> | |
| 202 | + {results?.entities.map((e) => ( | |
| 203 | + <button key={e.id} type="button" onClick={() => add({ kind: "entity", value: e.id })} className="block w-full truncate px-2 py-1.5 text-left hover:bg-panel-2"><span className="label mr-1 !text-[9.5px] !text-signal">entity</span> {e.name} <span className="text-fg-subtle">{e.type}</span></button> | |
| 204 | + ))} | |
| 205 | + {results?.sources.map((s) => ( | |
| 206 | + <button key={s.id} type="button" onClick={() => add({ kind: "source", value: s.id })} className="block w-full truncate px-2 py-1.5 text-left hover:bg-panel-2"><span className="label mr-1 !text-[9.5px] !text-info">source</span> {s.name} <span className="text-fg-subtle">{s.domain}</span></button> | |
| 207 | + ))} | |
| 208 | + {results && !results.entities.length && !results.sources.length && <div className="px-2 py-1.5 text-[11.5px] text-fg-subtle">No entity or source matches — add as a keyword.</div>} | |
| 209 | + </div> | |
| 210 | + )} | |
| 211 | + </div> | |
| 212 | + | |
| 213 | + <div> | |
| 214 | + <div className="label mb-1">URL</div> | |
| 215 | + <form | |
| 216 | + className="flex gap-1" | |
| 217 | + onSubmit={(e) => { | |
| 218 | + e.preventDefault(); | |
| 219 | + addUrl(); | |
| 220 | + }} | |
| 221 | + > | |
| 222 | + <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/pricing" aria-label="URL to watch" className={`${selectCls} font-mono text-[12px]`} /> | |
| 223 | + <button type="submit" disabled={!url.trim()} className={addBtn}><Plus className="size-3.5" /></button> | |
| 224 | + </form> | |
| 225 | + <p className="mt-1 text-[11px] text-fg-subtle">Matches events whose monitored URL equals this address. To watch a page WebSensor does not cover yet, create a <a href="/monitors" className="text-info hover:underline">custom monitor</a>.</p> | |
| 226 | + </div> | |
| 227 | + | |
| 228 | + <div> | |
| 229 | + <div className="label mb-1">Event type</div> | |
| 230 | + <div className="flex gap-1"> | |
| 231 | + <select value={typeSel} onChange={(e) => setTypeSel(e.target.value)} aria-label="Event type" className={selectCls}> | |
| 232 | + <option value="">Choose an event type…</option> | |
| 233 | + {Object.entries(EVENT_TYPES).map(([k, v]) => ( | |
| 234 | + <option key={k} value={k}>{v.label}</option> | |
| 235 | + ))} | |
| 236 | + </select> | |
| 237 | + <button | |
| 238 | + type="button" | |
| 239 | + disabled={!typeSel} | |
| 240 | + onClick={() => { | |
| 241 | + if (typeSel) add({ kind: "event_type", value: typeSel }); | |
| 242 | + setTypeSel(""); | |
| 243 | + }} | |
| 244 | + className={addBtn} | |
| 245 | + > | |
| 246 | + <Plus className="size-3.5" /> | |
| 247 | + </button> | |
| 248 | + </div> | |
| 249 | + </div> | |
| 250 | + | |
| 251 | + <div> | |
| 252 | + <div className="label mb-1">Country</div> | |
| 253 | + <div className="flex gap-1"> | |
| 254 | + <select value={countrySel} onChange={(e) => setCountrySel(e.target.value)} aria-label="Country" className={selectCls}> | |
| 255 | + <option value="">Choose a country…</option> | |
| 256 | + {Object.entries(COUNTRIES).map(([code, c]) => ( | |
| 257 | + <option key={code} value={code}>{c.flag} {c.name} ({code})</option> | |
| 258 | + ))} | |
| 259 | + </select> | |
| 260 | + <button | |
| 261 | + type="button" | |
| 262 | + disabled={!countrySel} | |
| 263 | + onClick={() => { | |
| 264 | + if (countrySel) add({ kind: "country", value: countrySel }); | |
| 265 | + setCountrySel(""); | |
| 266 | + }} | |
| 267 | + className={addBtn} | |
| 268 | + > | |
| 269 | + <Plus className="size-3.5" /> | |
| 270 | + </button> | |
| 271 | + </div> | |
| 272 | + </div> | |
| 273 | + | |
| 274 | + <div> | |
| 275 | + <div className="label mb-1">Group</div> | |
| 276 | + <div className="flex flex-wrap gap-1"> | |
| 277 | + {Object.entries(GROUP_LABELS).map(([k, v]) => { | |
| 278 | + const on = current.items.some((i) => i.kind === "group" && i.value === k); | |
| 279 | + return ( | |
| 280 | + <button key={k} type="button" aria-pressed={on} disabled={on} onClick={() => add({ kind: "group", value: k })} className={`rounded-sm border px-1.5 py-px text-[10.5px] leading-4 ${on ? "border-silent/40 bg-silent-soft text-silent" : "border-line text-fg-muted hover:text-fg"}`}> | |
| 281 | + {on ? "" : "+ "}{v} | |
| 282 | + </button> | |
| 283 | + ); | |
| 284 | + })} | |
| 285 | + </div> | |
| 286 | + </div> | |
| 287 | + | |
| 288 | + <div> | |
| 289 | + <div className="label mb-1">Category</div> | |
| 290 | + <div className="flex flex-wrap gap-1"> | |
| 291 | + {CHANNEL_KEYS.map((c) => { | |
| 292 | + const on = current.items.some((i) => i.kind === "category" && i.value === c); | |
| 293 | + return ( | |
| 294 | + <button key={c} type="button" aria-pressed={on} disabled={on} onClick={() => add({ kind: "category", value: c })} className={`rounded-sm border px-1.5 py-px text-[10.5px] leading-4 ${on ? "border-ok/40 bg-ok/10 text-ok" : "border-line text-fg-muted hover:text-fg"}`}> | |
| 295 | + {on ? "" : "+ "}{c} | |
| 296 | + </button> | |
| 297 | + ); | |
| 298 | + })} | |
| 299 | + </div> | |
| 139 | 300 | </div> |
| 140 | − )} | |
| 141 | − <div className="mt-2 flex flex-wrap gap-1"> | |
| 142 | − {CHANNEL_KEYS.map((c) => ( | |
| 143 | − <button key={c} type="button" onClick={() => add({ kind: "category", value: c })} className="rounded-sm border border-line px-1.5 py-px text-[10.5px] text-fg-muted hover:text-fg">+ {c}</button> | |
| 144 | − ))} | |
| 145 | 301 | </div> |
| 146 | 302 | </Panel> |
| 147 | 303 | )} |
| 148 | 304 | </aside> |
| 305 | + | |
| 149 | 306 | <Panel title={<span className="flex items-center gap-3">{current ? current.name : "Events"} {active && <LiveDot status={status} />}</span>} dense> |
| 150 | − {!current ? <Empty>Select or create a watchlist.</Empty> : events.length ? events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No events match this watchlist yet — new ones will appear live.</Empty>} | |
| 307 | + {!current ? ( | |
| 308 | + <Empty>{lists && lists.length === 0 ? "Create a watchlist to start following entities, sources, URLs, types, countries or groups." : "Select a watchlist."}</Empty> | |
| 309 | + ) : events === null ? ( | |
| 310 | + <SkeletonRows rows={6} /> | |
| 311 | + ) : events.length ? ( | |
| 312 | + events.map((e) => <EventRow key={e.id} ev={e} showDate />) | |
| 313 | + ) : ( | |
| 314 | + <Empty>{current.items.length === 0 ? "This watchlist is empty — add items on the left." : "No events match this watchlist yet — new ones will appear live."}</Empty> | |
| 315 | + )} | |
| 151 | 316 | </Panel> |
| 152 | 317 | </div> |
| 153 | 318 | ); |
added
apps/web/src/components/command-palette.tsx
+205 −0
@@ -0,0 +1,205 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useRouter } from "next/navigation"; | |
| 4 | +import { Activity, Building2, Compass, Globe2, Layers, Radar, Search, Siren, Zap } from "lucide-react"; | |
| 5 | +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 6 | +import type { SearchResult } from "@/lib/api"; | |
| 7 | +import { publicFetch } from "@/lib/owner"; | |
| 8 | +import { useEventDrawer } from "./event-drawer"; | |
| 9 | +import { usePrefs, type Density } from "./prefs"; | |
| 10 | +import { Chip, Kbd, Score } from "./ui"; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Global command palette (spec §29): ⌘K / Ctrl+K. Searches entities, sources, events, clusters, | |
| 14 | + * URLs and offers navigation + preference commands. Fully keyboard-driven (↑ ↓ ↵ Esc), ARIA listbox. | |
| 15 | + */ | |
| 16 | +interface Item { | |
| 17 | + id: string; | |
| 18 | + group: string; | |
| 19 | + label: string; | |
| 20 | + hint?: string; | |
| 21 | + icon?: ReactNode; | |
| 22 | + score?: number | null; | |
| 23 | + run: () => void; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const NAV: { label: string; href: string; hint: string; icon: ReactNode }[] = [ | |
| 27 | + { label: "Live feed", href: "/live", hint: "everything, live", icon: <Activity className="size-3.5" /> }, | |
| 28 | + { label: "Breaking", href: "/breaking", hint: "breaking · developing · confirmed", icon: <Siren className="size-3.5" /> }, | |
| 29 | + { label: "Pulse", href: "/pulse", hint: "what is changing on the Internet right now", icon: <Zap className="size-3.5" /> }, | |
| 30 | + { label: "Radar", href: "/radar", hint: "weak signals, not yet breaking", icon: <Radar className="size-3.5" /> }, | |
| 31 | + { label: "Silent changes", href: "/silent", hint: "modified without announcement", icon: <Layers className="size-3.5" /> }, | |
| 32 | + { label: "Explore", href: "/explore", hint: "trending · unusual · entities · sources", icon: <Compass className="size-3.5" /> }, | |
| 33 | + { label: "Entities", href: "/entities", hint: "organizations, products, models", icon: <Building2 className="size-3.5" /> }, | |
| 34 | + { label: "Countries", href: "/country", hint: "national desks", icon: <Globe2 className="size-3.5" /> }, | |
| 35 | + { label: "Sources", href: "/sources", hint: "monitored organizations", icon: <Building2 className="size-3.5" /> }, | |
| 36 | + { label: "Watchlists", href: "/watchlists", hint: "your entities, sources, keywords", icon: <Layers className="size-3.5" /> }, | |
| 37 | + { label: "Alerts", href: "/alerts", hint: "rules · webhooks", icon: <Siren className="size-3.5" /> }, | |
| 38 | + { label: "Bookmarks", href: "/bookmarks", hint: "saved events", icon: <Layers className="size-3.5" /> }, | |
| 39 | + { label: "Health", href: "/health", hint: "sensors, connectors, throughput", icon: <Activity className="size-3.5" /> }, | |
| 40 | + { label: "API", href: "/api", hint: "REST · RSS · WebSocket", icon: <Zap className="size-3.5" /> }, | |
| 41 | +]; | |
| 42 | + | |
| 43 | +export function CommandPalette() { | |
| 44 | + const [open, setOpen] = useState(false); | |
| 45 | + const [q, setQ] = useState(""); | |
| 46 | + const [res, setRes] = useState<SearchResult | null>(null); | |
| 47 | + const [busy, setBusy] = useState(false); | |
| 48 | + const [idx, setIdx] = useState(0); | |
| 49 | + const input = useRef<HTMLInputElement>(null); | |
| 50 | + const router = useRouter(); | |
| 51 | + const drawer = useEventDrawer(); | |
| 52 | + const { setDensity } = usePrefs(); | |
| 53 | + | |
| 54 | + const openPalette = (): void => { | |
| 55 | + setQ(""); | |
| 56 | + setRes(null); | |
| 57 | + setIdx(0); | |
| 58 | + setOpen(true); | |
| 59 | + setTimeout(() => input.current?.focus(), 10); | |
| 60 | + }; | |
| 61 | + useEffect(() => { | |
| 62 | + const onKey = (e: KeyboardEvent): void => { | |
| 63 | + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { | |
| 64 | + e.preventDefault(); | |
| 65 | + if (open) setOpen(false); | |
| 66 | + else openPalette(); | |
| 67 | + } else if (e.key === "/" && !open && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { | |
| 68 | + e.preventDefault(); | |
| 69 | + openPalette(); | |
| 70 | + } | |
| 71 | + }; | |
| 72 | + window.addEventListener("keydown", onKey); | |
| 73 | + const onOpen = (): void => openPalette(); | |
| 74 | + window.addEventListener("ws:palette", onOpen); | |
| 75 | + return () => { | |
| 76 | + window.removeEventListener("keydown", onKey); | |
| 77 | + window.removeEventListener("ws:palette", onOpen); | |
| 78 | + }; | |
| 79 | + }, [open]); | |
| 80 | + useEffect(() => { | |
| 81 | + if (!open) return; | |
| 82 | + const t = q.trim(); | |
| 83 | + const h = setTimeout(() => { | |
| 84 | + if (t.length < 2) { | |
| 85 | + setRes(null); | |
| 86 | + return; | |
| 87 | + } | |
| 88 | + setBusy(true); | |
| 89 | + publicFetch<SearchResult>(`/api/v1/search?q=${encodeURIComponent(t)}&limit=6`) | |
| 90 | + .then((r) => setRes(r)) | |
| 91 | + .catch(() => setRes(null)) | |
| 92 | + .finally(() => setBusy(false)); | |
| 93 | + }, 160); | |
| 94 | + return () => clearTimeout(h); | |
| 95 | + }, [q, open]); | |
| 96 | + | |
| 97 | + const go = useCallback( | |
| 98 | + (href: string) => { | |
| 99 | + setOpen(false); | |
| 100 | + router.push(href); | |
| 101 | + }, | |
| 102 | + [router], | |
| 103 | + ); | |
| 104 | + | |
| 105 | + const items = useMemo<Item[]>(() => { | |
| 106 | + const t = q.trim().toLowerCase(); | |
| 107 | + const out: Item[] = []; | |
| 108 | + if (res) { | |
| 109 | + for (const e of res.entities.slice(0, 5)) out.push({ id: `ent:${e.id}`, group: "Entities", label: e.name, hint: `${e.type}${e.domain ? " · " + e.domain : ""}`, icon: <Building2 className="size-3.5" />, run: () => go(`/entity/${e.id}`) }); | |
| 110 | + for (const s of res.sources.slice(0, 5)) out.push({ id: `src:${s.id}`, group: "Sources", label: s.name, hint: `${s.domain} · tier ${s.tier}${s.first_party === false ? " · media" : ""}`, icon: <Globe2 className="size-3.5" />, run: () => go(`/source/${s.id}`) }); | |
| 111 | + for (const c of res.clusters?.slice(0, 3) ?? []) out.push({ id: `clu:${c.id}`, group: "Clusters", label: c.title, hint: `${c.event_count} signals · ${c.source_count} sources · ${c.state}`, icon: <Layers className="size-3.5" />, score: c.max_importance, run: () => go(`/cluster/${c.slug ?? c.id}`) }); | |
| 112 | + for (const e of res.events.slice(0, 6)) out.push({ id: `evt:${e.id}`, group: "Events", label: e.title, hint: `${e.source?.name} · ${e.event_type.replace(/_/g, " ")}`, icon: <Activity className="size-3.5" />, score: e.signal_score ?? e.importance, run: () => { setOpen(false); drawer.open(e.slug, e); } }); | |
| 113 | + for (const u of res.urls.slice(0, 3)) out.push({ id: `url:${u.url}`, group: "URLs", label: u.url, hint: `${u.change_count} changes`, icon: <Globe2 className="size-3.5" />, run: () => go(`/url?u=${encodeURIComponent(u.url)}`) }); | |
| 114 | + } | |
| 115 | + if (t.length >= 2) out.push({ id: "search", group: "Search", label: `Search events for “${q.trim()}”`, hint: "supports entity: type: after: silent: importance:>", icon: <Search className="size-3.5" />, run: () => go(`/search?q=${encodeURIComponent(q.trim())}`) }); | |
| 116 | + const nav = NAV.filter((n) => !t || n.label.toLowerCase().includes(t) || n.hint.includes(t) || n.href.includes(t)).slice(0, t ? 5 : 14); | |
| 117 | + for (const n of nav) out.push({ id: `nav:${n.href}`, group: "Go to", label: n.label, hint: n.hint, icon: n.icon, run: () => go(n.href) }); | |
| 118 | + const cmds: { label: string; hint: string; run: () => void; match: string }[] = [ | |
| 119 | + { label: "Density: compact", hint: "denser rows", match: "density compact", run: () => setDensity("compact" as Density) }, | |
| 120 | + { label: "Density: normal", hint: "default rows", match: "density normal", run: () => setDensity("normal" as Density) }, | |
| 121 | + { label: "Density: comfortable", hint: "roomier rows with summaries", match: "density comfortable", run: () => setDensity("comfortable" as Density) }, | |
| 122 | + { label: "Toggle theme", hint: "dark / light", match: "theme dark light", run: () => document.documentElement.classList.toggle("dark") }, | |
| 123 | + ]; | |
| 124 | + for (const c of cmds.filter((c) => !t || c.match.includes(t) || c.label.toLowerCase().includes(t)).slice(0, t ? 3 : 4)) out.push({ id: `cmd:${c.label}`, group: "Commands", label: c.label, hint: c.hint, icon: <Zap className="size-3.5" />, run: () => { c.run(); setOpen(false); } }); | |
| 125 | + return out; | |
| 126 | + }, [q, res, go, drawer, setDensity]); | |
| 127 | + | |
| 128 | + // keep the highlighted row valid when the result list shrinks | |
| 129 | + const safeIdx = Math.min(idx, Math.max(0, items.length - 1)); | |
| 130 | + | |
| 131 | + if (!open) return null; | |
| 132 | + const groups = [...new Set(items.map((i) => i.group))]; | |
| 133 | + let flat = -1; | |
| 134 | + return ( | |
| 135 | + <div className="fixed inset-0 z-[60] flex items-start justify-center px-3 pt-[10vh]" role="dialog" aria-modal="true" aria-label="Command palette"> | |
| 136 | + <button type="button" aria-label="Close" onClick={() => setOpen(false)} className="scrim absolute inset-0 cursor-default" /> | |
| 137 | + <div className="relative w-full max-w-xl overflow-hidden rounded-lg border border-line bg-panel shadow-2xl animate-fade-in"> | |
| 138 | + <div className="flex items-center gap-2 border-b border-line px-3"> | |
| 139 | + <Search className="size-4 text-fg-subtle" /> | |
| 140 | + <input | |
| 141 | + ref={input} | |
| 142 | + value={q} | |
| 143 | + onChange={(e) => { | |
| 144 | + setQ(e.target.value); | |
| 145 | + setIdx(0); | |
| 146 | + }} | |
| 147 | + onKeyDown={(e) => { | |
| 148 | + if (e.key === "ArrowDown") { | |
| 149 | + e.preventDefault(); | |
| 150 | + setIdx((i) => Math.min(items.length - 1, i + 1)); | |
| 151 | + } else if (e.key === "ArrowUp") { | |
| 152 | + e.preventDefault(); | |
| 153 | + setIdx((i) => Math.max(0, i - 1)); | |
| 154 | + } else if (e.key === "Enter") { | |
| 155 | + e.preventDefault(); | |
| 156 | + items[safeIdx]?.run(); | |
| 157 | + } else if (e.key === "Escape") setOpen(false); | |
| 158 | + }} | |
| 159 | + placeholder="Search entities, sources, events, URLs — or jump to a page…" | |
| 160 | + className="h-11 w-full bg-transparent text-[14px] outline-none placeholder:text-fg-subtle" | |
| 161 | + role="combobox" | |
| 162 | + aria-expanded | |
| 163 | + aria-controls="palette-list" | |
| 164 | + aria-activedescendant={items[safeIdx] ? `pal-${items[safeIdx].id}` : undefined} | |
| 165 | + autoComplete="off" | |
| 166 | + /> | |
| 167 | + {busy ? <span className="size-3 animate-pulse rounded-full bg-signal" /> : <Kbd>esc</Kbd>} | |
| 168 | + </div> | |
| 169 | + <ul id="palette-list" role="listbox" className="max-h-[60vh] overflow-y-auto py-1"> | |
| 170 | + {groups.map((g) => ( | |
| 171 | + <li key={g} role="presentation"> | |
| 172 | + <div className="label px-3 pb-0.5 pt-2">{g}</div> | |
| 173 | + <ul role="group"> | |
| 174 | + {items | |
| 175 | + .filter((i) => i.group === g) | |
| 176 | + .map((it) => { | |
| 177 | + flat++; | |
| 178 | + const active = flat === safeIdx; | |
| 179 | + const my = flat; | |
| 180 | + return ( | |
| 181 | + <li key={it.id} id={`pal-${it.id}`} role="option" aria-selected={active} onMouseEnter={() => setIdx(my)} onClick={it.run} className={`flex cursor-pointer items-center gap-2.5 px-3 py-1.5 text-[13px] ${active ? "bg-panel-2" : ""}`}> | |
| 182 | + <span className="text-fg-subtle">{it.icon}</span> | |
| 183 | + <span className="min-w-0 flex-1"> | |
| 184 | + <span className="block truncate">{it.label}</span> | |
| 185 | + {it.hint && <span className="block truncate text-[11px] text-fg-subtle">{it.hint}</span>} | |
| 186 | + </span> | |
| 187 | + {it.score !== undefined && it.score !== null && <Score value={it.score} size="sm" kind="signal" />} | |
| 188 | + {active && <Kbd>↵</Kbd>} | |
| 189 | + </li> | |
| 190 | + ); | |
| 191 | + })} | |
| 192 | + </ul> | |
| 193 | + </li> | |
| 194 | + ))} | |
| 195 | + {!items.length && <li className="px-3 py-6 text-center text-[13px] text-fg-subtle">Nothing matches.</li>} | |
| 196 | + </ul> | |
| 197 | + <div className="flex items-center gap-3 border-t border-line px-3 py-1.5 text-[11px] text-fg-subtle"> | |
| 198 | + <span className="inline-flex items-center gap-1"><Kbd>↑</Kbd><Kbd>↓</Kbd> navigate</span> | |
| 199 | + <span className="inline-flex items-center gap-1"><Kbd>↵</Kbd> open</span> | |
| 200 | + <span className="ml-auto inline-flex items-center gap-1"><Chip>entity:openai</Chip><Chip>type:pricing_change</Chip><Chip>silent:true</Chip></span> | |
| 201 | + </div> | |
| 202 | + </div> | |
| 203 | + </div> | |
| 204 | + ); | |
| 205 | +} | |
added
apps/web/src/components/event-drawer.tsx
+330 −0
@@ -0,0 +1,330 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { usePathname } from "next/navigation"; | |
| 5 | +import { Bookmark, BookmarkCheck, ExternalLink, Maximize2, X } from "lucide-react"; | |
| 6 | +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 7 | +import type { EventDetail, EventItem } from "@/lib/api"; | |
| 8 | +import { fmtMs, fmtOffset, fmtScore, relTime, typeLabel, utcDateTime, CLASS_LABELS } from "@/lib/format"; | |
| 9 | +import { ownerFetch, publicFetch } from "@/lib/owner"; | |
| 10 | +import { FieldChanges } from "./field-changes"; | |
| 11 | +import { Badge, Bar, Chip, EvidenceTag, Flag, Score, Skeleton, StateBadge, TypeChip } from "./ui"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Intelligence panel (spec §33, §78): a right drawer on desktop, a full-screen sheet on mobile. | |
| 15 | + * Opens from any feed row without navigation; the permanent page stays one click away. | |
| 16 | + */ | |
| 17 | +interface DrawerCtx { | |
| 18 | + open: (slug: string, seed?: EventItem) => void; | |
| 19 | + close: () => void; | |
| 20 | +} | |
| 21 | +const Ctx = createContext<DrawerCtx>({ open: () => {}, close: () => {} }); | |
| 22 | +export const useEventDrawer = (): DrawerCtx => useContext(Ctx); | |
| 23 | + | |
| 24 | +export function EventDrawerProvider({ children }: { children: ReactNode }) { | |
| 25 | + // The drawer state is keyed by pathname: a route change opens a fresh (closed) drawer. | |
| 26 | + const pathname = usePathname(); | |
| 27 | + const [state, setState] = useState<{ path: string; slug: string | null; seed: EventItem | null; detail: EventDetail | null; loading: boolean }>({ path: pathname, slug: null, seed: null, detail: null, loading: false }); | |
| 28 | + const current = state.path === pathname ? state : { path: pathname, slug: null, seed: null, detail: null, loading: false }; | |
| 29 | + const slug = current.slug; | |
| 30 | + const seed = current.seed; | |
| 31 | + const detail = current.detail; | |
| 32 | + const loading = current.loading; | |
| 33 | + const open = useCallback((s: string, e?: EventItem) => setState({ path: pathname, slug: s, seed: e ?? null, detail: null, loading: true }), [pathname]); | |
| 34 | + const close = useCallback(() => setState({ path: pathname, slug: null, seed: null, detail: null, loading: false }), [pathname]); | |
| 35 | + useEffect(() => { | |
| 36 | + if (!slug) return; | |
| 37 | + let cancelled = false; | |
| 38 | + publicFetch<EventDetail>(`/api/v1/events/${encodeURIComponent(slug)}`) | |
| 39 | + .then((d) => { | |
| 40 | + if (!cancelled) setState((st) => (st.slug === slug ? { ...st, detail: d, loading: false } : st)); | |
| 41 | + }) | |
| 42 | + .catch(() => { | |
| 43 | + if (!cancelled) setState((st) => (st.slug === slug ? { ...st, loading: false } : st)); | |
| 44 | + }); | |
| 45 | + return () => { | |
| 46 | + cancelled = true; | |
| 47 | + }; | |
| 48 | + }, [slug]); | |
| 49 | + useEffect(() => { | |
| 50 | + if (!slug) return; | |
| 51 | + const onKey = (e: KeyboardEvent): void => { | |
| 52 | + if (e.key === "Escape") close(); | |
| 53 | + }; | |
| 54 | + document.addEventListener("keydown", onKey); | |
| 55 | + const prev = document.body.style.overflow; | |
| 56 | + if (window.innerWidth < 1024) document.body.style.overflow = "hidden"; | |
| 57 | + return () => { | |
| 58 | + document.removeEventListener("keydown", onKey); | |
| 59 | + document.body.style.overflow = prev; | |
| 60 | + }; | |
| 61 | + }, [slug, close]); | |
| 62 | + const value = useMemo(() => ({ open, close }), [open, close]); | |
| 63 | + return ( | |
| 64 | + <Ctx.Provider value={value}> | |
| 65 | + {children} | |
| 66 | + {slug && <Drawer slug={slug} seed={seed} detail={detail} loading={loading} onClose={close} />} | |
| 67 | + </Ctx.Provider> | |
| 68 | + ); | |
| 69 | +} | |
| 70 | + | |
| 71 | +function Drawer({ slug, seed, detail, loading, onClose }: { slug: string; seed: EventItem | null; detail: EventDetail | null; loading: boolean; onClose: () => void }) { | |
| 72 | + const ev = detail?.event ?? seed; | |
| 73 | + const ref = useRef<HTMLDivElement>(null); | |
| 74 | + useEffect(() => { | |
| 75 | + ref.current?.focus(); | |
| 76 | + }, [slug]); | |
| 77 | + return ( | |
| 78 | + <div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true" aria-label={ev?.title ?? "Event"}> | |
| 79 | + <button type="button" aria-label="Close" onClick={onClose} className="scrim absolute inset-0 hidden cursor-default lg:block" /> | |
| 80 | + <div ref={ref} tabIndex={-1} className="relative flex h-full w-full flex-col bg-bg shadow-2xl outline-none animate-sheet-up lg:w-[520px] lg:animate-slide-in lg:border-l lg:border-line xl:w-[600px]"> | |
| 81 | + <header className="flex items-center gap-2 border-b border-line px-3 py-2"> | |
| 82 | + <span className="label">Intelligence panel</span> | |
| 83 | + <span className="ml-auto flex items-center gap-1"> | |
| 84 | + {ev && <BookmarkButton eventId={ev.id} />} | |
| 85 | + {ev && ( | |
| 86 | + <Link href={`/event/${ev.slug}`} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-muted hover:text-fg" title="Open full page" aria-label="Open full page"> | |
| 87 | + <Maximize2 className="size-3.5" /> | |
| 88 | + </Link> | |
| 89 | + )} | |
| 90 | + <button type="button" onClick={onClose} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-muted hover:text-fg" aria-label="Close"> | |
| 91 | + <X className="size-4" /> | |
| 92 | + </button> | |
| 93 | + </span> | |
| 94 | + </header> | |
| 95 | + <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 pb-24 lg:pb-6"> | |
| 96 | + {!ev ? ( | |
| 97 | + <DrawerSkeleton /> | |
| 98 | + ) : ( | |
| 99 | + <> | |
| 100 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 101 | + <Link href={`/source/${ev.source?.id ?? ev.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{ev.source?.name}</Link> | |
| 102 | + {ev.country && <Flag code={ev.country} className="text-[12px]" />} | |
| 103 | + <StateBadge state={ev.cluster?.state} /> | |
| 104 | + {ev.silent_change && <Badge kind="silent" />} | |
| 105 | + {ev.first_party === false ? <Badge kind="external" /> : <Badge kind="first-party" />} | |
| 106 | + <EvidenceTag label={ev.evidence_label} /> | |
| 107 | + </div> | |
| 108 | + <h2 className="mt-1.5 text-[17px] font-semibold leading-snug">{ev.title}</h2> | |
| 109 | + <div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11.5px] text-fg-subtle"> | |
| 110 | + <TypeChip type={ev.event_type} /> | |
| 111 | + <span>detected {relTime(ev.detected_at)}</span> | |
| 112 | + <span>·</span> | |
| 113 | + <span className="font-mono">{utcDateTime(ev.detected_at)}</span> | |
| 114 | + </div> | |
| 115 | + <p className="mt-3 text-[13.5px] leading-relaxed">{ev.summary}</p> | |
| 116 | + | |
| 117 | + {/* WHAT CHANGED */} | |
| 118 | + {(ev.field_changes?.length ?? 0) > 0 && ( | |
| 119 | + <section className="mt-4"> | |
| 120 | + <div className="label mb-1.5">What changed</div> | |
| 121 | + <FieldChanges items={ev.field_changes} max={8} /> | |
| 122 | + </section> | |
| 123 | + )} | |
| 124 | + {ev.why_it_matters && ( | |
| 125 | + <section className="mt-4"> | |
| 126 | + <div className="label mb-1">Why it matters <span className="normal-case tracking-normal text-fg-subtle">· analysis</span></div> | |
| 127 | + <p className="text-[13px] leading-relaxed text-fg-muted">{ev.why_it_matters}</p> | |
| 128 | + </section> | |
| 129 | + )} | |
| 130 | + | |
| 131 | + {/* SCORES */} | |
| 132 | + <section className="mt-4 rounded-md border border-line bg-panel p-3"> | |
| 133 | + <div className="flex items-center gap-3"> | |
| 134 | + <Score value={ev.signal_score ?? ev.importance} size="lg" kind="signal" /> | |
| 135 | + <div className="min-w-0"> | |
| 136 | + <div className="label">WebSensor signal score</div> | |
| 137 | + <div className="text-[12px] text-fg-muted">{(ev.signal_score ?? ev.importance) >= 80 ? "attention now" : (ev.signal_score ?? ev.importance) >= 60 ? "worth a look" : "informational"}</div> | |
| 138 | + </div> | |
| 139 | + </div> | |
| 140 | + <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-[12px]"> | |
| 141 | + <Mini label="Importance" v={ev.importance} /> | |
| 142 | + <Mini label="Confidence" v={ev.confidence} tone="info" /> | |
| 143 | + <Mini label="Novelty" v={ev.novelty} /> | |
| 144 | + <Mini label="Impact" v={ev.impact_score ?? 0} tone="high" /> | |
| 145 | + <Mini label="Velocity" v={ev.velocity_score ?? 0} tone="silent" /> | |
| 146 | + <Mini label="Anomaly" v={ev.anomaly_score ?? 0} tone="mid" /> | |
| 147 | + </div> | |
| 148 | + {(ev.score_reasons?.length ?? 0) > 0 && ( | |
| 149 | + <ul className="mt-3 space-y-0.5 text-[12px]"> | |
| 150 | + {ev.score_reasons!.slice(0, 7).map((r, i) => ( | |
| 151 | + <li key={i} className="flex gap-2"> | |
| 152 | + <span className={`font-mono ${r.sign === "+" ? "text-signal" : "text-danger"}`}>{r.sign}</span> | |
| 153 | + <span className="text-fg-muted">{r.text}</span> | |
| 154 | + {r.points !== undefined && <span className="ml-auto font-mono text-fg-subtle tabular">{r.points > 0 ? "+" : ""}{r.points}</span>} | |
| 155 | + </li> | |
| 156 | + ))} | |
| 157 | + </ul> | |
| 158 | + )} | |
| 159 | + {ev.change_class && <div className="mt-2 text-[11px] text-fg-subtle">Semantic class: <span className="text-fg-muted">{CLASS_LABELS[ev.change_class] ?? ev.change_class}</span></div>} | |
| 160 | + </section> | |
| 161 | + | |
| 162 | + {/* CLUSTER / PROPAGATION */} | |
| 163 | + {ev.cluster && ev.cluster.event_count > 1 && ( | |
| 164 | + <section className="mt-4 rounded-md border border-line bg-panel p-3"> | |
| 165 | + <div className="flex items-center justify-between"> | |
| 166 | + <div className="label">Event cluster</div> | |
| 167 | + <Link href={`/cluster/${ev.cluster.slug ?? ev.cluster.id}`} className="text-[11.5px] text-info hover:underline">propagation timeline →</Link> | |
| 168 | + </div> | |
| 169 | + <div className="mt-2 grid grid-cols-3 gap-2 text-center"> | |
| 170 | + <Cell v={ev.cluster.event_count} l="signals" /> | |
| 171 | + <Cell v={ev.cluster.first_party_count ?? 0} l="first-party" /> | |
| 172 | + <Cell v={ev.cluster.external_count ?? 0} l="external" /> | |
| 173 | + </div> | |
| 174 | + {ev.cluster.lead_time_ms !== null && ev.cluster.lead_time_ms !== undefined && ev.cluster.lead_time_ms > 0 && ( | |
| 175 | + <p className="mt-2 text-[12px] text-fg-muted"> | |
| 176 | + <span className="font-semibold text-signal">WebSensor lead time</span> {fmtOffset(ev.cluster.lead_time_ms).replace("+", "")} before the first external report. | |
| 177 | + </p> | |
| 178 | + )} | |
| 179 | + </section> | |
| 180 | + )} | |
| 181 | + | |
| 182 | + {/* EVIDENCE */} | |
| 183 | + <section className="mt-4"> | |
| 184 | + <div className="label mb-1.5">Evidence</div> | |
| 185 | + <div className="rounded-md border border-line bg-panel p-3 text-[12.5px]"> | |
| 186 | + <a href={ev.url} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-full items-center gap-1 break-all font-mono text-[12px] text-info hover:underline"> | |
| 187 | + {ev.url} <ExternalLink className="size-3 shrink-0" /> | |
| 188 | + </a> | |
| 189 | + {detail ? ( | |
| 190 | + <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-[12px]"> | |
| 191 | + {detail.snapshots.map((s) => ( | |
| 192 | + <FragmentRow key={s.id} label={s.id === ev.old_snapshot_id ? "Before" : "After"} value={`${utcDateTime(s.captured_at)} · HTTP ${s.http_status ?? "—"} · ${s.canonical_hash?.slice(0, 12) ?? ""}`} /> | |
| 193 | + ))} | |
| 194 | + <FragmentRow label="Observed from" value={ev.observed_from ? utcDateTime(ev.observed_from) : "—"} /> | |
| 195 | + <FragmentRow label="Published" value={ev.published_at ? utcDateTime(ev.published_at) : "—"} /> | |
| 196 | + <FragmentRow label="Processing" value={fmtMs(ev.processing_latency_ms)} /> | |
| 197 | + </dl> | |
| 198 | + ) : ( | |
| 199 | + <Skeleton className="mt-2 h-10 w-full" /> | |
| 200 | + )} | |
| 201 | + <div className="mt-2 flex flex-wrap gap-2 text-[11.5px]"> | |
| 202 | + <Link href={`/event/${ev.slug}#diff`} className="text-info hover:underline">Open diff</Link> | |
| 203 | + {ev.old_snapshot_id && ev.new_snapshot_id && <Link href={`/compare?a=${ev.old_snapshot_id}&b=${ev.new_snapshot_id}`} className="text-info hover:underline">Compare snapshots</Link>} | |
| 204 | + <Link href={`/sensor/${ev.sensor_id}`} className="text-info hover:underline">Sensor</Link> | |
| 205 | + <Link href={`/url?u=${encodeURIComponent(ev.url)}`} className="text-info hover:underline">URL history</Link> | |
| 206 | + </div> | |
| 207 | + </div> | |
| 208 | + </section> | |
| 209 | + | |
| 210 | + {/* HISTORICAL CONTEXT */} | |
| 211 | + {detail?.history && detail.history.length > 0 && ( | |
| 212 | + <section className="mt-4"> | |
| 213 | + <div className="label mb-1.5">Previous changes on this page</div> | |
| 214 | + <ul className="divide-y divide-line rounded-md border border-line bg-panel"> | |
| 215 | + {detail.history.map((h) => ( | |
| 216 | + <li key={h.id} className="flex items-center gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 217 | + <span className="w-[5.5rem] shrink-0 font-mono text-[11px] text-fg-subtle tabular">{relTime(h.detected_at)}</span> | |
| 218 | + <Link href={`/event/${h.slug}`} className="min-w-0 flex-1 truncate hover:underline">{h.title}</Link> | |
| 219 | + <Score value={h.importance} size="sm" /> | |
| 220 | + </li> | |
| 221 | + ))} | |
| 222 | + </ul> | |
| 223 | + </section> | |
| 224 | + )} | |
| 225 | + | |
| 226 | + {/* RELATED */} | |
| 227 | + {detail?.related && detail.related.length > 0 && ( | |
| 228 | + <section className="mt-4"> | |
| 229 | + <div className="label mb-1.5">Related signals</div> | |
| 230 | + <ul className="divide-y divide-line rounded-md border border-line bg-panel"> | |
| 231 | + {detail.related.slice(0, 6).map((r) => ( | |
| 232 | + <li key={r.id} className="flex items-center gap-2 px-3 py-1.5 text-[12.5px]"> | |
| 233 | + <span className="w-[5.5rem] shrink-0 truncate font-mono text-[10.5px] uppercase text-fg-subtle">{r.source?.name}</span> | |
| 234 | + <Link href={`/event/${r.slug}`} className="min-w-0 flex-1 truncate hover:underline">{r.title}</Link> | |
| 235 | + <Score value={r.signal_score ?? r.importance} size="sm" kind="signal" /> | |
| 236 | + </li> | |
| 237 | + ))} | |
| 238 | + </ul> | |
| 239 | + </section> | |
| 240 | + )} | |
| 241 | + {ev.entities?.length > 0 && ( | |
| 242 | + <div className="mt-4 flex flex-wrap gap-1"> | |
| 243 | + {ev.entities.map((x) => ( | |
| 244 | + <Chip key={x.id} href={`/entity/${x.id}`} tone={x.role === "subject" ? "signal" : "default"}>{x.name}</Chip> | |
| 245 | + ))} | |
| 246 | + </div> | |
| 247 | + )} | |
| 248 | + <p className="mt-4 text-[11px] text-fg-subtle">{typeLabel(ev.event_type)} · {loading ? "loading details…" : "raw evidence is immutable; analysis is labelled and versioned."}</p> | |
| 249 | + </> | |
| 250 | + )} | |
| 251 | + </div> | |
| 252 | + </div> | |
| 253 | + </div> | |
| 254 | + ); | |
| 255 | +} | |
| 256 | + | |
| 257 | +function Mini({ label, v, tone }: { label: string; v: number; tone?: "signal" | "hot" | "high" | "mid" | "silent" | "info" }) { | |
| 258 | + return ( | |
| 259 | + <div> | |
| 260 | + <div className="flex items-baseline justify-between"> | |
| 261 | + <span className="text-fg-subtle">{label}</span> | |
| 262 | + <span className="font-mono tabular">{fmtScore(v)}</span> | |
| 263 | + </div> | |
| 264 | + <Bar value={v} tone={tone ?? (v >= 80 ? "hot" : v >= 60 ? "high" : "signal")} /> | |
| 265 | + </div> | |
| 266 | + ); | |
| 267 | +} | |
| 268 | +function Cell({ v, l }: { v: number; l: string }) { | |
| 269 | + return ( | |
| 270 | + <div className="rounded border border-line bg-panel-2 py-1.5"> | |
| 271 | + <div className="font-mono text-base font-semibold tabular">{v}</div> | |
| 272 | + <div className="label !text-[9.5px]">{l}</div> | |
| 273 | + </div> | |
| 274 | + ); | |
| 275 | +} | |
| 276 | +function FragmentRow({ label, value }: { label: string; value: string }) { | |
| 277 | + return ( | |
| 278 | + <> | |
| 279 | + <dt className="text-fg-subtle">{label}</dt> | |
| 280 | + <dd className="truncate font-mono text-fg-muted tabular">{value}</dd> | |
| 281 | + </> | |
| 282 | + ); | |
| 283 | +} | |
| 284 | +function DrawerSkeleton() { | |
| 285 | + return ( | |
| 286 | + <div className="flex flex-col gap-3"> | |
| 287 | + <Skeleton className="h-3 w-32" /> | |
| 288 | + <Skeleton className="h-5 w-11/12" /> | |
| 289 | + <Skeleton className="h-5 w-3/4" /> | |
| 290 | + <Skeleton className="h-16 w-full" /> | |
| 291 | + <Skeleton className="h-28 w-full" /> | |
| 292 | + </div> | |
| 293 | + ); | |
| 294 | +} | |
| 295 | + | |
| 296 | +export function BookmarkButton({ eventId, label = false }: { eventId: string; label?: boolean }) { | |
| 297 | + const [state, setState] = useState<"idle" | "on" | "busy">("idle"); | |
| 298 | + useEffect(() => { | |
| 299 | + let c = false; | |
| 300 | + ownerFetch<{ ids: string[] }>("/api/v1/bookmarks/ids") | |
| 301 | + .then((r) => { | |
| 302 | + if (!c && r.ids.includes(eventId)) setState("on"); | |
| 303 | + }) | |
| 304 | + .catch(() => undefined); | |
| 305 | + return () => { | |
| 306 | + c = true; | |
| 307 | + }; | |
| 308 | + }, [eventId]); | |
| 309 | + const toggle = async (): Promise<void> => { | |
| 310 | + const was = state; | |
| 311 | + setState("busy"); | |
| 312 | + try { | |
| 313 | + if (was === "on") { | |
| 314 | + await ownerFetch(`/api/v1/bookmarks/${encodeURIComponent(eventId)}`, { method: "DELETE" }); | |
| 315 | + setState("idle"); | |
| 316 | + } else { | |
| 317 | + await ownerFetch("/api/v1/bookmarks", { method: "POST", body: JSON.stringify({ event_id: eventId }) }); | |
| 318 | + setState("on"); | |
| 319 | + } | |
| 320 | + } catch { | |
| 321 | + setState(was); | |
| 322 | + } | |
| 323 | + }; | |
| 324 | + return ( | |
| 325 | + <button type="button" onClick={toggle} disabled={state === "busy"} aria-pressed={state === "on"} title={state === "on" ? "Remove bookmark" : "Bookmark"} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-1.5 text-[12px] ${state === "on" ? "text-signal" : "text-fg-muted hover:text-fg"}`}> | |
| 326 | + {state === "on" ? <BookmarkCheck className="size-3.5" /> : <Bookmark className="size-3.5" />} | |
| 327 | + {label && (state === "on" ? "Saved" : "Save")} | |
| 328 | + </button> | |
| 329 | + ); | |
| 330 | +} | |
modified
apps/web/src/components/event-row.tsx
+68 −22
@@ -1,48 +1,94 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import Link from "next/link"; |
| 4 | +import type { MouseEvent } from "react"; | |
| 4 | 5 | import type { EventItem } from "@/lib/api"; |
| 5 | −import { relTime, utcTime, utcDate } from "@/lib/format"; | |
| 6 | −import { Chip, EvidenceTag, Score, SilentBadge, TypeChip } from "./ui"; | |
| 6 | +import { relTime, utcTime, utcDate, utcDateTime, localDateTime } from "@/lib/format"; | |
| 7 | +import { useEventDrawer } from "./event-drawer"; | |
| 8 | +import { FieldChangeInline } from "./field-changes"; | |
| 9 | +import { usePrefs } from "./prefs"; | |
| 10 | +import { Badge, Chip, Flag, Score, TypeChip } from "./ui"; | |
| 7 | 11 | |
| 8 | −export function EventRow({ ev, flash = false, showDate = false, now }: { ev: EventItem; flash?: boolean; showDate?: boolean; now?: number }) { | |
| 9 | − const cats = (ev.categories ?? []).filter((c) => !["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure"].includes(c) || !ev.categories.some((x) => x !== c && sameChannel(c, x))).slice(0, 3); | |
| 12 | +const CHANNEL_GROUPS: Record<string, string[]> = { finance: ["finance", "payments", "crypto", "commerce"], health: ["health", "pharma"], government: ["government", "statistics"], science: ["science", "space"], products: ["consumer-tech", "automotive", "semiconductors", "enterprise"], infrastructure: ["cloud", "developer", "internet", "standards"], news: ["news", "media"] }; | |
| 13 | +function sameChannel(a: string, b: string): boolean { | |
| 14 | + return CHANNEL_GROUPS[a]?.includes(b) ?? false; | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Feed row (spec §31): TIME · ENTITY/SOURCE · SIGNAL · TYPE · TITLE · STATUS · TAGS, with badges | |
| 19 | + * BREAKING / DEVELOPING / SILENT / FIRST PARTY / CONFIRMED / ANOMALOUS. Density comes from CSS | |
| 20 | + * variables (spec §32). Clicking the title opens the intelligence drawer (spec §33); the link keeps | |
| 21 | + * a real href so middle-click / crawlers / no-JS still reach the permanent page. | |
| 22 | + */ | |
| 23 | +export function EventRow({ ev, flash = false, showDate = false, now, fresh = false, replayed = false }: { ev: EventItem; flash?: boolean; showDate?: boolean; now?: number; fresh?: boolean; replayed?: boolean }) { | |
| 24 | + const { open } = useEventDrawer(); | |
| 25 | + const { exactTime, density, mounted } = usePrefs(); | |
| 26 | + const iso = (() => { | |
| 27 | + const d = new Date(ev.detected_at); | |
| 28 | + return Number.isNaN(d.getTime()) ? ev.detected_at : d.toISOString(); | |
| 29 | + })(); | |
| 30 | + const cats = (ev.categories ?? []).filter((c) => !Object.keys(CHANNEL_GROUPS).includes(c) || !ev.categories.some((x) => x !== c && sameChannel(c, x))).slice(0, density === "compact" ? 1 : 2); | |
| 31 | + const signal = ev.signal_score ?? ev.importance; | |
| 32 | + const state = ev.cluster?.state; | |
| 33 | + const isBreaking = state === "breaking" || (signal >= 80 && !state); | |
| 34 | + const isDeveloping = state === "developing"; | |
| 35 | + const anomalous = (ev.anomaly_score ?? 0) >= 60; | |
| 36 | + const clusterHref = ev.cluster?.slug ? `/cluster/${ev.cluster.slug}` : ev.cluster_id ? `/cluster/${ev.cluster_id}` : null; | |
| 37 | + const onClick = (e: MouseEvent<HTMLAnchorElement>): void => { | |
| 38 | + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; | |
| 39 | + e.preventDefault(); | |
| 40 | + open(ev.slug, ev); | |
| 41 | + }; | |
| 10 | 42 | return ( |
| 11 | − <article className={`grid grid-cols-[auto_1fr] gap-x-3 px-3 py-2 hairline sm:grid-cols-[6.5rem_1fr_auto] ${flash ? "animate-flash" : ""}`}> | |
| 12 | − <div className="flex flex-col font-mono text-[11.5px] leading-4 text-fg-subtle tabular"> | |
| 13 | − <span className="text-fg-muted">{utcTime(ev.detected_at)}</span> | |
| 14 | − <span>{showDate ? utcDate(ev.detected_at) : relTime(ev.detected_at, now)}</span> | |
| 43 | + <article className={`row-dense offscreen-ok grid grid-cols-[auto_1fr] gap-x-3 px-3 hairline sm:grid-cols-[6.25rem_1fr_auto] ${flash || fresh ? "animate-row-in" : ""} ${isBreaking ? "border-l-2 border-l-hot/70" : isDeveloping ? "border-l-2 border-l-high/60" : ev.silent_change ? "border-l-2 border-l-silent/60" : "border-l-2 border-l-transparent"}`}> | |
| 44 | + <div className="flex flex-col font-mono meta leading-4 text-fg-subtle tabular"> | |
| 45 | + <time dateTime={iso} title={mounted ? localDateTime(ev.detected_at) : utcDateTime(ev.detected_at)} suppressHydrationWarning className="text-fg-muted">{utcTime(ev.detected_at)}</time> | |
| 46 | + <span suppressHydrationWarning>{showDate ? utcDate(ev.detected_at) : exactTime ? "UTC" : relTime(ev.detected_at, now)}</span> | |
| 15 | 47 | </div> |
| 16 | 48 | <div className="min-w-0 sm:col-start-2"> |
| 17 | − <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 18 | − <Link href={`/source/${ev.source?.id ?? ev.source_id}`} className="font-mono text-[11px] font-semibold uppercase tracking-wide text-fg-muted hover:text-fg"> | |
| 49 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | |
| 50 | + <Link href={`/source/${ev.source?.id ?? ev.source_id}`} className="font-mono meta font-semibold uppercase tracking-wide text-fg-muted hover:text-fg"> | |
| 19 | 51 | {ev.source?.name ?? ev.source_id} |
| 20 | 52 | </Link> |
| 53 | + {ev.country && <Flag code={ev.country} className="text-[11px]" />} | |
| 21 | 54 | <span className="sm:hidden"> |
| 22 | − <Score value={ev.importance} size="sm" /> | |
| 55 | + <Score value={signal} size="sm" kind="signal" /> | |
| 23 | 56 | </span> |
| 24 | − {ev.silent_change && <SilentBadge compact />} | |
| 57 | + {isBreaking && <Badge kind="breaking" compact={density === "compact"} />} | |
| 58 | + {isDeveloping && <Badge kind="developing" compact={density === "compact"} />} | |
| 59 | + {ev.silent_change && <Badge kind="silent" compact={density === "compact"} />} | |
| 60 | + {ev.first_party === false && <Badge kind="external" compact />} | |
| 61 | + {ev.evidence_label === "CONFIRMED" && <Badge kind="confirmed" compact={density === "compact"} />} | |
| 62 | + {anomalous && <Badge kind="anomalous" compact />} | |
| 63 | + {replayed && <Badge kind="replayed" compact />} | |
| 25 | 64 | </div> |
| 26 | − <Link href={`/event/${ev.slug}`} className="mt-0.5 block text-[13.5px] font-medium leading-snug text-fg hover:underline"> | |
| 65 | + <Link href={`/event/${ev.slug}`} onClick={onClick} className={`mt-0.5 block font-medium leading-snug text-fg hover:underline [overflow-wrap:anywhere] ${density === "compact" ? "truncate" : ""}`}> | |
| 27 | 66 | {ev.title} |
| 28 | 67 | </Link> |
| 68 | + {density !== "compact" && ev.field_changes && ev.field_changes.length > 0 && ( | |
| 69 | + <div className="mt-0.5"> | |
| 70 | + <FieldChangeInline items={ev.field_changes} max={density === "comfortable" ? 3 : 2} /> | |
| 71 | + </div> | |
| 72 | + )} | |
| 73 | + {density === "comfortable" && ev.summary && <p className="mt-0.5 line-clamp-2 text-[12.5px] text-fg-muted">{ev.summary}</p>} | |
| 29 | 74 | <div className="mt-1 flex flex-wrap items-center gap-1"> |
| 75 | + <TypeChip type={ev.event_type} href={`/live?event_type=${ev.event_type}`} /> | |
| 30 | 76 | {cats.map((c) => ( |
| 31 | 77 | <Chip key={c} href={`/category/${c}`}>{c}</Chip> |
| 32 | 78 | ))} |
| 33 | − <TypeChip type={ev.event_type} /> | |
| 34 | − <EvidenceTag label={ev.evidence_label} /> | |
| 35 | − {ev.cluster_size && ev.cluster_size > 1 && <Chip tone="info" href={`/event/${ev.slug}#cluster`}>+{ev.cluster_size - 1} related</Chip>} | |
| 79 | + {ev.entities?.filter((x) => x.role === "subject").slice(0, 1).map((x) => ( | |
| 80 | + <Chip key={x.id} href={`/entity/${x.id}`} tone="default" className="!text-fg-muted">{x.name}</Chip> | |
| 81 | + ))} | |
| 82 | + {ev.cluster_size && ev.cluster_size > 1 && clusterHref && ( | |
| 83 | + <Chip tone="info" href={clusterHref} title="Signals in this event cluster"> | |
| 84 | + {ev.cluster_size} signals{ev.cluster?.source_count && ev.cluster.source_count > 1 ? ` · ${ev.cluster.source_count} sources` : ""} | |
| 85 | + </Chip> | |
| 86 | + )} | |
| 36 | 87 | </div> |
| 37 | 88 | </div> |
| 38 | 89 | <div className="hidden items-start pt-0.5 sm:flex"> |
| 39 | − <Score value={ev.importance} /> | |
| 90 | + <Score value={signal} kind="signal" title={`Signal ${Math.round(signal)} · importance ${Math.round(ev.importance)} · confidence ${Math.round(ev.confidence)}`} /> | |
| 40 | 91 | </div> |
| 41 | 92 | </article> |
| 42 | 93 | ); |
| 43 | 94 | } |
| 44 | − | |
| 45 | −function sameChannel(a: string, b: string): boolean { | |
| 46 | − const groups: Record<string, string[]> = { finance: ["finance", "payments", "crypto", "commerce"], health: ["health", "pharma"], government: ["government", "statistics"], science: ["science", "space"], products: ["consumer-tech", "automotive", "semiconductors", "enterprise"], infrastructure: ["cloud", "developer", "internet", "standards"] }; | |
| 47 | − return groups[a]?.includes(b) ?? false; | |
| 48 | −} | |
added
apps/web/src/components/field-changes.tsx
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +import type { FieldChange } from "@/lib/api"; | |
| 2 | +import { fmtPctDelta } from "@/lib/format"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * WHAT CHANGED — field-level before → after table (spec §21, §78). Pure server-renderable component. | |
| 6 | + */ | |
| 7 | +export function FieldChanges({ items, compact = false, max }: { items: FieldChange[] | null | undefined; compact?: boolean; max?: number }) { | |
| 8 | + if (!items?.length) return null; | |
| 9 | + const rows = max ? items.slice(0, max) : items; | |
| 10 | + return ( | |
| 11 | + <div className={`overflow-hidden rounded-md border border-line ${compact ? "text-[12px]" : "text-[12.5px]"}`}> | |
| 12 | + <table className="w-full"> | |
| 13 | + <tbody className="divide-y divide-line"> | |
| 14 | + {rows.map((f, i) => ( | |
| 15 | + <tr key={i} className="align-top"> | |
| 16 | + <td className={`${compact ? "px-2 py-1" : "px-3 py-1.5"} w-[34%] text-fg-muted`}> | |
| 17 | + <span className="block truncate" title={f.label}>{f.label}</span> | |
| 18 | + <span className="label !text-[9.5px] !tracking-wider">{f.kind}</span> | |
| 19 | + </td> | |
| 20 | + <td className={`${compact ? "px-2 py-1" : "px-3 py-1.5"} font-mono tabular`}> | |
| 21 | + <span className="diff-line-del rounded px-1 break-words">{f.before ?? "∅"}</span> | |
| 22 | + <span className="mx-1.5 text-fg-subtle">→</span> | |
| 23 | + <span className="diff-line-add rounded px-1 break-words">{f.after ?? "∅"}</span> | |
| 24 | + {f.deltaPct !== null && f.deltaPct !== undefined && <span className={`ml-2 font-semibold ${f.deltaPct > 0 ? "text-high" : "text-signal"}`}>{fmtPctDelta(f.deltaPct)}</span>} | |
| 25 | + </td> | |
| 26 | + </tr> | |
| 27 | + ))} | |
| 28 | + </tbody> | |
| 29 | + </table> | |
| 30 | + {max && items.length > max && <div className="border-t border-line px-3 py-1 text-[11px] text-fg-subtle">+{items.length - max} more field change{items.length - max === 1 ? "" : "s"}</div>} | |
| 31 | + </div> | |
| 32 | + ); | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** One-line inline version for feed rows / drawer headers. */ | |
| 36 | +export function FieldChangeInline({ items, max = 2 }: { items: FieldChange[] | null | undefined; max?: number }) { | |
| 37 | + if (!items?.length) return null; | |
| 38 | + return ( | |
| 39 | + <span className="inline-flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-fg-muted tabular"> | |
| 40 | + {items.slice(0, max).map((f, i) => ( | |
| 41 | + <span key={i} className="inline-flex items-center gap-1 whitespace-nowrap"> | |
| 42 | + <span className="text-fg-subtle">{f.label.length > 22 ? f.label.slice(0, 21) + "…" : f.label}</span> | |
| 43 | + <span className="diff-line-del rounded px-0.5">{f.before ?? "∅"}</span> | |
| 44 | + <span className="text-fg-subtle">→</span> | |
| 45 | + <span className="diff-line-add rounded px-0.5">{f.after ?? "∅"}</span> | |
| 46 | + {f.deltaPct !== null && f.deltaPct !== undefined && <span className={f.deltaPct > 0 ? "text-high" : "text-signal"}>{fmtPctDelta(f.deltaPct)}</span>} | |
| 47 | + </span> | |
| 48 | + ))} | |
| 49 | + {items.length > max && <span className="text-fg-subtle">+{items.length - max}</span>} | |
| 50 | + </span> | |
| 51 | + ); | |
| 52 | +} | |
modified
apps/web/src/components/live-feed.tsx
+196 −38
@@ -1,56 +1,113 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import Link from "next/link"; |
| 4 | +import { usePathname, useRouter, useSearchParams } from "next/navigation"; | |
| 5 | +import { ArrowUp, Pause, Play, SlidersHorizontal, X } from "lucide-react"; | |
| 4 | 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 7 | import { liveToEvent, type EventItem, type EventQuery, type LiveEvent, eventQueryString } from "@/lib/api"; |
| 6 | −import { CHANNELS } from "@/lib/format"; | |
| 8 | +import { CHANNELS, GROUP_LABELS, SAVED_VIEWS, agoIso, feedHref } from "@/lib/format"; | |
| 9 | +import type { FeedFilters } from "@/lib/feed-filters"; | |
| 7 | 10 | import { publicFetch } from "@/lib/owner"; |
| 8 | 11 | import { useLive, type LiveStatus } from "@/lib/use-live"; |
| 9 | 12 | import { EventRow } from "./event-row"; |
| 10 | −import { Empty } from "./ui"; | |
| 13 | +import { DensityToggle, usePrefs } from "./prefs"; | |
| 14 | +import { Chip, Empty, Kbd } from "./ui"; | |
| 11 | 15 | |
| 12 | −const MAX_ROWS = 300; | |
| 16 | +const MAX_ROWS = 400; | |
| 13 | 17 | |
| 14 | −export function LiveDot({ status }: { status: LiveStatus }) { | |
| 15 | − const label = status === "live" ? "LIVE" : status === "connecting" ? "CONNECTING" : status === "reconnecting" ? "RECONNECTING" : "OFFLINE"; | |
| 16 | − const color = status === "live" ? "bg-signal animate-pulse-dot" : status === "offline" ? "bg-danger" : "bg-warn"; | |
| 18 | +export function LiveDot({ status, paused = false }: { status: LiveStatus; paused?: boolean }) { | |
| 19 | + const label = paused ? "PAUSED" : status === "live" ? "LIVE" : status === "connecting" ? "CONNECTING" : status === "reconnecting" ? "RECONNECTING" : "OFFLINE"; | |
| 20 | + const color = paused ? "bg-warn" : status === "live" ? "bg-signal animate-pulse-dot" : status === "offline" ? "bg-danger" : "bg-warn"; | |
| 17 | 21 | return ( |
| 18 | − <span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-semibold tracking-wider text-fg-muted"> | |
| 22 | + <span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-semibold tracking-wider text-fg-muted" aria-live="polite"> | |
| 19 | 23 | <span className={`inline-block size-2 rounded-full ${color}`} /> |
| 20 | 24 | {label} |
| 21 | 25 | </span> |
| 22 | 26 | ); |
| 23 | 27 | } |
| 24 | 28 | |
| 29 | +export type { FeedFilters } from "@/lib/feed-filters"; | |
| 30 | + | |
| 31 | +function matchesFilters(ev: EventItem, f: FeedFilters, fixedQuery?: EventQuery): boolean { | |
| 32 | + const sig = ev.signal_score ?? ev.importance; | |
| 33 | + if (f.signal_min !== undefined && sig < f.signal_min) return false; | |
| 34 | + if (f.importance_min !== undefined && ev.importance < f.importance_min) return false; | |
| 35 | + if (f.silent_change && !ev.silent_change) return false; | |
| 36 | + if (f.first_party && ev.first_party === false) return false; | |
| 37 | + if (f.confirmed && ev.evidence_label !== "CONFIRMED") return false; | |
| 38 | + if (f.country && ev.country !== f.country) return false; | |
| 39 | + if (f.language && ev.language !== f.language) return false; | |
| 40 | + if (f.category && !ev.categories.includes(f.category)) return false; | |
| 41 | + if (f.event_type && !f.event_type.split(",").includes(ev.event_type)) return false; | |
| 42 | + if (f.q) return false; // text search results are not extended live | |
| 43 | + if (fixedQuery?.source && ev.source_id !== fixedQuery.source) return false; | |
| 44 | + if (fixedQuery?.entity && !ev.entities.some((x) => x.id === fixedQuery.entity)) return false; | |
| 45 | + if (fixedQuery?.category && !ev.categories.includes(fixedQuery.category)) return false; | |
| 46 | + if (fixedQuery?.silent_change && !ev.silent_change) return false; | |
| 47 | + return true; | |
| 48 | +} | |
| 49 | + | |
| 25 | 50 | /** |
| 26 | − * The live feed: initial REST page, WebSocket prepends, channel tabs, cursor pagination. | |
| 27 | − * `fixed` restricts the feed to one filter (used by /breaking, /silent, /category/*). | |
| 51 | + * The live feed v2 (spec §31, §97–99): initial REST page, WebSocket prepends, URL-synced filters, | |
| 52 | + * pause, "↑ N new events" without scroll jumps, density modes, cursor pagination, drawer on click. | |
| 53 | + * `fixed` restricts the feed to one channel (used by /breaking, /silent, /category/*). | |
| 28 | 54 | */ |
| 29 | −export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title = "LIVE WEB", extraQuery }: { initial: EventItem[]; initialCursor: string | null; fixed?: string; showTabs?: boolean; title?: string; extraQuery?: EventQuery }) { | |
| 30 | − const [tab, setTab] = useState(fixed ?? "all"); | |
| 55 | +export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title = "LIVE WEB", extraQuery, syncUrl = false, initialFilters, showFilters = true, compactHeader = false }: { initial: EventItem[]; initialCursor: string | null; fixed?: string; showTabs?: boolean; title?: string; extraQuery?: EventQuery; syncUrl?: boolean; initialFilters?: FeedFilters; showFilters?: boolean; compactHeader?: boolean }) { | |
| 56 | + const router = useRouter(); | |
| 57 | + const pathname = usePathname(); | |
| 58 | + const sp = useSearchParams(); | |
| 59 | + const { paused, setPaused } = usePrefs(); | |
| 60 | + const [tab, setTab] = useState(fixed ?? initialFilters?.category ?? "all"); | |
| 61 | + const [filters, setFilters] = useState<FeedFilters>(initialFilters ?? {}); | |
| 31 | 62 | const [items, setItems] = useState<EventItem[]>(initial); |
| 32 | 63 | const [cursor, setCursor] = useState<string | null>(initialCursor); |
| 33 | 64 | const [loading, setLoading] = useState(false); |
| 34 | 65 | const [fresh, setFresh] = useState<Set<string>>(new Set()); |
| 66 | + const [pending, setPending] = useState<EventItem[]>([]); | |
| 67 | + const [atTop, setAtTop] = useState(true); | |
| 35 | 68 | const [now, setNow] = useState(() => Date.now()); |
| 69 | + const [showFilterBar, setShowFilterBar] = useState(Boolean(initialFilters && Object.keys(initialFilters).length)); | |
| 36 | 70 | const seen = useRef(new Set(initial.map((i) => i.id))); |
| 71 | + const topRef = useRef<HTMLDivElement>(null); | |
| 37 | 72 | const chan = useMemo(() => CHANNELS.find((c) => c.key === tab) ?? CHANNELS[0]!, [tab]); |
| 38 | 73 | |
| 74 | + // sync URL ← filters (spec §99) | |
| 75 | + useEffect(() => { | |
| 76 | + if (!syncUrl) return; | |
| 77 | + const href = feedHref({ ...filters, category: tab !== "all" && tab !== "breaking" && tab !== "silent" ? tab : filters.category, silent_change: tab === "silent" ? true : filters.silent_change, signal_min: tab === "breaking" ? Math.max(80, filters.signal_min ?? 0) : filters.signal_min }, pathname); | |
| 78 | + const current = `${pathname}${sp.toString() ? `?${sp.toString()}` : ""}`; | |
| 79 | + if (href !== current) router.replace(href, { scroll: false }); | |
| 80 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 81 | + }, [filters, tab, syncUrl, pathname]); | |
| 82 | + | |
| 39 | 83 | useEffect(() => { |
| 40 | 84 | const t = setInterval(() => setNow(Date.now()), 10_000); |
| 41 | 85 | return () => clearInterval(t); |
| 42 | 86 | }, []); |
| 87 | + // Is the top of the list visible? If not, new events are buffered (spec §97). | |
| 88 | + useEffect(() => { | |
| 89 | + const el = topRef.current; | |
| 90 | + if (!el) return; | |
| 91 | + const io = new IntersectionObserver(([e]) => setAtTop(Boolean(e?.isIntersecting)), { rootMargin: "80px 0px 0px 0px" }); | |
| 92 | + io.observe(el); | |
| 93 | + return () => io.disconnect(); | |
| 94 | + }, []); | |
| 95 | + | |
| 96 | + const query = useMemo<EventQuery>(() => { | |
| 97 | + const q: EventQuery = { ...(extraQuery ?? {}), ...(chan.query as EventQuery), ...filters, limit: 60 }; | |
| 98 | + if (chan.key === "breaking") { | |
| 99 | + q.order = q.order ?? "signal"; | |
| 100 | + q.signal_min = Math.max(80, q.signal_min ?? 0); | |
| 101 | + q.after = agoIso(48 * 3600e3); | |
| 102 | + } | |
| 103 | + return q; | |
| 104 | + }, [chan, extraQuery, filters]); | |
| 43 | 105 | |
| 44 | 106 | const load = useCallback( |
| 45 | 107 | async (reset: boolean, c: string | null) => { |
| 46 | 108 | setLoading(true); |
| 47 | 109 | try { |
| 48 | − const q: EventQuery = { ...(extraQuery ?? {}), ...(chan.query as EventQuery), limit: 60, cursor: reset ? undefined : (c ?? undefined) }; | |
| 49 | − if (chan.key === "breaking") { | |
| 50 | − q.order = "importance"; | |
| 51 | − q.after = new Date(Date.now() - 48 * 3600e3).toISOString(); | |
| 52 | − } | |
| 53 | − const page = await publicFetch<{ items: EventItem[]; nextCursor: string | null }>(`/api/v1/events${eventQueryString(q)}`); | |
| 110 | + const page = await publicFetch<{ items: EventItem[]; nextCursor: string | null }>(`/api/v1/events${eventQueryString({ ...query, cursor: reset ? undefined : (c ?? undefined) })}`); | |
| 54 | 111 | setItems((prev) => { |
| 55 | 112 | const base = reset ? [] : prev; |
| 56 | 113 | const ids = new Set(base.map((i) => i.id)); |
@@ -59,13 +116,14 @@ export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title | ||
| 59 | 116 | return merged; |
| 60 | 117 | }); |
| 61 | 118 | setCursor(page.nextCursor); |
| 119 | + if (reset) setPending([]); | |
| 62 | 120 | } catch { |
| 63 | 121 | // keep current list |
| 64 | 122 | } finally { |
| 65 | 123 | setLoading(false); |
| 66 | 124 | } |
| 67 | 125 | }, |
| 68 | − [chan, extraQuery], | |
| 126 | + [query], | |
| 69 | 127 | ); |
| 70 | 128 | |
| 71 | 129 | const first = useRef(true); |
@@ -77,50 +135,141 @@ export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title | ||
| 77 | 135 | void load(true, null); |
| 78 | 136 | }, [load]); |
| 79 | 137 | |
| 138 | + const flushPending = useCallback(() => { | |
| 139 | + setPending((p) => { | |
| 140 | + if (!p.length) return p; | |
| 141 | + setItems((prev) => [...p, ...prev].slice(0, MAX_ROWS)); | |
| 142 | + setFresh((prevF) => new Set([...prevF, ...p.map((x) => x.id)])); | |
| 143 | + setTimeout(() => setFresh(new Set()), 1800); | |
| 144 | + return []; | |
| 145 | + }); | |
| 146 | + topRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); | |
| 147 | + }, []); | |
| 148 | + | |
| 80 | 149 | const onEvent = useCallback( |
| 81 | 150 | (e: LiveEvent) => { |
| 82 | 151 | if (seen.current.has(e.id)) return; |
| 83 | 152 | const ev = liveToEvent(e); |
| 84 | − if (extraQuery?.source && ev.source_id !== extraQuery.source) return; | |
| 85 | − if (extraQuery?.entity && !ev.entities.some((x) => x.id === extraQuery.entity)) return; | |
| 153 | + if (!matchesFilters(ev, filters, extraQuery)) return; | |
| 154 | + if (chan.key === "breaking" && (ev.signal_score ?? ev.importance) < 80) return; | |
| 155 | + if (chan.key === "silent" && !ev.silent_change) return; | |
| 86 | 156 | seen.current.add(e.id); |
| 157 | + if (paused || !atTop || query.order === "signal" || query.order === "importance") { | |
| 158 | + setPending((p) => [ev, ...p].slice(0, 200)); | |
| 159 | + return; | |
| 160 | + } | |
| 87 | 161 | setItems((prev) => [ev, ...prev].slice(0, MAX_ROWS)); |
| 88 | 162 | setFresh((prev) => new Set([...prev, e.id])); |
| 89 | − setTimeout(() => setFresh((prev) => { | |
| 90 | − const n = new Set(prev); | |
| 91 | − n.delete(e.id); | |
| 92 | − return n; | |
| 93 | − }), 1500); | |
| 163 | + setTimeout( | |
| 164 | + () => | |
| 165 | + setFresh((prev) => { | |
| 166 | + const n = new Set(prev); | |
| 167 | + n.delete(e.id); | |
| 168 | + return n; | |
| 169 | + }), | |
| 170 | + 1800, | |
| 171 | + ); | |
| 94 | 172 | }, |
| 95 | − [extraQuery], | |
| 173 | + [filters, extraQuery, chan.key, paused, atTop, query.order], | |
| 96 | 174 | ); |
| 97 | − const status = useLive([chan.ws], onEvent); | |
| 175 | + const wsChannel = extraQuery?.source ? `source:${extraQuery.source}` : extraQuery?.entity ? `entity:${extraQuery.entity}` : filters.country && chan.key === "all" ? `country:${filters.country}` : filters.group && chan.key === "all" ? `group:${filters.group}` : chan.ws; | |
| 176 | + const wsChannels = useMemo(() => [wsChannel], [wsChannel]); | |
| 177 | + const status = useLive(wsChannels, onEvent); | |
| 178 | + | |
| 179 | + const setF = (patch: Partial<FeedFilters>): void => setFilters((f) => { | |
| 180 | + const n = { ...f, ...patch }; | |
| 181 | + for (const k of Object.keys(n) as (keyof FeedFilters)[]) if (n[k] === undefined || n[k] === false || n[k] === "") delete n[k]; | |
| 182 | + return n; | |
| 183 | + }); | |
| 184 | + const activeFilterCount = Object.keys(filters).length; | |
| 98 | 185 | |
| 99 | 186 | return ( |
| 100 | − <section className="panel overflow-hidden"> | |
| 101 | − <header className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2"> | |
| 102 | − <h2 className="label mr-2 !text-fg">{title}</h2> | |
| 103 | − <LiveDot status={status} /> | |
| 104 | − <span className="ml-auto font-mono text-[11px] text-fg-subtle tabular">{items.length} events</span> | |
| 187 | + <section className="panel overflow-hidden" aria-label={title}> | |
| 188 | + <header className={`flex flex-wrap items-center gap-2 border-b border-line px-3 ${compactHeader ? "py-1.5" : "py-2"}`}> | |
| 189 | + <h2 className="label mr-1 !text-fg">{title}</h2> | |
| 190 | + <LiveDot status={status} paused={paused} /> | |
| 191 | + <span className="hidden font-mono text-[11px] text-fg-subtle tabular sm:inline">{items.length} events</span> | |
| 192 | + <span className="ml-auto flex items-center gap-1"> | |
| 193 | + {showFilters && ( | |
| 194 | + <button type="button" onClick={() => setShowFilterBar((v) => !v)} aria-expanded={showFilterBar} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-2 text-[11.5px] ${activeFilterCount ? "border-signal/50 text-signal" : "text-fg-muted hover:text-fg"}`} title="Filters"> | |
| 195 | + <SlidersHorizontal className="size-3.5" /> <span className="hidden sm:inline">Filters</span> | |
| 196 | + {activeFilterCount > 0 && <span className="font-mono">{activeFilterCount}</span>} | |
| 197 | + </button> | |
| 198 | + )} | |
| 199 | + <button type="button" onClick={() => setPaused(!paused)} aria-pressed={paused} className={`inline-flex h-7 items-center gap-1 rounded-md border border-line px-2 text-[11.5px] ${paused ? "border-warn/50 text-warn" : "text-fg-muted hover:text-fg"}`} title={paused ? "Resume live" : "Pause live (backend keeps ingesting)"}> | |
| 200 | + {paused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />} <span className="hidden sm:inline">{paused ? "Resume" : "Pause"}</span> | |
| 201 | + </button> | |
| 202 | + <DensityToggle compact /> | |
| 203 | + </span> | |
| 105 | 204 | </header> |
| 106 | 205 | {showTabs && !fixed && ( |
| 107 | − <div className="flex gap-1 overflow-x-auto border-b border-line px-2 py-1.5 [scrollbar-width:none]"> | |
| 206 | + <div className="flex gap-1 overflow-x-auto border-b border-line px-2 py-1.5 no-scrollbar" role="tablist" aria-label="Channels"> | |
| 108 | 207 | {CHANNELS.map((c) => ( |
| 109 | − <button key={c.key} type="button" onClick={() => setTab(c.key)} className={`whitespace-nowrap rounded-md px-2 py-1 text-[12px] ${tab === c.key ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"} ${c.key === "silent" ? "text-silent" : ""}`}> | |
| 208 | + <button key={c.key} type="button" role="tab" aria-selected={tab === c.key} onClick={() => setTab(c.key)} className={`whitespace-nowrap rounded-md px-2 py-1 text-[12px] ${tab === c.key ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"} ${c.key === "silent" ? "text-silent" : c.key === "breaking" ? "text-hot" : ""}`}> | |
| 110 | 209 | {c.label} |
| 111 | 210 | </button> |
| 112 | 211 | ))} |
| 113 | 212 | </div> |
| 114 | 213 | )} |
| 115 | − <div> | |
| 116 | − {items.length === 0 && !loading && <Empty>No events in this channel yet. Sensors are being checked continuously — meaningful changes will appear here the moment they are detected.</Empty>} | |
| 214 | + {showFilters && showFilterBar && ( | |
| 215 | + <div className="flex flex-wrap items-center gap-1.5 border-b border-line bg-panel-2/40 px-3 py-2 text-[12px]"> | |
| 216 | + <Toggle on={Boolean(filters.silent_change)} onClick={() => setF({ silent_change: !filters.silent_change || undefined })} tone="silent">Silent</Toggle> | |
| 217 | + <Toggle on={Boolean(filters.first_party)} onClick={() => setF({ first_party: !filters.first_party || undefined })} tone="signal">First-party</Toggle> | |
| 218 | + <Toggle on={Boolean(filters.confirmed)} onClick={() => setF({ confirmed: !filters.confirmed || undefined })} tone="ok">Confirmed</Toggle> | |
| 219 | + <select aria-label="Minimum signal" value={filters.signal_min ?? ""} onChange={(e) => setF({ signal_min: e.target.value ? Number(e.target.value) : undefined })} className="h-6 rounded-md border border-line bg-panel px-1.5 text-[11.5px]"> | |
| 220 | + <option value="">signal ≥ any</option> | |
| 221 | + <option value="50">signal ≥ 50</option> | |
| 222 | + <option value="65">signal ≥ 65</option> | |
| 223 | + <option value="80">signal ≥ 80</option> | |
| 224 | + <option value="90">signal ≥ 90</option> | |
| 225 | + </select> | |
| 226 | + <select aria-label="Event group" value={filters.group ?? ""} onChange={(e) => setF({ group: e.target.value || undefined })} className="h-6 max-w-[11rem] rounded-md border border-line bg-panel px-1.5 text-[11.5px]"> | |
| 227 | + <option value="">all groups</option> | |
| 228 | + {Object.entries(GROUP_LABELS).map(([k, v]) => ( | |
| 229 | + <option key={k} value={k}>{v}</option> | |
| 230 | + ))} | |
| 231 | + </select> | |
| 232 | + <select aria-label="Order" value={filters.order ?? "recent"} onChange={(e) => setF({ order: e.target.value === "recent" ? undefined : (e.target.value as FeedFilters["order"]) })} className="h-6 rounded-md border border-line bg-panel px-1.5 text-[11.5px]"> | |
| 233 | + <option value="recent">newest first</option> | |
| 234 | + <option value="signal">highest signal</option> | |
| 235 | + <option value="importance">highest importance</option> | |
| 236 | + </select> | |
| 237 | + <input aria-label="Country" value={filters.country ?? ""} onChange={(e) => setF({ country: e.target.value.toUpperCase().slice(0, 3) || undefined })} placeholder="CC" maxLength={3} className="h-6 w-12 rounded-md border border-line bg-panel px-1.5 font-mono text-[11.5px] uppercase placeholder:text-fg-subtle" title="Country (ISO code)" /> | |
| 238 | + {filters.event_type && <Chip tone="info">type: {filters.event_type.replace(/_/g, " ")} <button type="button" aria-label="Clear type" onClick={() => setF({ event_type: undefined })}><X className="size-3" /></button></Chip>} | |
| 239 | + {filters.q && <Chip tone="info">“{filters.q}” <button type="button" aria-label="Clear search" onClick={() => setF({ q: undefined })}><X className="size-3" /></button></Chip>} | |
| 240 | + <span className="ml-auto flex items-center gap-1"> | |
| 241 | + <span className="text-[11px] text-fg-subtle">views:</span> | |
| 242 | + {SAVED_VIEWS.slice(0, 4).map((v) => ( | |
| 243 | + <Link key={v.key} href={feedHref(v.query, "/live")} className="rounded-sm border border-line px-1.5 py-px text-[10.5px] text-fg-muted hover:text-fg">{v.label}</Link> | |
| 244 | + ))} | |
| 245 | + {activeFilterCount > 0 && ( | |
| 246 | + <button type="button" onClick={() => setFilters({})} className="rounded-sm px-1.5 py-px text-[10.5px] text-fg-subtle hover:text-fg">clear</button> | |
| 247 | + )} | |
| 248 | + </span> | |
| 249 | + </div> | |
| 250 | + )} | |
| 251 | + <div ref={topRef} aria-hidden className="h-px" /> | |
| 252 | + {pending.length > 0 && ( | |
| 253 | + <div className="sticky top-12 z-20 flex justify-center py-1"> | |
| 254 | + <button type="button" onClick={flushPending} className="inline-flex items-center gap-1.5 rounded-full border border-signal/50 bg-panel px-3 py-1 text-[12px] font-medium text-signal shadow-lg hover:bg-signal-soft"> | |
| 255 | + <ArrowUp className="size-3.5" /> {pending.length} new event{pending.length === 1 ? "" : "s"}{paused ? " · paused" : ""} | |
| 256 | + </button> | |
| 257 | + </div> | |
| 258 | + )} | |
| 259 | + <div role="feed" aria-busy={loading}> | |
| 260 | + {items.length === 0 && !loading && ( | |
| 261 | + <Empty> | |
| 262 | + {activeFilterCount ? "No events match these filters. Live events that match will appear here." : "No events in this channel yet. Sensors are being checked continuously — meaningful changes will appear here the moment they are detected."} | |
| 263 | + </Empty> | |
| 264 | + )} | |
| 117 | 265 | {items.map((ev) => ( |
| 118 | − <EventRow key={ev.id} ev={ev} flash={fresh.has(ev.id)} now={now} /> | |
| 266 | + <EventRow key={ev.id} ev={ev} fresh={fresh.has(ev.id)} now={now} replayed={Boolean((ev as EventItem & { replayed?: boolean }).replayed)} /> | |
| 119 | 267 | ))} |
| 120 | 268 | </div> |
| 121 | 269 | <footer className="flex items-center justify-between px-3 py-2 text-[12px] text-fg-subtle"> |
| 122 | − <span> | |
| 270 | + <span className="inline-flex items-center gap-2"> | |
| 123 | 271 | Showing {items.length}{items.length >= MAX_ROWS ? ` (capped at ${MAX_ROWS})` : ""} |
| 272 | + <span className="hidden items-center gap-1 sm:inline-flex"><Kbd>⌘K</Kbd> search</span> | |
| 124 | 273 | </span> |
| 125 | 274 | {cursor ? ( |
| 126 | 275 | <button type="button" disabled={loading} onClick={() => load(false, cursor)} className="rounded-md border border-line bg-panel-2 px-2.5 py-1 text-[12px] text-fg hover:border-line-strong disabled:opacity-50"> |
@@ -133,3 +282,12 @@ export function LiveFeed({ initial, initialCursor, fixed, showTabs = true, title | ||
| 133 | 282 | </section> |
| 134 | 283 | ); |
| 135 | 284 | } |
| 285 | + | |
| 286 | +function Toggle({ on, onClick, children, tone }: { on: boolean; onClick: () => void; children: React.ReactNode; tone: "silent" | "signal" | "ok" }) { | |
| 287 | + const onCls = tone === "silent" ? "border-silent/50 bg-silent-soft text-silent" : tone === "ok" ? "border-ok/50 bg-ok/10 text-ok" : "border-signal/50 bg-signal-soft text-signal"; | |
| 288 | + return ( | |
| 289 | + <button type="button" aria-pressed={on} onClick={onClick} className={`h-6 rounded-md border px-2 text-[11.5px] ${on ? onCls : "border-line text-fg-muted hover:text-fg"}`}> | |
| 290 | + {children} | |
| 291 | + </button> | |
| 292 | + ); | |
| 293 | +} | |
added
apps/web/src/components/live-strip.tsx
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import Link from "next/link"; | |
| 4 | +import { useEffect, useRef, useState } from "react"; | |
| 5 | +import type { Stats } from "@/lib/api"; | |
| 6 | +import { fmtInt, relTime } from "@/lib/format"; | |
| 7 | +import { publicFetch } from "@/lib/owner"; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Live system strip (spec §65): SOURCES · SENSORS · CHECKS/MIN · EVENTS 24H · BREAKING · SILENT · STATUS. | |
| 11 | + * Server-rendered with real values, then refreshed every 10 s. Numbers tick when they change. | |
| 12 | + */ | |
| 13 | +export function LiveStrip({ initial }: { initial: Stats }) { | |
| 14 | + const [s, setS] = useState<Stats>(initial); | |
| 15 | + const [now, setNow] = useState(() => Date.now()); | |
| 16 | + useEffect(() => { | |
| 17 | + const tick = (): void => { | |
| 18 | + publicFetch<Stats>("/api/v1/stats").then(setS).catch(() => undefined); | |
| 19 | + setNow(Date.now()); | |
| 20 | + }; | |
| 21 | + const t = setInterval(tick, 10_000); | |
| 22 | + const n = setInterval(() => setNow(Date.now()), 1000); | |
| 23 | + return () => { | |
| 24 | + clearInterval(t); | |
| 25 | + clearInterval(n); | |
| 26 | + }; | |
| 27 | + }, []); | |
| 28 | + const lastCheckAge = s.last_check_at ? now - new Date(s.last_check_at).getTime() : Infinity; | |
| 29 | + const live = lastCheckAge < 120_000; | |
| 30 | + // `now` differs between server and client renders; the status word is re-rendered right after mount. | |
| 31 | + const items: { label: string; value: number | undefined; href?: string; tone?: string; hint?: string }[] = [ | |
| 32 | + { label: "Sources", value: s.sources, href: "/sources", hint: `${fmtInt(s.sources_first_party)} first-party` }, | |
| 33 | + { label: "Sensors", value: s.sensors, href: "/health", hint: s.sensors_degraded ? `${fmtInt(s.sensors_degraded)} degraded` : "all healthy" }, | |
| 34 | + { label: "Checks / min", value: s.checks_per_min, href: "/health", hint: s.not_modified_ratio_5m !== null && s.not_modified_ratio_5m !== undefined ? `${Math.round(s.not_modified_ratio_5m * 100)}% 304` : undefined }, | |
| 35 | + { label: "Events 24h", value: s.events_24h, href: "/live", hint: s.events_per_min !== undefined ? `${s.events_per_min}/min` : undefined }, | |
| 36 | + { label: "Breaking", value: s.breaking_now ?? s.breaking_24h, href: "/breaking", tone: "text-hot", hint: s.developing_now ? `${s.developing_now} developing` : undefined }, | |
| 37 | + { label: "Silent 24h", value: s.silent_24h, href: "/silent", tone: "text-silent" }, | |
| 38 | + ]; | |
| 39 | + return ( | |
| 40 | + <div className="panel mb-4 flex items-stretch overflow-x-auto no-scrollbar" role="region" aria-label="Live system status"> | |
| 41 | + {items.map((it) => ( | |
| 42 | + <Cell key={it.label} {...it} /> | |
| 43 | + ))} | |
| 44 | + <div className="ml-auto flex min-w-[7.5rem] flex-col justify-center gap-0.5 border-l border-line px-3 py-2"> | |
| 45 | + <span className="label">Status</span> | |
| 46 | + <span className="inline-flex items-center gap-1.5 font-mono text-[12px] font-semibold tracking-wider" suppressHydrationWarning> | |
| 47 | + <span className={`inline-block size-2 rounded-full ${live ? "bg-signal animate-pulse-dot" : "bg-warn"}`} /> | |
| 48 | + {live ? "LIVE" : "STALE"} | |
| 49 | + </span> | |
| 50 | + <span className="truncate text-[10.5px] text-fg-subtle" suppressHydrationWarning>{s.last_check_at ? `last check ${relTime(s.last_check_at, now)}` : "—"}</span> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + ); | |
| 54 | +} | |
| 55 | + | |
| 56 | +function Cell({ label, value, href, tone, hint }: { label: string; value: number | undefined; href?: string; tone?: string; hint?: string }) { | |
| 57 | + const prev = useRef(value); | |
| 58 | + const [tick, setTick] = useState(false); | |
| 59 | + useEffect(() => { | |
| 60 | + if (prev.current !== value) { | |
| 61 | + prev.current = value; | |
| 62 | + setTick(true); | |
| 63 | + const t = setTimeout(() => setTick(false), 600); | |
| 64 | + return () => clearTimeout(t); | |
| 65 | + } | |
| 66 | + }, [value]); | |
| 67 | + const body = ( | |
| 68 | + <> | |
| 69 | + <span className="label">{label}</span> | |
| 70 | + <span className={`font-mono text-lg font-semibold leading-tight tabular ${tone ?? ""} ${tick ? "animate-tick" : ""}`}>{fmtInt(value)}</span> | |
| 71 | + {hint && <span className="truncate text-[10.5px] text-fg-subtle">{hint}</span>} | |
| 72 | + </> | |
| 73 | + ); | |
| 74 | + const cls = "flex min-w-[7.25rem] flex-col gap-0.5 border-r border-line px-3 py-2 last:border-r-0"; | |
| 75 | + return href ? <Link href={href} className={`${cls} hover:bg-panel-2/60`}>{body}</Link> : <div className={cls}>{body}</div>; | |
| 76 | +} | |
modified
apps/web/src/components/nav.tsx
+78 −52
@@ -1,27 +1,29 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import Link from "next/link"; |
| 4 | −import { usePathname, useRouter } from "next/navigation"; | |
| 5 | −import { Activity, Bell, Compass, Eye, Search, User } from "lucide-react"; | |
| 6 | −import { useEffect, useRef, useState } from "react"; | |
| 4 | +import { usePathname } from "next/navigation"; | |
| 5 | +import { Activity, Bell, Bookmark, Compass, Eye, Globe2, LayoutGrid, Radar, Search, Siren, X, Zap } from "lucide-react"; | |
| 6 | +import { useState } from "react"; | |
| 7 | +import { DensityToggle } from "./prefs"; | |
| 7 | 8 | import { ThemeToggle } from "./theme"; |
| 9 | +import { Kbd } from "./ui"; | |
| 8 | 10 | |
| 9 | 11 | const NAV = [ |
| 10 | − ["/", "Live"], | |
| 12 | + ["/live", "Live"], | |
| 11 | 13 | ["/breaking", "Breaking"], |
| 14 | + ["/pulse", "Pulse"], | |
| 15 | + ["/radar", "Radar"], | |
| 16 | + ["/silent", "Silent"], | |
| 12 | 17 | ["/explore", "Explore"], |
| 13 | − ["/sources", "Sources"], | |
| 14 | 18 | ["/entities", "Entities"], |
| 15 | − ["/silent", "Silent"], | |
| 19 | + ["/sources", "Sources"], | |
| 16 | 20 | ["/watchlists", "Watchlists"], |
| 17 | 21 | ["/alerts", "Alerts"], |
| 18 | − ["/api", "API"], | |
| 19 | − ["/health", "Health"], | |
| 20 | 22 | ] as const; |
| 21 | 23 | |
| 22 | 24 | export function Wordmark() { |
| 23 | 25 | return ( |
| 24 | − <Link href="/" className="flex items-center gap-2 font-semibold tracking-tight"> | |
| 26 | + <Link href="/" className="flex items-center gap-2 font-semibold tracking-tight" aria-label="WebSensor home"> | |
| 25 | 27 | <span className="relative inline-flex size-2.5 items-center justify-center"> |
| 26 | 28 | <span className="absolute inset-0 rounded-full bg-signal animate-pulse-dot" /> |
| 27 | 29 | </span> |
@@ -34,72 +36,96 @@ export function Wordmark() { | ||
| 34 | 36 | |
| 35 | 37 | export function TopNav() { |
| 36 | 38 | const path = usePathname(); |
| 37 | − const router = useRouter(); | |
| 38 | − const [q, setQ] = useState(""); | |
| 39 | − const input = useRef<HTMLInputElement>(null); | |
| 40 | − useEffect(() => { | |
| 41 | − const onKey = (e: KeyboardEvent): void => { | |
| 42 | − if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { | |
| 43 | − e.preventDefault(); | |
| 44 | − input.current?.focus(); | |
| 45 | − } | |
| 46 | − }; | |
| 47 | − window.addEventListener("keydown", onKey); | |
| 48 | − return () => window.removeEventListener("keydown", onKey); | |
| 49 | − }, []); | |
| 39 | + const openPalette = (): void => { | |
| 40 | + window.dispatchEvent(new Event("ws:palette")); | |
| 41 | + }; | |
| 50 | 42 | return ( |
| 51 | 43 | <header className="sticky top-0 z-40 border-b border-line bg-bg/90 backdrop-blur"> |
| 52 | − <div className="mx-auto flex h-12 max-w-[1500px] items-center gap-4 px-3 sm:px-4"> | |
| 44 | + <div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3 sm:px-4"> | |
| 53 | 45 | <Wordmark /> |
| 54 | − <nav className="hidden items-center gap-0.5 lg:flex"> | |
| 46 | + <nav className="hidden items-center gap-0.5 lg:flex" aria-label="Primary"> | |
| 55 | 47 | {NAV.map(([href, label]) => { |
| 56 | − const active = href === "/" ? path === "/" : path.startsWith(href); | |
| 48 | + const active = href === "/live" ? path === "/" || path.startsWith("/live") : path.startsWith(href); | |
| 57 | 49 | return ( |
| 58 | − <Link key={href} href={href} className={`rounded-md px-2 py-1 text-[12.5px] ${active ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"}`}> | |
| 50 | + <Link key={href} href={href} aria-current={active ? "page" : undefined} className={`rounded-md px-1.5 py-1 text-[12px] xl:px-2 xl:text-[12.5px] ${active ? "bg-panel-2 text-fg" : "text-fg-muted hover:text-fg"} ${href === "/breaking" && active ? "!text-hot" : href === "/silent" && active ? "!text-silent" : ""}`}> | |
| 59 | 51 | {label} |
| 60 | 52 | </Link> |
| 61 | 53 | ); |
| 62 | 54 | })} |
| 63 | 55 | </nav> |
| 64 | − <form | |
| 65 | − className="ml-auto flex items-center" | |
| 66 | − onSubmit={(e) => { | |
| 67 | − e.preventDefault(); | |
| 68 | − if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`); | |
| 69 | − }} | |
| 70 | − > | |
| 71 | − <label className="relative block"> | |
| 72 | − <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" /> | |
| 73 | − <input ref={input} value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search events, entities, URLs…" className="h-8 w-40 rounded-md border border-line bg-panel pl-7 pr-9 text-[12.5px] placeholder:text-fg-subtle focus:w-64 sm:w-56 sm:focus:w-80 transition-[width]" /> | |
| 74 | − <kbd className="pointer-events-none absolute right-1.5 top-1/2 hidden -translate-y-1/2 rounded border border-line bg-panel-2 px-1 font-mono text-[10px] text-fg-subtle sm:block">⌘K</kbd> | |
| 75 | − </label> | |
| 76 | − </form> | |
| 56 | + <button type="button" onClick={openPalette} className="ml-auto inline-flex h-8 w-40 min-w-0 items-center gap-2 rounded-md border border-line bg-panel px-2 text-[12.5px] text-fg-subtle hover:border-line-strong sm:w-56 lg:w-48 xl:w-72" aria-label="Open search and commands (⌘K)"> | |
| 57 | + <Search className="size-3.5 shrink-0" /> | |
| 58 | + <span className="flex-1 truncate text-left">Search entities, events, URLs…</span> | |
| 59 | + <span className="hidden sm:inline-flex"><Kbd>⌘K</Kbd></span> | |
| 60 | + </button> | |
| 61 | + <div className="hidden xl:block"><DensityToggle compact /></div> | |
| 77 | 62 | <ThemeToggle /> |
| 78 | 63 | </div> |
| 79 | 64 | </header> |
| 80 | 65 | ); |
| 81 | 66 | } |
| 82 | 67 | |
| 68 | +/** Mobile bottom navigation (spec §95): Live · Breaking · Explore · Watchlists · More (bottom sheet). */ | |
| 83 | 69 | export function MobileNav() { |
| 84 | 70 | const path = usePathname(); |
| 71 | + const [moreFor, setMoreFor] = useState<string | null>(null); | |
| 72 | + const more = moreFor === path; | |
| 73 | + const setMore = (v: boolean): void => setMoreFor(v ? path : null); | |
| 85 | 74 | const items = [ |
| 86 | − ["/", "Live", Activity], | |
| 87 | − ["/explore", "Explore", Compass], | |
| 88 | − ["/alerts", "Alerts", Bell], | |
| 89 | − ["/watchlists", "Watchlist", Eye], | |
| 90 | − ["/watchlists", "Profile", User], | |
| 75 | + ["/live", "Live", Activity, path === "/" || path.startsWith("/live")], | |
| 76 | + ["/breaking", "Breaking", Siren, path.startsWith("/breaking")], | |
| 77 | + ["/explore", "Explore", Compass, path.startsWith("/explore")], | |
| 78 | + ["/watchlists", "Watch", Eye, path.startsWith("/watchlists")], | |
| 91 | 79 | ] as const; |
| 80 | + const moreItems: [string, string, typeof Activity][] = [ | |
| 81 | + ["/pulse", "Pulse", Zap], | |
| 82 | + ["/radar", "Radar", Radar], | |
| 83 | + ["/silent", "Silent changes", LayoutGrid], | |
| 84 | + ["/entities", "Entities", Globe2], | |
| 85 | + ["/sources", "Sources", Globe2], | |
| 86 | + ["/country", "Countries", Globe2], | |
| 87 | + ["/alerts", "Alerts", Bell], | |
| 88 | + ["/bookmarks", "Bookmarks", Bookmark], | |
| 89 | + ["/health", "Health", Activity], | |
| 90 | + ["/api", "API", Zap], | |
| 91 | + ]; | |
| 92 | 92 | return ( |
| 93 | − <nav className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-5 border-t border-line bg-bg/95 backdrop-blur lg:hidden" style={{ paddingBottom: "env(safe-area-inset-bottom)" }}> | |
| 94 | − {items.map(([href, label, Icon], i) => { | |
| 95 | − const active = href === "/" ? path === "/" : path.startsWith(href) && !(i === 4 && label === "Profile" && path.startsWith("/watchlists") && false); | |
| 96 | − return ( | |
| 97 | − <Link key={label} href={href} className={`flex flex-col items-center gap-0.5 py-2 text-[10.5px] ${active && !(i === 4) ? "text-signal" : "text-fg-muted"}`}> | |
| 93 | + <> | |
| 94 | + <nav className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-5 border-t border-line bg-bg/95 backdrop-blur lg:hidden" style={{ paddingBottom: "env(safe-area-inset-bottom)" }} aria-label="Mobile"> | |
| 95 | + {items.map(([href, label, Icon, active]) => ( | |
| 96 | + <Link key={href} href={href} aria-current={active ? "page" : undefined} className={`flex flex-col items-center gap-0.5 py-2 text-[10.5px] ${active ? (href === "/breaking" ? "text-hot" : "text-signal") : "text-fg-muted"}`}> | |
| 98 | 97 | <Icon className="size-4" /> |
| 99 | 98 | {label} |
| 100 | 99 | </Link> |
| 101 | − ); | |
| 102 | − })} | |
| 103 | − </nav> | |
| 100 | + ))} | |
| 101 | + <button type="button" onClick={() => setMore(true)} aria-expanded={more} className={`flex flex-col items-center gap-0.5 py-2 text-[10.5px] ${more ? "text-signal" : "text-fg-muted"}`}> | |
| 102 | + <LayoutGrid className="size-4" /> | |
| 103 | + More | |
| 104 | + </button> | |
| 105 | + </nav> | |
| 106 | + {more && ( | |
| 107 | + <div className="fixed inset-0 z-50 flex items-end lg:hidden" role="dialog" aria-modal="true" aria-label="More"> | |
| 108 | + <button type="button" aria-label="Close" onClick={() => setMore(false)} className="scrim absolute inset-0 cursor-default" /> | |
| 109 | + <div className="relative w-full rounded-t-xl border-t border-line bg-panel pb-6 animate-sheet-up" style={{ paddingBottom: "calc(1.5rem + env(safe-area-inset-bottom))" }}> | |
| 110 | + <div className="flex items-center justify-between px-4 py-3"> | |
| 111 | + <span className="label">More</span> | |
| 112 | + <span className="flex items-center gap-2"> | |
| 113 | + <DensityToggle /> | |
| 114 | + <button type="button" onClick={() => setMore(false)} aria-label="Close" className="inline-flex size-7 items-center justify-center rounded-md border border-line"><X className="size-4" /></button> | |
| 115 | + </span> | |
| 116 | + </div> | |
| 117 | + <ul className="grid grid-cols-2 gap-1 px-3"> | |
| 118 | + {moreItems.map(([href, label, Icon]) => ( | |
| 119 | + <li key={href}> | |
| 120 | + <Link href={href} className="flex items-center gap-2 rounded-md border border-line px-3 py-2.5 text-[13px] hover:bg-panel-2"> | |
| 121 | + <Icon className="size-4 text-fg-muted" /> {label} | |
| 122 | + </Link> | |
| 123 | + </li> | |
| 124 | + ))} | |
| 125 | + </ul> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + )} | |
| 129 | + </> | |
| 104 | 130 | ); |
| 105 | 131 | } |
added
apps/web/src/components/prefs.tsx
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from "react"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * User preferences (spec §32, §98, §110): density (compact / normal / comfortable), live paused, | |
| 7 | + * exact timestamps. Persisted in localStorage; density is mirrored on <html data-density> so CSS | |
| 8 | + * variables apply everywhere without re-rendering the tree. | |
| 9 | + */ | |
| 10 | +export type Density = "compact" | "normal" | "comfortable"; | |
| 11 | + | |
| 12 | +interface Prefs { | |
| 13 | + density: Density; | |
| 14 | + setDensity: (d: Density) => void; | |
| 15 | + paused: boolean; | |
| 16 | + setPaused: (p: boolean) => void; | |
| 17 | + exactTime: boolean; | |
| 18 | + setExactTime: (v: boolean) => void; | |
| 19 | + mounted: boolean; | |
| 20 | +} | |
| 21 | + | |
| 22 | +const Ctx = createContext<Prefs | null>(null); | |
| 23 | +const KEY = "ws_prefs"; | |
| 24 | + | |
| 25 | +export function PrefsProvider({ children }: { children: ReactNode }) { | |
| 26 | + // Stored preferences are applied once, right after hydration (reading storage during SSR would mismatch). | |
| 27 | + const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false); | |
| 28 | + const stored = useSyncExternalStore(subscribeNoop, readStored, () => null); | |
| 29 | + const [override, setOverride] = useState<{ density?: Density; exactTime?: boolean }>({}); | |
| 30 | + const density = override.density ?? stored?.density ?? "normal"; | |
| 31 | + const exactTime = override.exactTime ?? stored?.exactTime ?? false; | |
| 32 | + const [paused, setPaused] = useState(false); | |
| 33 | + const setDensityState = (d: Density): void => setOverride((o) => ({ ...o, density: d })); | |
| 34 | + const setExactTimeState = (v: boolean): void => setOverride((o) => ({ ...o, exactTime: v })); | |
| 35 | + useEffect(() => { | |
| 36 | + if (!mounted) return; | |
| 37 | + document.documentElement.dataset.density = density; | |
| 38 | + try { | |
| 39 | + window.localStorage.setItem(KEY, JSON.stringify({ density, exactTime })); | |
| 40 | + } catch { | |
| 41 | + // ignore | |
| 42 | + } | |
| 43 | + }, [density, exactTime, mounted]); | |
| 44 | + | |
| 45 | + const setDensity = useCallback((d: Density) => setDensityState(d), []); | |
| 46 | + const setExactTime = useCallback((v: boolean) => setExactTimeState(v), []); | |
| 47 | + const value = useMemo(() => ({ density, setDensity, paused, setPaused, exactTime, setExactTime, mounted }), [density, setDensity, paused, exactTime, setExactTime, mounted]); | |
| 48 | + return <Ctx.Provider value={value}>{children}</Ctx.Provider>; | |
| 49 | +} | |
| 50 | + | |
| 51 | +const subscribeNoop = (): (() => void) => () => {}; | |
| 52 | +let storedCache: { raw: string | null; value: { density?: Density; exactTime?: boolean } | null } = { raw: undefined as unknown as string | null, value: null }; | |
| 53 | +function readStored(): { density?: Density; exactTime?: boolean } | null { | |
| 54 | + try { | |
| 55 | + const raw = window.localStorage.getItem(KEY); | |
| 56 | + if (raw === storedCache.raw) return storedCache.value; | |
| 57 | + const value = raw ? (JSON.parse(raw) as { density?: Density; exactTime?: boolean }) : null; | |
| 58 | + storedCache = { raw, value }; | |
| 59 | + return value; | |
| 60 | + } catch { | |
| 61 | + return null; | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function usePrefs(): Prefs { | |
| 66 | + const v = useContext(Ctx); | |
| 67 | + if (!v) return { density: "normal", setDensity: () => {}, paused: false, setPaused: () => {}, exactTime: false, setExactTime: () => {}, mounted: false }; | |
| 68 | + return v; | |
| 69 | +} | |
| 70 | + | |
| 71 | +export function DensityToggle({ compact = false }: { compact?: boolean }) { | |
| 72 | + const { density, setDensity } = usePrefs(); | |
| 73 | + const opts: [Density, string][] = [ | |
| 74 | + ["compact", "Compact"], | |
| 75 | + ["normal", "Normal"], | |
| 76 | + ["comfortable", "Comfortable"], | |
| 77 | + ]; | |
| 78 | + return ( | |
| 79 | + <div role="radiogroup" aria-label="Density" className="inline-flex items-center rounded-md border border-line bg-panel p-0.5"> | |
| 80 | + {opts.map(([d, label]) => ( | |
| 81 | + <button key={d} type="button" role="radio" aria-checked={density === d} onClick={() => setDensity(d)} title={label} className={`rounded-sm px-1.5 py-0.5 text-[11px] ${density === d ? "bg-panel-3 text-fg" : "text-fg-subtle hover:text-fg"}`}> | |
| 82 | + {compact ? label[0] : label} | |
| 83 | + </button> | |
| 84 | + ))} | |
| 85 | + </div> | |
| 86 | + ); | |
| 87 | +} | |
modified
apps/web/src/components/rail.tsx
+108 −17
@@ -1,26 +1,27 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | −import type { Cluster, TrendingItem } from "@/lib/api"; | |
| 3 | −import { fmtScore, relTime } from "@/lib/format"; | |
| 4 | −import { Empty, Panel, Score } from "./ui"; | |
| 2 | +import type { Cluster, EventItem, TrendingItem } from "@/lib/api"; | |
| 3 | +import { fmtOffset, fmtScore, relTime } from "@/lib/format"; | |
| 4 | +import { Badge, Chip, Empty, Mark, Panel, Score, StateLabelText } from "./ui"; | |
| 5 | 5 | |
| 6 | −export function TrendingPanel({ items }: { items: TrendingItem[] }) { | |
| 6 | +export function TrendingPanel({ items, title = "Trending entities", hours = 24 }: { items: TrendingItem[]; title?: string; hours?: number }) { | |
| 7 | 7 | return ( |
| 8 | − <Panel title="Trending now" dense action={<Link href="/entities" className="text-[11px] text-fg-subtle hover:text-fg">all entities →</Link>}> | |
| 8 | + <Panel title={`${title} · ${hours} h`} dense action={<Link href="/explore?tab=trending" className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 9 | 9 | {items.length === 0 ? ( |
| 10 | − <Empty>Trending is computed from the last 24 h of events.</Empty> | |
| 10 | + <Empty>Trending is computed from the last {hours} h of events.</Empty> | |
| 11 | 11 | ) : ( |
| 12 | 12 | <ol className="divide-y divide-line"> |
| 13 | 13 | {items.map((t, i) => ( |
| 14 | − <li key={t.id} className="grid grid-cols-[1.5rem_1fr_auto] items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 15 | − <span className="font-mono text-[12px] text-fg-subtle tabular">{i + 1}</span> | |
| 14 | + <li key={t.id} className="grid grid-cols-[1.25rem_1.25rem_1fr_auto] items-center gap-2 px-3 py-1.5 text-[13px]"> | |
| 15 | + <span className="font-mono text-[11px] text-fg-subtle tabular">{i + 1}</span> | |
| 16 | + <Mark name={t.name} /> | |
| 16 | 17 | <div className="min-w-0"> |
| 17 | − <Link href={`/company/${t.id}`} className="block truncate font-medium hover:underline">{t.name}</Link> | |
| 18 | + <Link href={`/entity/${t.id}`} className="block truncate font-medium hover:underline">{t.name}</Link> | |
| 18 | 19 | <div className="truncate text-[11px] text-fg-subtle"> |
| 19 | − {t.events} events · {t.sources} source{t.sources === 1 ? "" : "s"}{t.silent ? ` · ${t.silent} silent` : ""} | |
| 20 | + {t.events} signals · {t.sources} source{t.sources === 1 ? "" : "s"}{t.first_party ? ` · ${t.first_party} first-party` : ""}{t.silent ? ` · ${t.silent} silent` : ""} | |
| 20 | 21 | </div> |
| 21 | 22 | </div> |
| 22 | − <span className={`font-mono text-[12px] font-semibold tabular ${t.events > t.prev_events ? "text-signal" : "text-fg-muted"}`}> | |
| 23 | − {t.events > t.prev_events ? "↑" : t.events < t.prev_events ? "↓" : "→"} {fmtScore(t.score)} | |
| 23 | + <span className={`font-mono text-[12px] font-semibold tabular ${t.direction === "up" ? "text-signal" : t.direction === "down" ? "text-fg-subtle" : "text-fg-muted"}`} title={`trend score ${fmtScore(t.score)} · ${t.events} vs ${t.prev_events} in the previous window`}> | |
| 24 | + {t.direction === "up" ? "↑" : t.direction === "down" ? "↓" : "→"} {fmtScore(t.score)} | |
| 24 | 25 | </span> |
| 25 | 26 | </li> |
| 26 | 27 | ))} |
@@ -30,10 +31,10 @@ export function TrendingPanel({ items }: { items: TrendingItem[] }) { | ||
| 30 | 31 | ); |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | −export function ClustersPanel({ items }: { items: Cluster[] }) { | |
| 34 | +export function ClustersPanel({ items, title = "Event clusters" }: { items: Cluster[]; title?: string }) { | |
| 34 | 35 | const multi = items.filter((c) => c.event_count > 1).slice(0, 8); |
| 35 | 36 | return ( |
| 36 | − <Panel title="Event clusters" dense> | |
| 37 | + <Panel title={title} dense action={<Link href="/explore?tab=clusters" className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 37 | 38 | {multi.length === 0 ? ( |
| 38 | 39 | <Empty>Related observations are grouped into clusters as they arrive.</Empty> |
| 39 | 40 | ) : ( |
@@ -41,9 +42,16 @@ export function ClustersPanel({ items }: { items: Cluster[] }) { | ||
| 41 | 42 | {multi.map((c) => ( |
| 42 | 43 | <li key={c.id} className="flex items-start gap-2 px-3 py-1.5 text-[13px]"> |
| 43 | 44 | <Score value={c.max_importance} size="sm" /> |
| 44 | − <div className="min-w-0"> | |
| 45 | − <Link href={c.events?.[0]?.slug ? `/event/${c.events[0].slug}` : "/explore"} className="line-clamp-2 font-medium hover:underline">{c.title}</Link> | |
| 46 | − <div className="text-[11px] text-fg-subtle">{c.event_count} observations · {relTime(c.last_at)}</div> | |
| 45 | + <div className="min-w-0 flex-1"> | |
| 46 | + <Link href={`/cluster/${c.slug ?? c.id}`} className="line-clamp-2 font-medium hover:underline">{c.title}</Link> | |
| 47 | + <div className="flex flex-wrap items-center gap-x-2 text-[11px] text-fg-subtle"> | |
| 48 | + <StateLabelText state={c.state} /> | |
| 49 | + <span>{c.event_count} signals</span> | |
| 50 | + {c.source_count && c.source_count > 1 && <span>{c.source_count} sources</span>} | |
| 51 | + {(c.first_party_count ?? 0) > 0 && <span className="text-signal">{c.first_party_count} 1st-party</span>} | |
| 52 | + {c.lead_time_ms && c.lead_time_ms > 0 ? <span title="WebSensor lead time before the first external report">lead {fmtOffset(c.lead_time_ms).replace("+", "")}</span> : null} | |
| 53 | + <span>{relTime(c.last_at)}</span> | |
| 54 | + </div> | |
| 47 | 55 | </div> |
| 48 | 56 | </li> |
| 49 | 57 | ))} |
@@ -52,3 +60,86 @@ export function ClustersPanel({ items }: { items: Cluster[] }) { | ||
| 52 | 60 | </Panel> |
| 53 | 61 | ); |
| 54 | 62 | } |
| 63 | + | |
| 64 | +/** Breaking now rail (spec §94): primary event of each breaking / developing cluster. */ | |
| 65 | +export function BreakingRail({ items, developing }: { items: Cluster[]; developing?: Cluster[] }) { | |
| 66 | + const all = [...items.map((c) => ({ c, state: "breaking" })), ...(developing ?? []).map((c) => ({ c, state: "developing" }))].slice(0, 8); | |
| 67 | + return ( | |
| 68 | + <Panel title={<span className="text-hot">Breaking now</span>} dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">desk →</Link>}> | |
| 69 | + {all.length === 0 ? ( | |
| 70 | + <Empty>Nothing is breaking right now. Breaking requires strong signal, freshness and confirmation — not just recency.</Empty> | |
| 71 | + ) : ( | |
| 72 | + <ul className="divide-y divide-line"> | |
| 73 | + {all.map(({ c, state }) => { | |
| 74 | + const e = c.event as EventItem | null | undefined; | |
| 75 | + return ( | |
| 76 | + <li key={c.id} className={`px-3 py-2 text-[13px] ${state === "breaking" ? "border-l-2 border-l-hot/70" : "border-l-2 border-l-high/60"}`}> | |
| 77 | + <div className="flex items-center gap-1.5"> | |
| 78 | + <Badge kind={state as "breaking" | "developing"} compact /> | |
| 79 | + <span className="truncate font-mono text-[10.5px] uppercase text-fg-subtle">{e?.source?.name ?? c.source?.name ?? ""}</span> | |
| 80 | + <span className="ml-auto font-mono text-[10.5px] text-fg-subtle">{relTime(c.last_at)}</span> | |
| 81 | + </div> | |
| 82 | + <Link href={e?.slug ? `/event/${e.slug}` : `/cluster/${c.slug ?? c.id}`} className="mt-0.5 line-clamp-2 font-medium hover:underline">{c.title}</Link> | |
| 83 | + <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[11px] text-fg-subtle"> | |
| 84 | + <Score value={e?.signal_score ?? c.max_importance} size="sm" kind="signal" /> | |
| 85 | + <span>{c.event_count} signal{c.event_count === 1 ? "" : "s"} · {c.source_count ?? 1} source{(c.source_count ?? 1) === 1 ? "" : "s"}</span> | |
| 86 | + {(c.first_party_count ?? 0) > 0 && <Chip tone="signal">{c.first_party_count} first-party</Chip>} | |
| 87 | + </div> | |
| 88 | + </li> | |
| 89 | + ); | |
| 90 | + })} | |
| 91 | + </ul> | |
| 92 | + )} | |
| 93 | + </Panel> | |
| 94 | + ); | |
| 95 | +} | |
| 96 | + | |
| 97 | +export function AnomalyRail({ items }: { items: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number; pct_vs_baseline?: number | null }[] }) { | |
| 98 | + const top = items.filter((s) => s.activity_score >= 45).slice(0, 6); | |
| 99 | + return ( | |
| 100 | + <Panel title="Anomalous activity" dense action={<Link href="/radar" className="text-[11px] text-fg-subtle hover:text-fg">radar →</Link>}> | |
| 101 | + {top.length === 0 ? ( | |
| 102 | + <Empty>No source is far above its baseline right now.</Empty> | |
| 103 | + ) : ( | |
| 104 | + <ul className="divide-y divide-line"> | |
| 105 | + {top.map((s) => ( | |
| 106 | + <li key={s.id} className="px-3 py-1.5 text-[13px]"> | |
| 107 | + <div className="flex items-center gap-2"> | |
| 108 | + <Link href={`/source/${s.id}`} className="min-w-0 flex-1 truncate font-medium hover:underline">{s.name}</Link> | |
| 109 | + <span className={`font-mono text-[12px] font-semibold tabular ${s.activity_score >= 70 ? "text-hot" : "text-high"}`}>{s.pct_vs_baseline !== null && s.pct_vs_baseline !== undefined ? `+${Math.min(9999, Math.round(s.pct_vs_baseline)).toLocaleString()}%` : fmtScore(s.activity_score)}</span> | |
| 110 | + </div> | |
| 111 | + <div className="text-[11px] text-fg-subtle">{s.changes_2h} changes in 2 h · baseline {s.baseline_per_day}/day</div> | |
| 112 | + </li> | |
| 113 | + ))} | |
| 114 | + </ul> | |
| 115 | + )} | |
| 116 | + </Panel> | |
| 117 | + ); | |
| 118 | +} | |
| 119 | + | |
| 120 | +export function SilentRail({ items }: { items: EventItem[] }) { | |
| 121 | + return ( | |
| 122 | + <Panel title={<span className="text-silent">Silent changes</span>} dense action={<Link href="/silent" className="text-[11px] text-fg-subtle hover:text-fg">all →</Link>}> | |
| 123 | + {items.length === 0 ? ( | |
| 124 | + <Empty>No silent change in this window.</Empty> | |
| 125 | + ) : ( | |
| 126 | + <ul className="divide-y divide-line"> | |
| 127 | + {items.slice(0, 6).map((e) => ( | |
| 128 | + <li key={e.id} className="px-3 py-1.5 text-[13px]"> | |
| 129 | + <div className="flex items-center gap-2 text-[11px] text-fg-subtle"> | |
| 130 | + <span className="truncate font-mono uppercase">{e.source?.name}</span> | |
| 131 | + <span className="ml-auto">{relTime(e.detected_at)}</span> | |
| 132 | + </div> | |
| 133 | + <Link href={`/event/${e.slug}`} className="line-clamp-2 hover:underline">{e.title}</Link> | |
| 134 | + {e.field_changes?.[0] && ( | |
| 135 | + <div className="mt-0.5 truncate font-mono text-[11px] text-fg-muted"> | |
| 136 | + {e.field_changes[0].label}: <span className="diff-line-del rounded px-0.5">{e.field_changes[0].before ?? "∅"}</span> → <span className="diff-line-add rounded px-0.5">{e.field_changes[0].after ?? "∅"}</span> | |
| 137 | + </div> | |
| 138 | + )} | |
| 139 | + </li> | |
| 140 | + ))} | |
| 141 | + </ul> | |
| 142 | + )} | |
| 143 | + </Panel> | |
| 144 | + ); | |
| 145 | +} | |
modified
apps/web/src/components/ui.tsx
+194 −35
@@ -1,32 +1,65 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { ReactNode } from "react"; |
| 3 | −import { fmtScore, importanceBand, typeLabel } from "@/lib/format"; | |
| 3 | +import { fmtScore, importanceBand, stateLabel, typeLabel } from "@/lib/format"; | |
| 4 | 4 | |
| 5 | −export function Score({ value, size = "md", title }: { value: number | null | undefined; size?: "sm" | "md" | "lg"; title?: string }) { | |
| 5 | +export function Score({ value, size = "md", title, kind = "importance" }: { value: number | null | undefined; size?: "sm" | "md" | "lg"; title?: string; kind?: "importance" | "signal" }) { | |
| 6 | 6 | const v = value ?? 0; |
| 7 | 7 | const band = importanceBand(v); |
| 8 | 8 | const color = band === "hot" ? "bg-hot/15 text-hot border-hot/40" : band === "high" ? "bg-high/15 text-high border-high/40" : band === "mid" ? "bg-mid/15 text-mid border-mid/40" : "bg-panel-2 text-fg-muted border-line"; |
| 9 | 9 | const sz = size === "sm" ? "min-w-7 px-1 text-[11px] h-5" : size === "lg" ? "min-w-14 px-2 text-xl h-9" : "min-w-9 px-1.5 text-xs h-6"; |
| 10 | 10 | return ( |
| 11 | − <span title={title ?? `Importance ${fmtScore(v)}`} className={`inline-flex items-center justify-center rounded-sm border font-mono font-semibold tabular ${color} ${sz}`}> | |
| 11 | + <span title={title ?? `${kind === "signal" ? "Signal" : "Importance"} ${fmtScore(v)}`} className={`inline-flex items-center justify-center rounded-sm border font-mono font-semibold tabular ${color} ${sz}`}> | |
| 12 | 12 | {fmtScore(v)} |
| 13 | 13 | </span> |
| 14 | 14 | ); |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | −export function Chip({ children, tone = "default", href, className = "" }: { children: ReactNode; tone?: "default" | "silent" | "signal" | "danger" | "warn" | "info" | "ok"; href?: string; className?: string }) { | |
| 18 | − const tones: Record<string, string> = { | |
| 19 | − default: "border-line bg-panel-2 text-fg-muted", | |
| 20 | − silent: "border-silent/40 bg-silent-soft text-silent", | |
| 21 | − signal: "border-signal/40 bg-signal-soft text-signal", | |
| 22 | − danger: "border-danger/40 bg-danger/10 text-danger", | |
| 23 | − warn: "border-warn/40 bg-warn/10 text-warn", | |
| 24 | − info: "border-info/40 bg-info/10 text-info", | |
| 25 | − ok: "border-ok/40 bg-ok/10 text-ok", | |
| 17 | +export type Tone = "default" | "silent" | "signal" | "danger" | "warn" | "info" | "ok" | "hot" | "high"; | |
| 18 | + | |
| 19 | +const TONES: Record<Tone, string> = { | |
| 20 | + default: "border-line bg-panel-2 text-fg-muted", | |
| 21 | + silent: "border-silent/40 bg-silent-soft text-silent", | |
| 22 | + signal: "border-signal/40 bg-signal-soft text-signal", | |
| 23 | + danger: "border-danger/40 bg-danger/10 text-danger", | |
| 24 | + warn: "border-warn/40 bg-warn/10 text-warn", | |
| 25 | + info: "border-info/40 bg-info/10 text-info", | |
| 26 | + ok: "border-ok/40 bg-ok/10 text-ok", | |
| 27 | + hot: "border-hot/50 bg-hot/12 text-hot", | |
| 28 | + high: "border-high/50 bg-high/12 text-high", | |
| 29 | +}; | |
| 30 | + | |
| 31 | +export function Chip({ children, tone = "default", href, className = "", title }: { children: ReactNode; tone?: Tone; href?: string; className?: string; title?: string }) { | |
| 32 | + const cls = `inline-flex items-center gap-1 rounded-sm border px-1.5 py-px text-[10.5px] font-medium leading-4 whitespace-nowrap ${TONES[tone]} ${className}`; | |
| 33 | + if (href) return <Link href={href} title={title} className={`${cls} hover:border-line-strong`}>{children}</Link>; | |
| 34 | + return <span title={title} className={cls}>{children}</span>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Signal badges (spec §31): BREAKING · SILENT · FIRST PARTY · CONFIRMED · DEVELOPING · ANOMALOUS · EXTERNAL */ | |
| 38 | +export type BadgeKind = "breaking" | "silent" | "first-party" | "confirmed" | "developing" | "anomalous" | "external" | "inferred" | "unconfirmed" | "replayed"; | |
| 39 | +export function Badge({ kind, compact = false }: { kind: BadgeKind; compact?: boolean }) { | |
| 40 | + const map: Record<BadgeKind, { tone: Tone; label: string; short: string; title: string }> = { | |
| 41 | + breaking: { tone: "hot", label: "BREAKING", short: "BRK", title: "Breaking: strong signal, fresh, confirmed or first-party" }, | |
| 42 | + developing: { tone: "high", label: "DEVELOPING", short: "DEV", title: "Developing: signals accumulating across sources" }, | |
| 43 | + silent: { tone: "silent", label: "SILENT", short: "SIL", title: "Silent change: modified without a matching announcement" }, | |
| 44 | + "first-party": { tone: "signal", label: "FIRST PARTY", short: "1ST", title: "First-party evidence: the organization's own channel" }, | |
| 45 | + confirmed: { tone: "ok", label: "CONFIRMED", short: "CFM", title: "Confirmed by independent sources" }, | |
| 46 | + anomalous: { tone: "warn", label: "ANOMALOUS", short: "ANM", title: "Activity far above this source's baseline" }, | |
| 47 | + external: { tone: "default", label: "EXTERNAL", short: "EXT", title: "Third-party report (media / aggregator)" }, | |
| 48 | + inferred: { tone: "info", label: "INFERRED", short: "INF", title: "Classification inferred, limited evidence" }, | |
| 49 | + unconfirmed: { tone: "warn", label: "UNCONFIRMED", short: "UNC", title: "Single-source, low heuristic confidence" }, | |
| 50 | + replayed: { tone: "default", label: "REPLAYED", short: "RPL", title: "Delivered from the durable stream after a reconnection" }, | |
| 26 | 51 | }; |
| 27 | − const cls = `inline-flex items-center gap-1 rounded-sm border px-1.5 py-px text-[10.5px] font-medium leading-4 whitespace-nowrap ${tones[tone]} ${className}`; | |
| 28 | − if (href) return <Link href={href} className={`${cls} hover:border-line-strong`}>{children}</Link>; | |
| 29 | − return <span className={cls}>{children}</span>; | |
| 52 | + const b = map[kind]; | |
| 53 | + return ( | |
| 54 | + <Chip tone={b.tone} title={b.title} className="font-mono font-semibold tracking-wider"> | |
| 55 | + {compact ? b.short : b.label} | |
| 56 | + </Chip> | |
| 57 | + ); | |
| 58 | +} | |
| 59 | + | |
| 60 | +export function StateBadge({ state }: { state: string | null | undefined }) { | |
| 61 | + if (!state || state === "watching" || state === "closed") return null; | |
| 62 | + return <Badge kind={state as BadgeKind} />; | |
| 30 | 63 | } |
| 31 | 64 | |
| 32 | 65 | export function TypeChip({ type, href }: { type: string; href?: string }) { |
@@ -34,25 +67,23 @@ export function TypeChip({ type, href }: { type: string; href?: string }) { | ||
| 34 | 67 | } |
| 35 | 68 | |
| 36 | 69 | export function SilentBadge({ compact = false }: { compact?: boolean }) { |
| 37 | − return ( | |
| 38 | − <Chip tone="silent" className="font-semibold tracking-wide"> | |
| 39 | − <span aria-hidden>⚠</span> {compact ? "SILENT" : "SILENT CHANGE"} | |
| 40 | − </Chip> | |
| 41 | − ); | |
| 70 | + return <Badge kind="silent" compact={compact} />; | |
| 42 | 71 | } |
| 43 | 72 | |
| 44 | 73 | export function EvidenceTag({ label }: { label: string | null | undefined }) { |
| 45 | 74 | const l = (label ?? "OBSERVED").toUpperCase(); |
| 46 | − const tone = l === "CONFIRMED" ? "ok" : l === "INFERRED" ? "info" : l === "UNCONFIRMED" ? "warn" : "default"; | |
| 47 | − return <Chip tone={tone as "ok" | "info" | "warn" | "default"} className="font-mono tracking-wider">{l}</Chip>; | |
| 75 | + if (l === "CONFIRMED") return <Badge kind="confirmed" />; | |
| 76 | + if (l === "INFERRED") return <Badge kind="inferred" />; | |
| 77 | + if (l === "UNCONFIRMED") return <Badge kind="unconfirmed" />; | |
| 78 | + return <Chip className="font-mono tracking-wider">{l}</Chip>; | |
| 48 | 79 | } |
| 49 | 80 | |
| 50 | 81 | export function HealthPill({ health }: { health: string | null | undefined }) { |
| 51 | 82 | const h = (health ?? "UP").toUpperCase(); |
| 52 | − const tone = h === "UP" ? "ok" : h === "DEGRADED" || h === "RATE_LIMITED" ? "warn" : h === "ERROR" ? "danger" : "default"; | |
| 83 | + const tone: Tone = h === "UP" || h === "ACTIVE" || h === "VALIDATED" ? "ok" : h === "DEGRADED" || h === "RATE_LIMITED" || h === "PENDING" ? "warn" : h === "ERROR" ? "danger" : "default"; | |
| 53 | 84 | return ( |
| 54 | − <Chip tone={tone as "ok" | "warn" | "danger" | "default"} className="font-mono"> | |
| 55 | − <span className={`inline-block size-1.5 rounded-full ${h === "UP" ? "bg-ok" : h === "ERROR" ? "bg-danger" : h === "DISABLED" ? "bg-low" : "bg-warn"}`} /> {h} | |
| 85 | + <Chip tone={tone} className="font-mono"> | |
| 86 | + <span className={`inline-block size-1.5 rounded-full ${tone === "ok" ? "bg-ok" : tone === "danger" ? "bg-danger" : tone === "default" ? "bg-low" : "bg-warn"}`} /> {h} | |
| 56 | 87 | </Chip> |
| 57 | 88 | ); |
| 58 | 89 | } |
@@ -61,9 +92,9 @@ export function TierBadge({ tier }: { tier: string | null | undefined }) { | ||
| 61 | 92 | return <span className="inline-flex size-5 items-center justify-center rounded-sm border border-line bg-panel-2 font-mono text-[11px] font-semibold text-fg-muted" title={`Tier ${tier}`}>{tier ?? "?"}</span>; |
| 62 | 93 | } |
| 63 | 94 | |
| 64 | −export function Panel({ title, action, children, className = "", dense = false }: { title?: ReactNode; action?: ReactNode; children: ReactNode; className?: string; dense?: boolean }) { | |
| 95 | +export function Panel({ title, action, children, className = "", dense = false, id }: { title?: ReactNode; action?: ReactNode; children: ReactNode; className?: string; dense?: boolean; id?: string }) { | |
| 65 | 96 | return ( |
| 66 | − <section className={`panel ${className}`}> | |
| 97 | + <section id={id} className={`panel ${className}`}> | |
| 67 | 98 | {title !== undefined && ( |
| 68 | 99 | <header className="flex items-center justify-between gap-3 border-b border-line px-3 py-2"> |
| 69 | 100 | <h2 className="label">{title}</h2> |
@@ -75,15 +106,21 @@ export function Panel({ title, action, children, className = "", dense = false } | ||
| 75 | 106 | ); |
| 76 | 107 | } |
| 77 | 108 | |
| 78 | −export function Empty({ children = "No data yet — the engine is warming up." }: { children?: ReactNode }) { | |
| 79 | − return <div className="px-3 py-8 text-center text-[13px] text-fg-subtle">{children}</div>; | |
| 109 | +export function Empty({ children = "No data yet — the engine is warming up.", icon }: { children?: ReactNode; icon?: ReactNode }) { | |
| 110 | + return ( | |
| 111 | + <div className="flex flex-col items-center gap-1 px-3 py-8 text-center text-[13px] text-fg-subtle"> | |
| 112 | + {icon} | |
| 113 | + <div>{children}</div> | |
| 114 | + </div> | |
| 115 | + ); | |
| 80 | 116 | } |
| 81 | 117 | |
| 82 | −export function Stat({ label, value, hint }: { label: string; value: ReactNode; hint?: ReactNode }) { | |
| 118 | +export function Stat({ label, value, hint, tone }: { label: string; value: ReactNode; hint?: ReactNode; tone?: Tone }) { | |
| 119 | + const color = tone === "hot" ? "text-hot" : tone === "silent" ? "text-silent" : tone === "signal" ? "text-signal" : tone === "warn" ? "text-warn" : ""; | |
| 83 | 120 | return ( |
| 84 | 121 | <div className="flex min-w-0 flex-col gap-0.5 px-3 py-2"> |
| 85 | 122 | <span className="label">{label}</span> |
| 86 | − <span className="font-mono text-lg font-semibold leading-tight tabular">{value}</span> | |
| 123 | + <span className={`font-mono text-lg font-semibold leading-tight tabular ${color}`}>{value}</span> | |
| 87 | 124 | {hint && <span className="truncate text-[11px] text-fg-subtle">{hint}</span>} |
| 88 | 125 | </div> |
| 89 | 126 | ); |
@@ -114,12 +151,12 @@ export function Gauge({ label, value, tone }: { label: string; value: number | n | ||
| 114 | 151 | ); |
| 115 | 152 | } |
| 116 | 153 | |
| 117 | −export function PageHeader({ title, kicker, description, actions }: { title: ReactNode; kicker?: ReactNode; description?: ReactNode; actions?: ReactNode }) { | |
| 154 | +export function PageHeader({ title, kicker, description, actions, compact = false }: { title: ReactNode; kicker?: ReactNode; description?: ReactNode; actions?: ReactNode; compact?: boolean }) { | |
| 118 | 155 | return ( |
| 119 | − <div className="mb-4 flex flex-wrap items-end justify-between gap-3"> | |
| 156 | + <div className={`${compact ? "mb-3" : "mb-4"} flex flex-wrap items-end justify-between gap-3`}> | |
| 120 | 157 | <div className="min-w-0"> |
| 121 | − {kicker && <div className="label mb-1">{kicker}</div>} | |
| 122 | − <h1 className="text-xl font-semibold leading-tight sm:text-2xl">{title}</h1> | |
| 158 | + {kicker && <div className="label mb-1 flex flex-wrap items-center gap-2 normal-case tracking-normal">{kicker}</div>} | |
| 159 | + <h1 className={`${compact ? "text-lg" : "text-xl sm:text-2xl"} font-semibold leading-tight`}>{title}</h1> | |
| 123 | 160 | {description && <p className="mt-1 max-w-3xl text-[13px] text-fg-muted">{description}</p>} |
| 124 | 161 | </div> |
| 125 | 162 | {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>} |
@@ -165,3 +202,125 @@ export function ExtLink({ href, children, className = "" }: { href: string; chil | ||
| 165 | 202 | </a> |
| 166 | 203 | ); |
| 167 | 204 | } |
| 205 | + | |
| 206 | +export function Kbd({ children }: { children: ReactNode }) { | |
| 207 | + return <kbd className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded border border-line bg-panel-2 px-1 font-mono text-[10px] text-fg-subtle">{children}</kbd>; | |
| 208 | +} | |
| 209 | + | |
| 210 | +/** Skeleton primitives (spec §113). */ | |
| 211 | +export function Skeleton({ className = "" }: { className?: string }) { | |
| 212 | + return <div aria-hidden className={`skeleton ${className}`} />; | |
| 213 | +} | |
| 214 | +export function SkeletonRows({ rows = 8 }: { rows?: number }) { | |
| 215 | + return ( | |
| 216 | + <div className="divide-y divide-line"> | |
| 217 | + {Array.from({ length: rows }, (_, i) => ( | |
| 218 | + <div key={i} className="grid grid-cols-[6.5rem_1fr_auto] gap-x-3 px-3 py-2.5"> | |
| 219 | + <div className="flex flex-col gap-1.5"> | |
| 220 | + <Skeleton className="h-3 w-14" /> | |
| 221 | + <Skeleton className="h-2.5 w-10" /> | |
| 222 | + </div> | |
| 223 | + <div className="flex flex-col gap-1.5"> | |
| 224 | + <Skeleton className="h-2.5 w-24" /> | |
| 225 | + <Skeleton className={`h-3.5 ${i % 3 === 0 ? "w-3/4" : i % 3 === 1 ? "w-11/12" : "w-2/3"}`} /> | |
| 226 | + <Skeleton className="h-2.5 w-40" /> | |
| 227 | + </div> | |
| 228 | + <Skeleton className="h-6 w-9" /> | |
| 229 | + </div> | |
| 230 | + ))} | |
| 231 | + </div> | |
| 232 | + ); | |
| 233 | +} | |
| 234 | +export function SkeletonPanel({ lines = 5, title = true }: { lines?: number; title?: boolean }) { | |
| 235 | + return ( | |
| 236 | + <div className="panel p-3"> | |
| 237 | + {title && <Skeleton className="mb-3 h-2.5 w-28" />} | |
| 238 | + <div className="flex flex-col gap-2"> | |
| 239 | + {Array.from({ length: lines }, (_, i) => ( | |
| 240 | + <Skeleton key={i} className={`h-3 ${i % 2 ? "w-5/6" : "w-full"}`} /> | |
| 241 | + ))} | |
| 242 | + </div> | |
| 243 | + </div> | |
| 244 | + ); | |
| 245 | +} | |
| 246 | + | |
| 247 | +/** Inline SVG sparkline — no chart library, no client JS. */ | |
| 248 | +export function Sparkline({ values, width = 120, height = 28, tone = "signal", fill = true, responsive = false }: { values: number[]; width?: number; height?: number; tone?: "signal" | "hot" | "info" | "silent" | "muted"; fill?: boolean; /** stretch to the container width (viewBox scaling) */ responsive?: boolean }) { | |
| 249 | + if (!values.length) return <svg width={responsive ? "100%" : width} height={height} aria-hidden />; | |
| 250 | + const max = Math.max(1, ...values); | |
| 251 | + const step = values.length > 1 ? width / (values.length - 1) : width; | |
| 252 | + const pts = values.map((v, i) => [i * step, height - 2 - (v / max) * (height - 4)] as const); | |
| 253 | + const d = pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`).join(" "); | |
| 254 | + const color = tone === "hot" ? "var(--hot)" : tone === "info" ? "var(--info)" : tone === "silent" ? "var(--silent)" : tone === "muted" ? "var(--fg-subtle)" : "var(--signal)"; | |
| 255 | + return ( | |
| 256 | + <svg width={responsive ? "100%" : width} height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio={responsive ? "none" : undefined} aria-hidden className="block max-w-full overflow-visible"> | |
| 257 | + {fill && <path d={`${d} L${width},${height} L0,${height} Z`} fill={color} opacity={0.12} />} | |
| 258 | + <path d={d} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" /> | |
| 259 | + </svg> | |
| 260 | + ); | |
| 261 | +} | |
| 262 | + | |
| 263 | +/** 35-day activity heatmap (spec §102). */ | |
| 264 | +export function Heatmap({ days, max }: { days: { day: string; events: number; silent?: number; breaking?: number }[]; max?: number }) { | |
| 265 | + const m = max ?? Math.max(1, ...days.map((d) => d.events)); | |
| 266 | + const cls = (n: number, breaking?: number): string => { | |
| 267 | + if (!n) return "heat-0"; | |
| 268 | + if (breaking && breaking >= 3) return "heat-hot"; | |
| 269 | + const r = n / m; | |
| 270 | + return r > 0.75 ? "heat-4" : r > 0.5 ? "heat-3" : r > 0.25 ? "heat-2" : "heat-1"; | |
| 271 | + }; | |
| 272 | + return ( | |
| 273 | + <div className="flex flex-wrap gap-[3px]" role="img" aria-label="Daily activity over the last 35 days"> | |
| 274 | + {days.map((d) => ( | |
| 275 | + <span key={d.day} title={`${d.day}: ${d.events} event${d.events === 1 ? "" : "s"}${d.silent ? ` · ${d.silent} silent` : ""}${d.breaking ? ` · ${d.breaking} breaking` : ""}`} className={`size-3 rounded-[2px] ${cls(d.events, d.breaking)}`} /> | |
| 276 | + ))} | |
| 277 | + </div> | |
| 278 | + ); | |
| 279 | +} | |
| 280 | + | |
| 281 | +/** Link-based tabs (URL-driven, server-renderable). */ | |
| 282 | +export function Tabs({ items, current, className = "" }: { items: { key: string; label: ReactNode; href: string; count?: number | null }[]; current: string; className?: string }) { | |
| 283 | + return ( | |
| 284 | + <nav className={`flex gap-0.5 overflow-x-auto border-b border-line no-scrollbar ${className}`} aria-label="Sections"> | |
| 285 | + {items.map((t) => { | |
| 286 | + const active = t.key === current; | |
| 287 | + return ( | |
| 288 | + <Link key={t.key} href={t.href} aria-current={active ? "page" : undefined} className={`-mb-px inline-flex items-center gap-1.5 whitespace-nowrap border-b-2 px-2.5 py-1.5 text-[12.5px] ${active ? "border-signal text-fg" : "border-transparent text-fg-muted hover:text-fg"}`}> | |
| 289 | + {t.label} | |
| 290 | + {t.count !== undefined && t.count !== null && <span className="font-mono text-[10.5px] text-fg-subtle tabular">{t.count}</span>} | |
| 291 | + </Link> | |
| 292 | + ); | |
| 293 | + })} | |
| 294 | + </nav> | |
| 295 | + ); | |
| 296 | +} | |
| 297 | + | |
| 298 | +export function StateLabelText({ state }: { state?: string | null }) { | |
| 299 | + const l = stateLabel(state); | |
| 300 | + if (!l) return null; | |
| 301 | + const c = state === "breaking" ? "text-hot" : state === "developing" ? "text-high" : state === "confirmed" ? "text-ok" : "text-fg-subtle"; | |
| 302 | + return <span className={`font-mono text-[10.5px] font-semibold tracking-wider ${c}`}>{l}</span>; | |
| 303 | +} | |
| 304 | + | |
| 305 | +export function Flag({ code, className = "" }: { code?: string | null; className?: string }) { | |
| 306 | + if (!code) return null; | |
| 307 | + const c = code.toUpperCase(); | |
| 308 | + if (c.length !== 2) return <span className={`font-mono text-[10px] ${className}`}>{c}</span>; | |
| 309 | + const flag = String.fromCodePoint(...[...c].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65)); | |
| 310 | + return <span className={className} title={c} aria-label={c}>{flag}</span>; | |
| 311 | +} | |
| 312 | + | |
| 313 | +/** Entity mark (spec §111): deterministic initials fallback — no third-party favicon requests. */ | |
| 314 | +export function Mark({ name, size = 5, className = "" }: { name: string; size?: 4 | 5 | 6 | 8; className?: string }) { | |
| 315 | + const words = name.replace(/[^\p{L}\p{N} ]/gu, " ").trim().split(/\s+/).filter(Boolean); | |
| 316 | + const initials = (words.length >= 2 ? words[0]![0]! + words[1]![0]! : name.slice(0, 2)).toUpperCase(); | |
| 317 | + let h = 0; | |
| 318 | + for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) >>> 0; | |
| 319 | + const hue = h % 360; | |
| 320 | + const sz = size === 4 ? "size-4 text-[8px]" : size === 6 ? "size-6 text-[10px]" : size === 8 ? "size-8 text-[12px]" : "size-5 text-[9px]"; | |
| 321 | + return ( | |
| 322 | + <span aria-hidden className={`inline-flex shrink-0 items-center justify-center rounded-[3px] border font-mono font-semibold tracking-tight ${sz} ${className}`} style={{ background: `color-mix(in oklab, oklch(0.62 0.12 ${hue}) 22%, var(--panel-2))`, borderColor: `color-mix(in oklab, oklch(0.62 0.12 ${hue}) 40%, var(--line))`, color: `oklch(0.78 0.11 ${hue})` }}> | |
| 323 | + {initials} | |
| 324 | + </span> | |
| 325 | + ); | |
| 326 | +} | |
modified
apps/web/src/lib/api.ts
+331 −10
@@ -78,6 +78,45 @@ export interface EventItem { | ||
| 78 | 78 | sensor: SensorRef; |
| 79 | 79 | entities: EntityRef[]; |
| 80 | 80 | cluster_size?: number | null; |
| 81 | + // 0.2 intelligence fields | |
| 82 | + signal_score?: number | null; | |
| 83 | + velocity_score?: number | null; | |
| 84 | + impact_score?: number | null; | |
| 85 | + anomaly_score?: number | null; | |
| 86 | + change_class?: string | null; | |
| 87 | + first_party?: boolean; | |
| 88 | + country?: string | null; | |
| 89 | + language?: string | null; | |
| 90 | + canonical_url?: string | null; | |
| 91 | + field_changes?: FieldChange[] | null; | |
| 92 | + score_reasons?: ScoreReason[] | null; | |
| 93 | + cluster?: ClusterRef | null; | |
| 94 | +} | |
| 95 | + | |
| 96 | +export interface FieldChange { | |
| 97 | + label: string; | |
| 98 | + kind: "price" | "percent" | "number" | "date" | "version" | "status" | "text"; | |
| 99 | + before: string | null; | |
| 100 | + after: string | null; | |
| 101 | + deltaPct?: number | null; | |
| 102 | +} | |
| 103 | + | |
| 104 | +export interface ScoreReason { | |
| 105 | + sign: "+" | "-"; | |
| 106 | + text: string; | |
| 107 | + points?: number; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export interface ClusterRef { | |
| 111 | + id: string; | |
| 112 | + slug?: string | null; | |
| 113 | + state?: string; | |
| 114 | + event_count: number; | |
| 115 | + source_count?: number; | |
| 116 | + first_party_count?: number; | |
| 117 | + external_count?: number; | |
| 118 | + velocity?: number; | |
| 119 | + lead_time_ms?: number | null; | |
| 81 | 120 | } |
| 82 | 121 | |
| 83 | 122 | export interface LiveEvent { |
@@ -97,8 +136,22 @@ export interface LiveEvent { | ||
| 97 | 136 | categories: string[]; |
| 98 | 137 | url: string; |
| 99 | 138 | clusterId?: string | null; |
| 139 | + clusterSlug?: string | null; | |
| 140 | + clusterSize?: number; | |
| 141 | + clusterState?: string; | |
| 100 | 142 | detectedAt: string; |
| 101 | 143 | publishedAt?: string | null; |
| 144 | + signal?: number; | |
| 145 | + impact?: number; | |
| 146 | + velocity?: number; | |
| 147 | + firstParty?: boolean; | |
| 148 | + country?: string | null; | |
| 149 | + language?: string | null; | |
| 150 | + changeClass?: string | null; | |
| 151 | + fieldChanges?: FieldChange[]; | |
| 152 | + group?: string; | |
| 153 | + sid?: string; | |
| 154 | + replayed?: boolean; | |
| 102 | 155 | } |
| 103 | 156 | |
| 104 | 157 | /** Normalize a WebSocket payload to the REST row shape used by all list components. */ |
@@ -125,7 +178,16 @@ export function liveToEvent(e: LiveEvent): EventItem { | ||
| 125 | 178 | source: e.source, |
| 126 | 179 | sensor: e.sensor, |
| 127 | 180 | entities: e.entities ?? [], |
| 128 | − cluster_size: 1, | |
| 181 | + cluster_size: e.clusterSize ?? 1, | |
| 182 | + cluster: e.clusterId ? { id: e.clusterId, slug: e.clusterSlug ?? null, state: e.clusterState, event_count: e.clusterSize ?? 1, velocity: e.velocity } : null, | |
| 183 | + signal_score: e.signal ?? null, | |
| 184 | + velocity_score: e.velocity ?? 0, | |
| 185 | + impact_score: e.impact ?? 0, | |
| 186 | + change_class: e.changeClass ?? null, | |
| 187 | + first_party: e.firstParty !== false, | |
| 188 | + country: e.country ?? null, | |
| 189 | + language: e.language ?? null, | |
| 190 | + field_changes: e.fieldChanges ?? null, | |
| 129 | 191 | }; |
| 130 | 192 | } |
| 131 | 193 | |
@@ -135,6 +197,14 @@ export interface EventsPage { | ||
| 135 | 197 | } |
| 136 | 198 | |
| 137 | 199 | export interface Stats { |
| 200 | + checks_per_min?: number; | |
| 201 | + events_per_min?: number; | |
| 202 | + events_1h?: number; | |
| 203 | + breaking_now?: number; | |
| 204 | + developing_now?: number; | |
| 205 | + sources_first_party?: number; | |
| 206 | + countries?: number; | |
| 207 | + not_modified_ratio_5m?: number | null; | |
| 138 | 208 | sources?: number; |
| 139 | 209 | sensors?: number; |
| 140 | 210 | entities?: number; |
@@ -168,6 +238,12 @@ export interface TrendingItem { | ||
| 168 | 238 | max_importance: number; |
| 169 | 239 | prev_events: number; |
| 170 | 240 | score: number; |
| 241 | + first_party?: number; | |
| 242 | + confirmed?: number; | |
| 243 | + avg_signal?: number; | |
| 244 | + baseline_per_day?: number; | |
| 245 | + direction?: "up" | "down" | "flat"; | |
| 246 | + entity_importance?: number; | |
| 171 | 247 | } |
| 172 | 248 | |
| 173 | 249 | export interface Cluster { |
@@ -182,7 +258,168 @@ export interface Cluster { | ||
| 182 | 258 | first_at: string; |
| 183 | 259 | last_at: string; |
| 184 | 260 | source?: SourceRef | null; |
| 185 | − events?: { id: string; slug: string; title: string; importance: number; event_type: string; detected_at: string; source_id: string; url: string }[] | null; | |
| 261 | + events?: { id: string; slug: string; title: string; importance: number; event_type: string; detected_at: string; source_id: string; url: string; first_party?: boolean }[] | null; | |
| 262 | + slug?: string | null; | |
| 263 | + state?: string; | |
| 264 | + source_count?: number; | |
| 265 | + first_party_count?: number; | |
| 266 | + external_count?: number; | |
| 267 | + velocity?: number; | |
| 268 | + lead_time_ms?: number | null; | |
| 269 | + first_party_at?: string | null; | |
| 270 | + first_external_at?: string | null; | |
| 271 | + primary_slug?: string | null; | |
| 272 | + event?: EventItem | null; | |
| 273 | + sources?: { id: string; name: string; domain: string; first_party?: boolean }[] | null; | |
| 274 | +} | |
| 275 | + | |
| 276 | +export interface ClusterDetail { | |
| 277 | + cluster: Cluster & { timeline?: { at: string; eventId: string; sourceId: string; sourceName?: string; sensorType?: string; firstParty: boolean; eventType: string; importance: number }[] }; | |
| 278 | + events: EventItem[]; | |
| 279 | + entities: { id: string; name: string; type: string; importance: number }[]; | |
| 280 | + propagation: { id: string; slug: string; at: string; offset_ms: number; source: SourceRef; sensor: SensorRef; first_party: boolean; event_type: string; importance: number; title: string }[]; | |
| 281 | + lead_time_ms: number | null; | |
| 282 | + first_party_signals: number; | |
| 283 | + external_signals: number; | |
| 284 | +} | |
| 285 | + | |
| 286 | +export interface BreakingDesk { | |
| 287 | + breaking_now: Cluster[]; | |
| 288 | + developing: Cluster[]; | |
| 289 | + recently_confirmed: Cluster[]; | |
| 290 | + watching: EventItem[]; | |
| 291 | + generated_at: string; | |
| 292 | +} | |
| 293 | + | |
| 294 | +export interface Pulse { | |
| 295 | + activity: { t: string; events: number; changes: number }[]; | |
| 296 | + desks: { desk: string; items: EventItem[] }[]; | |
| 297 | + rising_entities: { id: string; name: string; type: string; events_3h: number; events_prev_24h: number; acceleration: number }[]; | |
| 298 | + anomalies: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number; pct_vs_baseline: number | null }[]; | |
| 299 | + silent_changes: EventItem[]; | |
| 300 | + infrastructure: EventItem[]; | |
| 301 | + breaking: Cluster[]; | |
| 302 | + by_group_24h: Record<string, number>; | |
| 303 | + totals: { events_1h: number; changes_1h: number; checks_5m: number; active_sources_24h: number; active_countries_24h: number }; | |
| 304 | + generated_at: string; | |
| 305 | +} | |
| 306 | + | |
| 307 | +export interface Radar { | |
| 308 | + unusual_source_activity: { id: string; name: string; domain: string; categories: string[]; changes_3h: number; baseline_per_day: number; ratio: number }[]; | |
| 309 | + silent_clusters: { id: string; name: string; type: string; silent_24h: number; types: string[]; last_at: string }[]; | |
| 310 | + documentation_bursts: { id: string; name: string; domain: string; doc_changes_6h: number; types: string[]; last_at: string }[]; | |
| 311 | + repository_bursts: { id: string; name: string; domain: string; repo_events_6h: number; last_at: string }[]; | |
| 312 | + status_changes: EventItem[]; | |
| 313 | + developing: Cluster[]; | |
| 314 | + new_coverage: { id: string; name: string; source_id: string; source_name: string; first_event_at: string; events: number }[]; | |
| 315 | + generated_at: string; | |
| 316 | + disclaimer: string; | |
| 317 | +} | |
| 318 | + | |
| 319 | +export interface EntityInsights { | |
| 320 | + heatmap: { day: string; events: number; silent: number; breaking: number; max_importance: number }[]; | |
| 321 | + baseline_per_day: number; | |
| 322 | + today: number; | |
| 323 | + events_24h: number; | |
| 324 | + events_prev_24h: number; | |
| 325 | + velocity_ratio: number; | |
| 326 | + anomaly: { score: number; ratio: number; pct: number }; | |
| 327 | + silent_24h: number; | |
| 328 | + breaking_24h: number; | |
| 329 | + sources_24h: number; | |
| 330 | + most_active_sensors: { id: string; name: string; type: string; connector: string; source_id: string; events_7d: number; last_event_at: string }[]; | |
| 331 | + rank: { rank: number | null; total: number; score: number | null }; | |
| 332 | +} | |
| 333 | + | |
| 334 | +export interface RankedEntity { | |
| 335 | + id: string; | |
| 336 | + name: string; | |
| 337 | + type: string; | |
| 338 | + domain?: string | null; | |
| 339 | + importance: number; | |
| 340 | + events_24h: number; | |
| 341 | + events_7d: number; | |
| 342 | + avg_signal: number; | |
| 343 | + confirmed_ratio: number; | |
| 344 | + sources: number; | |
| 345 | + silent_24h: number; | |
| 346 | + breaking_24h: number; | |
| 347 | + last_at: string; | |
| 348 | + baseline_per_day: number; | |
| 349 | + rank_score: number; | |
| 350 | + rank: number; | |
| 351 | +} | |
| 352 | + | |
| 353 | +export interface CountryRow { | |
| 354 | + country: string; | |
| 355 | + name: string; | |
| 356 | + slug: string; | |
| 357 | + flag: string; | |
| 358 | + sources: number; | |
| 359 | + events_24h: number; | |
| 360 | + breaking_24h: number; | |
| 361 | +} | |
| 362 | + | |
| 363 | +export interface CountryDesk { | |
| 364 | + country: { code: string; name: string; flag: string }; | |
| 365 | + breaking: EventItem[]; | |
| 366 | + by_category: { category: string; items: EventItem[] }[]; | |
| 367 | + sources: SourceRow[]; | |
| 368 | + by_type: { event_type: string; n: number }[]; | |
| 369 | + silent: EventItem[]; | |
| 370 | + recent: EventItem[]; | |
| 371 | + nextCursor: string | null; | |
| 372 | +} | |
| 373 | + | |
| 374 | +export interface CategoryDesk { | |
| 375 | + channel: string; | |
| 376 | + categories: string[]; | |
| 377 | + breaking: EventItem[]; | |
| 378 | + silent: EventItem[]; | |
| 379 | + active_sources: { id: string; name: string; domain: string; tier: string; first_party?: boolean; events_24h: number; max_importance: number }[]; | |
| 380 | + by_type: { event_type: string; n: number }[]; | |
| 381 | + trending_entities: { id: string; name: string; type: string; events_24h: number; sources: number }[]; | |
| 382 | + recent: EventItem[]; | |
| 383 | + nextCursor: string | null; | |
| 384 | + series: { t: string; n: number }[]; | |
| 385 | +} | |
| 386 | + | |
| 387 | +export interface Bookmark extends EventItem { | |
| 388 | + bookmarked_at: string; | |
| 389 | + note?: string | null; | |
| 390 | +} | |
| 391 | + | |
| 392 | +export interface Notification { | |
| 393 | + id: number; | |
| 394 | + alert_id: string; | |
| 395 | + alert_name: string; | |
| 396 | + event_id: string; | |
| 397 | + channel: string; | |
| 398 | + status: string; | |
| 399 | + created_at: string; | |
| 400 | + read_at?: string | null; | |
| 401 | + event: { id: string; slug: string; title: string; importance: number; signal_score?: number | null; event_type: string; silent_change: boolean; detected_at: string; source: { id: string; name: string } }; | |
| 402 | +} | |
| 403 | + | |
| 404 | +export interface Monitor { | |
| 405 | + id: string; | |
| 406 | + name: string; | |
| 407 | + url: string; | |
| 408 | + tier: string; | |
| 409 | + health: string; | |
| 410 | + status: string; | |
| 411 | + enabled: boolean; | |
| 412 | + config: Record<string, unknown>; | |
| 413 | + next_check_at?: string | null; | |
| 414 | + last_check_at?: string | null; | |
| 415 | + last_change_at?: string | null; | |
| 416 | + last_event_at?: string | null; | |
| 417 | + last_status?: number | null; | |
| 418 | + last_error?: string | null; | |
| 419 | + total_runs?: number; | |
| 420 | + raw_changes?: number; | |
| 421 | + meaningful_changes?: number; | |
| 422 | + created_at: string; | |
| 186 | 423 | } |
| 187 | 424 | |
| 188 | 425 | export interface SourceRow { |
@@ -203,6 +440,9 @@ export interface SourceRow { | ||
| 203 | 440 | last_check_at?: string | null; |
| 204 | 441 | sensors_degraded?: number; |
| 205 | 442 | robots_checked_at?: string | null; |
| 443 | + first_party?: boolean; | |
| 444 | + country?: string | null; | |
| 445 | + language?: string | null; | |
| 206 | 446 | } |
| 207 | 447 | |
| 208 | 448 | export interface SensorRow { |
@@ -233,14 +473,47 @@ export interface SensorRow { | ||
| 233 | 473 | source_name?: string; |
| 234 | 474 | domain?: string; |
| 235 | 475 | config?: Record<string, unknown>; |
| 476 | + status?: string; | |
| 477 | + priority?: number; | |
| 478 | + validated_at?: string | null; | |
| 479 | + etag?: string | null; | |
| 480 | + last_modified?: string | null; | |
| 481 | + checks_24h?: number; | |
| 482 | + changes_24h?: number; | |
| 483 | + snapshot_count?: number; | |
| 484 | + current_interval_seconds?: number | null; | |
| 485 | + not_modified_24h?: number; | |
| 486 | + errors_24h?: number; | |
| 487 | + avg_ms_24h?: number | null; | |
| 488 | +} | |
| 489 | + | |
| 490 | +export interface SourceQuality { | |
| 491 | + sensors?: number; | |
| 492 | + sensors_up?: number; | |
| 493 | + avg_latency_ms?: number | null; | |
| 494 | + raw_changes?: number; | |
| 495 | + meaningful_changes?: number; | |
| 496 | + total_runs?: number; | |
| 497 | + total_not_modified?: number; | |
| 498 | + structured_sensors?: number; | |
| 499 | + checks_7d?: number; | |
| 500 | + errors_7d?: number; | |
| 501 | + avg_confidence?: number | null; | |
| 502 | + success_rate: number; | |
| 503 | + structured_share: number; | |
| 504 | + usefulness: number; | |
| 505 | + quality_score: number; | |
| 236 | 506 | } |
| 237 | 507 | |
| 238 | 508 | export interface SourceDetail { |
| 239 | 509 | source: SourceRow; |
| 240 | 510 | sensors: SensorRow[]; |
| 241 | 511 | entities: { id: string; name: string; type: string; importance: number; event_count: number }[]; |
| 242 | − activity: { changes_2h?: number; changes_14d?: number; events_24h?: number; events_14d?: number; baseline_changes_per_day?: number; activity_score?: number }; | |
| 512 | + activity: { changes_2h?: number; changes_14d?: number; events_24h?: number; events_14d?: number; baseline_changes_per_day?: number; activity_score?: number; silent_24h?: number; breaking_24h?: number }; | |
| 513 | + quality?: SourceQuality; | |
| 243 | 514 | discovery: { url: string; kind: string; evidence?: string | null; score?: { value?: number; itemCount?: number | null; title?: string | null } | null; status: string; found_at: string }[]; |
| 515 | + by_type?: { event_type: string; n: number }[]; | |
| 516 | + daily?: { day: string; checks: number; not_modified: number; errors: number; raw_changes: number; events: number }[]; | |
| 244 | 517 | } |
| 245 | 518 | |
| 246 | 519 | export interface SensorDetail { |
@@ -248,6 +521,7 @@ export interface SensorDetail { | ||
| 248 | 521 | runs: { id: string; started_at: string; finished_at?: string | null; http_status?: number | null; outcome: string; error?: string | null; duration_ms?: number | null; bytes?: number | null; fetch_method?: string | null; snapshot_id?: string | null }[]; |
| 249 | 522 | snapshots: SnapshotRow[]; |
| 250 | 523 | changes: ChangeRow[]; |
| 524 | + events?: EventItem[]; | |
| 251 | 525 | } |
| 252 | 526 | |
| 253 | 527 | export interface SnapshotRow { |
@@ -266,6 +540,9 @@ export interface SnapshotRow { | ||
| 266 | 540 | fetch_duration_ms?: number | null; |
| 267 | 541 | extraction_confidence?: number | null; |
| 268 | 542 | storage_key?: string | null; |
| 543 | + has_raw?: boolean; | |
| 544 | + has_change?: boolean; | |
| 545 | + event_slug?: string | null; | |
| 269 | 546 | } |
| 270 | 547 | |
| 271 | 548 | export interface ChangeRow { |
@@ -282,6 +559,8 @@ export interface ChangeRow { | ||
| 282 | 559 | heuristic_type?: string | null; |
| 283 | 560 | diff?: DiffSummary; |
| 284 | 561 | heuristic?: Record<string, unknown>; |
| 562 | + change_class?: string | null; | |
| 563 | + field_changes?: FieldChange[] | null; | |
| 285 | 564 | } |
| 286 | 565 | |
| 287 | 566 | export interface DiffSummary { |
@@ -303,6 +582,7 @@ export interface EventDetail { | ||
| 303 | 582 | interpretations: { version: number; model: string; created_at: string }[]; |
| 304 | 583 | snapshots: SnapshotRow[]; |
| 305 | 584 | sensor_reliability: { health?: string; success_rate?: number | null; avg_latency_ms?: number | null; total_runs?: number; raw_changes?: number; meaningful_changes?: number; last_check_at?: string | null } | null; |
| 585 | + history?: { id: string; slug: string; title: string; event_type: string; importance: number; silent_change: boolean; detected_at: string; field_changes?: FieldChange[] | null }[]; | |
| 306 | 586 | } |
| 307 | 587 | |
| 308 | 588 | export interface EntityRow { |
@@ -322,23 +602,31 @@ export interface EntityRow { | ||
| 322 | 602 | |
| 323 | 603 | export interface EntityDetail { |
| 324 | 604 | entity: EntityRow; |
| 605 | + parent?: { id: string; name: string; type: string } | null; | |
| 325 | 606 | children: EntityRow[]; |
| 326 | 607 | relations: { relation: string; from_id: string; to_id: string; from_name: string; to_name: string; to_type: string }[]; |
| 327 | − sources: SourceRef[]; | |
| 608 | + sources: (SourceRef & { first_party?: boolean; country?: string | null; sensor_count?: number; events_24h?: number })[]; | |
| 328 | 609 | aliases: string[]; |
| 329 | 610 | recent: EventItem[]; |
| 611 | + nextCursor?: string | null; | |
| 330 | 612 | by_type: { event_type: string; n: number }[]; |
| 613 | + insights?: EntityInsights; | |
| 614 | + silent?: EventItem[]; | |
| 615 | + related?: { id: string; name: string; type: string; shared_events: number }[]; | |
| 331 | 616 | } |
| 332 | 617 | |
| 333 | 618 | export interface Explore { |
| 334 | − most_active_sources: { id: string; name: string; domain: string; events_24h: number; max_importance: number }[]; | |
| 619 | + most_active_sources: { id: string; name: string; domain: string; first_party?: boolean; events_24h: number; max_importance: number }[]; | |
| 335 | 620 | biggest_changes: EventItem[]; |
| 336 | 621 | silent_changes: EventItem[]; |
| 337 | 622 | clusters: Cluster[]; |
| 338 | − unusual_activity: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number }[]; | |
| 623 | + unusual_activity: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number; pct_vs_baseline?: number | null }[]; | |
| 624 | + newly_detected?: EventItem[]; | |
| 625 | + confirmed_first_party?: EventItem[]; | |
| 339 | 626 | by_type: { event_type: string; n: number }[]; |
| 340 | 627 | by_category: { category: string; n: number }[]; |
| 341 | 628 | channels: Record<string, string[]>; |
| 629 | + groups?: Record<string, string>; | |
| 342 | 630 | event_types: Record<string, string>; |
| 343 | 631 | } |
| 344 | 632 | |
@@ -366,14 +654,22 @@ export interface HealthReport { | ||
| 366 | 654 | noisy_sensors: { id: string; name: string; source_id: string; url: string; raw_changes: number; meaningful_changes: number; noise_ratio: number | null }[]; |
| 367 | 655 | daily: Record<string, number | string>[]; |
| 368 | 656 | live: { clients: number; published: number }; |
| 657 | + sensors_by_status?: { status: string; n: number }[]; | |
| 658 | + sensors_by_connector?: { connector: string; sensors: number; up: number; avg_latency_ms: number | null }[]; | |
| 659 | + throughput?: { checks_5m: number; not_modified_5m: number; events_5m: number; changes_5m: number; queue_due: number; avg_latency_5m_ms: number | null; noise_filtered_24h: number; checks_per_min: number; events_per_min: number; not_modified_ratio: number | null }; | |
| 660 | + engine?: { inflight?: number; concurrency?: number; due?: number; busyHosts?: { host: string; inflight: number }[]; circuitOpen?: { host: string; failures: number; until: string }[]; at?: string; version?: string } | null; | |
| 661 | + top_failing_domains?: { host: string; failures: number; last_error: string | null }[]; | |
| 662 | + slowest_sensors?: { id: string; name: string; source_id: string; avg_latency_ms: number; connector: string }[]; | |
| 369 | 663 | } |
| 370 | 664 | |
| 371 | 665 | export interface SearchResult { |
| 372 | 666 | query: string; |
| 667 | + parsed?: { text: string; filters: Record<string, unknown> }; | |
| 373 | 668 | events: EventItem[]; |
| 374 | 669 | entities: EntityRow[]; |
| 375 | 670 | sources: SourceRow[]; |
| 376 | 671 | urls: { url: string; domain: string; status: string; change_count: number; last_seen_at: string }[]; |
| 672 | + clusters?: { id: string; slug: string; title: string; state: string; event_count: number; source_count: number; max_importance: number; last_at: string }[]; | |
| 377 | 673 | } |
| 378 | 674 | |
| 379 | 675 | export interface DomainTimeline { |
@@ -408,12 +704,17 @@ export interface Watchlist { | ||
| 408 | 704 | |
| 409 | 705 | export interface AlertRule { |
| 410 | 706 | importance_min?: number; |
| 707 | + signal_min?: number; | |
| 411 | 708 | event_types?: string[]; |
| 709 | + groups?: string[]; | |
| 412 | 710 | entities?: string[]; |
| 413 | 711 | sources?: string[]; |
| 414 | 712 | keywords?: string[]; |
| 415 | 713 | silent_only?: boolean; |
| 714 | + first_party_only?: boolean; | |
| 715 | + confirmed_only?: boolean; | |
| 416 | 716 | categories?: string[]; |
| 717 | + countries?: string[]; | |
| 417 | 718 | } |
| 418 | 719 | |
| 419 | 720 | export interface Alert { |
@@ -421,9 +722,12 @@ export interface Alert { | ||
| 421 | 722 | name: string; |
| 422 | 723 | rule: AlertRule; |
| 423 | 724 | channel: string; |
| 725 | + channel_config?: { url?: string }; | |
| 424 | 726 | enabled: boolean; |
| 425 | 727 | created_at: string; |
| 426 | 728 | last_fired_at?: string | null; |
| 729 | + fired_count?: number; | |
| 730 | + fired_24h?: number; | |
| 427 | 731 | } |
| 428 | 732 | |
| 429 | 733 | // --------------------------------------------------------------------------------------- |
@@ -452,12 +756,19 @@ export type EventQuery = Partial<{ | ||
| 452 | 756 | cluster: string; |
| 453 | 757 | importance_min: number; |
| 454 | 758 | confidence_min: number; |
| 759 | + signal_min: number; | |
| 455 | 760 | event_type: string; |
| 761 | + group: string; | |
| 456 | 762 | silent_change: boolean; |
| 763 | + first_party: boolean; | |
| 764 | + confirmed: boolean; | |
| 765 | + country: string; | |
| 766 | + language: string; | |
| 767 | + change_class: string; | |
| 457 | 768 | q: string; |
| 458 | 769 | limit: number; |
| 459 | 770 | cursor: string; |
| 460 | − order: "recent" | "importance"; | |
| 771 | + order: "recent" | "importance" | "signal"; | |
| 461 | 772 | }>; |
| 462 | 773 | |
| 463 | 774 | export function eventQueryString(q: EventQuery): string { |
@@ -469,14 +780,24 @@ export function eventQueryString(q: EventQuery): string { | ||
| 469 | 780 | |
| 470 | 781 | export const api = { |
| 471 | 782 | events: (q: EventQuery = {}) => getJson<EventsPage>(`/api/v1/events${eventQueryString(q)}`, { items: [], nextCursor: null }), |
| 472 | − /** Importance ≥ 80 over the last 48 h, ranked. */ | |
| 473 | − breaking: (limit = 60) => getJson<EventsPage>(`/api/v1/events${eventQueryString({ importance_min: 80, order: "importance", after: new Date(Date.now() - 48 * 3600e3).toISOString(), limit })}`, { items: [], nextCursor: null }), | |
| 783 | + /** Signal ≥ 80 over the last 48 h, ranked (legacy list; the /breaking desk uses `breakingDesk`). */ | |
| 784 | + breaking: (limit = 60) => getJson<EventsPage>(`/api/v1/events${eventQueryString({ signal_min: 80, order: "signal", after: new Date(Date.now() - 48 * 3600e3).toISOString(), limit })}`, { items: [], nextCursor: null }), | |
| 785 | + breakingDesk: () => getJson<BreakingDesk | null>("/api/v1/breaking", null), | |
| 786 | + pulse: () => getJson<Pulse | null>("/api/v1/pulse", null), | |
| 787 | + radar: () => getJson<Radar | null>("/api/v1/radar", null), | |
| 788 | + cluster: (idOrSlug: string) => getJson<ClusterDetail | null>(`/api/v1/clusters/${encodeURIComponent(idOrSlug)}`, null), | |
| 789 | + rank: (limit = 100) => getJson<{ items: RankedEntity[] }>(`/api/v1/entities/rank?limit=${limit}`, { items: [] }), | |
| 790 | + countries: () => getJson<{ items: CountryRow[] }>("/api/v1/countries", { items: [] }), | |
| 791 | + country: (code: string) => getJson<CountryDesk | null>(`/api/v1/countries/${encodeURIComponent(code)}`, null), | |
| 792 | + categoryDesk: (channel: string) => getJson<CategoryDesk | null>(`/api/v1/categories/${encodeURIComponent(channel)}`, null), | |
| 793 | + sensorSnapshots: (id: string, limit = 200) => getJson<{ items: SnapshotRow[] }>(`/api/v1/sensors/${encodeURIComponent(id)}/snapshots?limit=${limit}`, { items: [] }), | |
| 794 | + eventCount: (q: EventQuery = {}) => getJson<{ count: number }>(`/api/v1/events/count${eventQueryString(q)}`, { count: 0 }), | |
| 474 | 795 | event: (idOrSlug: string) => getJson<EventDetail | null>(`/api/v1/events/${encodeURIComponent(idOrSlug)}`, null), |
| 475 | 796 | stats: () => getJson<Stats>("/api/v1/stats", {}, { revalidate: 5 }), |
| 476 | 797 | trending: (hours = 24, limit = 10) => getJson<{ items: TrendingItem[] }>(`/api/v1/trending?hours=${hours}&limit=${limit}`, { items: [] }), |
| 477 | 798 | explore: () => getJson<Explore | null>("/api/v1/explore", null), |
| 478 | 799 | clusters: (limit = 20, since = 72) => getJson<{ items: Cluster[] }>(`/api/v1/clusters?limit=${limit}&since=${since}`, { items: [] }), |
| 479 | − sources: (q: { category?: string; q?: string } = {}) => getJson<{ items: SourceRow[] }>(`/api/v1/sources${eventQueryString(q)}`, { items: [] }), | |
| 800 | + sources: (q: { category?: string; q?: string; country?: string; tier?: string; first_party?: string } = {}) => getJson<{ items: SourceRow[] }>(`/api/v1/sources${eventQueryString(q as EventQuery)}`, { items: [] }), | |
| 480 | 801 | source: (id: string) => getJson<SourceDetail | null>(`/api/v1/sources/${encodeURIComponent(id)}`, null), |
| 481 | 802 | sensor: (id: string) => getJson<SensorDetail | null>(`/api/v1/sensors/${encodeURIComponent(id)}`, null), |
| 482 | 803 | entities: (q: { type?: string; q?: string; limit?: number } = {}) => getJson<{ items: EntityRow[] }>(`/api/v1/entities${eventQueryString(q)}`, { items: [] }), |
added
apps/web/src/lib/feed-filters.ts
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +/** Filters that live in the URL (spec §99). Shared by server pages and the client feed. */ | |
| 2 | +export interface FeedFilters { | |
| 3 | + category?: string; | |
| 4 | + group?: string; | |
| 5 | + event_type?: string; | |
| 6 | + signal_min?: number; | |
| 7 | + importance_min?: number; | |
| 8 | + silent_change?: boolean; | |
| 9 | + first_party?: boolean; | |
| 10 | + confirmed?: boolean; | |
| 11 | + country?: string; | |
| 12 | + language?: string; | |
| 13 | + q?: string; | |
| 14 | + order?: "recent" | "signal" | "importance"; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function filtersFromParams(sp: URLSearchParams | Record<string, string | undefined>): FeedFilters { | |
| 18 | + const get = (k: string): string | undefined => (sp instanceof URLSearchParams ? sp.get(k) ?? undefined : sp[k]); | |
| 19 | + const f: FeedFilters = {}; | |
| 20 | + if (get("category")) f.category = get("category"); | |
| 21 | + if (get("group")) f.group = get("group"); | |
| 22 | + if (get("event_type")) f.event_type = get("event_type"); | |
| 23 | + if (get("signal_min")) f.signal_min = Number(get("signal_min")); | |
| 24 | + if (get("importance_min")) f.importance_min = Number(get("importance_min")); | |
| 25 | + if (get("silent_change") === "true") f.silent_change = true; | |
| 26 | + if (get("first_party") === "true") f.first_party = true; | |
| 27 | + if (get("confirmed") === "true") f.confirmed = true; | |
| 28 | + if (get("country")) f.country = get("country")!.toUpperCase(); | |
| 29 | + if (get("language")) f.language = get("language"); | |
| 30 | + if (get("q")) f.q = get("q"); | |
| 31 | + const o = get("order"); | |
| 32 | + if (o === "signal" || o === "importance" || o === "recent") f.order = o; | |
| 33 | + return f; | |
| 34 | +} | |
modified
apps/web/src/lib/format.ts
+83 −0
@@ -142,3 +142,86 @@ export const CHANNELS: { key: string; label: string; query: Record<string, strin | ||
| 142 | 142 | ]; |
| 143 | 143 | |
| 144 | 144 | export const CHANNEL_KEYS = ["ai", "cyber", "finance", "health", "government", "science", "products", "infrastructure", "news"] as const; |
| 145 | + | |
| 146 | +// --------------------------------------------------------------------------------------- | |
| 147 | +// 0.2 additions | |
| 148 | +// --------------------------------------------------------------------------------------- | |
| 149 | + | |
| 150 | +/** Lead time / offsets: "+14 s", "+2 min", "+3.2 h" */ | |
| 151 | +export function fmtOffset(ms: number | null | undefined): string { | |
| 152 | + if (ms === null || ms === undefined || Number.isNaN(ms)) return "—"; | |
| 153 | + const sign = ms < 0 ? "−" : "+"; | |
| 154 | + const a = Math.abs(ms); | |
| 155 | + if (a < 1000) return `${sign}${Math.round(a)} ms`; | |
| 156 | + if (a < 60_000) return `${sign}${Math.round(a / 1000)} s`; | |
| 157 | + if (a < 3_600_000) return `${sign}${Math.round(a / 60_000)} min`; | |
| 158 | + if (a < 86_400_000) return `${sign}${(a / 3_600_000).toFixed(1)} h`; | |
| 159 | + return `${sign}${(a / 86_400_000).toFixed(1)} d`; | |
| 160 | +} | |
| 161 | + | |
| 162 | +export function fmtPctDelta(pct: number | null | undefined): string { | |
| 163 | + if (pct === null || pct === undefined || Number.isNaN(pct)) return ""; | |
| 164 | + return `${pct > 0 ? "+" : ""}${Math.abs(pct) >= 100 ? Math.round(pct) : pct}%`; | |
| 165 | +} | |
| 166 | + | |
| 167 | +export function signalBand(score: number | null | undefined): "hot" | "high" | "mid" | "low" { | |
| 168 | + return importanceBand(score ?? 0); | |
| 169 | +} | |
| 170 | + | |
| 171 | +export function localDateTime(iso: string | Date | null | undefined): string { | |
| 172 | + if (!iso) return "—"; | |
| 173 | + const d = typeof iso === "string" ? new Date(iso) : iso; | |
| 174 | + if (Number.isNaN(d.getTime())) return "—"; | |
| 175 | + return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: "short" }).format(d); | |
| 176 | +} | |
| 177 | + | |
| 178 | +export const GROUP_LABELS: Record<string, string> = { security: "Security", reliability: "Reliability", product: "Product & API", commercial: "Pricing & terms", corporate: "Corporate", government: "Government & legal", science: "Science & health", transport: "Transport", sports: "Sports", web: "Web" }; | |
| 179 | + | |
| 180 | +export const CLASS_LABELS: Record<string, string> = { meaningful: "Content", pricing: "Pricing", policy: "Policy", product: "Product", personnel: "Personnel", cosmetic: "Cosmetic", navigation: "Navigation", timestamp: "Timestamp", advertisement: "Advertising", boilerplate: "Boilerplate" }; | |
| 181 | + | |
| 182 | +export function stateLabel(s: string | null | undefined): string { | |
| 183 | + switch (s) { | |
| 184 | + case "breaking": | |
| 185 | + return "BREAKING"; | |
| 186 | + case "developing": | |
| 187 | + return "DEVELOPING"; | |
| 188 | + case "confirmed": | |
| 189 | + return "CONFIRMED"; | |
| 190 | + case "watching": | |
| 191 | + return "WATCHING"; | |
| 192 | + case "closed": | |
| 193 | + return "CLOSED"; | |
| 194 | + default: | |
| 195 | + return ""; | |
| 196 | + } | |
| 197 | +} | |
| 198 | + | |
| 199 | +/** Build the live-feed URL for a filter set (spec §99). */ | |
| 200 | +export function feedHref(q: Record<string, string | number | boolean | undefined | null>, base = "/live"): string { | |
| 201 | + const p = new URLSearchParams(); | |
| 202 | + for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== "" && v !== false) p.set(k, String(v)); | |
| 203 | + const s = p.toString(); | |
| 204 | + return s ? `${base}?${s}` : base; | |
| 205 | +} | |
| 206 | + | |
| 207 | +export const SAVED_VIEWS: { key: string; label: string; query: Record<string, string | number | boolean> }[] = [ | |
| 208 | + { key: "ai-releases", label: "AI releases", query: { category: "ai", event_type: "model_release,software_release,product_launch,API_change,api_change" } }, | |
| 209 | + { key: "cyber-critical", label: "Cyber critical", query: { group: "security", signal_min: 70 } }, | |
| 210 | + { key: "canada-gov", label: "Canadian government", query: { country: "CA", category: "government" } }, | |
| 211 | + { key: "market-breaking", label: "Market breaking", query: { category: "finance", signal_min: 75 } }, | |
| 212 | + { key: "cloud-outages", label: "Cloud outages", query: { category: "infrastructure", group: "reliability" } }, | |
| 213 | + { key: "silent-pricing", label: "Silent pricing & terms", query: { silent_change: true, group: "commercial" } }, | |
| 214 | + { key: "first-party-confirmed", label: "First-party & confirmed", query: { first_party: true, confirmed: true } }, | |
| 215 | +]; | |
| 216 | + | |
| 217 | +/** ISO timestamp `ms` milliseconds ago — keeps `Date.now()` out of component bodies (react-hooks/purity). */ | |
| 218 | +export function agoIso(ms: number): string { | |
| 219 | + return new Date(Date.now() - ms).toISOString(); | |
| 220 | +} | |
| 221 | + | |
| 222 | +/** True when `iso` falls within the last `windowMs` milliseconds. */ | |
| 223 | +export function withinLast(iso: string | null | undefined, windowMs: number): boolean { | |
| 224 | + if (!iso) return false; | |
| 225 | + const t = new Date(iso).getTime(); | |
| 226 | + return Number.isFinite(t) && Date.now() - t < windowMs; | |
| 227 | +} | |
modified
apps/web/src/lib/use-live.ts
+30 −6
@@ -14,12 +14,15 @@ export function wsUrl(): string { | ||
| 14 | 14 | } |
| 15 | 15 | |
| 16 | 16 | /** |
| 17 | − * Shared WebSocket to /api/v1/live. Subscribes to the given channels and calls `onEvent` | |
| 18 | − * for every matching event. Reconnects with exponential backoff (1 s → 30 s). | |
| 17 | + * Shared WebSocket to /api/v1/live (protocol 2). Subscribes to the given channels and calls | |
| 18 | + * `onEvent` for every matching event. Reconnects with exponential backoff (1 s → 30 s) and, on | |
| 19 | + * reconnection, asks the gateway to replay everything after the last stream id seen (spec §60), | |
| 20 | + * so a laptop waking from sleep catches up without duplicates (ids are de-duplicated by callers). | |
| 19 | 21 | */ |
| 20 | 22 | export function useLive(channels: string[], onEvent: (ev: LiveEvent, channels: string[]) => void): LiveStatus { |
| 21 | 23 | const [status, setStatus] = useState<LiveStatus>("connecting"); |
| 22 | 24 | const handler = useRef(onEvent); |
| 25 | + const lastSid = useRef<string | null>(null); | |
| 23 | 26 | useEffect(() => { |
| 24 | 27 | handler.current = onEvent; |
| 25 | 28 | }); |
@@ -30,6 +33,8 @@ export function useLive(channels: string[], onEvent: (ev: LiveEvent, channels: s | ||
| 30 | 33 | let closed = false; |
| 31 | 34 | let attempt = 0; |
| 32 | 35 | let timer: ReturnType<typeof setTimeout> | null = null; |
| 36 | + let watchdog: ReturnType<typeof setInterval> | null = null; | |
| 37 | + let lastFrame = Date.now(); | |
| 33 | 38 | const subs = key.split("|").filter(Boolean); |
| 34 | 39 | |
| 35 | 40 | const connect = (): void => { |
@@ -41,14 +46,20 @@ export function useLive(channels: string[], onEvent: (ev: LiveEvent, channels: s | ||
| 41 | 46 | return; |
| 42 | 47 | } |
| 43 | 48 | ws.onopen = () => { |
| 49 | + const wasReconnect = attempt > 0; | |
| 44 | 50 | attempt = 0; |
| 51 | + lastFrame = Date.now(); | |
| 45 | 52 | setStatus("live"); |
| 46 | − ws?.send(JSON.stringify({ subscribe: subs, unsubscribe: subs.includes("events:global") ? [] : ["events:global"] })); | |
| 53 | + ws?.send(JSON.stringify({ subscribe: subs, unsubscribe: subs.includes("events:global") ? [] : ["events:global"], ...(wasReconnect && lastSid.current ? { since: lastSid.current } : {}) })); | |
| 47 | 54 | }; |
| 48 | 55 | ws.onmessage = (m) => { |
| 56 | + lastFrame = Date.now(); | |
| 49 | 57 | try { |
| 50 | − const msg = JSON.parse(String(m.data)) as { type: string; event?: LiveEvent; channels?: string[] }; | |
| 51 | − if (msg.type === "event" && msg.event) handler.current(msg.event, msg.channels ?? []); | |
| 58 | + const msg = JSON.parse(String(m.data)) as { type: string; sid?: string; event?: LiveEvent; channels?: string[] }; | |
| 59 | + if (msg.type === "event" && msg.event) { | |
| 60 | + if (msg.sid) lastSid.current = msg.sid; | |
| 61 | + handler.current({ ...msg.event, sid: msg.sid }, msg.channels ?? []); | |
| 62 | + } | |
| 52 | 63 | } catch { |
| 53 | 64 | // ignore malformed frames |
| 54 | 65 | } |
@@ -64,14 +75,27 @@ export function useLive(channels: string[], onEvent: (ev: LiveEvent, channels: s | ||
| 64 | 75 | }; |
| 65 | 76 | const schedule = (): void => { |
| 66 | 77 | if (closed) return; |
| 67 | − const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)); | |
| 78 | + const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)) * (0.8 + Math.random() * 0.4); | |
| 68 | 79 | attempt++; |
| 69 | 80 | timer = setTimeout(connect, delay); |
| 70 | 81 | }; |
| 82 | + // Heartbeats arrive every 25 s; if nothing for 70 s the socket is half-dead → reconnect. | |
| 83 | + watchdog = setInterval(() => { | |
| 84 | + if (ws && ws.readyState === WebSocket.OPEN && Date.now() - lastFrame > 70_000) ws.close(); | |
| 85 | + }, 10_000); | |
| 86 | + const onVisible = (): void => { | |
| 87 | + if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) { | |
| 88 | + if (timer) clearTimeout(timer); | |
| 89 | + connect(); | |
| 90 | + } | |
| 91 | + }; | |
| 92 | + document.addEventListener("visibilitychange", onVisible); | |
| 71 | 93 | connect(); |
| 72 | 94 | return () => { |
| 73 | 95 | closed = true; |
| 74 | 96 | if (timer) clearTimeout(timer); |
| 97 | + if (watchdog) clearInterval(watchdog); | |
| 98 | + document.removeEventListener("visibilitychange", onVisible); | |
| 75 | 99 | ws?.close(); |
| 76 | 100 | }; |
| 77 | 101 | }, [key]); |
added
config/sources.d/40-ai-frontier.yaml
+1129 −0
@@ -0,0 +1,1129 @@ | ||
| 1 | +# WebSensor — AI frontier depth coverage (added 2026-09-11). Many validated sensors per AI organization: model | |
| 2 | +# releases (Hugging Face public API `api/models?author=…`, sorted by lastModified — items carry no URL because the | |
| 3 | +# jsonlist `urlTemplate` URL-encodes the `owner/model` key), API documentation (model lists, pricing, deprecations, | |
| 4 | +# release notes), SDK / runtime GitHub releases, research & blog feeds, system-card / transparency pages, status pages | |
| 5 | +# (Atlassian summary.json only — Betterstack pages such as status.huggingface.co, status.together.ai, status.modal.com, | |
| 6 | +# uptime.runpod.io expose no JSON), llms.txt indexes and Epoch AI open CSV datasets. Existing organizations are | |
| 7 | +# extended (`extend: true`); Chinese labs, inference clouds, coding assistants, tooling and evaluators are declared here. | |
| 8 | +# Blocked / unusable (do not re-probe blindly): x.ai + status.x.ai (403), status.mistral.ai + status.replit.com + | |
| 9 | +# docs.midjourney.com + help.runwayml.com + perplexity.ai/hub (Cloudflare challenge), ai.meta.com + llama.com (400 to | |
| 10 | +# non-browser clients), help.openai.com + openai.com/research (403), ir.baidu.com (403), status.openrouter.ai (403), | |
| 11 | +# api.ngc.nvidia.com (401), status.deepseek.com (custom Flashcat page), status.langchain.com / status.moonshot.ai / | |
| 12 | +# status.z.ai / status.runpod.io (no DNS), platform.stability.ai + platform.openai.com + bigmodel.cn (client-rendered). | |
| 13 | +sources: | |
| 14 | + # ───────────────────────── Frontier labs (extend founding sources) ───────────────────────── | |
| 15 | + - id: openai | |
| 16 | + extend: true | |
| 17 | + country: US | |
| 18 | + products: | |
| 19 | + - { name: gpt-oss, type: AI_model, aliases: [gpt-oss-120b, gpt-oss-20b] } | |
| 20 | + - { name: Whisper, type: AI_model } | |
| 21 | + - { name: Agents SDK, type: software, aliases: [openai agents sdk] } | |
| 22 | + sensors: | |
| 23 | + - { name: api changelog, url: "https://developers.openai.com/api/docs/changelog", type: HTML, connector: http, tier: A } | |
| 24 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=openai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 25 | + - { name: sdk node releases, url: "https://github.com/openai/openai-node/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: openai/openai-node, kind: releases } } | |
| 26 | + - { name: codex releases, url: "https://github.com/openai/codex/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: openai/codex, kind: releases } } | |
| 27 | + - { name: agents sdk python releases, url: "https://github.com/openai/openai-agents-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: openai/openai-agents-python, kind: releases } } | |
| 28 | + - { name: agents sdk js releases, url: "https://github.com/openai/openai-agents-js/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: openai/openai-agents-js, kind: releases } } | |
| 29 | + - { name: gpt-oss tags, url: "https://github.com/openai/gpt-oss/tags.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: openai/gpt-oss, kind: tags } } | |
| 30 | + - { name: tiktoken releases, url: "https://github.com/openai/tiktoken/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: openai/tiktoken, kind: releases } } | |
| 31 | + - { name: whisper tags, url: "https://github.com/openai/whisper/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: openai/whisper, kind: tags } } | |
| 32 | + - { name: openapi spec commits, url: "https://github.com/openai/openai-openapi/commits/manual_spec.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: openai/openai-openapi, kind: commits, branch: manual_spec } } | |
| 33 | + - { name: cookbook commits, url: "https://github.com/openai/openai-cookbook/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: openai/openai-cookbook, kind: commits, branch: main } } | |
| 34 | + - id: anthropic | |
| 35 | + extend: true | |
| 36 | + country: US | |
| 37 | + aliases: [claude ai] | |
| 38 | + products: | |
| 39 | + - { name: Claude Developer Platform, type: API, aliases: [platform.claude.com, claude platform] } | |
| 40 | + - { name: Claude Agent SDK, type: software, aliases: [anthropic sdk] } | |
| 41 | + sensors: | |
| 42 | + - { name: models overview, url: "https://platform.claude.com/docs/en/models/overview", type: HTML, connector: http, tier: A } | |
| 43 | + - { name: model deprecations, url: "https://platform.claude.com/docs/en/about-claude/model-deprecations", type: HTML, connector: http, tier: B } | |
| 44 | + - { name: platform release notes, url: "https://platform.claude.com/docs/en/release-notes/overview", type: HTML, connector: http, tier: A } | |
| 45 | + - { name: docs llms.txt, url: "https://platform.claude.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 46 | + - { name: claude code changelog, url: "https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md", type: FILE, connector: http, tier: A } | |
| 47 | + - { name: research, url: "https://www.anthropic.com/research", type: HTML, connector: http, tier: A } | |
| 48 | + - { name: engineering blog, url: "https://www.anthropic.com/engineering", type: HTML, connector: http, tier: B } | |
| 49 | + - { name: transparency hub, url: "https://www.anthropic.com/transparency", type: HTML, connector: http, tier: C } | |
| 50 | + - { name: alignment science blog, url: "https://alignment.anthropic.com/", type: HTML, connector: http, tier: B } | |
| 51 | + - { name: red team blog, url: "https://red.anthropic.com/", type: HTML, connector: http, tier: B } | |
| 52 | + - { name: transformer circuits, url: "https://transformer-circuits.pub/", type: HTML, connector: http, tier: B } | |
| 53 | + - { name: claude.com pricing, url: "https://claude.com/pricing", type: HTML, connector: http, tier: C } | |
| 54 | + - { name: claude.com blog, url: "https://claude.com/blog", type: HTML, connector: http, tier: B } | |
| 55 | + - { name: sdk python releases, url: "https://github.com/anthropics/anthropic-sdk-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: anthropics/anthropic-sdk-python, kind: releases } } | |
| 56 | + - { name: sdk go releases, url: "https://github.com/anthropics/anthropic-sdk-go/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: anthropics/anthropic-sdk-go, kind: releases } } | |
| 57 | + - { name: sdk java releases, url: "https://github.com/anthropics/anthropic-sdk-java/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: anthropics/anthropic-sdk-java, kind: releases } } | |
| 58 | + - { name: cookbooks commits, url: "https://github.com/anthropics/claude-cookbooks/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: anthropics/claude-cookbooks, kind: commits, branch: main } } | |
| 59 | + - { name: skills commits, url: "https://github.com/anthropics/skills/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: anthropics/skills, kind: commits, branch: main } } | |
| 60 | + - id: deepmind | |
| 61 | + extend: true | |
| 62 | + country: US | |
| 63 | + products: | |
| 64 | + - { name: Gemma, type: AI_model, aliases: [gemma 3] } | |
| 65 | + - { name: Gemini app, type: product, aliases: [gemini.google] } | |
| 66 | + sensors: | |
| 67 | + - { name: google blog deepmind feed, url: "https://blog.google/innovation-and-ai/models-and-research/google-deepmind/rss/", type: RSS, connector: rss, tier: A } | |
| 68 | + - { name: models page, url: "https://deepmind.google/models/", type: HTML, connector: http, tier: A } | |
| 69 | + - { name: publications, url: "https://deepmind.google/research/publications/", type: HTML, connector: http, tier: B } | |
| 70 | + - { name: gemini app release notes, url: "https://gemini.google/release-notes/", type: HTML, connector: http, tier: A } | |
| 71 | + - { name: gemma releases, url: "https://github.com/google-deepmind/gemma/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: google-deepmind/gemma, kind: releases } } | |
| 72 | + - { name: alphafold3 tags, url: "https://github.com/google-deepmind/alphafold3/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: google-deepmind/alphafold3, kind: tags } } | |
| 73 | + - id: google-ai | |
| 74 | + extend: true | |
| 75 | + country: US | |
| 76 | + sensors: | |
| 77 | + - { name: google blog ai feed, url: "https://blog.google/innovation-and-ai/technology/ai/rss/", type: RSS, connector: rss, tier: A } | |
| 78 | + - { name: google research blog feed, url: "https://research.google/blog/rss/", type: RSS, connector: rss, tier: A } | |
| 79 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=google&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 80 | + - id: google-developers-ai | |
| 81 | + extend: true | |
| 82 | + country: US | |
| 83 | + products: | |
| 84 | + - { name: Gemini CLI, type: software } | |
| 85 | + - { name: Agent Development Kit, type: software, aliases: [adk] } | |
| 86 | + sensors: | |
| 87 | + - { name: gemini api pricing, url: "https://ai.google.dev/gemini-api/docs/pricing", type: HTML, connector: http, tier: B } | |
| 88 | + - { name: gemini api models, url: "https://ai.google.dev/gemini-api/docs/models", type: HTML, connector: http, tier: A } | |
| 89 | + - { name: gemini api deprecations, url: "https://ai.google.dev/gemini-api/docs/deprecations", type: HTML, connector: http, tier: B } | |
| 90 | + - { name: gemma releases page, url: "https://ai.google.dev/gemma/docs/releases", type: HTML, connector: http, tier: B } | |
| 91 | + - { name: developers blog gemini feed, url: "https://developers.googleblog.com/feeds/posts/default/?category=Gemini", type: ATOM, connector: rss, tier: B } | |
| 92 | + - { name: python genai sdk releases, url: "https://github.com/googleapis/python-genai/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: googleapis/python-genai, kind: releases } } | |
| 93 | + - { name: js genai sdk releases, url: "https://github.com/googleapis/js-genai/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: googleapis/js-genai, kind: releases } } | |
| 94 | + - { name: gemini cli releases, url: "https://github.com/google-gemini/gemini-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: google-gemini/gemini-cli, kind: releases } } | |
| 95 | + - { name: adk python releases, url: "https://github.com/google/adk-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: google/adk-python, kind: releases } } | |
| 96 | + - { name: gemini cookbook commits, url: "https://github.com/google-gemini/cookbook/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: google-gemini/cookbook, kind: commits, branch: main } } | |
| 97 | + - id: google-cloud | |
| 98 | + extend: true | |
| 99 | + products: | |
| 100 | + - { name: Vertex AI, type: product, aliases: [vertex] } | |
| 101 | + sensors: | |
| 102 | + - { name: vertex ai release notes feed, url: "https://docs.cloud.google.com/feeds/vertex-ai-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 103 | + - { name: gemini for google cloud release notes feed, url: "https://docs.cloud.google.com/feeds/gemini-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 104 | + - id: meta-ai | |
| 105 | + extend: true | |
| 106 | + country: US | |
| 107 | + products: | |
| 108 | + - { name: Llama Stack, type: software } | |
| 109 | + notes: "ai.meta.com and llama.com answer 400 to non-browser clients; coverage comes from Hugging Face and GitHub." | |
| 110 | + sensors: | |
| 111 | + - { name: hugging face meta-llama models, url: "https://huggingface.co/api/models?author=meta-llama&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 112 | + - { name: hugging face facebook models, url: "https://huggingface.co/api/models?author=facebook&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 113 | + - { name: engineering blog feed, url: "https://engineering.fb.com/feed/", type: RSS, connector: rss, tier: B } | |
| 114 | + - { name: llama-models commits, url: "https://github.com/meta-llama/llama-models/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: meta-llama/llama-models, kind: commits, branch: main } } | |
| 115 | + - { name: llama-models tags, url: "https://github.com/meta-llama/llama-models/tags.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: meta-llama/llama-models, kind: tags } } | |
| 116 | + - { name: faiss releases, url: "https://github.com/facebookresearch/faiss/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: facebookresearch/faiss, kind: releases } } | |
| 117 | + - id: microsoft-ai | |
| 118 | + extend: true | |
| 119 | + country: US | |
| 120 | + products: | |
| 121 | + - { name: Phi, type: AI_model, aliases: [phi-4, phi-3] } | |
| 122 | + - { name: Semantic Kernel, type: software } | |
| 123 | + - { name: AutoGen, type: software } | |
| 124 | + - { name: Agent Framework, type: software, aliases: [microsoft agent framework] } | |
| 125 | + sensors: | |
| 126 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=microsoft&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 127 | + - { name: agent framework blog feed, url: "https://devblogs.microsoft.com/agent-framework/feed/", type: RSS, connector: rss, tier: B } | |
| 128 | + - { name: semantic kernel releases, url: "https://github.com/microsoft/semantic-kernel/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: microsoft/semantic-kernel, kind: releases } } | |
| 129 | + - { name: autogen releases, url: "https://github.com/microsoft/autogen/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: microsoft/autogen, kind: releases } } | |
| 130 | + - { name: agent framework releases, url: "https://github.com/microsoft/agent-framework/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: microsoft/agent-framework, kind: releases } } | |
| 131 | + - { name: onnx runtime releases, url: "https://github.com/microsoft/onnxruntime/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: microsoft/onnxruntime, kind: releases } } | |
| 132 | + - { name: deepspeed releases, url: "https://github.com/deepspeedai/DeepSpeed/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: deepspeedai/DeepSpeed, kind: releases } } | |
| 133 | + - { name: markitdown releases, url: "https://github.com/microsoft/markitdown/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: microsoft/markitdown, kind: releases } } | |
| 134 | + - { name: vscode copilot chat releases, url: "https://github.com/microsoft/vscode-copilot-chat/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: microsoft/vscode-copilot-chat, kind: releases } } | |
| 135 | + - id: azure-ai | |
| 136 | + extend: true | |
| 137 | + country: US | |
| 138 | + sensors: | |
| 139 | + - { name: foundry blog feed, url: "https://devblogs.microsoft.com/foundry/feed/", type: RSS, connector: rss, tier: B } | |
| 140 | + - { name: azure blog feed, url: "https://azure.microsoft.com/en-us/blog/feed/", type: RSS, connector: rss, tier: B } | |
| 141 | + - { name: foundry whats new, url: "https://learn.microsoft.com/en-us/azure/foundry/whats-new-foundry", type: HTML, connector: http, tier: B } | |
| 142 | + - { name: azure openai whats new, url: "https://learn.microsoft.com/en-us/azure/foundry-classic/openai/whats-new", type: HTML, connector: http, tier: A } | |
| 143 | + - { name: foundry models sold directly by azure, url: "https://learn.microsoft.com/en-us/azure/ai-foundry/foundry-models/concepts/models-sold-directly-by-azure", type: HTML, connector: http, tier: B } | |
| 144 | + - id: microsoft-research | |
| 145 | + name: Microsoft Research | |
| 146 | + domain: microsoft.com | |
| 147 | + homepage: https://www.microsoft.com/en-us/research/ | |
| 148 | + categories: [ai, research, technology] | |
| 149 | + tier: B | |
| 150 | + weight: 1.1 | |
| 151 | + country: US | |
| 152 | + aliases: [msr, microsoft research] | |
| 153 | + discover: { rss: true } | |
| 154 | + sensors: | |
| 155 | + - { name: research blog feed, url: "https://www.microsoft.com/en-us/research/blog/feed/", type: RSS, connector: rss, tier: B } | |
| 156 | + - id: xai | |
| 157 | + extend: true | |
| 158 | + country: US | |
| 159 | + notes: "x.ai and status.x.ai return 403 to non-browser clients (Scrapfly fallback on the news page); docs.x.ai is open." | |
| 160 | + sensors: | |
| 161 | + - { name: api release notes, url: "https://docs.x.ai/developers/release-notes", type: HTML, connector: http, tier: A } | |
| 162 | + - { name: docs llms.txt, url: "https://docs.x.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 163 | + - { name: xai sdk python releases, url: "https://github.com/xai-org/xai-sdk-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: xai-org/xai-sdk-python, kind: releases } } | |
| 164 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=xai-org&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 165 | + - id: mistral | |
| 166 | + extend: true | |
| 167 | + country: FR | |
| 168 | + products: | |
| 169 | + - { name: Mistral Vibe, type: software, aliases: [vibe cli] } | |
| 170 | + - { name: Magistral, type: AI_model } | |
| 171 | + - { name: Devstral, type: AI_model } | |
| 172 | + notes: "status.mistral.ai sits behind a Cloudflare challenge." | |
| 173 | + sensors: | |
| 174 | + - { name: news feed, url: "https://mistral.ai/news/rss", type: RSS, connector: rss, tier: A } | |
| 175 | + - { name: pricing, url: "https://mistral.ai/pricing/", type: HTML, connector: http, tier: C } | |
| 176 | + - { name: models docs, url: "https://docs.mistral.ai/models", type: HTML, connector: http, tier: A } | |
| 177 | + - { name: docs llms.txt, url: "https://docs.mistral.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 178 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=mistralai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 179 | + - { name: client python releases, url: "https://github.com/mistralai/client-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: mistralai/client-python, kind: releases } } | |
| 180 | + - { name: client ts releases, url: "https://github.com/mistralai/client-ts/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: mistralai/client-ts, kind: releases } } | |
| 181 | + - { name: mistral-common releases, url: "https://github.com/mistralai/mistral-common/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: mistralai/mistral-common, kind: releases } } | |
| 182 | + - { name: mistral vibe releases, url: "https://github.com/mistralai/mistral-vibe/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: mistralai/mistral-vibe, kind: releases } } | |
| 183 | + - { name: mistral-inference tags, url: "https://github.com/mistralai/mistral-inference/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: mistralai/mistral-inference, kind: tags } } | |
| 184 | + - id: cohere | |
| 185 | + extend: true | |
| 186 | + country: CA | |
| 187 | + products: | |
| 188 | + - { name: Aya, type: AI_model } | |
| 189 | + - { name: Embed, type: AI_model, aliases: [cohere embed] } | |
| 190 | + - { name: Rerank, type: AI_model, aliases: [cohere rerank] } | |
| 191 | + notes: "cohere.com/pricing and /research are client-hydrated (validator: thin, even with selector main)." | |
| 192 | + sensors: | |
| 193 | + - { name: status, url: "https://status.cohere.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 194 | + - { name: models docs, url: "https://docs.cohere.com/docs/models", type: HTML, connector: http, tier: A } | |
| 195 | + - { name: blog index, url: "https://cohere.com/blog", type: HTML, connector: http, tier: B } | |
| 196 | + - { name: docs llms.txt, url: "https://docs.cohere.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 197 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=CohereLabs&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 198 | + - { name: sdk python releases, url: "https://github.com/cohere-ai/cohere-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cohere-ai/cohere-python, kind: releases } } | |
| 199 | + - { name: sdk typescript releases, url: "https://github.com/cohere-ai/cohere-typescript/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: cohere-ai/cohere-typescript, kind: releases } } | |
| 200 | + - id: huggingface | |
| 201 | + extend: true | |
| 202 | + country: US | |
| 203 | + products: | |
| 204 | + - { name: Diffusers, type: software } | |
| 205 | + - { name: Hugging Face Hub, type: service, aliases: [hf hub] } | |
| 206 | + - { name: SmolLM, type: AI_model, aliases: [smollm3] } | |
| 207 | + - { name: Text Generation Inference, type: software, aliases: [tgi] } | |
| 208 | + notes: "status.huggingface.co is a Betterstack page (no summary JSON)." | |
| 209 | + sensors: | |
| 210 | + - { name: pricing, url: "https://huggingface.co/pricing", type: HTML, connector: http, tier: C } | |
| 211 | + - { name: hub changelog, url: "https://huggingface.co/changelog", type: HTML, connector: http, tier: B } | |
| 212 | + - { name: daily papers api, url: "https://huggingface.co/api/daily_papers", type: REST_API, connector: jsonlist, tier: B, config: { keyField: paper.id, titleField: title, dateField: publishedAt, urlTemplate: "https://huggingface.co/papers/{key}", compareFields: [title], maxItems: 50 } } | |
| 213 | + - { name: hugging face smollm models, url: "https://huggingface.co/api/models?author=HuggingFaceTB&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 214 | + - { name: diffusers releases, url: "https://github.com/huggingface/diffusers/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: huggingface/diffusers, kind: releases } } | |
| 215 | + - { name: huggingface_hub releases, url: "https://github.com/huggingface/huggingface_hub/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: huggingface/huggingface_hub, kind: releases } } | |
| 216 | + - { name: datasets releases, url: "https://github.com/huggingface/datasets/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/datasets, kind: releases } } | |
| 217 | + - { name: tokenizers releases, url: "https://github.com/huggingface/tokenizers/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/tokenizers, kind: releases } } | |
| 218 | + - { name: peft releases, url: "https://github.com/huggingface/peft/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/peft, kind: releases } } | |
| 219 | + - { name: trl releases, url: "https://github.com/huggingface/trl/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/trl, kind: releases } } | |
| 220 | + - { name: accelerate releases, url: "https://github.com/huggingface/accelerate/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/accelerate, kind: releases } } | |
| 221 | + - { name: text generation inference releases, url: "https://github.com/huggingface/text-generation-inference/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: huggingface/text-generation-inference, kind: releases } } | |
| 222 | + - { name: smolagents releases, url: "https://github.com/huggingface/smolagents/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/smolagents, kind: releases } } | |
| 223 | + - { name: lerobot releases, url: "https://github.com/huggingface/lerobot/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/lerobot, kind: releases } } | |
| 224 | + - { name: transformers.js releases, url: "https://github.com/huggingface/transformers.js/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/transformers.js, kind: releases } } | |
| 225 | + - { name: candle tags, url: "https://github.com/huggingface/candle/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: huggingface/candle, kind: tags } } | |
| 226 | + # ───────────────────────── Silicon & platforms ───────────────────────── | |
| 227 | + - id: nvidia | |
| 228 | + extend: true | |
| 229 | + country: US | |
| 230 | + products: | |
| 231 | + - { name: TensorRT-LLM, type: software, aliases: [tensorrt llm] } | |
| 232 | + - { name: NeMo, type: software, aliases: [nvidia nemo] } | |
| 233 | + - { name: Nemotron, type: AI_model } | |
| 234 | + notes: "NGC catalog API (api.ngc.nvidia.com) requires authentication; NIM release notes are client-rendered." | |
| 235 | + sensors: | |
| 236 | + - { name: corporate blog feed, url: "https://blogs.nvidia.com/feed/", type: RSS, connector: rss, tier: A } | |
| 237 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=nvidia&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 238 | + - { name: cuda toolkit release notes, url: "https://docs.nvidia.com/cuda/cuda-toolkit-release-notes/index.html", type: HTML, connector: http, tier: B } | |
| 239 | + - { name: tensorrt release notes, url: "https://docs.nvidia.com/deeplearning/tensorrt/latest/getting-started/release-notes.html", type: HTML, connector: http, tier: C } | |
| 240 | + - { name: research publications, url: "https://research.nvidia.com/publications", type: HTML, connector: http, tier: B } | |
| 241 | + - { name: tensorrt-llm releases, url: "https://github.com/NVIDIA/TensorRT-LLM/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: NVIDIA/TensorRT-LLM, kind: releases } } | |
| 242 | + - { name: tensorrt releases, url: "https://github.com/NVIDIA/TensorRT/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/TensorRT, kind: releases } } | |
| 243 | + - { name: triton inference server releases, url: "https://github.com/triton-inference-server/server/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: triton-inference-server/server, kind: releases } } | |
| 244 | + - { name: megatron-lm releases, url: "https://github.com/NVIDIA/Megatron-LM/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/Megatron-LM, kind: releases } } | |
| 245 | + - { name: cutlass releases, url: "https://github.com/NVIDIA/cutlass/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/cutlass, kind: releases } } | |
| 246 | + - { name: cuda python releases, url: "https://github.com/NVIDIA/cuda-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/cuda-python, kind: releases } } | |
| 247 | + - { name: container toolkit releases, url: "https://github.com/NVIDIA/nvidia-container-toolkit/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/nvidia-container-toolkit, kind: releases } } | |
| 248 | + - { name: transformer engine releases, url: "https://github.com/NVIDIA/TransformerEngine/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/TransformerEngine, kind: releases } } | |
| 249 | + - { name: nemo agent toolkit releases, url: "https://github.com/NVIDIA/NeMo-Agent-Toolkit/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/NeMo-Agent-Toolkit, kind: releases } } | |
| 250 | + - { name: nemo guardrails releases, url: "https://github.com/NVIDIA-NeMo/Guardrails/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA-NeMo/Guardrails, kind: releases } } | |
| 251 | + - { name: open gpu kernel modules releases, url: "https://github.com/NVIDIA/open-gpu-kernel-modules/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/open-gpu-kernel-modules, kind: releases } } | |
| 252 | + - { name: nim deploy commits, url: "https://github.com/NVIDIA/nim-deploy/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: NVIDIA/nim-deploy, kind: commits, branch: main } } | |
| 253 | + - id: amd | |
| 254 | + extend: true | |
| 255 | + country: US | |
| 256 | + products: | |
| 257 | + - { name: ROCm, type: software, aliases: [rocm] } | |
| 258 | + - { name: Instinct, type: product, aliases: [mi300, mi350, mi400] } | |
| 259 | + sensors: | |
| 260 | + - { name: rocm blogs feed, url: "https://rocm.blogs.amd.com/blog/atom.xml", type: ATOM, connector: rss, tier: B } | |
| 261 | + - { name: rocm release notes, url: "https://rocm.docs.amd.com/en/latest/about/release-notes.html", type: HTML, connector: http, tier: B } | |
| 262 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=amd&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 263 | + - { name: rocm systems releases, url: "https://github.com/ROCm/rocm-systems/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: ROCm/rocm-systems, kind: releases } } | |
| 264 | + - { name: rocm libraries releases, url: "https://github.com/ROCm/rocm-libraries/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: ROCm/rocm-libraries, kind: releases } } | |
| 265 | + - { name: rocm vllm releases, url: "https://github.com/ROCm/vllm/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ROCm/vllm, kind: releases } } | |
| 266 | + - { name: composable kernel releases, url: "https://github.com/ROCm/composable_kernel/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ROCm/composable_kernel, kind: releases } } | |
| 267 | + - { name: rocblas releases, url: "https://github.com/ROCm/rocBLAS/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ROCm/rocBLAS, kind: releases } } | |
| 268 | + - id: apple-ml-research | |
| 269 | + name: Apple Machine Learning Research | |
| 270 | + domain: machinelearning.apple.com | |
| 271 | + categories: [ai, research, technology] | |
| 272 | + tier: B | |
| 273 | + weight: 1.1 | |
| 274 | + country: US | |
| 275 | + aliases: [apple ml research, apple machine learning] | |
| 276 | + products: | |
| 277 | + - { name: Core ML, type: software, aliases: [coreml, coremltools] } | |
| 278 | + - { name: Apple Foundation Models, type: AI_model, aliases: [apple intelligence models] } | |
| 279 | + discover: { rss: true } | |
| 280 | + sensors: | |
| 281 | + - { name: research feed, url: "https://machinelearning.apple.com/rss.xml", type: RSS, connector: rss, tier: B } | |
| 282 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=apple&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 283 | + - { name: coremltools releases, url: "https://github.com/apple/coremltools/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: apple/coremltools, kind: releases } } | |
| 284 | + - id: mlx | |
| 285 | + name: MLX | |
| 286 | + domain: ml-explore.github.io | |
| 287 | + homepage: https://ml-explore.github.io/mlx/ | |
| 288 | + categories: [ai, open-source, developer] | |
| 289 | + tier: B | |
| 290 | + country: US | |
| 291 | + aliases: [mlx framework, apple mlx, mlx-lm] | |
| 292 | + products: | |
| 293 | + - { name: mlx-lm, type: software } | |
| 294 | + - { name: MLX Swift, type: software, aliases: [mlx-swift] } | |
| 295 | + discover: { rss: false } | |
| 296 | + sensors: | |
| 297 | + - { name: mlx releases, url: "https://github.com/ml-explore/mlx/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: ml-explore/mlx, kind: releases } } | |
| 298 | + - { name: mlx-lm releases, url: "https://github.com/ml-explore/mlx-lm/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: ml-explore/mlx-lm, kind: releases } } | |
| 299 | + - { name: mlx-swift releases, url: "https://github.com/ml-explore/mlx-swift/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ml-explore/mlx-swift, kind: releases } } | |
| 300 | + - { name: mlx-swift-lm releases, url: "https://github.com/ml-explore/mlx-swift-lm/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ml-explore/mlx-swift-lm, kind: releases } } | |
| 301 | + - { name: mlx-examples commits, url: "https://github.com/ml-explore/mlx-examples/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ml-explore/mlx-examples, kind: commits, branch: main } } | |
| 302 | + - id: aws | |
| 303 | + extend: true | |
| 304 | + country: US | |
| 305 | + products: | |
| 306 | + - { name: Amazon Nova, type: AI_model, aliases: [nova] } | |
| 307 | + - { name: Strands Agents, type: software } | |
| 308 | + - { name: Bedrock AgentCore, type: product, aliases: [agentcore] } | |
| 309 | + sensors: | |
| 310 | + - { name: machine learning blog feed, url: "https://aws.amazon.com/blogs/machine-learning/feed/", type: RSS, connector: rss, tier: B } | |
| 311 | + - { name: news blog ai category feed, url: "https://aws.amazon.com/blogs/aws/category/artificial-intelligence/feed/", type: RSS, connector: rss, tier: B } | |
| 312 | + - { name: bedrock pricing, url: "https://aws.amazon.com/bedrock/pricing/", type: HTML, connector: http, tier: C } | |
| 313 | + - { name: bedrock user guide history, url: "https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-ug-doc-history.html", type: HTML, connector: http, tier: B } | |
| 314 | + - { name: bedrock model lifecycle, url: "https://docs.aws.amazon.com/bedrock/latest/userguide/model-lifecycle.html", type: HTML, connector: http, tier: B } | |
| 315 | + - { name: strands agents sdk releases, url: "https://github.com/strands-agents/harness-sdk/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: strands-agents/harness-sdk, kind: releases } } | |
| 316 | + - { name: bedrock agentcore sdk releases, url: "https://github.com/aws/bedrock-agentcore-sdk-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: aws/bedrock-agentcore-sdk-python, kind: releases } } | |
| 317 | + # ───────────────────────── Chinese labs ───────────────────────── | |
| 318 | + - id: qwen | |
| 319 | + name: Alibaba Qwen | |
| 320 | + domain: qwen.ai | |
| 321 | + homepage: https://qwenlm.github.io | |
| 322 | + categories: [ai, technology] | |
| 323 | + tier: A | |
| 324 | + weight: 1.3 | |
| 325 | + country: CN | |
| 326 | + aliases: [qwen, qwenlm, alibaba qwen, tongyi qianwen, alibaba cloud model studio] | |
| 327 | + products: | |
| 328 | + - { name: Qwen3, type: AI_model, aliases: [qwen 3, qwen3-max, qwen3-coder, qwen3-vl] } | |
| 329 | + - { name: Qwen-Image, type: AI_model } | |
| 330 | + - { name: Qwen Code, type: software, aliases: [qwen-code] } | |
| 331 | + - { name: Model Studio, type: API, aliases: [dashscope, bailian] } | |
| 332 | + discover: { rss: true } | |
| 333 | + notes: "qwen.ai is client-rendered; the blog lives on qwenlm.github.io. Model Studio pricing page is Alibaba Cloud help." | |
| 334 | + sensors: | |
| 335 | + - { name: blog feed, url: "https://qwenlm.github.io/blog/index.xml", type: RSS, connector: rss, tier: A } | |
| 336 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=Qwen&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 337 | + - { name: model studio pricing, url: "https://www.alibabacloud.com/help/en/model-studio/model-pricing", type: HTML, connector: http, tier: C } | |
| 338 | + - { name: qwen code releases, url: "https://github.com/QwenLM/qwen-code/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: QwenLM/qwen-code, kind: releases } } | |
| 339 | + - { name: qwen-agent releases, url: "https://github.com/QwenLM/Qwen-Agent/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: QwenLM/Qwen-Agent, kind: releases } } | |
| 340 | + - { name: qwen-image commits, url: "https://github.com/QwenLM/Qwen-Image/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: QwenLM/Qwen-Image, kind: commits, branch: main } } | |
| 341 | + - { name: qwen3-omni commits, url: "https://github.com/QwenLM/Qwen3-Omni/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: QwenLM/Qwen3-Omni, kind: commits, branch: main } } | |
| 342 | + - id: deepseek | |
| 343 | + name: DeepSeek | |
| 344 | + domain: deepseek.com | |
| 345 | + homepage: https://www.deepseek.com | |
| 346 | + categories: [ai, technology] | |
| 347 | + tier: A | |
| 348 | + weight: 1.3 | |
| 349 | + country: CN | |
| 350 | + aliases: [deepseek ai, deep seek] | |
| 351 | + products: | |
| 352 | + - { name: DeepSeek-V3, type: AI_model, aliases: [deepseek v3, deepseek-v3.1, deepseek-v3.2] } | |
| 353 | + - { name: DeepSeek-R1, type: AI_model, aliases: [deepseek r1] } | |
| 354 | + - { name: DeepSeek API, type: API } | |
| 355 | + discover: { rss: true, sitemap: true } | |
| 356 | + notes: "platform.deepseek.com and status.deepseek.com (Flashcat) are client-rendered; api-docs.deepseek.com is Docusaurus (server-rendered)." | |
| 357 | + sensors: | |
| 358 | + - { name: api updates, url: "https://api-docs.deepseek.com/updates/", type: HTML, connector: http, tier: A } | |
| 359 | + - { name: api pricing, url: "https://api-docs.deepseek.com/quick_start/pricing/", type: HTML, connector: http, tier: B } | |
| 360 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=deepseek-ai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 361 | + - { name: deepseek-v3 commits, url: "https://github.com/deepseek-ai/DeepSeek-V3/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: deepseek-ai/DeepSeek-V3, kind: commits, branch: main } } | |
| 362 | + - { name: deepseek-v3 tags, url: "https://github.com/deepseek-ai/DeepSeek-V3/tags.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: deepseek-ai/DeepSeek-V3, kind: tags } } | |
| 363 | + - { name: deepseek-r1 commits, url: "https://github.com/deepseek-ai/DeepSeek-R1/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: deepseek-ai/DeepSeek-R1, kind: commits, branch: main } } | |
| 364 | + - { name: deepseek-v3.2-exp commits, url: "https://github.com/deepseek-ai/DeepSeek-V3.2-Exp/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: deepseek-ai/DeepSeek-V3.2-Exp, kind: commits, branch: main } } | |
| 365 | + - id: moonshot-ai | |
| 366 | + name: Moonshot AI | |
| 367 | + domain: moonshot.ai | |
| 368 | + homepage: https://platform.kimi.ai | |
| 369 | + categories: [ai, technology] | |
| 370 | + tier: A | |
| 371 | + weight: 1.1 | |
| 372 | + country: CN | |
| 373 | + aliases: [moonshot, kimi, kimi ai, moonshot ai] | |
| 374 | + products: | |
| 375 | + - { name: Kimi K2, type: AI_model, aliases: [kimi-k2, kimi k2 thinking] } | |
| 376 | + - { name: Kimi CLI, type: software } | |
| 377 | + - { name: Kimi API, type: API, aliases: [moonshot api] } | |
| 378 | + discover: { rss: true } | |
| 379 | + notes: "platform.moonshot.ai redirects to platform.kimi.ai; status.moonshot.ai does not resolve." | |
| 380 | + sensors: | |
| 381 | + - { name: api pricing (chat), url: "https://platform.kimi.ai/docs/pricing/chat", type: HTML, connector: http, tier: B } | |
| 382 | + - { name: api rate limits, url: "https://platform.kimi.ai/docs/pricing/limits", type: HTML, connector: http, tier: C } | |
| 383 | + - { name: api overview, url: "https://platform.kimi.ai/docs/overview", type: HTML, connector: http, tier: B } | |
| 384 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=moonshotai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 385 | + - { name: kimi-k2 commits, url: "https://github.com/MoonshotAI/Kimi-K2/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: MoonshotAI/Kimi-K2, kind: commits, branch: main } } | |
| 386 | + - { name: kimi cli releases, url: "https://github.com/MoonshotAI/kimi-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: MoonshotAI/kimi-cli, kind: releases } } | |
| 387 | + - id: zhipu-ai | |
| 388 | + name: Zhipu AI (Z.ai) | |
| 389 | + domain: z.ai | |
| 390 | + homepage: https://z.ai | |
| 391 | + categories: [ai, technology] | |
| 392 | + tier: A | |
| 393 | + weight: 1.1 | |
| 394 | + country: CN | |
| 395 | + aliases: [zhipu, zhipu ai, z.ai, bigmodel, chatglm, thudm] | |
| 396 | + products: | |
| 397 | + - { name: GLM, type: AI_model, aliases: [glm-4.5, glm-4.6, glm-5, chatglm] } | |
| 398 | + - { name: Z.ai API, type: API, aliases: [bigmodel api] } | |
| 399 | + discover: { rss: true } | |
| 400 | + notes: "open.bigmodel.cn / bigmodel.cn are client-rendered; docs.z.ai (Mintlify) is server-rendered." | |
| 401 | + sensors: | |
| 402 | + - { name: api pricing, url: "https://docs.z.ai/guides/overview/pricing", type: HTML, connector: http, tier: B } | |
| 403 | + - { name: release notes, url: "https://docs.z.ai/release-notes/new-released", type: HTML, connector: http, tier: A } | |
| 404 | + - { name: docs llms.txt, url: "https://docs.z.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 405 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=zai-org&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 406 | + - { name: glm-4.5 commits, url: "https://github.com/zai-org/GLM-4.5/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: zai-org/GLM-4.5, kind: commits, branch: main } } | |
| 407 | + - id: minimax | |
| 408 | + name: MiniMax | |
| 409 | + domain: minimax.io | |
| 410 | + homepage: https://www.minimax.io | |
| 411 | + categories: [ai, technology] | |
| 412 | + tier: B | |
| 413 | + weight: 1.1 | |
| 414 | + country: CN | |
| 415 | + aliases: [minimax ai, hailuo] | |
| 416 | + products: | |
| 417 | + - { name: MiniMax-M1, type: AI_model, aliases: [minimax m1] } | |
| 418 | + - { name: MiniMax-M2, type: AI_model, aliases: [minimax m2] } | |
| 419 | + - { name: Hailuo, type: product, aliases: [hailuo video] } | |
| 420 | + discover: { rss: true } | |
| 421 | + notes: "minimax.io/news is client-rendered." | |
| 422 | + sensors: | |
| 423 | + - { name: status, url: "https://status.minimax.io/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 424 | + - { name: models intro, url: "https://platform.minimax.io/docs/guides/models-intro", type: HTML, connector: http, tier: B } | |
| 425 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=MiniMaxAI&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 426 | + - { name: minimax-m2 commits, url: "https://github.com/MiniMax-AI/MiniMax-M2/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: MiniMax-AI/MiniMax-M2, kind: commits, branch: main } } | |
| 427 | + - { name: minimax-m1 commits, url: "https://github.com/MiniMax-AI/MiniMax-M1/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: MiniMax-AI/MiniMax-M1, kind: commits, branch: main } } | |
| 428 | + - id: baidu | |
| 429 | + name: Baidu | |
| 430 | + domain: baidu.com | |
| 431 | + homepage: https://www.baidu.com | |
| 432 | + categories: [ai, technology, internet] | |
| 433 | + tier: B | |
| 434 | + weight: 1.2 | |
| 435 | + country: CN | |
| 436 | + aliases: [baidu ai, ernie, wenxin, paddlepaddle] | |
| 437 | + products: | |
| 438 | + - { name: ERNIE, type: AI_model, aliases: [ernie 4.5, ernie bot, wenxin yiyan] } | |
| 439 | + - { name: PaddlePaddle, type: software, aliases: [paddle] } | |
| 440 | + - { name: Qianfan, type: API, aliases: [wenxin workshop] } | |
| 441 | + discover: { rss: true } | |
| 442 | + notes: "ir.baidu.com returns 403; Qianfan docs (cloud.baidu.com) are Chinese and mostly client-rendered." | |
| 443 | + sensors: | |
| 444 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=baidu&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 445 | + - { name: ernie blog, url: "https://ernie.baidu.com/blog", type: HTML, connector: http, tier: B } | |
| 446 | + - { name: paddle releases, url: "https://github.com/PaddlePaddle/Paddle/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: PaddlePaddle/Paddle, kind: releases } } | |
| 447 | + - { name: ernie commits, url: "https://github.com/PaddlePaddle/ERNIE/commits/develop.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: PaddlePaddle/ERNIE, kind: commits, branch: develop } } | |
| 448 | + - { name: paddleocr releases, url: "https://github.com/PaddlePaddle/PaddleOCR/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: PaddlePaddle/PaddleOCR, kind: releases } } | |
| 449 | + - id: tencent | |
| 450 | + extend: true | |
| 451 | + country: CN | |
| 452 | + categories: [ai] | |
| 453 | + aliases: [hunyuan, tencent hunyuan] | |
| 454 | + products: | |
| 455 | + - { name: Hunyuan, type: AI_model, aliases: [hunyuan-a13b, hunyuanvideo, hunyuan3d] } | |
| 456 | + sensors: | |
| 457 | + - { name: hugging face tencent models, url: "https://huggingface.co/api/models?author=tencent&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 458 | + - { name: hugging face hunyuan models, url: "https://huggingface.co/api/models?author=Tencent-Hunyuan&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 459 | + - { name: hunyuanvideo tags, url: "https://github.com/Tencent-Hunyuan/HunyuanVideo/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: Tencent-Hunyuan/HunyuanVideo, kind: tags } } | |
| 460 | + - { name: hunyuan3d-2 commits, url: "https://github.com/Tencent-Hunyuan/Hunyuan3D-2/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: Tencent-Hunyuan/Hunyuan3D-2, kind: commits, branch: main } } | |
| 461 | + - { name: hunyuan-a13b commits, url: "https://github.com/Tencent-Hunyuan/Hunyuan-A13B/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: Tencent-Hunyuan/Hunyuan-A13B, kind: commits, branch: main } } | |
| 462 | + - id: bytedance-seed | |
| 463 | + name: ByteDance Seed | |
| 464 | + domain: seed.bytedance.com | |
| 465 | + homepage: https://seed.bytedance.com/en/ | |
| 466 | + categories: [ai, technology] | |
| 467 | + tier: B | |
| 468 | + weight: 1.1 | |
| 469 | + country: CN | |
| 470 | + aliases: [bytedance, bytedance seed, seed team, doubao, volcano engine] | |
| 471 | + products: | |
| 472 | + - { name: Seed, type: AI_model, aliases: [seed-oss, seed-coder, seedance, seedream] } | |
| 473 | + - { name: Doubao, type: AI_model } | |
| 474 | + - { name: verl, type: software } | |
| 475 | + discover: { rss: false } | |
| 476 | + notes: "seed.bytedance.com/en/blog is client-rendered; the research index is server-rendered." | |
| 477 | + sensors: | |
| 478 | + - { name: research index, url: "https://seed.bytedance.com/en/research", type: HTML, connector: http, tier: B } | |
| 479 | + - { name: hugging face seed models, url: "https://huggingface.co/api/models?author=ByteDance-Seed&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 480 | + - { name: hugging face bytedance models, url: "https://huggingface.co/api/models?author=ByteDance&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 481 | + - { name: deer-flow releases, url: "https://github.com/bytedance/deer-flow/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: bytedance/deer-flow, kind: releases } } | |
| 482 | + - { name: verl releases, url: "https://github.com/verl-project/verl/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: verl-project/verl, kind: releases } } | |
| 483 | + - { name: seed-coder commits, url: "https://github.com/ByteDance-Seed/Seed-Coder/commits/master.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ByteDance-Seed/Seed-Coder, kind: commits, branch: master } } | |
| 484 | + - id: shanghai-ai-lab | |
| 485 | + name: Shanghai AI Laboratory | |
| 486 | + domain: shlab.org.cn | |
| 487 | + homepage: https://www.shlab.org.cn | |
| 488 | + categories: [ai, research] | |
| 489 | + tier: B | |
| 490 | + country: CN | |
| 491 | + aliases: [shanghai ai lab, internlm, opengvlab, intern-s1] | |
| 492 | + products: | |
| 493 | + - { name: InternLM, type: AI_model, aliases: [intern-s1, internlm3] } | |
| 494 | + - { name: InternVL, type: AI_model, aliases: [internvl3] } | |
| 495 | + discover: { rss: false } | |
| 496 | + sensors: | |
| 497 | + - { name: hugging face internlm models, url: "https://huggingface.co/api/models?author=internlm&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 498 | + - { name: hugging face opengvlab models, url: "https://huggingface.co/api/models?author=OpenGVLab&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 499 | + - id: stepfun | |
| 500 | + name: StepFun | |
| 501 | + domain: stepfun.com | |
| 502 | + homepage: https://www.stepfun.com | |
| 503 | + categories: [ai, technology] | |
| 504 | + tier: C | |
| 505 | + country: CN | |
| 506 | + aliases: [stepfun ai, step-3] | |
| 507 | + products: | |
| 508 | + - { name: Step, type: AI_model, aliases: [step-3, step3-vl] } | |
| 509 | + discover: { rss: false } | |
| 510 | + sensors: | |
| 511 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=stepfun-ai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 512 | + # ───────────────────────── Inference clouds & AI infrastructure ───────────────────────── | |
| 513 | + - id: together-ai | |
| 514 | + extend: true | |
| 515 | + country: US | |
| 516 | + notes: "status.together.ai is a Betterstack page (no summary JSON)." | |
| 517 | + sensors: | |
| 518 | + - { name: blog feed, url: "https://www.together.ai/blog/rss.xml", type: RSS, connector: rss, tier: B } | |
| 519 | + - { name: serverless models docs, url: "https://docs.together.ai/docs/serverless/models", type: HTML, connector: http, tier: B } | |
| 520 | + - { name: dedicated endpoint models docs, url: "https://docs.together.ai/docs/dedicated-endpoints/models", type: HTML, connector: http, tier: C } | |
| 521 | + - { name: docs llms.txt, url: "https://docs.together.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 522 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=togethercomputer&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 523 | + - { name: sdk python releases, url: "https://github.com/togethercomputer/together-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: togethercomputer/together-python, kind: releases } } | |
| 524 | + - { name: sdk typescript releases, url: "https://github.com/togethercomputer/together-typescript/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: togethercomputer/together-typescript, kind: releases } } | |
| 525 | + - id: groq | |
| 526 | + extend: true | |
| 527 | + country: US | |
| 528 | + products: | |
| 529 | + - { name: GroqCloud, type: service, aliases: [groq cloud, groq api] } | |
| 530 | + - { name: LPU, type: technology } | |
| 531 | + notes: "groq.com/pricing redirects to the home page." | |
| 532 | + sensors: | |
| 533 | + - { name: models docs, url: "https://console.groq.com/docs/models", type: HTML, connector: http, tier: A } | |
| 534 | + - { name: docs changelog, url: "https://console.groq.com/docs/changelog", type: HTML, connector: http, tier: A } | |
| 535 | + - { name: deprecations, url: "https://console.groq.com/docs/deprecations", type: HTML, connector: http, tier: B } | |
| 536 | + - { name: docs llms.txt, url: "https://console.groq.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 537 | + - { name: blog index, url: "https://groq.com/blog", type: HTML, connector: http, tier: B } | |
| 538 | + - { name: sdk python releases, url: "https://github.com/groq/groq-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: groq/groq-python, kind: releases } } | |
| 539 | + - { name: sdk typescript releases, url: "https://github.com/groq/groq-typescript/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: groq/groq-typescript, kind: releases } } | |
| 540 | + - id: cerebras | |
| 541 | + extend: true | |
| 542 | + country: US | |
| 543 | + products: | |
| 544 | + - { name: Cerebras Inference, type: service, aliases: [cerebras cloud] } | |
| 545 | + - { name: WSE-3, type: product, aliases: [wafer scale engine] } | |
| 546 | + sensors: | |
| 547 | + - { name: status, url: "https://status.cerebras.ai/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 548 | + - { name: inference models overview, url: "https://inference-docs.cerebras.ai/models/overview", type: HTML, connector: http, tier: A } | |
| 549 | + - { name: inference changelog, url: "https://inference-docs.cerebras.ai/support/change-log", type: HTML, connector: http, tier: B } | |
| 550 | + - { name: pricing, url: "https://www.cerebras.ai/pricing", type: HTML, connector: http, tier: C } | |
| 551 | + - { name: docs llms.txt, url: "https://inference-docs.cerebras.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 552 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=cerebras&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 553 | + - id: sambanova | |
| 554 | + name: SambaNova Systems | |
| 555 | + domain: sambanova.ai | |
| 556 | + categories: [ai, semiconductors] | |
| 557 | + tier: B | |
| 558 | + country: US | |
| 559 | + aliases: [sambanova, samba nova, sambacloud] | |
| 560 | + products: | |
| 561 | + - { name: SambaNova Cloud, type: service, aliases: [sambacloud] } | |
| 562 | + - { name: SN40L, type: product } | |
| 563 | + discover: { rss: true } | |
| 564 | + sensors: | |
| 565 | + - { name: status, url: "https://status.sambanova.ai/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 566 | + - { name: blog feed, url: "https://sambanova.ai/blog/rss.xml", type: RSS, connector: rss, tier: B } | |
| 567 | + - { name: cloud models docs, url: "https://docs.sambanova.ai/docs/en/models/sambacloud-models", type: HTML, connector: http, tier: B } | |
| 568 | + - { name: release notes, url: "https://docs.sambanova.ai/docs/en/release-notes/overview", type: HTML, connector: http, tier: B } | |
| 569 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=sambanovasystems&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 570 | + - { name: ai starter kit releases, url: "https://github.com/sambanova/ai-starter-kit/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: sambanova/ai-starter-kit, kind: releases } } | |
| 571 | + - id: fireworks-ai | |
| 572 | + extend: true | |
| 573 | + country: US | |
| 574 | + sensors: | |
| 575 | + - { name: status, url: "https://status.fireworks.ai/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 576 | + - { name: pricing, url: "https://fireworks.ai/pricing", type: HTML, connector: http, tier: C } | |
| 577 | + - { name: models library, url: "https://fireworks.ai/models", type: HTML, connector: http, tier: B } | |
| 578 | + - { name: docs llms.txt, url: "https://docs.fireworks.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 579 | + - { name: blog index, url: "https://fireworks.ai/blog", type: HTML, connector: http, tier: B } | |
| 580 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=fireworks-ai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 581 | + - id: perplexity | |
| 582 | + extend: true | |
| 583 | + country: US | |
| 584 | + products: | |
| 585 | + - { name: Sonar, type: AI_model, aliases: [sonar pro, sonar api] } | |
| 586 | + - { name: Comet, type: product, aliases: [comet browser] } | |
| 587 | + notes: "perplexity.ai/hub is behind a Cloudflare challenge." | |
| 588 | + sensors: | |
| 589 | + - { name: status, url: "https://status.perplexity.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 590 | + - { name: api pricing, url: "https://docs.perplexity.ai/docs/getting-started/pricing", type: HTML, connector: http, tier: B } | |
| 591 | + - { name: docs llms.txt, url: "https://docs.perplexity.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 592 | + - { name: sdk python releases, url: "https://github.com/perplexityai/perplexity-py/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: perplexityai/perplexity-py, kind: releases } } | |
| 593 | + - id: replicate | |
| 594 | + extend: true | |
| 595 | + country: US | |
| 596 | + notes: "status.replicate.com redirects to cloudflarestatus.com." | |
| 597 | + sensors: | |
| 598 | + - { name: blog feed, url: "https://replicate.com/blog/rss", type: RSS, connector: rss, tier: B } | |
| 599 | + - { name: pricing, url: "https://replicate.com/pricing", type: HTML, connector: http, tier: C } | |
| 600 | + - { name: explore models, url: "https://replicate.com/explore", type: HTML, connector: http, tier: B } | |
| 601 | + - { name: sdk python releases, url: "https://github.com/replicate/replicate-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: replicate/replicate-python, kind: releases } } | |
| 602 | + - { name: sdk javascript releases, url: "https://github.com/replicate/replicate-javascript/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: replicate/replicate-javascript, kind: releases } } | |
| 603 | + - { name: cog releases, url: "https://github.com/replicate/cog/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: replicate/cog, kind: releases } } | |
| 604 | + - id: modal | |
| 605 | + name: Modal | |
| 606 | + domain: modal.com | |
| 607 | + categories: [ai, cloud, developer] | |
| 608 | + tier: B | |
| 609 | + country: US | |
| 610 | + aliases: [modal labs] | |
| 611 | + products: | |
| 612 | + - { name: Modal client, type: software, aliases: [modal python] } | |
| 613 | + discover: { rss: true } | |
| 614 | + notes: "modal.com/blog and the docs changelog are client-rendered; status.modal.com is Betterstack." | |
| 615 | + sensors: | |
| 616 | + - { name: pricing, url: "https://modal.com/pricing", type: HTML, connector: http, tier: C } | |
| 617 | + - { name: llms.txt, url: "https://modal.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 618 | + - { name: client releases, url: "https://github.com/modal-labs/modal-client/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: modal-labs/modal-client, kind: releases } } | |
| 619 | + - id: baseten | |
| 620 | + name: Baseten | |
| 621 | + domain: baseten.co | |
| 622 | + homepage: https://www.baseten.co | |
| 623 | + categories: [ai, cloud, developer] | |
| 624 | + tier: B | |
| 625 | + country: US | |
| 626 | + products: | |
| 627 | + - { name: Truss, type: software } | |
| 628 | + discover: { rss: true } | |
| 629 | + sensors: | |
| 630 | + - { name: status, url: "https://status.baseten.co/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 631 | + - { name: pricing, url: "https://www.baseten.co/pricing/", type: HTML, connector: http, tier: C } | |
| 632 | + - { name: model library, url: "https://www.baseten.co/library/", type: HTML, connector: http, tier: B } | |
| 633 | + - { name: docs llms.txt, url: "https://docs.baseten.co/llms.txt", type: FILE, connector: http, tier: D } | |
| 634 | + - { name: truss releases, url: "https://github.com/basetenlabs/truss/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: basetenlabs/truss, kind: releases } } | |
| 635 | + - id: runpod | |
| 636 | + name: RunPod | |
| 637 | + domain: runpod.io | |
| 638 | + homepage: https://www.runpod.io | |
| 639 | + categories: [ai, cloud] | |
| 640 | + tier: B | |
| 641 | + country: US | |
| 642 | + aliases: [run pod] | |
| 643 | + discover: { rss: true } | |
| 644 | + notes: "uptime.runpod.io is a Betterstack page; status.runpod.io does not resolve." | |
| 645 | + sensors: | |
| 646 | + - { name: blog feed, url: "https://www.runpod.io/blog/rss.xml", type: RSS, connector: rss, tier: B } | |
| 647 | + - { name: release notes, url: "https://docs.runpod.io/release-notes", type: HTML, connector: http, tier: B } | |
| 648 | + - { name: pricing, url: "https://www.runpod.io/pricing", type: HTML, connector: http, tier: C } | |
| 649 | + - { name: docs llms.txt, url: "https://docs.runpod.io/llms.txt", type: FILE, connector: http, tier: D } | |
| 650 | + - { name: sdk python releases, url: "https://github.com/runpod/runpod-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: runpod/runpod-python, kind: releases } } | |
| 651 | + - { name: runpodctl releases, url: "https://github.com/runpod/runpodctl/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: runpod/runpodctl, kind: releases } } | |
| 652 | + - id: lambda | |
| 653 | + name: Lambda | |
| 654 | + domain: lambda.ai | |
| 655 | + categories: [ai, cloud] | |
| 656 | + tier: B | |
| 657 | + country: US | |
| 658 | + aliases: [lambda labs, lambdalabs, lambda cloud] | |
| 659 | + products: | |
| 660 | + - { name: Lambda Cloud, type: service, aliases: [gpu cloud] } | |
| 661 | + discover: { rss: true } | |
| 662 | + sensors: | |
| 663 | + - { name: status, url: "https://status.lambda.ai/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 664 | + - { name: blog feed, url: "https://lambda.ai/blog/rss.xml", type: RSS, connector: rss, tier: B } | |
| 665 | + - { name: pricing, url: "https://lambda.ai/pricing", type: HTML, connector: http, tier: C } | |
| 666 | + - { name: instances, url: "https://lambda.ai/instances", type: HTML, connector: http, tier: C } | |
| 667 | + - { name: on-demand docs, url: "https://docs.lambda.ai/public-cloud/on-demand/", type: HTML, connector: http, tier: C } | |
| 668 | + - id: coreweave | |
| 669 | + name: CoreWeave | |
| 670 | + domain: coreweave.com | |
| 671 | + homepage: https://www.coreweave.com | |
| 672 | + categories: [ai, cloud, filings] | |
| 673 | + tier: B | |
| 674 | + weight: 1.1 | |
| 675 | + country: US | |
| 676 | + aliases: [crwv, core weave] | |
| 677 | + discover: { rss: true } | |
| 678 | + notes: "SIC: Services-Computer Programming, Data Processing · NASDAQ: CRWV · CIK 1769628" | |
| 679 | + sensors: | |
| 680 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001769628.json", type: REST_API, connector: edgar, tier: B } | |
| 681 | + - { name: blog feed, url: "https://www.coreweave.com/blog/rss.xml", type: RSS, connector: rss, tier: B } | |
| 682 | + - { name: pricing, url: "https://www.coreweave.com/pricing", type: HTML, connector: http, tier: C } | |
| 683 | + - { name: docs changelog, url: "https://docs.coreweave.com/changelog", type: HTML, connector: http, tier: B } | |
| 684 | + - { name: docs llms.txt, url: "https://docs.coreweave.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 685 | + - id: openrouter | |
| 686 | + name: OpenRouter | |
| 687 | + domain: openrouter.ai | |
| 688 | + categories: [ai, developer] | |
| 689 | + tier: B | |
| 690 | + weight: 1.1 | |
| 691 | + country: US | |
| 692 | + aliases: [open router] | |
| 693 | + products: | |
| 694 | + - { name: OpenRouter API, type: API } | |
| 695 | + discover: { rss: false } | |
| 696 | + notes: "The public /api/v1/models catalogue (≈440 models with prices) is a cross-provider model-release and price-change signal. status.openrouter.ai returns 403; the blog is client-rendered." | |
| 697 | + sensors: | |
| 698 | + - { name: models api, url: "https://openrouter.ai/api/v1/models", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: data, keyField: id, titleField: name, compareFields: [pricing.prompt, pricing.completion, context_length], maxItems: 1000 } } | |
| 699 | + - { name: docs llms.txt, url: "https://openrouter.ai/docs/llms.txt", type: FILE, connector: http, tier: D } | |
| 700 | + - id: litellm | |
| 701 | + name: LiteLLM | |
| 702 | + domain: litellm.ai | |
| 703 | + homepage: https://docs.litellm.ai | |
| 704 | + categories: [ai, open-source, developer] | |
| 705 | + tier: C | |
| 706 | + country: US | |
| 707 | + aliases: [berriai, lite llm] | |
| 708 | + llm: false | |
| 709 | + discover: { rss: false } | |
| 710 | + notes: "Releases feed publishes several builds a day (heuristics only). model_prices_and_context_window.json is the de-facto open price/context registry for ~2 000 models." | |
| 711 | + sensors: | |
| 712 | + - { name: model prices and context window, url: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", type: JSON, connector: http, tier: C } | |
| 713 | + - { name: release notes feed, url: "https://docs.litellm.ai/blog/rss.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 30 } } | |
| 714 | + - { name: releases, url: "https://github.com/BerriAI/litellm/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: BerriAI/litellm, kind: releases } } | |
| 715 | + # ───────────────────────── Coding assistants ───────────────────────── | |
| 716 | + - id: cursor | |
| 717 | + name: Cursor | |
| 718 | + domain: cursor.com | |
| 719 | + categories: [ai, developer] | |
| 720 | + tier: A | |
| 721 | + weight: 1.1 | |
| 722 | + country: US | |
| 723 | + aliases: [anysphere, cursor ai, cursor editor] | |
| 724 | + products: | |
| 725 | + - { name: Cursor, type: software, aliases: [cursor ide] } | |
| 726 | + discover: { rss: true } | |
| 727 | + sensors: | |
| 728 | + - { name: status, url: "https://status.cursor.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 729 | + - { name: changelog feed, url: "https://cursor.com/changelog/rss.xml", type: RSS, connector: rss, tier: A } | |
| 730 | + - { name: changelog, url: "https://cursor.com/changelog", type: HTML, connector: http, tier: B } | |
| 731 | + - { name: pricing, url: "https://cursor.com/pricing", type: HTML, connector: http, tier: C } | |
| 732 | + - { name: blog index, url: "https://cursor.com/blog", type: HTML, connector: http, tier: B } | |
| 733 | + - { name: docs llms.txt, url: "https://cursor.com/docs/llms.txt", type: FILE, connector: http, tier: D } | |
| 734 | + - { name: forum announcements feed, url: "https://forum.cursor.com/c/announcements/11.rss", type: RSS, connector: rss, tier: B } | |
| 735 | + - id: replit | |
| 736 | + name: Replit | |
| 737 | + domain: replit.com | |
| 738 | + categories: [ai, developer] | |
| 739 | + tier: B | |
| 740 | + country: US | |
| 741 | + aliases: [repl.it, replit agent] | |
| 742 | + products: | |
| 743 | + - { name: Replit Agent, type: product } | |
| 744 | + discover: { rss: true } | |
| 745 | + notes: "status.replit.com is behind a Cloudflare challenge." | |
| 746 | + sensors: | |
| 747 | + - { name: blog feed, url: "https://replit.com/blog/feed.xml", type: RSS, connector: rss, tier: B } | |
| 748 | + - { name: pricing, url: "https://replit.com/pricing", type: HTML, connector: http, tier: C } | |
| 749 | + - { name: docs updates, url: "https://docs.replit.com/updates", type: HTML, connector: http, tier: B } | |
| 750 | + - { name: docs llms.txt, url: "https://docs.replit.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 751 | + - id: windsurf | |
| 752 | + name: Windsurf | |
| 753 | + domain: windsurf.com | |
| 754 | + categories: [ai, developer] | |
| 755 | + tier: B | |
| 756 | + country: US | |
| 757 | + aliases: [codeium, cognition, windsurf editor, devin] | |
| 758 | + products: | |
| 759 | + - { name: Windsurf Editor, type: software } | |
| 760 | + - { name: Devin, type: product, aliases: [devin ai] } | |
| 761 | + discover: { rss: true } | |
| 762 | + notes: "Windsurf now belongs to Cognition: the changelog redirects to docs.devin.ai; windsurf.com/pricing redirects to devin.ai and rate-limits (429)." | |
| 763 | + sensors: | |
| 764 | + - { name: status, url: "https://status.windsurf.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 765 | + - { name: changelog, url: "https://windsurf.com/changelog", type: HTML, connector: http, tier: B } | |
| 766 | + - { name: docs llms.txt, url: "https://docs.windsurf.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 767 | + - id: github | |
| 768 | + extend: true | |
| 769 | + aliases: [github copilot, copilot coding agent] | |
| 770 | + sensors: | |
| 771 | + - { name: copilot blog feed, url: "https://github.blog/ai-and-ml/github-copilot/feed/", type: RSS, connector: rss, tier: B } | |
| 772 | + - { name: copilot supported models, url: "https://docs.github.com/en/copilot/reference/ai-models/supported-models", type: HTML, connector: http, tier: B } | |
| 773 | + - { name: copilot plans, url: "https://docs.github.com/en/copilot/get-started/plans", type: HTML, connector: http, tier: C } | |
| 774 | + - { name: copilot sdk releases, url: "https://github.com/github/copilot-sdk/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: github/copilot-sdk, kind: releases } } | |
| 775 | + - { name: copilot cli releases, url: "https://github.com/github/copilot-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: github/copilot-cli, kind: releases } } | |
| 776 | + - { name: github mcp server releases, url: "https://github.com/github/github-mcp-server/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: github/github-mcp-server, kind: releases } } | |
| 777 | + # ───────────────────────── Data & ML platforms ───────────────────────── | |
| 778 | + - id: scale-ai | |
| 779 | + name: Scale AI | |
| 780 | + domain: scale.com | |
| 781 | + categories: [ai, technology] | |
| 782 | + tier: B | |
| 783 | + weight: 1.1 | |
| 784 | + country: US | |
| 785 | + aliases: [scale, scale ai, seal leaderboards] | |
| 786 | + products: | |
| 787 | + - { name: SEAL Leaderboards, type: index, aliases: [seal] } | |
| 788 | + discover: { rss: false } | |
| 789 | + notes: "scale.com/blog is client-rendered; research moved to labs.scale.com." | |
| 790 | + sensors: | |
| 791 | + - { name: status, url: "https://status.scale.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 792 | + - { name: research papers, url: "https://labs.scale.com/papers", type: HTML, connector: http, tier: B } | |
| 793 | + - { name: leaderboard, url: "https://labs.scale.com/leaderboard", type: HTML, connector: http, tier: B } | |
| 794 | + - id: databricks | |
| 795 | + extend: true | |
| 796 | + country: US | |
| 797 | + products: | |
| 798 | + - { name: MLflow, type: software } | |
| 799 | + sensors: | |
| 800 | + - { name: platform release notes, url: "https://docs.databricks.com/aws/en/release-notes/product/", type: HTML, connector: http, tier: B } | |
| 801 | + - { name: foundation model overview, url: "https://docs.databricks.com/aws/en/machine-learning/model-serving/foundation-model-overview", type: HTML, connector: http, tier: C } | |
| 802 | + - { name: mlflow releases, url: "https://github.com/mlflow/mlflow/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: mlflow/mlflow, kind: releases } } | |
| 803 | + - id: snowflake | |
| 804 | + extend: true | |
| 805 | + country: US | |
| 806 | + products: | |
| 807 | + - { name: Arctic, type: AI_model, aliases: [snowflake arctic] } | |
| 808 | + sensors: | |
| 809 | + - { name: cortex ai sql docs, url: "https://docs.snowflake.com/en/user-guide/snowflake-cortex/aisql", type: HTML, connector: http, tier: C } | |
| 810 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=Snowflake&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 811 | + - { name: medium engineering feed, url: "https://medium.com/feed/snowflake", type: RSS, connector: rss, tier: C } | |
| 812 | + - id: wandb | |
| 813 | + name: Weights & Biases | |
| 814 | + domain: wandb.ai | |
| 815 | + categories: [ai, developer] | |
| 816 | + tier: B | |
| 817 | + country: US | |
| 818 | + aliases: [weights and biases, weights & biases, w&b, wandb] | |
| 819 | + products: | |
| 820 | + - { name: Weave, type: software, aliases: [wandb weave] } | |
| 821 | + discover: { rss: true } | |
| 822 | + sensors: | |
| 823 | + - { name: status, url: "https://status.wandb.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 824 | + - { name: sdk releases, url: "https://github.com/wandb/wandb/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: wandb/wandb, kind: releases } } | |
| 825 | + - { name: weave releases, url: "https://github.com/wandb/weave/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: wandb/weave, kind: releases } } | |
| 826 | + - { name: pricing, url: "https://wandb.ai/site/pricing/", type: HTML, connector: http, tier: C } | |
| 827 | + - { name: docs release notes, url: "https://docs.wandb.ai/release-notes", type: HTML, connector: http, tier: B } | |
| 828 | + - { name: docs llms.txt, url: "https://docs.wandb.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 829 | + # ───────────────────────── Generative media ───────────────────────── | |
| 830 | + - id: stability-ai | |
| 831 | + extend: true | |
| 832 | + country: GB | |
| 833 | + notes: "platform.stability.ai is client-rendered; stability.ai/news redirects to /news-updates (Squarespace, no RSS)." | |
| 834 | + sensors: | |
| 835 | + - { name: news updates, url: "https://stability.ai/news-updates", type: HTML, connector: http, tier: B } | |
| 836 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=stabilityai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 837 | + - { name: generative-models tags, url: "https://github.com/Stability-AI/generative-models/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: Stability-AI/generative-models, kind: tags } } | |
| 838 | + - id: black-forest-labs | |
| 839 | + name: Black Forest Labs | |
| 840 | + domain: bfl.ai | |
| 841 | + categories: [ai, technology] | |
| 842 | + tier: B | |
| 843 | + weight: 1.1 | |
| 844 | + country: DE | |
| 845 | + aliases: [bfl, black forest labs, flux] | |
| 846 | + products: | |
| 847 | + - { name: FLUX, type: AI_model, aliases: [flux.1, flux.2, flux kontext] } | |
| 848 | + - { name: BFL API, type: API } | |
| 849 | + discover: { rss: true } | |
| 850 | + sensors: | |
| 851 | + - { name: status, url: "https://status.bfl.ml/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 852 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=black-forest-labs&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 853 | + - { name: pricing, url: "https://bfl.ai/pricing", type: HTML, connector: http, tier: C } | |
| 854 | + - { name: blog index, url: "https://bfl.ai/blog", type: HTML, connector: http, tier: B } | |
| 855 | + - { name: docs llms.txt, url: "https://docs.bfl.ai/llms.txt", type: FILE, connector: http, tier: D } | |
| 856 | + - { name: flux commits, url: "https://github.com/black-forest-labs/flux/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: black-forest-labs/flux, kind: commits, branch: main } } | |
| 857 | + - id: elevenlabs | |
| 858 | + extend: true | |
| 859 | + country: US | |
| 860 | + sensors: | |
| 861 | + - { name: docs changelog, url: "https://elevenlabs.io/docs/changelog", type: HTML, connector: http, tier: A } | |
| 862 | + - { name: pricing, url: "https://elevenlabs.io/pricing", type: HTML, connector: http, tier: C } | |
| 863 | + - { name: api pricing, url: "https://elevenlabs.io/pricing/api", type: HTML, connector: http, tier: C } | |
| 864 | + - { name: docs llms.txt, url: "https://elevenlabs.io/docs/llms.txt", type: FILE, connector: http, tier: D } | |
| 865 | + - { name: sdk python releases, url: "https://github.com/elevenlabs/elevenlabs-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: elevenlabs/elevenlabs-python, kind: releases } } | |
| 866 | + - { name: sdk js releases, url: "https://github.com/elevenlabs/elevenlabs-js/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: elevenlabs/elevenlabs-js, kind: releases } } | |
| 867 | + - id: midjourney | |
| 868 | + name: Midjourney | |
| 869 | + domain: midjourney.com | |
| 870 | + homepage: https://www.midjourney.com | |
| 871 | + categories: [ai, technology] | |
| 872 | + tier: B | |
| 873 | + weight: 1.1 | |
| 874 | + country: US | |
| 875 | + aliases: [mid journey, mj] | |
| 876 | + discover: { rss: false } | |
| 877 | + notes: "docs.midjourney.com (Zendesk) is behind a Cloudflare challenge; midjourney.com/updates is client-rendered; the Ghost updates site has RSS." | |
| 878 | + sensors: | |
| 879 | + - { name: updates feed, url: "https://updates.midjourney.com/rss/", type: RSS, connector: rss, tier: A } | |
| 880 | + - id: runway | |
| 881 | + name: Runway | |
| 882 | + domain: runway.com | |
| 883 | + categories: [ai, technology, entertainment] | |
| 884 | + tier: B | |
| 885 | + weight: 1.1 | |
| 886 | + country: US | |
| 887 | + aliases: [runwayml, runway ml, runway ai] | |
| 888 | + products: | |
| 889 | + - { name: Gen-4, type: AI_model, aliases: [gen-4 turbo, gen-3 alpha] } | |
| 890 | + - { name: Runway API, type: API } | |
| 891 | + discover: { rss: true } | |
| 892 | + notes: "runwayml.com redirects to runway.com; help.runwayml.com (Zendesk) is behind a Cloudflare challenge; /news is client-rendered." | |
| 893 | + sensors: | |
| 894 | + - { name: status, url: "https://status.runwayml.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 895 | + - { name: research, url: "https://runway.com/research", type: HTML, connector: http, tier: B } | |
| 896 | + - { name: pricing, url: "https://runway.com/pricing", type: HTML, connector: http, tier: C } | |
| 897 | + - { name: api docs llms.txt, url: "https://docs.dev.runwayml.com/llms.txt", type: FILE, connector: http, tier: D } | |
| 898 | + - { name: sdk python releases, url: "https://github.com/runwayml/sdk-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: runwayml/sdk-python, kind: releases } } | |
| 899 | + - { name: sdk node releases, url: "https://github.com/runwayml/sdk-node/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: runwayml/sdk-node, kind: releases } } | |
| 900 | + # ───────────────────────── Frameworks, runtimes, protocols ───────────────────────── | |
| 901 | + - id: langchain | |
| 902 | + extend: true | |
| 903 | + country: US | |
| 904 | + sensors: | |
| 905 | + - { name: langsmith changelog, url: "https://docs.langchain.com/langsmith/changelog", type: HTML, connector: http, tier: B } | |
| 906 | + - { name: langgraph releases, url: "https://github.com/langchain-ai/langgraph/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: langchain-ai/langgraph, kind: releases } } | |
| 907 | + - { name: langchainjs releases, url: "https://github.com/langchain-ai/langchainjs/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: langchain-ai/langchainjs, kind: releases } } | |
| 908 | + - { name: deepagents releases, url: "https://github.com/langchain-ai/deepagents/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: langchain-ai/deepagents, kind: releases } } | |
| 909 | + - id: llamaindex | |
| 910 | + name: LlamaIndex | |
| 911 | + domain: llamaindex.ai | |
| 912 | + homepage: https://www.llamaindex.ai | |
| 913 | + categories: [ai, open-source, developer] | |
| 914 | + tier: B | |
| 915 | + country: US | |
| 916 | + aliases: [llama index, llamacloud, llamaparse, run-llama] | |
| 917 | + products: | |
| 918 | + - { name: LlamaCloud, type: service, aliases: [llamaparse] } | |
| 919 | + discover: { rss: true, status: true } | |
| 920 | + sensors: | |
| 921 | + - { name: status, url: "https://llamaindex.statuspage.io/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 922 | + - { name: releases, url: "https://github.com/run-llama/llama_index/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: run-llama/llama_index, kind: releases } } | |
| 923 | + - { name: llama cloud services releases, url: "https://github.com/run-llama/llama_cloud_services/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: run-llama/llama_cloud_services, kind: releases } } | |
| 924 | + - { name: blog index, url: "https://www.llamaindex.ai/blog", type: HTML, connector: http, tier: B } | |
| 925 | + - { name: pricing, url: "https://www.llamaindex.ai/pricing", type: HTML, connector: http, tier: C } | |
| 926 | + - id: ollama | |
| 927 | + extend: true | |
| 928 | + country: US | |
| 929 | + sensors: | |
| 930 | + - { name: model library, url: "https://ollama.com/library", type: HTML, connector: http, tier: A } | |
| 931 | + - { name: sdk python releases, url: "https://github.com/ollama/ollama-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ollama/ollama-python, kind: releases } } | |
| 932 | + - { name: sdk js releases, url: "https://github.com/ollama/ollama-js/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ollama/ollama-js, kind: releases } } | |
| 933 | + - id: vllm | |
| 934 | + extend: true | |
| 935 | + country: US | |
| 936 | + sensors: | |
| 937 | + - { name: supported models docs, url: "https://docs.vllm.ai/en/latest/models/supported_models/", type: HTML, connector: http, tier: B } | |
| 938 | + - { name: llm-compressor releases, url: "https://github.com/vllm-project/llm-compressor/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: vllm-project/llm-compressor, kind: releases } } | |
| 939 | + - id: llama-cpp | |
| 940 | + name: llama.cpp (ggml) | |
| 941 | + domain: ggml.ai | |
| 942 | + homepage: https://github.com/ggml-org/llama.cpp | |
| 943 | + categories: [ai, open-source, developer] | |
| 944 | + tier: B | |
| 945 | + weight: 1.1 | |
| 946 | + country: BG | |
| 947 | + aliases: [llama.cpp, llamacpp, ggml, gguf, whisper.cpp, ggerganov] | |
| 948 | + products: | |
| 949 | + - { name: GGUF, type: technology } | |
| 950 | + - { name: whisper.cpp, type: software } | |
| 951 | + llm: false | |
| 952 | + discover: { rss: false } | |
| 953 | + notes: "llama.cpp tags a build (b####) several times a day — heuristics only." | |
| 954 | + sensors: | |
| 955 | + - { name: llama.cpp releases, url: "https://github.com/ggml-org/llama.cpp/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: ggml-org/llama.cpp, kind: releases } } | |
| 956 | + - { name: whisper.cpp releases, url: "https://github.com/ggml-org/whisper.cpp/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ggml-org/whisper.cpp, kind: releases } } | |
| 957 | + - { name: ggml tags, url: "https://github.com/ggml-org/ggml/tags.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ggml-org/ggml, kind: tags } } | |
| 958 | + - id: pytorch | |
| 959 | + extend: true | |
| 960 | + country: US | |
| 961 | + sensors: | |
| 962 | + - { name: executorch releases, url: "https://github.com/pytorch/executorch/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: pytorch/executorch, kind: releases } } | |
| 963 | + - { name: torchao releases, url: "https://github.com/pytorch/ao/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: pytorch/ao, kind: releases } } | |
| 964 | + - { name: torchvision releases, url: "https://github.com/pytorch/vision/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: pytorch/vision, kind: releases } } | |
| 965 | + - { name: torchtune releases, url: "https://github.com/meta-pytorch/torchtune/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: meta-pytorch/torchtune, kind: releases } } | |
| 966 | + - id: tensorflow | |
| 967 | + extend: true | |
| 968 | + country: US | |
| 969 | + sensors: | |
| 970 | + - { name: keras releases, url: "https://github.com/keras-team/keras/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: keras-team/keras, kind: releases } } | |
| 971 | + - id: jax | |
| 972 | + extend: true | |
| 973 | + country: US | |
| 974 | + sensors: | |
| 975 | + - { name: flax releases, url: "https://github.com/google/flax/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: google/flax, kind: releases } } | |
| 976 | + - id: model-context-protocol | |
| 977 | + name: Model Context Protocol | |
| 978 | + domain: modelcontextprotocol.io | |
| 979 | + categories: [ai, standards, open-source, developer] | |
| 980 | + tier: B | |
| 981 | + weight: 1.1 | |
| 982 | + country: US | |
| 983 | + aliases: [mcp, model context protocol, mcp specification] | |
| 984 | + products: | |
| 985 | + - { name: MCP specification, type: standard } | |
| 986 | + - { name: MCP TypeScript SDK, type: software } | |
| 987 | + - { name: MCP Python SDK, type: software } | |
| 988 | + discover: { rss: true } | |
| 989 | + sensors: | |
| 990 | + - { name: blog feed, url: "https://blog.modelcontextprotocol.io/index.xml", type: RSS, connector: rss, tier: B } | |
| 991 | + - { name: specification changelog, url: "https://modelcontextprotocol.io/specification/latest/changelog", type: HTML, connector: http, tier: B } | |
| 992 | + - { name: llms.txt, url: "https://modelcontextprotocol.io/llms.txt", type: FILE, connector: http, tier: D } | |
| 993 | + - { name: specification releases, url: "https://github.com/modelcontextprotocol/modelcontextprotocol/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: modelcontextprotocol/modelcontextprotocol, kind: releases } } | |
| 994 | + - { name: typescript sdk releases, url: "https://github.com/modelcontextprotocol/typescript-sdk/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: modelcontextprotocol/typescript-sdk, kind: releases } } | |
| 995 | + - { name: python sdk releases, url: "https://github.com/modelcontextprotocol/python-sdk/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: modelcontextprotocol/python-sdk, kind: releases } } | |
| 996 | + - { name: reference servers releases, url: "https://github.com/modelcontextprotocol/servers/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: modelcontextprotocol/servers, kind: releases } } | |
| 997 | + # ───────────────────────── Evaluators, trackers, other labs ───────────────────────── | |
| 998 | + - id: lmarena | |
| 999 | + name: LMArena | |
| 1000 | + domain: arena.ai | |
| 1001 | + categories: [ai, research, statistics] | |
| 1002 | + tier: B | |
| 1003 | + weight: 1.1 | |
| 1004 | + country: US | |
| 1005 | + first_party: false | |
| 1006 | + aliases: [lmarena, lm arena, chatbot arena, lmsys, arena.ai] | |
| 1007 | + products: | |
| 1008 | + - { name: Arena Leaderboard, type: index, aliases: [chatbot arena leaderboard, text arena] } | |
| 1009 | + discover: { rss: true } | |
| 1010 | + notes: "lmarena.ai redirects to arena.ai; the leaderboard and blog are client-hydrated (validator: thin, even with selector main) and there is no RSS — discovery only until arena.ai exposes a feed or JSON." | |
| 1011 | + - id: artificial-analysis | |
| 1012 | + name: Artificial Analysis | |
| 1013 | + domain: artificialanalysis.ai | |
| 1014 | + categories: [ai, research, statistics] | |
| 1015 | + tier: B | |
| 1016 | + weight: 1.1 | |
| 1017 | + country: US | |
| 1018 | + first_party: false | |
| 1019 | + aliases: [artificial analysis, aa intelligence index] | |
| 1020 | + products: | |
| 1021 | + - { name: Intelligence Index, type: index, aliases: [artificial analysis intelligence index] } | |
| 1022 | + discover: { rss: false } | |
| 1023 | + notes: "The data API (/api/v2) requires a key; the leaderboard page is server-rendered." | |
| 1024 | + sensors: | |
| 1025 | + - { name: models leaderboard, url: "https://artificialanalysis.ai/leaderboards/models", type: HTML, connector: http, tier: B } | |
| 1026 | + - { name: models index, url: "https://artificialanalysis.ai/models", type: HTML, connector: http, tier: B } | |
| 1027 | + - id: epoch-ai | |
| 1028 | + extend: true | |
| 1029 | + country: US | |
| 1030 | + first_party: false | |
| 1031 | + products: | |
| 1032 | + - { name: Notable AI Models, type: dataset, aliases: [epoch ai models database] } | |
| 1033 | + - { name: AI Supercomputers, type: dataset, aliases: [gpu clusters dataset] } | |
| 1034 | + notes: "Open CSV datasets (CC-BY): notable_ai_models (newest first), large_scale_ai_models, ml_hardware, gpu_clusters (ai_supercomputers.csv redirects there). No site-wide RSS (feed.xml 404)." | |
| 1035 | + sensors: | |
| 1036 | + - { name: notable ai models dataset, url: "https://epoch.ai/data/notable_ai_models.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: Model, titleColumn: Model, dateColumn: "Publication date", compareColumns: [Organization, "Publication date", Parameters, "Training compute (FLOP)", Link], maxRows: 2000 } } | |
| 1037 | + - { name: large-scale ai models dataset, url: "https://epoch.ai/data/large_scale_ai_models.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: Model, titleColumn: Model, dateColumn: "Publication date", compareColumns: [Organization, "Publication date", Parameters, "Training compute (FLOP)", "Model accessibility"], maxRows: 2000 } } | |
| 1038 | + - { name: ml hardware dataset, url: "https://epoch.ai/data/ml_hardware.csv", type: FILE, connector: csv, tier: D, config: { keyColumn: "Hardware name", titleColumn: "Hardware name", dateColumn: "Release date", compareColumns: [Manufacturer, "Release date", "Tensor-FP16/BF16 performance (FLOP/s)", "Memory (bytes)", "TDP (W)"], maxRows: 2000 } } | |
| 1039 | + - { name: ai supercomputers dataset, url: "https://epoch.ai/data/gpu_clusters.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: Name, titleColumn: Name, dateColumn: "First Operational Date", compareColumns: [Status, "Chip type (primary)", "Chip quantity (primary)", "Power Capacity (MW)", Owner], maxRows: 2000 } } | |
| 1040 | + - id: ibm-research | |
| 1041 | + name: IBM Research | |
| 1042 | + domain: research.ibm.com | |
| 1043 | + categories: [ai, research, technology] | |
| 1044 | + tier: B | |
| 1045 | + country: US | |
| 1046 | + aliases: [ibm research, ibm granite, granite] | |
| 1047 | + products: | |
| 1048 | + - { name: Granite, type: AI_model, aliases: [granite 4, ibm granite] } | |
| 1049 | + discover: { rss: true } | |
| 1050 | + sensors: | |
| 1051 | + - { name: news feed, url: "https://research.ibm.com/rss", type: RSS, connector: rss, tier: B } | |
| 1052 | + - { name: granite page, url: "https://www.ibm.com/granite", type: HTML, connector: http, tier: C } | |
| 1053 | + - { name: hugging face granite models, url: "https://huggingface.co/api/models?author=ibm-granite&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 1054 | + - { name: granite 4.0 commits, url: "https://github.com/ibm-granite/granite-4.0-language-models/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ibm-granite/granite-4.0-language-models, kind: commits, branch: main } } | |
| 1055 | + - id: tii | |
| 1056 | + name: Technology Innovation Institute | |
| 1057 | + domain: tii.ae | |
| 1058 | + homepage: https://falconllm.tii.ae | |
| 1059 | + categories: [ai, research] | |
| 1060 | + tier: C | |
| 1061 | + country: AE | |
| 1062 | + aliases: [tii, falcon llm, falcon] | |
| 1063 | + products: | |
| 1064 | + - { name: Falcon, type: AI_model, aliases: [falcon-h1, falcon 3, falcon mamba] } | |
| 1065 | + discover: { rss: false } | |
| 1066 | + sensors: | |
| 1067 | + - { name: falcon llm site, url: "https://falconllm.tii.ae/", type: HTML, connector: http, tier: C } | |
| 1068 | + - { name: falcon models, url: "https://falconllm.tii.ae/falcon-models.html", type: HTML, connector: http, tier: C } | |
| 1069 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=tiiuae&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 1070 | + - { name: news, url: "https://www.tii.ae/news", type: HTML, connector: http, tier: C } | |
| 1071 | + - id: ai2 | |
| 1072 | + extend: true | |
| 1073 | + country: US | |
| 1074 | + sensors: | |
| 1075 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=allenai&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 1076 | + - id: nous-research | |
| 1077 | + name: Nous Research | |
| 1078 | + domain: nousresearch.com | |
| 1079 | + categories: [ai, research] | |
| 1080 | + tier: C | |
| 1081 | + country: US | |
| 1082 | + aliases: [nous, hermes] | |
| 1083 | + products: | |
| 1084 | + - { name: Hermes, type: AI_model, aliases: [hermes 4] } | |
| 1085 | + discover: { rss: false } | |
| 1086 | + notes: "No RSS (feed paths 404); the blog index is server-rendered." | |
| 1087 | + sensors: | |
| 1088 | + - { name: blog index, url: "https://nousresearch.com/blog", type: HTML, connector: http, tier: C } | |
| 1089 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=NousResearch&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 1090 | + - id: ai21 | |
| 1091 | + name: AI21 Labs | |
| 1092 | + domain: ai21.com | |
| 1093 | + homepage: https://www.ai21.com | |
| 1094 | + categories: [ai, technology] | |
| 1095 | + tier: C | |
| 1096 | + country: IL | |
| 1097 | + aliases: [ai21 labs, jamba, maestro] | |
| 1098 | + products: | |
| 1099 | + - { name: Jamba, type: AI_model } | |
| 1100 | + discover: { rss: true } | |
| 1101 | + sensors: | |
| 1102 | + - { name: status, url: "https://status.ai21.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 1103 | + - { name: docs changelog, url: "https://docs.ai21.com/changelog", type: HTML, connector: http, tier: B } | |
| 1104 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=ai21labs&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 1105 | + - { name: blog index, url: "https://www.ai21.com/blog/", type: HTML, connector: http, tier: C } | |
| 1106 | + - id: liquid-ai | |
| 1107 | + name: Liquid AI | |
| 1108 | + domain: liquid.ai | |
| 1109 | + homepage: https://www.liquid.ai | |
| 1110 | + categories: [ai, technology] | |
| 1111 | + tier: C | |
| 1112 | + country: US | |
| 1113 | + aliases: [liquid, lfm, leap] | |
| 1114 | + products: | |
| 1115 | + - { name: LFM, type: AI_model, aliases: [liquid foundation models, lfm2] } | |
| 1116 | + discover: { rss: false } | |
| 1117 | + sensors: | |
| 1118 | + - { name: model news, url: "https://www.liquid.ai/news/models", type: HTML, connector: http, tier: C } | |
| 1119 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=LiquidAI&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 1120 | + - id: salesforce | |
| 1121 | + extend: true | |
| 1122 | + categories: [ai] | |
| 1123 | + sensors: | |
| 1124 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=Salesforce&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
| 1125 | + - id: servicenow | |
| 1126 | + extend: true | |
| 1127 | + categories: [ai] | |
| 1128 | + sensors: | |
| 1129 | + - { name: hugging face models, url: "https://huggingface.co/api/models?author=ServiceNow-AI&sort=lastModified&direction=-1&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { keyField: id, titleField: id, dateField: lastModified, compareFields: [pipeline_tag, library_name], maxItems: 50 } } | |
added
config/sources.d/41-cloud-infrastructure.yaml
+994 −0
@@ -0,0 +1,994 @@ | ||
| 1 | +# config/sources.d/41-cloud-infrastructure.yaml — 2026-09-11 — depth coverage of cloud / Internet infrastructure | |
| 2 | +# providers: outages and status changes, regions, new services, pricing, deprecations, incidents, postmortems and | |
| 3 | +# API modifications. Hyperscalers, European/Asian clouds, PaaS, managed databases, object storage/CDN, network | |
| 4 | +# operators, IXPs, RIRs, DNS root, CAs, identity, communications, observability, developer platforms and the | |
| 5 | +# infrastructure software they run on. Every sensor below was fetched and parsed by the validator on 2026-09-11. | |
| 6 | +# Blocked / not machine-readable (documented, not added): Vultr web (Cloudflare 403 — the public v2 API is used | |
| 7 | +# instead), linode.com docs/pricing (Akamai 403), Alibaba/Tencent/Huawei status pages (JS shells), Redis and | |
| 8 | +# Backblaze status (FireHydrant, no API), Railway status (custom), Neon/Databricks/PagerDuty/Okta status (custom), | |
| 9 | +# Equinix corporate status (Akamai 403), DE-CIX/LINX/Zayo/Lumen status hosts (unreachable), PCH (no feed). | |
| 10 | +sources: | |
| 11 | + # ───────────────────────── hyperscalers ───────────────────────── | |
| 12 | + - id: aws | |
| 13 | + extend: true | |
| 14 | + country: US | |
| 15 | + language: en | |
| 16 | + products: | |
| 17 | + - { name: Amazon RDS, type: product, aliases: [rds] } | |
| 18 | + - { name: Amazon EKS, type: product, aliases: [eks] } | |
| 19 | + - { name: Amazon Route 53, type: product, aliases: [route 53, route53] } | |
| 20 | + - { name: Amazon CloudFront, type: product, aliases: [cloudfront] } | |
| 21 | + - { name: AWS Health Dashboard, type: service, aliases: [aws status] } | |
| 22 | + notes: "health.aws.amazon.com/public/currentevents is JSON but served as UTF-16 (BOM FF FE) regardless of Accept headers — the fetcher decodes UTF-8, so it cannot be a jsonlist sensor until the connector handles UTF-16. Per-service status RSS feeds (status.aws.amazon.com/rss/<service>-<region>.rss) are empty until an incident occurs; only route53 and cloudfront had items on 2026-09-11 and were kept." | |
| 23 | + sensors: | |
| 24 | + - { name: route 53 status feed, url: "https://status.aws.amazon.com/rss/route53.rss", type: RSS, connector: rss, tier: S } | |
| 25 | + - { name: cloudfront status feed, url: "https://status.aws.amazon.com/rss/cloudfront.rss", type: RSS, connector: rss, tier: S } | |
| 26 | + - { name: compute blog feed, url: "https://aws.amazon.com/blogs/compute/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 27 | + - { name: architecture blog feed (architecture), url: "https://aws.amazon.com/blogs/architecture/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 28 | + - { name: networking blog feed, url: "https://aws.amazon.com/blogs/networking-and-content-delivery/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 29 | + - { name: database blog feed, url: "https://aws.amazon.com/blogs/database/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 30 | + - { name: containers blog feed, url: "https://aws.amazon.com/blogs/containers/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 31 | + - { name: storage blog feed, url: "https://aws.amazon.com/blogs/storage/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 32 | + - { name: devops blog feed, url: "https://aws.amazon.com/blogs/devops/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 33 | + - { name: open source blog feed, url: "https://aws.amazon.com/blogs/opensource/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 34 | + - { name: regions and availability zones, url: "https://aws.amazon.com/about-aws/global-infrastructure/regions_az/", type: HTML, connector: http, tier: C } | |
| 35 | + - { name: service endpoints and quotas reference, url: "https://docs.aws.amazon.com/general/latest/gr/rande.html", type: HTML, connector: http, tier: C } | |
| 36 | + - { name: ec2 on-demand pricing, url: "https://aws.amazon.com/ec2/pricing/on-demand/", type: HTML, connector: http, tier: C } | |
| 37 | + - { name: ip ranges, url: "https://ip-ranges.amazonaws.com/ip-ranges.json", type: JSON, connector: http, tier: C, config: { ignoreKeys: [syncToken, createDate] } } | |
| 38 | + - { name: aws cli releases, url: "https://github.com/aws/aws-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: aws/aws-cli, kind: releases } } | |
| 39 | + - { name: aws cdk releases, url: "https://github.com/aws/aws-cdk/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: aws/aws-cdk, kind: releases } } | |
| 40 | + - { name: aws sdk js v3 releases, url: "https://github.com/aws/aws-sdk-js-v3/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: aws/aws-sdk-js-v3, kind: releases } } | |
| 41 | + - { name: botocore releases, url: "https://github.com/boto/botocore/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: boto/botocore, kind: releases } } | |
| 42 | + - { name: karpenter releases, url: "https://github.com/aws/karpenter-provider-aws/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: aws/karpenter-provider-aws, kind: releases } } | |
| 43 | + - { name: cloudformation resource schema commits, url: "https://github.com/aws-cloudformation/cloudformation-resource-schema/commits/master.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: aws-cloudformation/cloudformation-resource-schema, kind: commits, branch: master } } | |
| 44 | + - id: azure | |
| 45 | + extend: true | |
| 46 | + country: US | |
| 47 | + language: en | |
| 48 | + products: | |
| 49 | + - { name: Azure Kubernetes Service, type: product, aliases: [aks] } | |
| 50 | + - { name: Azure DevOps, type: product, aliases: [azure devops] } | |
| 51 | + - { name: Bicep, type: software } | |
| 52 | + sensors: | |
| 53 | + - { name: azure sdk blog feed, url: "https://devblogs.microsoft.com/azure-sdk/feed/", type: RSS, connector: rss, tier: B } | |
| 54 | + - { name: azure devops blog feed, url: "https://devblogs.microsoft.com/devops/feed/", type: RSS, connector: rss, tier: B } | |
| 55 | + - { name: learn azure docs feed, url: "https://learn.microsoft.com/api/search/rss?search=azure&locale=en-us&$filter=scopes%2Fany(t%3A%20t%20eq%20%27Azure%27)", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 56 | + - { name: regions list, url: "https://learn.microsoft.com/en-us/azure/reliability/regions-list", type: HTML, connector: http, tier: C } | |
| 57 | + - { name: global infrastructure geographies, url: "https://azure.microsoft.com/en-us/explore/global-infrastructure/geographies/", type: HTML, connector: http, tier: C } | |
| 58 | + - { name: linux vm pricing, url: "https://azure.microsoft.com/en-us/pricing/details/virtual-machines/linux/", type: HTML, connector: http, tier: C } | |
| 59 | + - { name: status history, url: "https://azure.status.microsoft/en-us/status/history/", type: HTML, connector: http, tier: A } | |
| 60 | + - { name: azure cli releases, url: "https://github.com/Azure/azure-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: Azure/azure-cli, kind: releases } } | |
| 61 | + - { name: aks releases, url: "https://github.com/Azure/AKS/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: Azure/AKS, kind: releases } } | |
| 62 | + - { name: bicep releases, url: "https://github.com/Azure/bicep/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: Azure/bicep, kind: releases } } | |
| 63 | + - { name: azure sdk for js releases, url: "https://github.com/Azure/azure-sdk-for-js/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: Azure/azure-sdk-for-js, kind: releases } } | |
| 64 | + - { name: azure rest api specs commits, url: "https://github.com/Azure/azure-rest-api-specs/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: Azure/azure-rest-api-specs, kind: commits, branch: main } } | |
| 65 | + - id: google-cloud | |
| 66 | + extend: true | |
| 67 | + country: US | |
| 68 | + language: en | |
| 69 | + products: | |
| 70 | + - { name: Compute Engine, type: product, aliases: [gce] } | |
| 71 | + - { name: Google Kubernetes Engine, type: product, aliases: [gke] } | |
| 72 | + - { name: Cloud Run, type: product } | |
| 73 | + - { name: Cloud SQL, type: product } | |
| 74 | + - { name: Spanner, type: product, aliases: [cloud spanner] } | |
| 75 | + - { name: AlloyDB, type: product } | |
| 76 | + sensors: | |
| 77 | + - { name: cloud blog feed, url: "https://cloudblog.withgoogle.com/rss/", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 78 | + - { name: compute engine release notes, url: "https://cloud.google.com/feeds/compute-engine-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 79 | + - { name: gke release notes, url: "https://cloud.google.com/feeds/kubernetes-engine-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 80 | + - { name: cloud run release notes, url: "https://cloud.google.com/feeds/cloud-run-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 81 | + - { name: bigquery release notes, url: "https://cloud.google.com/feeds/bigquery-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 82 | + - { name: vertex ai release notes, url: "https://cloud.google.com/feeds/vertex-ai-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 83 | + - { name: cloud sql release notes, url: "https://cloud.google.com/feeds/cloud-sql-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 84 | + - { name: spanner release notes, url: "https://cloud.google.com/feeds/spanner-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 85 | + - { name: alloydb release notes, url: "https://cloud.google.com/feeds/alloydb-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 86 | + - { name: cloud storage release notes, url: "https://cloud.google.com/feeds/cloud-storage-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 87 | + - { name: iam release notes, url: "https://cloud.google.com/feeds/iam-release-notes.xml", type: ATOM, connector: rss, tier: A } | |
| 88 | + - { name: cloud load balancing release notes, url: "https://cloud.google.com/feeds/cloud-load-balancing-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 89 | + - { name: cloud dns release notes, url: "https://cloud.google.com/feeds/cloud-dns-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 90 | + - { name: vpc release notes, url: "https://cloud.google.com/feeds/vpc-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 91 | + - { name: cloud cdn release notes, url: "https://cloud.google.com/feeds/cloud-cdn-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 92 | + - { name: cloud armor release notes, url: "https://cloud.google.com/feeds/cloud-armor-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 93 | + - { name: pub/sub release notes, url: "https://cloud.google.com/feeds/pubsub-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 94 | + - { name: cloud functions release notes, url: "https://cloud.google.com/feeds/cloud-functions-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 95 | + - { name: bigtable release notes, url: "https://cloud.google.com/feeds/bigtable-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 96 | + - { name: memorystore release notes, url: "https://cloud.google.com/feeds/memorystore-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 97 | + - { name: dataflow release notes, url: "https://cloud.google.com/feeds/dataflow-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 98 | + - { name: dataproc release notes, url: "https://cloud.google.com/feeds/dataproc-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 99 | + - { name: cloud build release notes, url: "https://cloud.google.com/feeds/cloud-build-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 100 | + - { name: cloud logging release notes, url: "https://cloud.google.com/feeds/cloud-logging-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 101 | + - { name: cloud monitoring release notes, url: "https://cloud.google.com/feeds/cloud-monitoring-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 102 | + - { name: secret manager release notes, url: "https://cloud.google.com/feeds/secret-manager-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 103 | + - { name: apigee release notes, url: "https://cloud.google.com/feeds/apigee-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 104 | + - { name: gemini for google cloud release notes, url: "https://cloud.google.com/feeds/gemini-release-notes.xml", type: ATOM, connector: rss, tier: B } | |
| 105 | + - { name: anthos / gdc release notes, url: "https://cloud.google.com/feeds/anthos-release-notes.xml", type: ATOM, connector: rss, tier: C } | |
| 106 | + - { name: locations, url: "https://cloud.google.com/about/locations", type: HTML, connector: http, tier: C } | |
| 107 | + - { name: compute engine deprecations, url: "https://cloud.google.com/compute/docs/deprecations", type: HTML, connector: http, tier: C } | |
| 108 | + - { name: compute engine pricing, url: "https://cloud.google.com/compute/all-pricing", type: HTML, connector: http, tier: C } | |
| 109 | + - { name: gcloud cli release notes, url: "https://cloud.google.com/sdk/docs/release-notes", type: HTML, connector: http, tier: B } | |
| 110 | + - { name: ip ranges, url: "https://www.gstatic.com/ipranges/cloud.json", type: JSON, connector: http, tier: C, config: { ignoreKeys: [syncToken, creationTime] } } | |
| 111 | + - { name: google-cloud-go releases, url: "https://github.com/googleapis/google-cloud-go/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: googleapis/google-cloud-go, kind: releases } } | |
| 112 | + - { name: google-cloud-python releases, url: "https://github.com/googleapis/google-cloud-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: googleapis/google-cloud-python, kind: releases } } | |
| 113 | + - { name: googleapis proto commits, url: "https://github.com/googleapis/googleapis/commits/master.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: googleapis/googleapis, kind: commits, branch: master } } | |
| 114 | + - id: cloudflare | |
| 115 | + extend: true | |
| 116 | + country: US | |
| 117 | + language: en | |
| 118 | + products: | |
| 119 | + - { name: Cloudflare R2, type: product, aliases: [r2] } | |
| 120 | + - { name: Cloudflare D1, type: product, aliases: [d1] } | |
| 121 | + - { name: Cloudflare Pages, type: product } | |
| 122 | + - { name: Workers AI, type: product } | |
| 123 | + - { name: Cloudflare One, type: product, aliases: [zero trust] } | |
| 124 | + - { name: cloudflared, type: software } | |
| 125 | + - { name: workerd, type: software } | |
| 126 | + sensors: | |
| 127 | + - { name: workers changelog feed, url: "https://developers.cloudflare.com/changelog/rss/workers.xml", type: RSS, connector: rss, tier: A } | |
| 128 | + - { name: workers ai changelog feed, url: "https://developers.cloudflare.com/changelog/rss/workers-ai.xml", type: RSS, connector: rss, tier: B } | |
| 129 | + - { name: r2 changelog feed, url: "https://developers.cloudflare.com/changelog/rss/r2.xml", type: RSS, connector: rss, tier: B } | |
| 130 | + - { name: d1 changelog feed, url: "https://developers.cloudflare.com/changelog/rss/d1.xml", type: RSS, connector: rss, tier: B } | |
| 131 | + - { name: pages changelog feed, url: "https://developers.cloudflare.com/changelog/rss/pages.xml", type: RSS, connector: rss, tier: B } | |
| 132 | + - { name: waf changelog feed, url: "https://developers.cloudflare.com/changelog/rss/waf.xml", type: RSS, connector: rss, tier: A } | |
| 133 | + - { name: dns changelog feed, url: "https://developers.cloudflare.com/changelog/rss/dns.xml", type: RSS, connector: rss, tier: B } | |
| 134 | + - { name: cloudflare one changelog feed, url: "https://developers.cloudflare.com/changelog/rss/cloudflare-one.xml", type: RSS, connector: rss, tier: B } | |
| 135 | + - { name: agents changelog feed, url: "https://developers.cloudflare.com/changelog/rss/agents.xml", type: RSS, connector: rss, tier: B } | |
| 136 | + - { name: workers platform changelog feed, url: "https://developers.cloudflare.com/workers/platform/changelog/index.xml", type: RSS, connector: rss, tier: A } | |
| 137 | + - { name: api deprecations, url: "https://developers.cloudflare.com/fundamentals/api/reference/deprecations/", type: HTML, connector: http, tier: B } | |
| 138 | + - { name: workers pricing, url: "https://developers.cloudflare.com/workers/platform/pricing/", type: HTML, connector: http, tier: C } | |
| 139 | + - { name: workers limits, url: "https://developers.cloudflare.com/workers/platform/limits/", type: HTML, connector: http, tier: C } | |
| 140 | + - { name: plans, url: "https://www.cloudflare.com/plans/", type: HTML, connector: http, tier: C } | |
| 141 | + - { name: network map, url: "https://www.cloudflare.com/network/", type: HTML, connector: http, tier: C } | |
| 142 | + - { name: ip ranges api, url: "https://api.cloudflare.com/client/v4/ips", type: JSON, connector: http, tier: D } | |
| 143 | + - { name: terraform provider registry, url: "https://registry.terraform.io/v1/providers/cloudflare/cloudflare", type: JSON, connector: http, tier: C, config: { ignoreKeys: [downloads] } } | |
| 144 | + - { name: workerd releases, url: "https://github.com/cloudflare/workerd/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cloudflare/workerd, kind: releases } } | |
| 145 | + - { name: cloudflared releases, url: "https://github.com/cloudflare/cloudflared/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cloudflare/cloudflared, kind: releases } } | |
| 146 | + - { name: workers-sdk releases, url: "https://github.com/cloudflare/workers-sdk/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cloudflare/workers-sdk, kind: releases } } | |
| 147 | + - { name: terraform provider releases, url: "https://github.com/cloudflare/terraform-provider-cloudflare/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cloudflare/terraform-provider-cloudflare, kind: releases } } | |
| 148 | + - { name: pingora releases, url: "https://github.com/cloudflare/pingora/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cloudflare/pingora, kind: releases } } | |
| 149 | + - id: oracle-cloud | |
| 150 | + extend: true | |
| 151 | + country: US | |
| 152 | + language: en | |
| 153 | + sensors: | |
| 154 | + - { name: oci status indicator, url: "https://ocistatus.oraclecloud.com/api/v2/status.json", type: JSON, connector: http, tier: S } | |
| 155 | + - { name: oci status history feed, url: "https://ocistatus.oraclecloud.com/history.rss", type: RSS, connector: rss, tier: S } | |
| 156 | + - { name: regions and availability domains, url: "https://docs.oracle.com/en-us/iaas/Content/General/Concepts/regions.htm", type: HTML, connector: http, tier: C } | |
| 157 | + - { name: oci cli releases, url: "https://github.com/oracle/oci-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: oracle/oci-cli, kind: releases } } | |
| 158 | + - { name: terraform provider releases, url: "https://github.com/oracle/terraform-provider-oci/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: oracle/terraform-provider-oci, kind: releases } } | |
| 159 | + notes: "ocistatus.oraclecloud.com is a custom page: /api/v2/status.json (indicator) and history.rss work, summary/incidents do not; blogs.oracle.com RSS returns 403." | |
| 160 | + - id: ibm-cloud | |
| 161 | + extend: true | |
| 162 | + country: US | |
| 163 | + language: en | |
| 164 | + sensors: | |
| 165 | + - { name: status notifications feed, url: "https://cloud.ibm.com/status/api/notifications/feed.rss", type: RSS, connector: rss, tier: S, config: { maxItems: 80 } } | |
| 166 | + - { name: terraform provider releases, url: "https://github.com/IBM-Cloud/terraform-provider-ibm/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: IBM-Cloud/terraform-provider-ibm, kind: releases } } | |
| 167 | + - { name: cli releases, url: "https://github.com/IBM-Cloud/ibm-cloud-cli-release/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: IBM-Cloud/ibm-cloud-cli-release, kind: releases } } | |
| 168 | + notes: "cloud.ibm.com/status is a JS console; the notifications RSS is the machine-readable side. Release-notes feed redirects to login." | |
| 169 | + # ───────────────────────── european / regional clouds ───────────────────────── | |
| 170 | + - id: ovhcloud | |
| 171 | + extend: true | |
| 172 | + country: FR | |
| 173 | + language: en | |
| 174 | + products: | |
| 175 | + - { name: OVHcloud Public Cloud, type: product } | |
| 176 | + - { name: OVHcloud Bare Metal, type: product } | |
| 177 | + sensors: | |
| 178 | + - { name: public cloud status, url: "https://public-cloud.status-ovhcloud.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 179 | + - { name: network status, url: "https://network.status-ovhcloud.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 180 | + - { name: bare metal status, url: "https://bare-metal-servers.status-ovhcloud.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 181 | + - { name: web cloud status, url: "https://web-cloud.status-ovhcloud.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 182 | + - { name: hosted private cloud status, url: "https://hosted-private-cloud.status-ovhcloud.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 183 | + - { name: customer service status, url: "https://customer-service.status-ovhcloud.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: A } | |
| 184 | + - { name: blog feed, url: "https://blog.ovhcloud.com/feed/", type: RSS, connector: rss, tier: B } | |
| 185 | + - { name: public cloud prices, url: "https://www.ovhcloud.com/en-ca/public-cloud/prices/", type: HTML, connector: http, tier: C } | |
| 186 | + - { name: public cloud compute changelog, url: "https://help.ovhcloud.com/csm/en-ca-public-cloud-compute-changelog", type: HTML, connector: http, tier: B } | |
| 187 | + notes: "status.ovhcloud.com / travaux.ovh.net redirect to a Vue SPA (www.status-ovhcloud.com) that aggregates six Atlassian Statuspage instances — those are polled directly." | |
| 188 | + - id: hetzner | |
| 189 | + extend: true | |
| 190 | + country: DE | |
| 191 | + language: en | |
| 192 | + products: | |
| 193 | + - { name: Hetzner Cloud, type: product, aliases: [hcloud] } | |
| 194 | + sensors: | |
| 195 | + - { name: status feed, url: "https://status.hetzner.com/en.atom", type: ATOM, connector: rss, tier: S } | |
| 196 | + - { name: cloud api openapi, url: "https://docs.hetzner.cloud/cloud.spec.json", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://docs.hetzner.cloud/reference/cloud" } } | |
| 197 | + - { name: cloud api changelog, url: "https://docs.hetzner.cloud/changelog", type: HTML, connector: http, tier: B } | |
| 198 | + - { name: cloud locations, url: "https://docs.hetzner.com/cloud/general/locations/", type: HTML, connector: http, tier: C } | |
| 199 | + - { name: hcloud cli releases, url: "https://github.com/hetznercloud/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: hetznercloud/cli, kind: releases } } | |
| 200 | + - { name: hcloud-go releases, url: "https://github.com/hetznercloud/hcloud-go/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: hetznercloud/hcloud-go, kind: releases } } | |
| 201 | + - { name: terraform provider releases, url: "https://github.com/hetznercloud/terraform-provider-hcloud/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: hetznercloud/terraform-provider-hcloud, kind: releases } } | |
| 202 | + notes: "status.hetzner.com is a custom page (no /api/v2) but publishes en.atom; www.hetzner.com/news rate-limits (429)." | |
| 203 | + - id: scaleway | |
| 204 | + name: Scaleway | |
| 205 | + domain: scaleway.com | |
| 206 | + homepage: https://www.scaleway.com | |
| 207 | + categories: [cloud, infrastructure] | |
| 208 | + tier: B | |
| 209 | + weight: 1.1 | |
| 210 | + country: FR | |
| 211 | + language: en | |
| 212 | + aliases: [scaleway, online.net, iliad cloud] | |
| 213 | + products: | |
| 214 | + - { name: Scaleway Instances, type: product } | |
| 215 | + - { name: Scaleway Kubernetes Kapsule, type: product, aliases: [kapsule] } | |
| 216 | + discover: { rss: true, status: true } | |
| 217 | + sensors: | |
| 218 | + - { name: status, url: "https://status.scaleway.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 219 | + - { name: pricing, url: "https://www.scaleway.com/en/pricing/", type: HTML, connector: http, tier: C } | |
| 220 | + - { name: instance server catalogue fr-par-1, url: "https://api.scaleway.com/instance/v1/zones/fr-par-1/products/servers", type: JSON, connector: http, tier: C } | |
| 221 | + - { name: cli releases, url: "https://github.com/scaleway/scaleway-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: scaleway/scaleway-cli, kind: releases } } | |
| 222 | + - { name: terraform provider releases, url: "https://github.com/scaleway/terraform-provider-scaleway/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: scaleway/terraform-provider-scaleway, kind: releases } } | |
| 223 | + notes: "Blog has no feed; the public instance products API exposes the catalogue (prices, availability) without auth." | |
| 224 | + - id: upcloud | |
| 225 | + name: UpCloud | |
| 226 | + domain: upcloud.com | |
| 227 | + categories: [cloud, infrastructure] | |
| 228 | + tier: B | |
| 229 | + country: FI | |
| 230 | + language: en | |
| 231 | + aliases: [upcloud] | |
| 232 | + discover: { status: true } | |
| 233 | + sensors: | |
| 234 | + - { name: status, url: "https://status.upcloud.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 235 | + notes: "upcloud.com blog feed is Cloudflare-blocked (403); the API requires auth." | |
| 236 | + - id: aiven | |
| 237 | + name: Aiven | |
| 238 | + domain: aiven.io | |
| 239 | + categories: [cloud, enterprise] | |
| 240 | + tier: B | |
| 241 | + country: FI | |
| 242 | + language: en | |
| 243 | + aliases: [aiven] | |
| 244 | + discover: { rss: true, status: true } | |
| 245 | + sensors: | |
| 246 | + - { name: status, url: "https://status.aiven.io/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 247 | + - { name: changelog, url: "https://aiven.io/changelog", type: HTML, connector: http, tier: B } | |
| 248 | + - id: alibaba-cloud | |
| 249 | + name: Alibaba Cloud | |
| 250 | + domain: alibabacloud.com | |
| 251 | + homepage: https://www.alibabacloud.com | |
| 252 | + categories: [cloud, infrastructure] | |
| 253 | + tier: B | |
| 254 | + weight: 1.2 | |
| 255 | + country: CN | |
| 256 | + language: en | |
| 257 | + aliases: [alibaba cloud, aliyun, alicloud] | |
| 258 | + products: | |
| 259 | + - { name: Elastic Compute Service, type: product, aliases: [ecs] } | |
| 260 | + discover: { rss: false, status: false, pages: true } | |
| 261 | + sensors: | |
| 262 | + - { name: ecs pricing, url: "https://www.alibabacloud.com/en/product/ecs/pricing", type: HTML, connector: http, tier: C } | |
| 263 | + - { name: aliyun cli releases, url: "https://github.com/aliyun/aliyun-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: aliyun/aliyun-cli, kind: releases } } | |
| 264 | + - { name: terraform provider releases, url: "https://github.com/aliyun/terraform-provider-alicloud/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: aliyun/terraform-provider-alicloud, kind: releases } } | |
| 265 | + notes: "status.alibabacloud.com redirects to a Taobao error page for non-browser clients; blog/notice RSS endpoints return HTML." | |
| 266 | + - id: tencent-cloud | |
| 267 | + name: Tencent Cloud | |
| 268 | + domain: tencentcloud.com | |
| 269 | + homepage: https://www.tencentcloud.com | |
| 270 | + categories: [cloud, infrastructure] | |
| 271 | + tier: B | |
| 272 | + country: CN | |
| 273 | + language: en | |
| 274 | + aliases: [tencent cloud, qcloud] | |
| 275 | + discover: { rss: false, status: false } | |
| 276 | + sensors: | |
| 277 | + - { name: cli releases, url: "https://github.com/TencentCloud/tencentcloud-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: TencentCloud/tencentcloud-cli, kind: releases } } | |
| 278 | + - { name: terraform provider releases, url: "https://github.com/tencentcloudstack/terraform-provider-tencentcloud/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: tencentcloudstack/terraform-provider-tencentcloud, kind: releases } } | |
| 279 | + notes: "status.cloud.tencent.com and tencentcloud.com announcements are JS shells (same 29 KB body on every path)." | |
| 280 | + - id: huawei-cloud | |
| 281 | + name: Huawei Cloud | |
| 282 | + domain: huaweicloud.com | |
| 283 | + homepage: https://www.huaweicloud.com/intl/en-us/ | |
| 284 | + categories: [cloud, infrastructure] | |
| 285 | + tier: B | |
| 286 | + country: CN | |
| 287 | + language: en | |
| 288 | + aliases: [huawei cloud] | |
| 289 | + discover: { rss: false, status: false } | |
| 290 | + sensors: | |
| 291 | + - { name: sdk go v3 releases, url: "https://github.com/huaweicloud/huaweicloud-sdk-go-v3/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: huaweicloud/huaweicloud-sdk-go-v3, kind: releases } } | |
| 292 | + - { name: terraform provider releases, url: "https://github.com/huaweicloud/terraform-provider-huaweicloud/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: huaweicloud/terraform-provider-huaweicloud, kind: releases } } | |
| 293 | + notes: "status.huaweicloud.com does not answer from outside China; intl news pages return 404." | |
| 294 | + # ───────────────────────── developer clouds / VPS ───────────────────────── | |
| 295 | + - id: digitalocean | |
| 296 | + extend: true | |
| 297 | + country: US | |
| 298 | + language: en | |
| 299 | + sensors: | |
| 300 | + - { name: blog feed, url: "https://www.digitalocean.com/blog/rss", type: ATOM, connector: rss, tier: B } | |
| 301 | + - { name: droplet pricing, url: "https://www.digitalocean.com/pricing/droplets", type: HTML, connector: http, tier: C } | |
| 302 | + - { name: regional availability matrix, url: "https://docs.digitalocean.com/products/platform/availability-matrix/", type: HTML, connector: http, tier: C } | |
| 303 | + - { name: doctl releases, url: "https://github.com/digitalocean/doctl/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: digitalocean/doctl, kind: releases } } | |
| 304 | + - { name: terraform provider releases, url: "https://github.com/digitalocean/terraform-provider-digitalocean/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: digitalocean/terraform-provider-digitalocean, kind: releases } } | |
| 305 | + - id: vultr | |
| 306 | + extend: true | |
| 307 | + country: US | |
| 308 | + language: en | |
| 309 | + sensors: | |
| 310 | + - { name: regions api, url: "https://api.vultr.com/v2/regions", type: JSON, connector: http, tier: C } | |
| 311 | + - { name: plans and pricing api, url: "https://api.vultr.com/v2/plans", type: JSON, connector: http, tier: C } | |
| 312 | + - { name: vultr cli releases, url: "https://github.com/vultr/vultr-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: vultr/vultr-cli, kind: releases } } | |
| 313 | + - { name: terraform provider releases, url: "https://github.com/vultr/terraform-provider-vultr/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: vultr/terraform-provider-vultr, kind: releases } } | |
| 314 | + notes: "www.vultr.com and status.vultr.com are Cloudflare-challenged (403) for bots; the public v2 API (regions, plans) and docs.vultr.com are open." | |
| 315 | + - id: linode | |
| 316 | + extend: true | |
| 317 | + country: US | |
| 318 | + language: en | |
| 319 | + sensors: | |
| 320 | + - { name: blog feed, url: "https://www.linode.com/blog/feed/", type: RSS, connector: rss, tier: B } | |
| 321 | + - { name: cloud computing changelog feed, url: "https://techdocs.akamai.com/cloud-computing/changelog.rss", type: RSS, connector: rss, tier: A } | |
| 322 | + - { name: api v4 openapi, url: "https://raw.githubusercontent.com/linode/linode-api-docs/development/openapi.json", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://techdocs.akamai.com/linode-api/reference/api" } } | |
| 323 | + - { name: regions api, url: "https://api.linode.com/v4/regions", type: JSON, connector: http, tier: C } | |
| 324 | + - { name: instance types and pricing api, url: "https://api.linode.com/v4/linode/types", type: JSON, connector: http, tier: C } | |
| 325 | + - { name: linode cli releases, url: "https://github.com/linode/linode-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: linode/linode-cli, kind: releases } } | |
| 326 | + - { name: terraform provider releases, url: "https://github.com/linode/terraform-provider-linode/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: linode/terraform-provider-linode, kind: releases } } | |
| 327 | + notes: "linode.com/docs and /pricing are Akamai-blocked (403); the unauthenticated v4 API exposes regions and types with prices." | |
| 328 | + - id: akamai | |
| 329 | + extend: true | |
| 330 | + country: US | |
| 331 | + language: en | |
| 332 | + sensors: | |
| 333 | + - { name: status, url: "https://www.akamaistatus.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 334 | + # ───────────────────────── CDN / edge ───────────────────────── | |
| 335 | + - id: fastly | |
| 336 | + extend: true | |
| 337 | + country: US | |
| 338 | + language: en | |
| 339 | + sensors: | |
| 340 | + - { name: status history feed, url: "https://www.fastlystatus.com/rss", type: RSS, connector: rss, tier: S } | |
| 341 | + - { name: api and product changes, url: "https://www.fastly.com/documentation/reference/changes/", type: HTML, connector: http, tier: B } | |
| 342 | + - { name: public ip list, url: "https://api.fastly.com/public-ip-list", type: JSON, connector: http, tier: D } | |
| 343 | + - { name: cli releases, url: "https://github.com/fastly/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: fastly/cli, kind: releases } } | |
| 344 | + - { name: terraform provider releases, url: "https://github.com/fastly/terraform-provider-fastly/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: fastly/terraform-provider-fastly, kind: releases } } | |
| 345 | + notes: "status.fastly.com is a custom page (no /api/v2); www.fastlystatus.com/rss is the machine-readable history. Blog has no feed." | |
| 346 | + - id: bunny-net | |
| 347 | + extend: true | |
| 348 | + country: SI | |
| 349 | + language: en | |
| 350 | + sensors: | |
| 351 | + - { name: blog feed, url: "https://bunny.net/blog/rss/", type: RSS, connector: rss, tier: B } | |
| 352 | + - { name: docs changelog, url: "https://docs.bunny.net/changelog", type: HTML, connector: http, tier: B } | |
| 353 | + - { name: network pops, url: "https://bunny.net/network/", type: HTML, connector: http, tier: C } | |
| 354 | + # ───────────────────────── PaaS / frontend clouds ───────────────────────── | |
| 355 | + - id: vercel | |
| 356 | + extend: true | |
| 357 | + country: US | |
| 358 | + language: en | |
| 359 | + sensors: | |
| 360 | + - { name: pricing, url: "https://vercel.com/pricing", type: HTML, connector: http, tier: C } | |
| 361 | + - { name: edge network regions, url: "https://vercel.com/docs/edge-network/regions", type: HTML, connector: http, tier: C } | |
| 362 | + - { name: vercel cli releases, url: "https://github.com/vercel/vercel/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: vercel/vercel, kind: releases } } | |
| 363 | + - { name: turborepo releases, url: "https://github.com/vercel/turborepo/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: vercel/turborepo, kind: releases } } | |
| 364 | + - id: netlify | |
| 365 | + extend: true | |
| 366 | + country: US | |
| 367 | + language: en | |
| 368 | + sensors: | |
| 369 | + - { name: changelog feed, url: "https://www.netlify.com/changelog/feed.xml", type: RSS, connector: rss, tier: A } | |
| 370 | + - { name: blog feed, url: "https://www.netlify.com/feed.xml", type: RSS, connector: rss, tier: B } | |
| 371 | + - { name: pricing, url: "https://www.netlify.com/pricing/", type: HTML, connector: http, tier: C } | |
| 372 | + - { name: api openapi, url: "https://open-api.netlify.com/swagger.json", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://open-api.netlify.com/" } } | |
| 373 | + - { name: cli releases, url: "https://github.com/netlify/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: netlify/cli, kind: releases } } | |
| 374 | + - { name: build releases, url: "https://github.com/netlify/build/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: netlify/build, kind: releases } } | |
| 375 | + - id: fly-io | |
| 376 | + extend: true | |
| 377 | + country: US | |
| 378 | + language: en | |
| 379 | + sensors: | |
| 380 | + - { name: infra log feed, url: "https://community.fly.io/c/infra-log/34.rss", type: RSS, connector: rss, tier: A } | |
| 381 | + - { name: machines api openapi, url: "https://docs.machines.dev/spec/openapi3.json", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://docs.machines.dev/" } } | |
| 382 | + - { name: pricing, url: "https://fly.io/docs/about/pricing/", type: HTML, connector: http, tier: C } | |
| 383 | + - { name: regions, url: "https://fly.io/docs/reference/regions/", type: HTML, connector: http, tier: C } | |
| 384 | + - { name: flyctl releases, url: "https://github.com/superfly/flyctl/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: superfly/flyctl, kind: releases } } | |
| 385 | + - id: railway | |
| 386 | + extend: true | |
| 387 | + country: US | |
| 388 | + language: en | |
| 389 | + sensors: | |
| 390 | + - { name: changelog feed, url: "https://railway.com/changelog/rss.xml", type: RSS, connector: rss, tier: A } | |
| 391 | + - { name: blog feed, url: "https://blog.railway.com/rss.xml", type: RSS, connector: rss, tier: B } | |
| 392 | + - { name: pricing, url: "https://railway.com/pricing", type: HTML, connector: http, tier: C } | |
| 393 | + - { name: cli releases, url: "https://github.com/railwayapp/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: railwayapp/cli, kind: releases } } | |
| 394 | + notes: "status.railway.com is a custom Next.js page (every path returns the same HTML); public API is GraphQL only." | |
| 395 | + # ───────────────────────── managed databases / data platforms ───────────────────────── | |
| 396 | + - id: supabase | |
| 397 | + extend: true | |
| 398 | + country: US | |
| 399 | + language: en | |
| 400 | + sensors: | |
| 401 | + - { name: pricing, url: "https://supabase.com/pricing", type: HTML, connector: http, tier: C } | |
| 402 | + - { name: supabase releases, url: "https://github.com/supabase/supabase/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: supabase/supabase, kind: releases } } | |
| 403 | + - { name: cli releases, url: "https://github.com/supabase/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: supabase/cli, kind: releases } } | |
| 404 | + - { name: postgres image releases, url: "https://github.com/supabase/postgres/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: supabase/postgres, kind: releases } } | |
| 405 | + - id: neon | |
| 406 | + extend: true | |
| 407 | + country: US | |
| 408 | + language: en | |
| 409 | + sensors: | |
| 410 | + - { name: changelog feed, url: "https://neon.com/docs/changelog/rss.xml", type: RSS, connector: rss, tier: A } | |
| 411 | + - { name: pricing, url: "https://neon.com/pricing", type: HTML, connector: http, tier: C } | |
| 412 | + - { name: api v2 openapi, url: "https://neon.com/api_spec/release/v2.json", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://api-docs.neon.tech/reference/getting-started-with-neon-api" } } | |
| 413 | + - id: planetscale | |
| 414 | + extend: true | |
| 415 | + country: US | |
| 416 | + language: en | |
| 417 | + sensors: | |
| 418 | + - { name: changelog feed, url: "https://planetscale.com/changelog/feed.atom", type: ATOM, connector: rss, tier: A } | |
| 419 | + - { name: pricing, url: "https://planetscale.com/pricing", type: HTML, connector: http, tier: C } | |
| 420 | + - { name: api openapi, url: "https://api.planetscale.com/v1/openapi-spec", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://api-docs.planetscale.com/" } } | |
| 421 | + - id: mongodb | |
| 422 | + extend: true | |
| 423 | + country: US | |
| 424 | + language: en | |
| 425 | + products: | |
| 426 | + - { name: MongoDB Atlas, type: product, aliases: [atlas] } | |
| 427 | + sensors: | |
| 428 | + - { name: status history feed, url: "https://status.mongodb.com/history.rss", type: RSS, connector: rss, tier: A } | |
| 429 | + - { name: blog feed, url: "https://www.mongodb.com/blog/rss", type: RSS, connector: rss, tier: B } | |
| 430 | + - { name: atlas release notes, url: "https://www.mongodb.com/docs/atlas/release-notes/", type: HTML, connector: http, tier: B } | |
| 431 | + - { name: server release notes, url: "https://www.mongodb.com/docs/manual/release-notes/", type: HTML, connector: http, tier: B } | |
| 432 | + - { name: pricing, url: "https://www.mongodb.com/pricing", type: HTML, connector: http, tier: C } | |
| 433 | + - { name: atlas cli releases, url: "https://github.com/mongodb/mongodb-atlas-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: mongodb/mongodb-atlas-cli, kind: releases } } | |
| 434 | + - id: redis | |
| 435 | + extend: true | |
| 436 | + country: US | |
| 437 | + language: en | |
| 438 | + sensors: | |
| 439 | + - { name: blog feed, url: "https://redis.io/blog/feed/", type: RSS, connector: rss, tier: B } | |
| 440 | + - { name: redis software release notes, url: "https://redis.io/docs/latest/operate/rs/release-notes/", type: HTML, connector: http, tier: B } | |
| 441 | + - { name: redis-py releases, url: "https://github.com/redis/redis-py/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: redis/redis-py, kind: releases } } | |
| 442 | + - { name: node-redis releases, url: "https://github.com/redis/node-redis/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: redis/node-redis, kind: releases } } | |
| 443 | + notes: "status.redis.io is a FireHydrant page (HTML only, no JSON/RSS)." | |
| 444 | + - id: elastic | |
| 445 | + extend: true | |
| 446 | + country: NL | |
| 447 | + language: en | |
| 448 | + sensors: | |
| 449 | + - { name: blog feed, url: "https://www.elastic.co/blog/feed", type: RSS, connector: rss, tier: B } | |
| 450 | + - { name: elasticsearch release notes, url: "https://www.elastic.co/docs/release-notes/elasticsearch", type: HTML, connector: http, tier: B } | |
| 451 | + - { name: pricing, url: "https://www.elastic.co/pricing", type: HTML, connector: http, tier: C } | |
| 452 | + - { name: kibana releases, url: "https://github.com/elastic/kibana/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: elastic/kibana, kind: releases } } | |
| 453 | + - { name: eck operator releases, url: "https://github.com/elastic/cloud-on-k8s/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: elastic/cloud-on-k8s, kind: releases } } | |
| 454 | + - id: cockroachdb | |
| 455 | + extend: true | |
| 456 | + country: US | |
| 457 | + language: en | |
| 458 | + sensors: | |
| 459 | + - { name: releases page, url: "https://www.cockroachlabs.com/docs/releases/", type: HTML, connector: http, tier: B } | |
| 460 | + - { name: cloud release notes, url: "https://www.cockroachlabs.com/docs/releases/cloud", type: HTML, connector: http, tier: B } | |
| 461 | + - { name: pricing, url: "https://www.cockroachlabs.com/pricing/", type: HTML, connector: http, tier: C } | |
| 462 | + - id: confluent | |
| 463 | + extend: true | |
| 464 | + country: US | |
| 465 | + language: en | |
| 466 | + sensors: | |
| 467 | + - { name: cloud release notes, url: "https://docs.confluent.io/cloud/current/release-notes/index.html", type: HTML, connector: http, tier: B } | |
| 468 | + - { name: cloud pricing, url: "https://www.confluent.io/confluent-cloud/pricing/", type: HTML, connector: http, tier: C } | |
| 469 | + - { name: cli releases, url: "https://github.com/confluentinc/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: confluentinc/cli, kind: releases } } | |
| 470 | + - { name: confluent-kafka-go releases, url: "https://github.com/confluentinc/confluent-kafka-go/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: confluentinc/confluent-kafka-go, kind: releases } } | |
| 471 | + - { name: terraform provider releases, url: "https://github.com/confluentinc/terraform-provider-confluent/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: confluentinc/terraform-provider-confluent, kind: releases } } | |
| 472 | + - id: snowflake | |
| 473 | + extend: true | |
| 474 | + country: US | |
| 475 | + language: en | |
| 476 | + sensors: | |
| 477 | + - { name: behavior change releases, url: "https://docs.snowflake.com/en/release-notes/behavior-changes", type: HTML, connector: http, tier: B } | |
| 478 | + - { name: pricing, url: "https://www.snowflake.com/pricing/", type: HTML, connector: http, tier: C } | |
| 479 | + - { name: snowflake cli releases, url: "https://github.com/snowflakedb/snowflake-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: snowflakedb/snowflake-cli, kind: releases } } | |
| 480 | + - { name: snowpark python releases, url: "https://github.com/snowflakedb/snowpark-python/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: snowflakedb/snowpark-python, kind: releases } } | |
| 481 | + - id: databricks | |
| 482 | + extend: true | |
| 483 | + country: US | |
| 484 | + language: en | |
| 485 | + sensors: | |
| 486 | + - { name: product release notes, url: "https://docs.databricks.com/aws/en/release-notes/product/index.html", type: HTML, connector: http, tier: B } | |
| 487 | + - { name: pricing, url: "https://www.databricks.com/product/pricing", type: HTML, connector: http, tier: C } | |
| 488 | + - { name: cli releases, url: "https://github.com/databricks/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: databricks/cli, kind: releases } } | |
| 489 | + - { name: terraform provider releases, url: "https://github.com/databricks/terraform-provider-databricks/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: databricks/terraform-provider-databricks, kind: releases } } | |
| 490 | + notes: "status.databricks.com is a custom page (no Statuspage/Instatus API)." | |
| 491 | + - id: upstash | |
| 492 | + extend: true | |
| 493 | + country: US | |
| 494 | + language: en | |
| 495 | + sensors: | |
| 496 | + - { name: pricing, url: "https://upstash.com/pricing", type: HTML, connector: http, tier: C } | |
| 497 | + - { name: redis changelog, url: "https://upstash.com/docs/redis/overall/changelog", type: HTML, connector: http, tier: B } | |
| 498 | + - id: turso | |
| 499 | + name: Turso | |
| 500 | + domain: turso.tech | |
| 501 | + categories: [cloud, developer] | |
| 502 | + tier: B | |
| 503 | + country: US | |
| 504 | + language: en | |
| 505 | + aliases: [turso, libsql] | |
| 506 | + products: | |
| 507 | + - { name: libSQL, type: software, aliases: [libsql] } | |
| 508 | + discover: { rss: true } | |
| 509 | + sensors: | |
| 510 | + - { name: blog feed, url: "https://turso.tech/blog/feed.xml", type: ATOM, connector: rss, tier: B } | |
| 511 | + - { name: pricing, url: "https://turso.tech/pricing", type: HTML, connector: http, tier: C } | |
| 512 | + - { name: turso database releases, url: "https://github.com/tursodatabase/turso/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: tursodatabase/turso, kind: releases } } | |
| 513 | + - { name: libsql releases, url: "https://github.com/tursodatabase/libsql/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: tursodatabase/libsql, kind: releases } } | |
| 514 | + - { name: cli releases, url: "https://github.com/tursodatabase/turso-cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: tursodatabase/turso-cli, kind: releases } } | |
| 515 | + notes: "status.turso.tech is a Better Stack page (HTML only)." | |
| 516 | + # ───────────────────────── object storage ───────────────────────── | |
| 517 | + - id: backblaze | |
| 518 | + name: Backblaze | |
| 519 | + domain: backblaze.com | |
| 520 | + homepage: https://www.backblaze.com | |
| 521 | + categories: [cloud, infrastructure] | |
| 522 | + tier: B | |
| 523 | + country: US | |
| 524 | + language: en | |
| 525 | + aliases: [backblaze, b2] | |
| 526 | + products: | |
| 527 | + - { name: Backblaze B2, type: product, aliases: [b2 cloud storage] } | |
| 528 | + discover: { rss: true } | |
| 529 | + sensors: | |
| 530 | + - { name: blog feed, url: "https://www.backblaze.com/blog/feed/", type: RSS, connector: rss, tier: B } | |
| 531 | + - { name: b2 pricing, url: "https://www.backblaze.com/cloud-storage/pricing", type: HTML, connector: http, tier: C } | |
| 532 | + - { name: b2 cli releases, url: "https://github.com/Backblaze/B2_Command_Line_Tool/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: Backblaze/B2_Command_Line_Tool, kind: releases } } | |
| 533 | + notes: "status.backblaze.com is a FireHydrant page (HTML only)." | |
| 534 | + - id: wasabi | |
| 535 | + name: Wasabi Technologies | |
| 536 | + domain: wasabi.com | |
| 537 | + categories: [cloud, infrastructure] | |
| 538 | + tier: B | |
| 539 | + country: US | |
| 540 | + language: en | |
| 541 | + aliases: [wasabi, wasabi hot cloud storage] | |
| 542 | + discover: { status: true } | |
| 543 | + sensors: | |
| 544 | + - { name: status, url: "https://status.wasabi.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 545 | + - { name: pricing, url: "https://wasabi.com/pricing/", type: HTML, connector: http, tier: C } | |
| 546 | + notes: "wasabi.com has no blog feed; docs release notes 404." | |
| 547 | + - id: tigris | |
| 548 | + name: Tigris Data | |
| 549 | + domain: tigrisdata.com | |
| 550 | + homepage: https://www.tigrisdata.com | |
| 551 | + categories: [cloud, developer] | |
| 552 | + tier: C | |
| 553 | + country: US | |
| 554 | + language: en | |
| 555 | + aliases: [tigris, tigris data] | |
| 556 | + discover: { rss: true, status: true } | |
| 557 | + sensors: | |
| 558 | + - { name: status, url: "https://status.tigrisdata.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 559 | + - { name: blog feed, url: "https://www.tigrisdata.com/blog/rss.xml", type: RSS, connector: rss, tier: B } | |
| 560 | + - { name: pricing, url: "https://www.tigrisdata.com/pricing/", type: HTML, connector: http, tier: C } | |
| 561 | + # ───────────────────────── network operators / IXPs / data centres ───────────────────────── | |
| 562 | + - id: equinix | |
| 563 | + extend: true | |
| 564 | + country: US | |
| 565 | + language: en | |
| 566 | + products: | |
| 567 | + - { name: Equinix Metal, type: product, aliases: [packet] } | |
| 568 | + sensors: | |
| 569 | + - { name: equinix metal status, url: "https://status.equinixmetal.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 570 | + - id: zayo | |
| 571 | + name: Zayo | |
| 572 | + domain: zayo.com | |
| 573 | + homepage: https://www.zayo.com | |
| 574 | + categories: [telecom, infrastructure] | |
| 575 | + tier: C | |
| 576 | + country: US | |
| 577 | + language: en | |
| 578 | + aliases: [zayo, zayo group] | |
| 579 | + discover: { rss: true } | |
| 580 | + sensors: | |
| 581 | + - { name: blog feed, url: "https://www.zayo.com/feed/", type: RSS, connector: rss, tier: C } | |
| 582 | + notes: "newsroom feed is empty; status.zayo.com does not resolve." | |
| 583 | + - id: arelion | |
| 584 | + name: Arelion | |
| 585 | + domain: arelion.com | |
| 586 | + homepage: https://www.arelion.com | |
| 587 | + categories: [telecom, infrastructure] | |
| 588 | + tier: C | |
| 589 | + country: SE | |
| 590 | + language: en | |
| 591 | + aliases: [arelion, telia carrier, as1299] | |
| 592 | + discover: { rss: true } | |
| 593 | + sensors: | |
| 594 | + - { name: blog feed, url: "https://blog.arelion.com/feed", type: RSS, connector: rss, tier: C } | |
| 595 | + notes: "arelion.com press pages return 404 to non-browser clients; the blog is open." | |
| 596 | + - id: hurricane-electric | |
| 597 | + name: Hurricane Electric | |
| 598 | + domain: he.net | |
| 599 | + categories: [telecom, infrastructure, internet] | |
| 600 | + tier: C | |
| 601 | + country: US | |
| 602 | + language: en | |
| 603 | + aliases: [hurricane electric, he.net, as6939] | |
| 604 | + discover: { rss: false } | |
| 605 | + sensors: | |
| 606 | + - { name: news, url: "https://he.net/news.html", type: HTML, connector: http, tier: C } | |
| 607 | + - id: arin | |
| 608 | + extend: true | |
| 609 | + country: US | |
| 610 | + language: en | |
| 611 | + sensors: | |
| 612 | + - { name: announcements feed, url: "https://www.arin.net/announcements/rss.xml", type: RSS, connector: rss, tier: B } | |
| 613 | + # ───────────────────────── DNS root / registries ───────────────────────── | |
| 614 | + - id: root-servers | |
| 615 | + name: Root Server Operators | |
| 616 | + domain: root-servers.org | |
| 617 | + categories: [internet, infrastructure, standards] | |
| 618 | + tier: B | |
| 619 | + weight: 1.3 | |
| 620 | + country: INT | |
| 621 | + language: en | |
| 622 | + aliases: [root servers, root-servers.org, rssac, dns root] | |
| 623 | + discover: { rss: false } | |
| 624 | + sensors: | |
| 625 | + - { name: root servers overview, url: "https://root-servers.org/", type: HTML, connector: http, tier: C } | |
| 626 | + - id: iana | |
| 627 | + extend: true | |
| 628 | + country: INT | |
| 629 | + language: en | |
| 630 | + sensors: | |
| 631 | + - { name: root hints file, url: "https://www.internic.net/domain/named.root", type: FILE, connector: http, tier: C } | |
| 632 | + - { name: dnssec root trust anchors, url: "https://data.iana.org/root-anchors/root-anchors.xml", type: XML, connector: http, tier: C } | |
| 633 | + - { name: root servers list, url: "https://www.iana.org/domains/root/servers", type: HTML, connector: http, tier: C } | |
| 634 | + - id: nlnet-labs | |
| 635 | + name: NLnet Labs | |
| 636 | + domain: nlnetlabs.nl | |
| 637 | + categories: [open-source, internet, infrastructure] | |
| 638 | + tier: B | |
| 639 | + country: NL | |
| 640 | + language: en | |
| 641 | + aliases: [nlnet labs, nlnetlabs] | |
| 642 | + products: | |
| 643 | + - { name: Unbound, type: software } | |
| 644 | + - { name: NSD, type: software } | |
| 645 | + - { name: Routinator, type: software } | |
| 646 | + - { name: Krill, type: software } | |
| 647 | + discover: { rss: true } | |
| 648 | + sensors: | |
| 649 | + - { name: blog feed, url: "https://blog.nlnetlabs.nl/rss/", type: RSS, connector: rss, tier: B } | |
| 650 | + - { name: unbound releases, url: "https://github.com/NLnetLabs/unbound/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: NLnetLabs/unbound, kind: releases } } | |
| 651 | + - { name: nsd releases, url: "https://github.com/NLnetLabs/nsd/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: NLnetLabs/nsd, kind: releases } } | |
| 652 | + - { name: routinator releases, url: "https://github.com/NLnetLabs/routinator/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: NLnetLabs/routinator, kind: releases } } | |
| 653 | + - { name: krill releases, url: "https://github.com/NLnetLabs/krill/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: NLnetLabs/krill, kind: releases } } | |
| 654 | + - id: isc | |
| 655 | + name: Internet Systems Consortium | |
| 656 | + domain: isc.org | |
| 657 | + homepage: https://www.isc.org | |
| 658 | + categories: [open-source, internet, infrastructure] | |
| 659 | + tier: B | |
| 660 | + weight: 1.1 | |
| 661 | + country: US | |
| 662 | + language: en | |
| 663 | + aliases: [isc, internet systems consortium, bind] | |
| 664 | + products: | |
| 665 | + - { name: BIND 9, type: software, aliases: [bind, bind9, named] } | |
| 666 | + - { name: Kea DHCP, type: software, aliases: [kea] } | |
| 667 | + discover: { rss: true } | |
| 668 | + sensors: | |
| 669 | + - { name: bind 9 tags (gitlab), url: "https://gitlab.isc.org/isc-projects/bind9/-/tags?format=atom", type: ATOM, connector: rss, tier: A } | |
| 670 | + - { name: bind 9 releases (github mirror), url: "https://github.com/isc-projects/bind9/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: isc-projects/bind9, kind: releases } } | |
| 671 | + - { name: kea tags, url: "https://github.com/isc-projects/kea/tags.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: isc-projects/kea, kind: tags } } | |
| 672 | + notes: "isc.org and kb.isc.org publish no RSS (404)." | |
| 673 | + # ───────────────────────── certificate authorities ───────────────────────── | |
| 674 | + - id: letsencrypt | |
| 675 | + extend: true | |
| 676 | + country: US | |
| 677 | + language: en | |
| 678 | + sensors: | |
| 679 | + - { name: status (status.io), url: "https://letsencrypt.status.io/1.0/status/55957a99e800baa4470002da", type: STATUSPAGE, connector: statusjson, tier: S, config: { flavor: statusio } } | |
| 680 | + - { name: api announcements feed, url: "https://community.letsencrypt.org/c/api-announcements.rss", type: RSS, connector: rss, tier: A } | |
| 681 | + - { name: rate limits, url: "https://letsencrypt.org/docs/rate-limits/", type: HTML, connector: http, tier: C } | |
| 682 | + - { name: boulder releases, url: "https://github.com/letsencrypt/boulder/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: letsencrypt/boulder, kind: releases } } | |
| 683 | + - id: tailscale | |
| 684 | + extend: true | |
| 685 | + country: CA | |
| 686 | + language: en | |
| 687 | + sensors: | |
| 688 | + - { name: changelog feed, url: "https://tailscale.com/changelog/index.xml", type: RSS, connector: rss, tier: A } | |
| 689 | + - { name: security bulletins feed, url: "https://tailscale.com/security-bulletins/index.xml", type: RSS, connector: rss, tier: S } | |
| 690 | + - { name: pricing, url: "https://tailscale.com/pricing", type: HTML, connector: http, tier: C } | |
| 691 | + - { name: tailscale releases, url: "https://github.com/tailscale/tailscale/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: tailscale/tailscale, kind: releases } } | |
| 692 | + - id: 1password | |
| 693 | + extend: true | |
| 694 | + country: CA | |
| 695 | + language: en | |
| 696 | + sensors: | |
| 697 | + - { name: release notes feed, url: "https://releases.1password.com/index.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 698 | + - { name: pricing, url: "https://1password.com/pricing", type: HTML, connector: http, tier: C } | |
| 699 | + - id: okta | |
| 700 | + extend: true | |
| 701 | + country: US | |
| 702 | + language: en | |
| 703 | + sensors: | |
| 704 | + - { name: developer feed, url: "https://developer.okta.com/feed.xml", type: RSS, connector: rss, tier: B } | |
| 705 | + - { name: developer release notes 2026, url: "https://developer.okta.com/docs/release-notes/2026/", type: HTML, connector: http, tier: B } | |
| 706 | + notes: "status.okta.com/api/v2 requires auth (401) — custom page." | |
| 707 | + - id: auth0 | |
| 708 | + extend: true | |
| 709 | + country: US | |
| 710 | + language: en | |
| 711 | + sensors: | |
| 712 | + - { name: pricing, url: "https://auth0.com/pricing", type: HTML, connector: http, tier: C } | |
| 713 | + # ───────────────────────── communications APIs ───────────────────────── | |
| 714 | + - id: sendgrid | |
| 715 | + name: Twilio SendGrid | |
| 716 | + domain: sendgrid.com | |
| 717 | + categories: [enterprise, developer, cloud] | |
| 718 | + tier: B | |
| 719 | + country: US | |
| 720 | + language: en | |
| 721 | + aliases: [sendgrid, twilio sendgrid] | |
| 722 | + discover: { status: true } | |
| 723 | + sensors: | |
| 724 | + - { name: status, url: "https://status.sendgrid.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 725 | + notes: "sendgrid.com blog feed URLs return HTML; docs changelog 404 (merged into twilio.com changelog)." | |
| 726 | + - id: resend | |
| 727 | + extend: true | |
| 728 | + country: US | |
| 729 | + language: en | |
| 730 | + sensors: | |
| 731 | + - { name: status (incident.io), url: "https://resend-status.com/api/v1/summary", type: STATUSPAGE, connector: statusjson, tier: S, config: { flavor: incidentio } } | |
| 732 | + - id: datadog | |
| 733 | + extend: true | |
| 734 | + country: US | |
| 735 | + language: en | |
| 736 | + notes: "github.com/DataDog/datadog-agent releases.atom and tags.atom return 502/504 (feed too large for GitHub to render) — not a sensor." | |
| 737 | + sensors: | |
| 738 | + - { name: pricing, url: "https://www.datadoghq.com/pricing/", type: HTML, connector: http, tier: C } | |
| 739 | + - { name: api v2 openapi, url: "https://raw.githubusercontent.com/DataDog/datadog-api-client-python/master/.generator/schemas/v2/openapi.yaml", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://docs.datadoghq.com/api/latest/" } } | |
| 740 | + - id: new-relic | |
| 741 | + extend: true | |
| 742 | + country: US | |
| 743 | + language: en | |
| 744 | + sensors: | |
| 745 | + - { name: whats new feed, url: "https://docs.newrelic.com/whats-new/feed.xml", type: RSS, connector: rss, tier: B } | |
| 746 | + - { name: pricing, url: "https://newrelic.com/pricing", type: HTML, connector: http, tier: C } | |
| 747 | + - { name: infrastructure agent releases, url: "https://github.com/newrelic/infrastructure-agent/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: newrelic/infrastructure-agent, kind: releases } } | |
| 748 | + - id: grafana | |
| 749 | + extend: true | |
| 750 | + country: US | |
| 751 | + language: en | |
| 752 | + products: | |
| 753 | + - { name: Grafana Loki, type: software, aliases: [loki] } | |
| 754 | + - { name: Grafana Tempo, type: software, aliases: [tempo] } | |
| 755 | + - { name: Grafana Mimir, type: software, aliases: [mimir] } | |
| 756 | + - { name: Grafana Alloy, type: software, aliases: [alloy] } | |
| 757 | + sensors: | |
| 758 | + - { name: cloud status, url: "https://status.grafana.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 759 | + - { name: blog feed, url: "https://grafana.com/blog/index.xml", type: RSS, connector: rss, tier: B } | |
| 760 | + - { name: pricing, url: "https://grafana.com/pricing/", type: HTML, connector: http, tier: C } | |
| 761 | + - { name: loki releases, url: "https://github.com/grafana/loki/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: grafana/loki, kind: releases } } | |
| 762 | + - { name: tempo releases, url: "https://github.com/grafana/tempo/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: grafana/tempo, kind: releases } } | |
| 763 | + - { name: mimir releases, url: "https://github.com/grafana/mimir/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: grafana/mimir, kind: releases } } | |
| 764 | + - { name: alloy releases, url: "https://github.com/grafana/alloy/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: grafana/alloy, kind: releases } } | |
| 765 | + - id: sentry | |
| 766 | + extend: true | |
| 767 | + country: US | |
| 768 | + language: en | |
| 769 | + sensors: | |
| 770 | + - { name: blog feed, url: "https://blog.sentry.io/feed.xml", type: RSS, connector: rss, tier: B } | |
| 771 | + - { name: pricing, url: "https://sentry.io/pricing/", type: HTML, connector: http, tier: C } | |
| 772 | + - { name: sentry releases, url: "https://github.com/getsentry/sentry/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: getsentry/sentry, kind: releases } } | |
| 773 | + - { name: self-hosted releases, url: "https://github.com/getsentry/self-hosted/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: getsentry/self-hosted, kind: releases } } | |
| 774 | + - id: honeycomb | |
| 775 | + name: Honeycomb | |
| 776 | + domain: honeycomb.io | |
| 777 | + homepage: https://www.honeycomb.io | |
| 778 | + categories: [enterprise, developer, cloud] | |
| 779 | + tier: B | |
| 780 | + country: US | |
| 781 | + language: en | |
| 782 | + aliases: [honeycomb, honeycomb.io] | |
| 783 | + products: | |
| 784 | + - { name: Refinery, type: software } | |
| 785 | + discover: { rss: true, status: true } | |
| 786 | + sensors: | |
| 787 | + - { name: status, url: "https://status.honeycomb.io/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 788 | + - { name: pricing, url: "https://www.honeycomb.io/pricing", type: HTML, connector: http, tier: C } | |
| 789 | + - { name: refinery releases, url: "https://github.com/honeycombio/refinery/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: honeycombio/refinery, kind: releases } } | |
| 790 | + notes: "honeycomb.io blog has no feed; docs changelog 404." | |
| 791 | + # ───────────────────────── developer platforms / registries ───────────────────────── | |
| 792 | + - id: atlassian | |
| 793 | + extend: true | |
| 794 | + country: AU | |
| 795 | + language: en | |
| 796 | + products: | |
| 797 | + - { name: Bitbucket, type: product, aliases: [bitbucket cloud] } | |
| 798 | + sensors: | |
| 799 | + - { name: bitbucket status, url: "https://bitbucket.status.atlassian.com/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 800 | + - { name: bitbucket api openapi, url: "https://api.bitbucket.org/swagger.json", type: JSON, connector: openapi, tier: B, config: { docsUrl: "https://developer.atlassian.com/cloud/bitbucket/rest/" } } | |
| 801 | + - { name: bitbucket cloud changelog, url: "https://developer.atlassian.com/cloud/bitbucket/changelog/", type: HTML, connector: http, tier: B } | |
| 802 | + - { name: jira platform changelog, url: "https://developer.atlassian.com/cloud/jira/platform/changelog/", type: HTML, connector: http, tier: B } | |
| 803 | + - id: github | |
| 804 | + extend: true | |
| 805 | + country: US | |
| 806 | + language: en | |
| 807 | + sensors: | |
| 808 | + - { name: engineering blog feed, url: "https://github.blog/engineering/feed/", type: RSS, connector: rss, tier: B } | |
| 809 | + - { name: actions changelog feed, url: "https://github.blog/changelog/label/actions/feed/", type: RSS, connector: rss, tier: A } | |
| 810 | + - { name: rest api breaking changes, url: "https://docs.github.com/en/rest/overview/breaking-changes", type: HTML, connector: http, tier: B } | |
| 811 | + - { name: meta ip ranges api, url: "https://api.github.com/meta", type: JSON, connector: http, tier: C } | |
| 812 | + - { name: gh cli releases, url: "https://github.com/cli/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cli/cli, kind: releases } } | |
| 813 | + - id: gitlab | |
| 814 | + extend: true | |
| 815 | + country: US | |
| 816 | + language: en | |
| 817 | + sensors: | |
| 818 | + - { name: status (status.io), url: "https://api.status.io/1.0/status/5b36dc6502d06804c08349f7", type: STATUSPAGE, connector: statusjson, tier: S, config: { flavor: statusio } } | |
| 819 | + - { name: status history feed, url: "https://status.gitlab.com/pages/5b36dc6502d06804c08349f7/rss", type: RSS, connector: rss, tier: A } | |
| 820 | + - id: npm | |
| 821 | + name: npm Registry | |
| 822 | + domain: npmjs.com | |
| 823 | + homepage: https://www.npmjs.com | |
| 824 | + categories: [packages, developer, infrastructure] | |
| 825 | + tier: A | |
| 826 | + weight: 1.3 | |
| 827 | + country: US | |
| 828 | + language: en | |
| 829 | + aliases: [npm, npmjs, npm registry] | |
| 830 | + products: | |
| 831 | + - { name: npm CLI, type: software } | |
| 832 | + discover: { status: true } | |
| 833 | + sensors: | |
| 834 | + - { name: status, url: "https://status.npmjs.org/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 835 | + - { name: npm changelog feed (github), url: "https://github.blog/changelog/label/npm/feed/", type: RSS, connector: rss, tier: A } | |
| 836 | + - { name: npm cli releases, url: "https://github.com/npm/cli/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: npm/cli, kind: releases } } | |
| 837 | + notes: "blog.npmjs.org is gone; www.npmjs.com/blog is Cloudflare-blocked (403)." | |
| 838 | + - id: pypi | |
| 839 | + name: PyPI | |
| 840 | + domain: pypi.org | |
| 841 | + categories: [packages, developer, infrastructure] | |
| 842 | + tier: A | |
| 843 | + weight: 1.3 | |
| 844 | + country: US | |
| 845 | + language: en | |
| 846 | + aliases: [pypi, python package index, warehouse] | |
| 847 | + products: | |
| 848 | + - { name: Warehouse, type: software } | |
| 849 | + discover: { status: true } | |
| 850 | + sensors: | |
| 851 | + - { name: status (python infrastructure), url: "https://status.python.org/api/v2/summary.json", type: STATUSPAGE, connector: statuspage, tier: S } | |
| 852 | + - { name: blog feed, url: "https://blog.pypi.org/feed_rss_created.xml", type: RSS, connector: rss, tier: B } | |
| 853 | + - { name: warehouse commits, url: "https://github.com/pypi/warehouse/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: pypi/warehouse, kind: commits, branch: main } } | |
| 854 | + notes: "pypi.org/rss/updates.xml and packages.xml are firehoses of every release — deliberately not added." | |
| 855 | + # ───────────────────────── infrastructure software / foundations ───────────────────────── | |
| 856 | + - id: kubernetes | |
| 857 | + extend: true | |
| 858 | + country: US | |
| 859 | + language: en | |
| 860 | + sensors: | |
| 861 | + - { name: release schedule, url: "https://kubernetes.io/releases/", type: HTML, connector: http, tier: B } | |
| 862 | + - { name: api deprecation guide, url: "https://kubernetes.io/docs/reference/using-api/deprecation-guide/", type: HTML, connector: http, tier: C } | |
| 863 | + - { name: ingress-nginx releases, url: "https://github.com/kubernetes/ingress-nginx/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: kubernetes/ingress-nginx, kind: releases } } | |
| 864 | + - { name: cluster autoscaler releases, url: "https://github.com/kubernetes/autoscaler/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: kubernetes/autoscaler, kind: releases } } | |
| 865 | + - { name: kubespray releases, url: "https://github.com/kubernetes-sigs/kubespray/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: kubernetes-sigs/kubespray, kind: releases } } | |
| 866 | + - id: cncf | |
| 867 | + extend: true | |
| 868 | + country: US | |
| 869 | + language: en | |
| 870 | + products: | |
| 871 | + - { name: containerd, type: software } | |
| 872 | + - { name: etcd, type: software } | |
| 873 | + - { name: CoreDNS, type: software, aliases: [coredns] } | |
| 874 | + - { name: Cilium, type: software } | |
| 875 | + - { name: Linkerd, type: software } | |
| 876 | + - { name: Flux, type: software, aliases: [fluxcd] } | |
| 877 | + - { name: Crossplane, type: software } | |
| 878 | + - { name: cert-manager, type: software } | |
| 879 | + - { name: OpenTelemetry Collector, type: software, aliases: [otel collector] } | |
| 880 | + sensors: | |
| 881 | + - { name: announcements feed, url: "https://www.cncf.io/announcements/feed/", type: RSS, connector: rss, tier: B } | |
| 882 | + - { name: containerd releases, url: "https://github.com/containerd/containerd/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: containerd/containerd, kind: releases } } | |
| 883 | + - { name: etcd releases, url: "https://github.com/etcd-io/etcd/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: etcd-io/etcd, kind: releases } } | |
| 884 | + - { name: coredns releases, url: "https://github.com/coredns/coredns/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: coredns/coredns, kind: releases } } | |
| 885 | + - { name: cilium releases, url: "https://github.com/cilium/cilium/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cilium/cilium, kind: releases } } | |
| 886 | + - { name: linkerd releases, url: "https://github.com/linkerd/linkerd2/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: linkerd/linkerd2, kind: releases } } | |
| 887 | + - { name: flux releases, url: "https://github.com/fluxcd/flux2/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: fluxcd/flux2, kind: releases } } | |
| 888 | + - { name: crossplane releases, url: "https://github.com/crossplane/crossplane/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: crossplane/crossplane, kind: releases } } | |
| 889 | + - { name: cert-manager releases, url: "https://github.com/cert-manager/cert-manager/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cert-manager/cert-manager, kind: releases } } | |
| 890 | + - { name: external-secrets releases, url: "https://github.com/external-secrets/external-secrets/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: external-secrets/external-secrets, kind: releases } } | |
| 891 | + - { name: opentelemetry collector releases, url: "https://github.com/open-telemetry/opentelemetry-collector/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: open-telemetry/opentelemetry-collector, kind: releases } } | |
| 892 | + - { name: calico releases, url: "https://github.com/projectcalico/calico/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: projectcalico/calico, kind: releases } } | |
| 893 | + - id: hashicorp | |
| 894 | + extend: true | |
| 895 | + country: US | |
| 896 | + language: en | |
| 897 | + products: | |
| 898 | + - { name: Consul, type: software } | |
| 899 | + - { name: Nomad, type: software } | |
| 900 | + - { name: Packer, type: software } | |
| 901 | + - { name: Terraform Registry, type: service } | |
| 902 | + sensors: | |
| 903 | + - { name: blog feed, url: "https://www.hashicorp.com/blog/feed.xml", type: RSS, connector: rss, tier: B } | |
| 904 | + - { name: consul releases, url: "https://github.com/hashicorp/consul/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: hashicorp/consul, kind: releases } } | |
| 905 | + - { name: nomad releases, url: "https://github.com/hashicorp/nomad/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: hashicorp/nomad, kind: releases } } | |
| 906 | + - { name: packer releases, url: "https://github.com/hashicorp/packer/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: hashicorp/packer, kind: releases } } | |
| 907 | + - { name: terraform provider aws releases, url: "https://github.com/hashicorp/terraform-provider-aws/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: hashicorp/terraform-provider-aws, kind: releases } } | |
| 908 | + - { name: terraform provider azurerm releases, url: "https://github.com/hashicorp/terraform-provider-azurerm/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: hashicorp/terraform-provider-azurerm, kind: releases } } | |
| 909 | + - { name: terraform provider google releases, url: "https://github.com/hashicorp/terraform-provider-google/releases.atom", type: GITHUB_RELEASE, connector: github, tier: A, config: { repo: hashicorp/terraform-provider-google, kind: releases } } | |
| 910 | + - { name: registry provider aws, url: "https://registry.terraform.io/v1/providers/hashicorp/aws", type: JSON, connector: http, tier: C, config: { ignoreKeys: [downloads] } } | |
| 911 | + - { name: registry provider azurerm, url: "https://registry.terraform.io/v1/providers/hashicorp/azurerm", type: JSON, connector: http, tier: C, config: { ignoreKeys: [downloads] } } | |
| 912 | + - { name: registry provider google, url: "https://registry.terraform.io/v1/providers/hashicorp/google", type: JSON, connector: http, tier: C, config: { ignoreKeys: [downloads] } } | |
| 913 | + - id: pulumi | |
| 914 | + extend: true | |
| 915 | + country: US | |
| 916 | + language: en | |
| 917 | + sensors: | |
| 918 | + - { name: pulumi-aws releases, url: "https://github.com/pulumi/pulumi-aws/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: pulumi/pulumi-aws, kind: releases } } | |
| 919 | + - id: traefik | |
| 920 | + name: Traefik Labs | |
| 921 | + domain: traefik.io | |
| 922 | + categories: [open-source, infrastructure] | |
| 923 | + tier: B | |
| 924 | + country: FR | |
| 925 | + language: en | |
| 926 | + aliases: [traefik, traefik labs, containous] | |
| 927 | + products: | |
| 928 | + - { name: Traefik Proxy, type: software, aliases: [traefik] } | |
| 929 | + discover: { rss: true } | |
| 930 | + sensors: | |
| 931 | + - { name: traefik releases, url: "https://github.com/traefik/traefik/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: traefik/traefik, kind: releases } } | |
| 932 | + - id: minio | |
| 933 | + name: MinIO | |
| 934 | + domain: min.io | |
| 935 | + categories: [open-source, infrastructure, cloud] | |
| 936 | + tier: B | |
| 937 | + country: US | |
| 938 | + language: en | |
| 939 | + aliases: [minio] | |
| 940 | + discover: { rss: true } | |
| 941 | + sensors: | |
| 942 | + - { name: minio releases, url: "https://github.com/minio/minio/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: minio/minio, kind: releases } } | |
| 943 | + - id: rancher | |
| 944 | + name: Rancher (SUSE) | |
| 945 | + domain: rancher.com | |
| 946 | + categories: [open-source, infrastructure] | |
| 947 | + tier: B | |
| 948 | + country: US | |
| 949 | + language: en | |
| 950 | + aliases: [rancher, k3s, suse rancher] | |
| 951 | + products: | |
| 952 | + - { name: K3s, type: software, aliases: [k3s] } | |
| 953 | + discover: { rss: true } | |
| 954 | + sensors: | |
| 955 | + - { name: rancher releases, url: "https://github.com/rancher/rancher/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: rancher/rancher, kind: releases } } | |
| 956 | + - { name: k3s releases, url: "https://github.com/k3s-io/k3s/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: k3s-io/k3s, kind: releases } } | |
| 957 | + - id: frrouting | |
| 958 | + name: FRRouting | |
| 959 | + domain: frrouting.org | |
| 960 | + categories: [open-source, infrastructure, internet] | |
| 961 | + tier: C | |
| 962 | + country: US | |
| 963 | + language: en | |
| 964 | + aliases: [frr, frrouting] | |
| 965 | + discover: { rss: false } | |
| 966 | + sensors: | |
| 967 | + - { name: frr releases, url: "https://github.com/FRRouting/frr/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: FRRouting/frr, kind: releases } } | |
| 968 | + - id: powerdns | |
| 969 | + name: PowerDNS | |
| 970 | + domain: powerdns.com | |
| 971 | + homepage: https://www.powerdns.com | |
| 972 | + categories: [open-source, infrastructure, internet] | |
| 973 | + tier: C | |
| 974 | + country: NL | |
| 975 | + language: en | |
| 976 | + aliases: [powerdns, pdns] | |
| 977 | + discover: { rss: true } | |
| 978 | + sensors: | |
| 979 | + - { name: pdns releases, url: "https://github.com/PowerDNS/pdns/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: PowerDNS/pdns, kind: releases } } | |
| 980 | + - id: victoriametrics | |
| 981 | + name: VictoriaMetrics | |
| 982 | + domain: victoriametrics.com | |
| 983 | + categories: [open-source, infrastructure] | |
| 984 | + tier: C | |
| 985 | + country: US | |
| 986 | + language: en | |
| 987 | + aliases: [victoriametrics, victoria metrics] | |
| 988 | + discover: { rss: true } | |
| 989 | + sensors: | |
| 990 | + - { name: releases, url: "https://github.com/VictoriaMetrics/VictoriaMetrics/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: VictoriaMetrics/VictoriaMetrics, kind: releases } } | |
| 991 | + - id: prometheus | |
| 992 | + extend: true | |
| 993 | + sensors: | |
| 994 | + - { name: alertmanager releases, url: "https://github.com/prometheus/alertmanager/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: prometheus/alertmanager, kind: releases } } | |
added
config/sources.d/42-cybersecurity.yaml
+1770 −0
@@ -0,0 +1,1770 @@ | ||
| 1 | +# config/sources.d/42-cybersecurity.yaml — cybersecurity coverage (2026-09-11): CVE/KEV/EPSS feeds, vendor PSIRT | |
| 2 | +# advisories, national CERTs, exploit & ransomware trackers, threat-intel research blogs, supply-chain / dependency | |
| 3 | +# vulnerability databases, security tooling releases and security media. Extends the founding cyber block (cisa, nvd, | |
| 4 | +# nist, cert-cc, mitre, microsoft-security, …) and earlier fragments; every URL below was fetched and parsed on | |
| 5 | +# 2026-09-11. Blocked / JS-only pages (Oracle CPU, Adobe PSIRT, Broadcom/VMware advisories, Citrix bulletins, SonicWall | |
| 6 | +# PSIRT, Juniper, F5 MyF5, Akamai blog RSS, Trellix, BSI RSS, ENISA news RSS, ACSC, Shadowserver, Packet Storm TOS wall, | |
| 7 | +# CVE.org list API, OSV POST API, VulnCheck auth) are recorded in `notes:` instead of pretending. | |
| 8 | +sources: | |
| 9 | + # ───────────────────────── A · US government, CVE ecosystem, scoring ───────────────────────── | |
| 10 | + - id: cisa | |
| 11 | + extend: true | |
| 12 | + country: US | |
| 13 | + aliases: [us-cert, ics-cert, cisa kev] | |
| 14 | + products: | |
| 15 | + - { name: ICS Advisories, type: service, aliases: [ics-cert advisories, icsa] } | |
| 16 | + - { name: Vulnrichment, type: dataset, aliases: [cisa vulnrichment, ssvc] } | |
| 17 | + sensors: | |
| 18 | + - { name: ics advisories feed, url: "https://www.cisa.gov/cybersecurity-advisories/ics-advisories.xml", type: RSS, connector: rss, tier: A, config: { maxItems: 60 } } | |
| 19 | + - { name: ics medical advisories feed, url: "https://www.cisa.gov/cybersecurity-advisories/ics-medical-advisories.xml", type: RSS, connector: rss, tier: B } | |
| 20 | + - { name: alerts feed, url: "https://www.cisa.gov/cybersecurity-advisories/alerts.xml", type: RSS, connector: rss, tier: S } | |
| 21 | + - { name: cybersecurity advisories feed, url: "https://www.cisa.gov/cybersecurity-advisories/cybersecurity-advisories.xml", type: RSS, connector: rss, tier: A } | |
| 22 | + - { name: analysis reports feed, url: "https://www.cisa.gov/cybersecurity-advisories/analysis-reports.xml", type: RSS, connector: rss, tier: B } | |
| 23 | + - { name: blog feed, url: "https://www.cisa.gov/blog.xml", type: RSS, connector: rss, tier: C } | |
| 24 | + - { name: vulnrichment commits, url: "https://github.com/cisagov/vulnrichment/commits/develop.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: cisagov/vulnrichment, kind: commits, branch: develop } } | |
| 25 | + - { name: scubagear releases, url: "https://github.com/cisagov/ScubaGear/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: cisagov/ScubaGear, kind: releases } } | |
| 26 | + - id: nvd | |
| 27 | + extend: true | |
| 28 | + country: US | |
| 29 | + llm: false | |
| 30 | + sensors: | |
| 31 | + - name: newly published cves | |
| 32 | + url: "https://services.nvd.nist.gov/rest/json/cves/2.0?pubStartDate={now-2h}&pubEndDate={now}&resultsPerPage=200" | |
| 33 | + type: REST_API | |
| 34 | + connector: jsonlist | |
| 35 | + tier: A | |
| 36 | + interval: 900 | |
| 37 | + config: { itemsPath: vulnerabilities, keyField: cve.id, titleTemplate: "{cve.id} — {cve.vulnStatus} ({cve.sourceIdentifier})", summaryField: cve.descriptions, dateField: cve.published, urlTemplate: "https://nvd.nist.gov/vuln/detail/{key}", compareFields: [cve.vulnStatus, cve.metrics], noConditional: true, maxItems: 200 } | |
| 38 | + - id: nist | |
| 39 | + extend: true | |
| 40 | + country: US | |
| 41 | + sensors: | |
| 42 | + - { name: cybersecurity insights blog, url: "https://www.nist.gov/blogs/cybersecurity-insights/rss.xml", type: RSS, connector: rss, tier: C } | |
| 43 | + - id: cert-cc | |
| 44 | + extend: true | |
| 45 | + country: US | |
| 46 | + sensors: | |
| 47 | + - { name: vulnerability notes atom, url: "https://www.kb.cert.org/vuls/atomfeed/", type: ATOM, connector: rss, tier: A } | |
| 48 | + - id: mitre | |
| 49 | + extend: true | |
| 50 | + country: US | |
| 51 | + products: | |
| 52 | + - { name: CVE Program, type: service, aliases: [cve.org, cve list, cna] } | |
| 53 | + - { name: ATT&CK, type: dataset, aliases: [mitre att&ck, attack framework] } | |
| 54 | + - { name: CWE, type: dataset, aliases: [common weakness enumeration] } | |
| 55 | + notes: "CVE.org list API (cveawg.mitre.org/api/cve-id) requires CNA credentials → not a sensor; mitre.org RSS returns 403 (Akamai)." | |
| 56 | + sensors: | |
| 57 | + - { name: cve program blog, url: "https://medium.com/feed/@cve_program", type: RSS, connector: rss, tier: C } | |
| 58 | + - { name: attack blog, url: "https://medium.com/feed/mitre-attack", type: RSS, connector: rss, tier: C } | |
| 59 | + - { name: cti stix releases, url: "https://github.com/mitre/cti/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: mitre/cti, kind: releases } } | |
| 60 | + - { name: cve schema releases, url: "https://github.com/CVEProject/cve-schema/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: CVEProject/cve-schema, kind: releases } } | |
| 61 | + - { name: attack navigator releases, url: "https://github.com/mitre-attack/attack-navigator/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: mitre-attack/attack-navigator, kind: releases } } | |
| 62 | + - { name: attack flow releases, url: "https://github.com/center-for-threat-informed-defense/attack-flow/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: center-for-threat-informed-defense/attack-flow, kind: releases } } | |
| 63 | + - id: first-org | |
| 64 | + extend: true | |
| 65 | + country: US | |
| 66 | + products: | |
| 67 | + - { name: EPSS, type: dataset, aliases: [exploit prediction scoring system, epss score] } | |
| 68 | + sensors: | |
| 69 | + - { name: epss scores updated today, url: "https://api.first.org/data/v1/epss?days=1&limit=100", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: data, keyField: cve, titleTemplate: "{cve} — EPSS {epss} (percentile {percentile})", dateField: date, urlTemplate: "https://nvd.nist.gov/vuln/detail/{key}", compareFields: [epss, percentile], maxItems: 100 } } | |
| 70 | + - { name: epss top scores, url: "https://api.first.org/data/v1/epss?epss-gt=0.9&order=!date&limit=100", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: data, keyField: cve, titleTemplate: "{cve} — EPSS {epss}", dateField: date, urlTemplate: "https://nvd.nist.gov/vuln/detail/{key}", compareFields: [epss], maxItems: 100 } } | |
| 71 | + - { name: press releases, url: "https://www.first.org/newsroom/releases/rss.xml", type: RSS, connector: rss, tier: C } | |
| 72 | + - id: ic3 | |
| 73 | + name: FBI Internet Crime Complaint Center | |
| 74 | + domain: ic3.gov | |
| 75 | + homepage: https://www.ic3.gov | |
| 76 | + categories: [cyber, government] | |
| 77 | + tier: A | |
| 78 | + country: US | |
| 79 | + aliases: [ic3, fbi ic3, internet crime complaint center] | |
| 80 | + discover: { rss: false } | |
| 81 | + sensors: | |
| 82 | + - { name: public service announcements, url: "https://www.ic3.gov/PSA/RSS", type: RSS, connector: rss, tier: A } | |
| 83 | + - { name: cybersecurity advisories, url: "https://www.ic3.gov/CSA/RSS", type: RSS, connector: rss, tier: A } | |
| 84 | + - id: circl | |
| 85 | + name: CIRCL (Computer Incident Response Center Luxembourg) | |
| 86 | + domain: circl.lu | |
| 87 | + homepage: https://www.circl.lu | |
| 88 | + categories: [cyber, government] | |
| 89 | + tier: B | |
| 90 | + country: LU | |
| 91 | + aliases: [circl, vulnerability-lookup, cve-search] | |
| 92 | + products: | |
| 93 | + - { name: Vulnerability-Lookup, type: service, aliases: [vulnerability.circl.lu] } | |
| 94 | + - { name: MISP, type: software, aliases: [misp project, malware information sharing platform] } | |
| 95 | + discover: { rss: false } | |
| 96 | + sensors: | |
| 97 | + - { name: news feed, url: "https://www.circl.lu/rss.xml", type: RSS, connector: rss, tier: C } | |
| 98 | + - { name: latest vulnerabilities (vulnerability-lookup), url: "https://cve.circl.lu/api/last/30", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "", keyField: id, titleTemplate: "{id}", summaryField: details, dateField: published, urlTemplate: "https://vulnerability.circl.lu/vuln/{key}", compareFields: [modified], maxItems: 30 } } | |
| 99 | + - { name: misp releases, url: "https://github.com/MISP/MISP/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: MISP/MISP, kind: releases } } | |
| 100 | + | |
| 101 | + # ───────────────────────── B · Platform vendors: advisories & security blogs ───────────────────────── | |
| 102 | + - id: microsoft-security | |
| 103 | + extend: true | |
| 104 | + country: US | |
| 105 | + products: | |
| 106 | + - { name: Security Update Guide, type: service, aliases: [msrc update guide, patch tuesday, msrc cvrf] } | |
| 107 | + - { name: Microsoft Threat Intelligence, type: service, aliases: [mstic, microsoft threat intelligence center] } | |
| 108 | + sensors: | |
| 109 | + - { name: security update guide feed, url: "https://api.msrc.microsoft.com/update-guide/rss", type: RSS, connector: rss, tier: S, config: { maxItems: 200 } } | |
| 110 | + - { name: security blog feed, url: "https://www.microsoft.com/en-us/security/blog/feed/", type: RSS, connector: rss, tier: B } | |
| 111 | + - { name: threat intelligence blog feed, url: "https://www.microsoft.com/en-us/security/blog/topic/threat-intelligence/feed/", type: RSS, connector: rss, tier: A } | |
| 112 | + - id: apple-security | |
| 113 | + extend: true | |
| 114 | + country: US | |
| 115 | + sensors: | |
| 116 | + - { name: security research blog, url: "https://security.apple.com/blog/", type: HTML, connector: http, tier: C } | |
| 117 | + - id: android | |
| 118 | + name: Android Security | |
| 119 | + domain: source.android.com | |
| 120 | + homepage: https://source.android.com/docs/security | |
| 121 | + categories: [cyber, consumer-tech] | |
| 122 | + tier: A | |
| 123 | + country: US | |
| 124 | + aliases: [android security bulletin, android open source project security, aosp security] | |
| 125 | + discover: { rss: false } | |
| 126 | + sensors: | |
| 127 | + - { name: security bulletins index, url: "https://source.android.com/docs/security/bulletin", type: HTML, connector: http, tier: A } | |
| 128 | + - id: chromium | |
| 129 | + extend: true | |
| 130 | + country: US | |
| 131 | + sensors: | |
| 132 | + - { name: chrome stable channel updates, url: "https://chromereleases.googleblog.com/feeds/posts/default/-/Stable%20updates", type: ATOM, connector: rss, tier: A } | |
| 133 | + - id: google | |
| 134 | + extend: true | |
| 135 | + sensors: | |
| 136 | + - { name: safety & security blog, url: "https://blog.google/technology/safety-security/rss/", type: RSS, connector: rss, tier: B } | |
| 137 | + - id: google-cloud | |
| 138 | + extend: true | |
| 139 | + products: | |
| 140 | + - { name: Google Cloud Security Bulletins, type: service, aliases: [gcp security bulletins, gke security bulletins] } | |
| 141 | + sensors: | |
| 142 | + - { name: security bulletins feed, url: "https://cloud.google.com/feeds/google-cloud-security-bulletins.xml", type: ATOM, connector: rss, tier: A } | |
| 143 | + - { name: gke security bulletins feed, url: "https://cloud.google.com/feeds/gke-security-bulletins.xml", type: ATOM, connector: rss, tier: A } | |
| 144 | + - { name: identity & security blog, url: "https://cloudblog.withgoogle.com/products/identity-security/rss/", type: RSS, connector: rss, tier: C } | |
| 145 | + - id: aws | |
| 146 | + extend: true | |
| 147 | + sensors: | |
| 148 | + - { name: security blog feed, url: "https://aws.amazon.com/blogs/security/feed/", type: RSS, connector: rss, tier: B } | |
| 149 | + - id: cisco-security | |
| 150 | + extend: true | |
| 151 | + country: US | |
| 152 | + products: | |
| 153 | + - { name: Cisco Talos, type: service, aliases: [talos, talos intelligence] } | |
| 154 | + sensors: | |
| 155 | + - { name: event responses feed, url: "https://sec.cloudapps.cisco.com/security/center/eventResponses_20.xml", type: RSS, connector: rss, tier: A } | |
| 156 | + - { name: talos blog feed, url: "https://blog.talosintelligence.com/rss/", type: RSS, connector: rss, tier: B } | |
| 157 | + - { name: cisco security blog feed, url: "https://blogs.cisco.com/security/feed", type: RSS, connector: rss, tier: C } | |
| 158 | + - id: palo-alto-networks | |
| 159 | + extend: true | |
| 160 | + country: US | |
| 161 | + sensors: | |
| 162 | + - { name: corporate blog feed, url: "https://www.paloaltonetworks.com/blog/feed/", type: RSS, connector: rss, tier: C } | |
| 163 | + - id: fortinet | |
| 164 | + extend: true | |
| 165 | + country: US | |
| 166 | + products: | |
| 167 | + - { name: FortiGuard Outbreak Alerts, type: service, aliases: [outbreak alerts] } | |
| 168 | + notes: "fortinet.com blog RSS paths return 404; feeds.fortinet.com does not resolve." | |
| 169 | + sensors: | |
| 170 | + - { name: outbreak alerts feed, url: "https://filestore.fortinet.com/fortiguard/rss/outbreakalert.xml", type: RSS, connector: rss, tier: A } | |
| 171 | + - id: sentinelone | |
| 172 | + extend: true | |
| 173 | + country: US | |
| 174 | + products: | |
| 175 | + - { name: SentinelLabs, type: service, aliases: [sentinel labs] } | |
| 176 | + sensors: | |
| 177 | + - { name: sentinellabs feed, url: "https://www.sentinelone.com/labs/feed/", type: RSS, connector: rss, tier: B } | |
| 178 | + - id: sophos | |
| 179 | + extend: true | |
| 180 | + country: GB | |
| 181 | + products: | |
| 182 | + - { name: Sophos X-Ops, type: service, aliases: [x-ops, sophoslabs] } | |
| 183 | + sensors: | |
| 184 | + - { name: threat research feed, url: "https://www.sophos.com/en-us/category/threat-research/feed", type: RSS, connector: rss, tier: B } | |
| 185 | + - id: trend-micro | |
| 186 | + extend: true | |
| 187 | + country: JP | |
| 188 | + notes: "trendmicro.com research RSS paths return 404; FeedBurner security-news feed still served." | |
| 189 | + sensors: | |
| 190 | + - { name: security news feed, url: "https://feeds.feedburner.com/TrendMicroSecurityNews", type: RSS, connector: rss, tier: B } | |
| 191 | + - id: zero-day-initiative | |
| 192 | + name: Zero Day Initiative | |
| 193 | + domain: zerodayinitiative.com | |
| 194 | + homepage: https://www.zerodayinitiative.com | |
| 195 | + categories: [cyber] | |
| 196 | + tier: A | |
| 197 | + country: US | |
| 198 | + aliases: [zdi, trend micro zdi, pwn2own] | |
| 199 | + llm: false | |
| 200 | + discover: { rss: false } | |
| 201 | + sensors: | |
| 202 | + - { name: published advisories feed, url: "https://www.zerodayinitiative.com/rss/published/", type: RSS, connector: rss, tier: A, config: { maxItems: 100 } } | |
| 203 | + - { name: upcoming advisories feed, url: "https://www.zerodayinitiative.com/rss/upcoming/", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 204 | + - { name: blog feed, url: "https://www.zerodayinitiative.com/blog?format=rss", type: RSS, connector: rss, tier: C } | |
| 205 | + - id: tenable | |
| 206 | + extend: true | |
| 207 | + country: US | |
| 208 | + sensors: | |
| 209 | + - { name: cyber exposure alerts feed, url: "https://www.tenable.com/blog/cyber-exposure-alerts/feed", type: RSS, connector: rss, tier: A } | |
| 210 | + - { name: product security advisories feed, url: "https://www.tenable.com/security/feed", type: RSS, connector: rss, tier: B } | |
| 211 | + - id: rapid7 | |
| 212 | + extend: true | |
| 213 | + country: US | |
| 214 | + sensors: | |
| 215 | + - { name: emergent threat response feed, url: "https://www.rapid7.com/blog/tag/emergent-threat-response/rss/", type: RSS, connector: rss, tier: A } | |
| 216 | + - id: crowdstrike | |
| 217 | + extend: true | |
| 218 | + country: US | |
| 219 | + - id: mandiant | |
| 220 | + extend: true | |
| 221 | + country: US | |
| 222 | + - id: malwarebytes | |
| 223 | + extend: true | |
| 224 | + country: US | |
| 225 | + - id: ivanti | |
| 226 | + extend: true | |
| 227 | + country: US | |
| 228 | + sensors: | |
| 229 | + - { name: security advisory blog feed, url: "https://www.ivanti.com/blog/topics/security-advisory/rss", type: RSS, connector: rss, tier: A } | |
| 230 | + - id: broadcom | |
| 231 | + extend: true | |
| 232 | + categories: [cyber] | |
| 233 | + notes: "VMware security advisories moved to support.broadcom.com (JS-rendered) → no advisories sensor; VMware security blog RSS kept." | |
| 234 | + sensors: | |
| 235 | + - { name: vmware security blog feed, url: "https://blogs.vmware.com/security/feed", type: RSS, connector: rss, tier: B } | |
| 236 | + - id: atlassian | |
| 237 | + extend: true | |
| 238 | + categories: [cyber] | |
| 239 | + sensors: | |
| 240 | + - { name: security advisories space feed, url: "https://confluence.atlassian.com/createrssfeed.action?types=page&spaces=SECURITY&title=Security+Advisories&labelString=&excludedSpaceKeys=&sort=modified&maxResults=25&timeSpan=600&showContent=true&confirm=Create+RSS+Feed", type: ATOM, connector: rss, tier: A } | |
| 241 | + - id: yubico | |
| 242 | + name: Yubico | |
| 243 | + domain: yubico.com | |
| 244 | + homepage: https://www.yubico.com | |
| 245 | + categories: [cyber, consumer-tech] | |
| 246 | + tier: B | |
| 247 | + country: SE | |
| 248 | + aliases: [yubikey] | |
| 249 | + discover: { rss: true } | |
| 250 | + sensors: | |
| 251 | + - { name: blog feed, url: "https://www.yubico.com/feed/", type: RSS, connector: rss, tier: C } | |
| 252 | + - id: jamf | |
| 253 | + name: Jamf | |
| 254 | + domain: jamf.com | |
| 255 | + homepage: https://www.jamf.com | |
| 256 | + categories: [cyber, enterprise] | |
| 257 | + tier: C | |
| 258 | + country: US | |
| 259 | + aliases: [jamf threat labs] | |
| 260 | + discover: { rss: false } | |
| 261 | + sensors: | |
| 262 | + - { name: blog feed, url: "https://www.jamf.com/blog/rss/", type: RSS, connector: rss, tier: C } | |
| 263 | + - id: hashicorp | |
| 264 | + extend: true | |
| 265 | + sensors: | |
| 266 | + - { name: security announcements forum, url: "https://discuss.hashicorp.com/c/security/52.rss", type: RSS, connector: rss, tier: A } | |
| 267 | + - id: mongodb | |
| 268 | + extend: true | |
| 269 | + categories: [cyber] | |
| 270 | + sensors: | |
| 271 | + - { name: server security issues (jira), url: "https://jira.mongodb.org/sr/jira.issueviews:searchrequest-rss/temp/SearchRequest.xml?jqlQuery=project+%3D+SERVER+AND+labels+%3D+security&tempMax=50", type: RSS, connector: rss, tier: B } | |
| 272 | + - id: postgresql | |
| 273 | + extend: true | |
| 274 | + categories: [cyber] | |
| 275 | + sensors: | |
| 276 | + - { name: security information page, url: "https://www.postgresql.org/support/security/", type: HTML, connector: http, tier: B } | |
| 277 | + | |
| 278 | + # ───────────────────────── C · Source code platforms, Linux, language ecosystems (dependency vulnerabilities) ───────────────────────── | |
| 279 | + - id: github | |
| 280 | + extend: true | |
| 281 | + categories: [cyber] | |
| 282 | + products: | |
| 283 | + - { name: GitHub Advisory Database, type: dataset, aliases: [ghsa, github advisories, advisory database] } | |
| 284 | + - { name: GitHub Security Lab, type: service, aliases: [security lab] } | |
| 285 | + sensors: | |
| 286 | + - { name: advisory database (all ecosystems), url: "https://api.github.com/advisories?per_page=100&sort=published", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, summaryField: description, dateField: published_at, compareFields: [severity, updated_at, withdrawn_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 287 | + - { name: advisories npm, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=npm", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 288 | + - { name: advisories pypi, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=pip", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 289 | + - { name: advisories maven, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=maven", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 290 | + - { name: advisories go, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=go", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 291 | + - { name: advisories rubygems, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=rubygems", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 292 | + - { name: advisories rust crates, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=rust", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 293 | + - { name: advisories composer, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=composer", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 294 | + - { name: advisories nuget, url: "https://api.github.com/advisories?per_page=100&sort=published&ecosystem=nuget", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — {severity}: {summary}", urlField: html_url, dateField: published_at, compareFields: [severity, updated_at], maxItems: 100, headers: { accept: application/vnd.github+json } } } | |
| 295 | + - { name: critical advisories, url: "https://api.github.com/advisories?per_page=50&sort=published&severity=critical", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "", keyField: ghsa_id, titleTemplate: "{ghsa_id} {cve_id} — CRITICAL: {summary}", urlField: html_url, summaryField: description, dateField: published_at, compareFields: [updated_at, withdrawn_at], maxItems: 50, headers: { accept: application/vnd.github+json } } } | |
| 296 | + - { name: security lab advisories feed, url: "https://securitylab.github.com/advisories/feed.xml", type: RSS, connector: rss, tier: B } | |
| 297 | + - { name: vulnerability research blog feed, url: "https://github.blog/security/vulnerability-research/feed/", type: RSS, connector: rss, tier: C } | |
| 298 | + - { name: advisory database commits, url: "https://github.com/github/advisory-database/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: github/advisory-database, kind: commits, branch: main } } | |
| 299 | + - id: gitlab | |
| 300 | + extend: true | |
| 301 | + categories: [cyber] | |
| 302 | + sensors: | |
| 303 | + - { name: patch & security releases feed, url: "https://docs.gitlab.com/releases/patch-releases.xml", type: ATOM, connector: rss, tier: A, config: { maxItems: 50 } } | |
| 304 | + - id: kubernetes | |
| 305 | + extend: true | |
| 306 | + categories: [cyber] | |
| 307 | + products: | |
| 308 | + - { name: Kubernetes Official CVE Feed, type: dataset, aliases: [k8s cve feed] } | |
| 309 | + sensors: | |
| 310 | + - { name: official cve feed, url: "https://kubernetes.io/docs/reference/issues-security/official-cve-feed/index.json", type: JSON, connector: jsonlist, tier: A, config: { itemsPath: items, keyField: id, titleField: summary, urlField: url, summaryField: content_text, dateField: date_published, compareFields: [status, summary], maxItems: 200 } } | |
| 311 | + - id: docker | |
| 312 | + extend: true | |
| 313 | + categories: [cyber] | |
| 314 | + sensors: | |
| 315 | + - { name: security blog category feed, url: "https://www.docker.com/blog/category/security/feed/", type: RSS, connector: rss, tier: C } | |
| 316 | + - id: red-hat | |
| 317 | + extend: true | |
| 318 | + categories: [cyber] | |
| 319 | + products: | |
| 320 | + - { name: Red Hat Product Security, type: service, aliases: [rhsa, red hat security advisories, red hat security data api] } | |
| 321 | + notes: "securitydata/cve.json returns a JSON-encoded string (double-encoded) → not parseable by jsonlist; csaf.json is a proper array." | |
| 322 | + sensors: | |
| 323 | + - { name: security advisories (csaf api), url: "https://access.redhat.com/hydra/rest/securitydata/csaf.json?per_page=100", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: RHSA, titleTemplate: "{RHSA} — {severity}: {CVEs}", urlField: resource_url, dateField: released_on, compareFields: [severity, released_on], maxItems: 100 } } | |
| 324 | + - { name: security blog channel feed, url: "https://www.redhat.com/en/rss/blog/channel/security", type: RSS, connector: rss, tier: C } | |
| 325 | + - id: suse | |
| 326 | + extend: true | |
| 327 | + categories: [cyber] | |
| 328 | + sensors: | |
| 329 | + - { name: security blog tag feed, url: "https://www.suse.com/c/tag/security/feed/", type: RSS, connector: rss, tier: C } | |
| 330 | + - id: gentoo | |
| 331 | + name: Gentoo Linux | |
| 332 | + domain: gentoo.org | |
| 333 | + homepage: https://www.gentoo.org | |
| 334 | + categories: [open-source, cyber, developer] | |
| 335 | + tier: B | |
| 336 | + country: US | |
| 337 | + aliases: [gentoo, glsa, gentoo linux security advisories] | |
| 338 | + discover: { rss: false } | |
| 339 | + sensors: | |
| 340 | + - { name: glsa feed, url: "https://security.gentoo.org/glsa/feed.rss", type: RSS, connector: rss, tier: A } | |
| 341 | + - id: linux-kernel | |
| 342 | + extend: true | |
| 343 | + categories: [cyber] | |
| 344 | + products: | |
| 345 | + - { name: Linux kernel CVE team, type: service, aliases: [linux-cve-announce, kernel cve] } | |
| 346 | + sensors: | |
| 347 | + - { name: linux-cve-announce feed, url: "https://lore.kernel.org/linux-cve-announce/new.atom", type: ATOM, connector: rss, tier: A, config: { maxItems: 100 } } | |
| 348 | + - id: openssl | |
| 349 | + extend: true | |
| 350 | + sensors: | |
| 351 | + - { name: vulnerabilities page, url: "https://openssl-library.org/news/vulnerabilities/", type: HTML, connector: http, tier: A } | |
| 352 | + - id: python | |
| 353 | + extend: true | |
| 354 | + categories: [cyber] | |
| 355 | + sensors: | |
| 356 | + - { name: psf advisory database commits, url: "https://github.com/psf/advisory-database/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: psf/advisory-database, kind: commits, branch: main } } | |
| 357 | + - { name: pypa advisory database commits, url: "https://github.com/pypa/advisory-database/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: pypa/advisory-database, kind: commits, branch: main } } | |
| 358 | + - id: go | |
| 359 | + extend: true | |
| 360 | + categories: [cyber] | |
| 361 | + products: | |
| 362 | + - { name: Go Vulnerability Database, type: dataset, aliases: [govulndb, vuln.go.dev, govulncheck] } | |
| 363 | + sensors: | |
| 364 | + - { name: vulnerability database index, url: "https://vuln.go.dev/index/vulns.json", type: JSON, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: id, titleTemplate: "{id} — {aliases}", dateField: modified, urlTemplate: "https://pkg.go.dev/vuln/{key}", compareFields: [modified], maxItems: 10000 } } | |
| 365 | + - id: rust | |
| 366 | + extend: true | |
| 367 | + categories: [cyber] | |
| 368 | + products: | |
| 369 | + - { name: RustSec Advisory Database, type: dataset, aliases: [rustsec, cargo-audit] } | |
| 370 | + sensors: | |
| 371 | + - { name: rustsec advisories feed, url: "https://rustsec.org/feed.xml", type: ATOM, connector: rss, tier: A } | |
| 372 | + - { name: rustsec advisory-db commits, url: "https://github.com/rustsec/advisory-db/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: rustsec/advisory-db, kind: commits, branch: main } } | |
| 373 | + - id: ruby | |
| 374 | + extend: true | |
| 375 | + categories: [cyber] | |
| 376 | + sensors: | |
| 377 | + - { name: ruby advisory database commits, url: "https://github.com/rubysec/ruby-advisory-db/commits/master.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: rubysec/ruby-advisory-db, kind: commits, branch: master } } | |
| 378 | + - id: php | |
| 379 | + extend: true | |
| 380 | + categories: [cyber] | |
| 381 | + sensors: | |
| 382 | + - { name: friendsofphp security advisories commits, url: "https://github.com/FriendsOfPHP/security-advisories/commits/master.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: FriendsOfPHP/security-advisories, kind: commits, branch: master } } | |
| 383 | + - id: nodejs | |
| 384 | + extend: true | |
| 385 | + categories: [cyber] | |
| 386 | + sensors: | |
| 387 | + - { name: security working group commits, url: "https://github.com/nodejs/security-wg/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: nodejs/security-wg, kind: commits, branch: main } } | |
| 388 | + - id: wordpress | |
| 389 | + extend: true | |
| 390 | + categories: [cyber] | |
| 391 | + sensors: | |
| 392 | + - { name: security news feed, url: "https://wordpress.org/news/category/security/feed/", type: RSS, connector: rss, tier: A } | |
| 393 | + - id: wordfence | |
| 394 | + name: Wordfence | |
| 395 | + domain: wordfence.com | |
| 396 | + homepage: https://www.wordfence.com | |
| 397 | + categories: [cyber, internet] | |
| 398 | + tier: B | |
| 399 | + country: US | |
| 400 | + aliases: [defiant, wordfence threat intelligence] | |
| 401 | + discover: { rss: false } | |
| 402 | + sensors: | |
| 403 | + - { name: blog feed, url: "https://www.wordfence.com/blog/feed/", type: RSS, connector: rss, tier: B } | |
| 404 | + - id: patchstack | |
| 405 | + name: Patchstack | |
| 406 | + domain: patchstack.com | |
| 407 | + homepage: https://patchstack.com | |
| 408 | + categories: [cyber, internet] | |
| 409 | + tier: B | |
| 410 | + country: EE | |
| 411 | + discover: { rss: false } | |
| 412 | + sensors: | |
| 413 | + - { name: articles feed, url: "https://patchstack.com/feed/", type: RSS, connector: rss, tier: B } | |
| 414 | + - id: wpscan | |
| 415 | + name: WPScan | |
| 416 | + domain: wpscan.com | |
| 417 | + homepage: https://wpscan.com | |
| 418 | + categories: [cyber, internet] | |
| 419 | + tier: C | |
| 420 | + country: GB | |
| 421 | + aliases: [wpscan vulnerability database] | |
| 422 | + discover: { rss: false } | |
| 423 | + sensors: | |
| 424 | + - { name: blog feed, url: "https://wpscan.com/blog/feed/", type: RSS, connector: rss, tier: C } | |
| 425 | + - id: sucuri | |
| 426 | + name: Sucuri | |
| 427 | + domain: sucuri.net | |
| 428 | + homepage: https://sucuri.net | |
| 429 | + categories: [cyber, internet] | |
| 430 | + tier: C | |
| 431 | + country: US | |
| 432 | + aliases: [sucuri labs] | |
| 433 | + discover: { rss: false } | |
| 434 | + sensors: | |
| 435 | + - { name: blog feed, url: "https://blog.sucuri.net/feed", type: RSS, connector: rss, tier: C } | |
| 436 | + | |
| 437 | + # ───────────────────────── D · Supply-chain security, SBOM/VEX standards, security tooling ───────────────────────── | |
| 438 | + - id: openssf | |
| 439 | + extend: true | |
| 440 | + products: | |
| 441 | + - { name: OpenSSF Scorecard, type: software, aliases: [scorecard] } | |
| 442 | + - { name: OSV Schema, type: standard, aliases: [osv schema, open source vulnerability schema] } | |
| 443 | + sensors: | |
| 444 | + - { name: scorecard releases, url: "https://github.com/ossf/scorecard/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: ossf/scorecard, kind: releases } } | |
| 445 | + - { name: osv schema releases, url: "https://github.com/ossf/osv-schema/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: ossf/osv-schema, kind: releases } } | |
| 446 | + - id: osv | |
| 447 | + name: OSV.dev | |
| 448 | + domain: osv.dev | |
| 449 | + homepage: https://osv.dev | |
| 450 | + categories: [cyber, open-source, developer] | |
| 451 | + tier: B | |
| 452 | + country: US | |
| 453 | + aliases: [open source vulnerabilities, osv scanner, google osv] | |
| 454 | + notes: "The OSV query API is POST-only → no jsonlist sensor; release feed of the scanner used instead." | |
| 455 | + discover: { rss: false } | |
| 456 | + sensors: | |
| 457 | + - { name: osv-scanner releases, url: "https://github.com/google/osv-scanner/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: google/osv-scanner, kind: releases } } | |
| 458 | + - id: sigstore | |
| 459 | + name: Sigstore | |
| 460 | + domain: sigstore.dev | |
| 461 | + homepage: https://www.sigstore.dev | |
| 462 | + categories: [cyber, open-source, developer] | |
| 463 | + tier: C | |
| 464 | + country: US | |
| 465 | + aliases: [cosign, fulcio, rekor] | |
| 466 | + discover: { rss: false } | |
| 467 | + sensors: | |
| 468 | + - { name: cosign releases, url: "https://github.com/sigstore/cosign/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: sigstore/cosign, kind: releases } } | |
| 469 | + - id: chainguard | |
| 470 | + name: Chainguard | |
| 471 | + domain: chainguard.dev | |
| 472 | + homepage: https://www.chainguard.dev | |
| 473 | + categories: [cyber, developer, cloud] | |
| 474 | + tier: C | |
| 475 | + country: US | |
| 476 | + aliases: [wolfi, chainguard images] | |
| 477 | + discover: { rss: false } | |
| 478 | + sensors: | |
| 479 | + - { name: unchained blog feed, url: "https://www.chainguard.dev/unchained/rss.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 480 | + - { name: wolfi os commits, url: "https://github.com/wolfi-dev/os/commits/main.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: wolfi-dev/os, kind: commits, branch: main } } | |
| 481 | + - { name: melange releases, url: "https://github.com/chainguard-dev/melange/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: chainguard-dev/melange, kind: releases } } | |
| 482 | + - id: aqua-security | |
| 483 | + name: Aqua Security | |
| 484 | + domain: aquasec.com | |
| 485 | + homepage: https://www.aquasec.com | |
| 486 | + categories: [cyber, cloud, developer] | |
| 487 | + tier: C | |
| 488 | + country: IL | |
| 489 | + aliases: [aqua nautilus, trivy] | |
| 490 | + products: | |
| 491 | + - { name: Trivy, type: software, aliases: [trivy scanner] } | |
| 492 | + discover: { rss: false } | |
| 493 | + sensors: | |
| 494 | + - { name: blog feed, url: "https://blog.aquasec.com/rss.xml", type: RSS, connector: rss, tier: C } | |
| 495 | + - { name: trivy releases, url: "https://github.com/aquasecurity/trivy/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: aquasecurity/trivy, kind: releases } } | |
| 496 | + - id: anchore | |
| 497 | + name: Anchore | |
| 498 | + domain: anchore.com | |
| 499 | + homepage: https://anchore.com | |
| 500 | + categories: [cyber, developer] | |
| 501 | + tier: D | |
| 502 | + country: US | |
| 503 | + aliases: [grype, syft] | |
| 504 | + discover: { rss: true } | |
| 505 | + sensors: | |
| 506 | + - { name: grype releases, url: "https://github.com/anchore/grype/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: anchore/grype, kind: releases } } | |
| 507 | + - { name: syft releases, url: "https://github.com/anchore/syft/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: anchore/syft, kind: releases } } | |
| 508 | + - id: cyclonedx | |
| 509 | + name: OWASP CycloneDX | |
| 510 | + domain: cyclonedx.org | |
| 511 | + homepage: https://cyclonedx.org | |
| 512 | + categories: [cyber, standards, open-source] | |
| 513 | + tier: D | |
| 514 | + country: US | |
| 515 | + aliases: [cyclonedx, sbom standard] | |
| 516 | + discover: { rss: false } | |
| 517 | + sensors: | |
| 518 | + - { name: specification releases, url: "https://github.com/CycloneDX/specification/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: CycloneDX/specification, kind: releases } } | |
| 519 | + - id: spdx | |
| 520 | + name: SPDX | |
| 521 | + domain: spdx.dev | |
| 522 | + homepage: https://spdx.dev | |
| 523 | + categories: [cyber, standards, open-source] | |
| 524 | + tier: D | |
| 525 | + country: US | |
| 526 | + aliases: [software package data exchange] | |
| 527 | + discover: { rss: false } | |
| 528 | + sensors: | |
| 529 | + - { name: specification releases, url: "https://github.com/spdx/spdx-spec/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: spdx/spdx-spec, kind: releases } } | |
| 530 | + - id: oasis-csaf | |
| 531 | + name: OASIS CSAF | |
| 532 | + domain: oasis-open.org | |
| 533 | + homepage: https://oasis-open.github.io/csaf-documentation/ | |
| 534 | + categories: [cyber, standards] | |
| 535 | + tier: D | |
| 536 | + country: US | |
| 537 | + aliases: [csaf, common security advisory framework] | |
| 538 | + discover: { rss: false } | |
| 539 | + sensors: | |
| 540 | + - { name: csaf releases, url: "https://github.com/oasis-tcs/csaf/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: oasis-tcs/csaf, kind: releases } } | |
| 541 | + - id: openvex | |
| 542 | + name: OpenVEX | |
| 543 | + domain: openvex.dev | |
| 544 | + homepage: https://openvex.dev | |
| 545 | + categories: [cyber, standards, open-source] | |
| 546 | + tier: D | |
| 547 | + country: US | |
| 548 | + aliases: [vex, vulnerability exploitability exchange] | |
| 549 | + discover: { rss: false } | |
| 550 | + sensors: | |
| 551 | + - { name: vexctl releases, url: "https://github.com/openvex/vexctl/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: openvex/vexctl, kind: releases } } | |
| 552 | + - id: projectdiscovery | |
| 553 | + name: ProjectDiscovery | |
| 554 | + domain: projectdiscovery.io | |
| 555 | + homepage: https://projectdiscovery.io | |
| 556 | + categories: [cyber, open-source, developer] | |
| 557 | + tier: B | |
| 558 | + country: US | |
| 559 | + aliases: [nuclei, nuclei templates] | |
| 560 | + discover: { rss: false } | |
| 561 | + sensors: | |
| 562 | + - { name: nuclei releases, url: "https://github.com/projectdiscovery/nuclei/releases.atom", type: GITHUB_RELEASE, connector: github, tier: C, config: { repo: projectdiscovery/nuclei, kind: releases } } | |
| 563 | + - { name: nuclei templates releases, url: "https://github.com/projectdiscovery/nuclei-templates/releases.atom", type: GITHUB_RELEASE, connector: github, tier: B, config: { repo: projectdiscovery/nuclei-templates, kind: releases } } | |
| 564 | + - id: owasp | |
| 565 | + extend: true | |
| 566 | + products: | |
| 567 | + - { name: OWASP ZAP, type: software, aliases: [zap, zed attack proxy] } | |
| 568 | + sensors: | |
| 569 | + - { name: zap releases, url: "https://github.com/zaproxy/zaproxy/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: zaproxy/zaproxy, kind: releases } } | |
| 570 | + - id: semgrep | |
| 571 | + name: Semgrep | |
| 572 | + domain: semgrep.dev | |
| 573 | + homepage: https://semgrep.dev | |
| 574 | + categories: [cyber, developer] | |
| 575 | + tier: D | |
| 576 | + country: US | |
| 577 | + discover: { rss: true } | |
| 578 | + sensors: | |
| 579 | + - { name: releases, url: "https://github.com/semgrep/semgrep/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: semgrep/semgrep, kind: releases } } | |
| 580 | + - id: truffle-security | |
| 581 | + name: Truffle Security | |
| 582 | + domain: trufflesecurity.com | |
| 583 | + homepage: https://trufflesecurity.com | |
| 584 | + categories: [cyber, developer] | |
| 585 | + tier: D | |
| 586 | + country: US | |
| 587 | + aliases: [trufflehog] | |
| 588 | + discover: { rss: true } | |
| 589 | + sensors: | |
| 590 | + - { name: trufflehog releases, url: "https://github.com/trufflesecurity/trufflehog/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: trufflesecurity/trufflehog, kind: releases } } | |
| 591 | + - id: gitleaks | |
| 592 | + name: Gitleaks | |
| 593 | + domain: gitleaks.io | |
| 594 | + homepage: https://gitleaks.io | |
| 595 | + categories: [cyber, developer, open-source] | |
| 596 | + tier: D | |
| 597 | + country: US | |
| 598 | + discover: { rss: false } | |
| 599 | + sensors: | |
| 600 | + - { name: releases, url: "https://github.com/gitleaks/gitleaks/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: gitleaks/gitleaks, kind: releases } } | |
| 601 | + - id: wazuh | |
| 602 | + name: Wazuh | |
| 603 | + domain: wazuh.com | |
| 604 | + homepage: https://wazuh.com | |
| 605 | + categories: [cyber, open-source] | |
| 606 | + tier: D | |
| 607 | + country: US | |
| 608 | + discover: { rss: true } | |
| 609 | + sensors: | |
| 610 | + - { name: releases, url: "https://github.com/wazuh/wazuh/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: wazuh/wazuh, kind: releases } } | |
| 611 | + - id: osquery | |
| 612 | + name: osquery | |
| 613 | + domain: osquery.io | |
| 614 | + homepage: https://osquery.io | |
| 615 | + categories: [cyber, open-source] | |
| 616 | + tier: D | |
| 617 | + country: US | |
| 618 | + discover: { rss: false } | |
| 619 | + sensors: | |
| 620 | + - { name: releases, url: "https://github.com/osquery/osquery/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: osquery/osquery, kind: releases } } | |
| 621 | + - id: falco | |
| 622 | + name: Falco | |
| 623 | + domain: falco.org | |
| 624 | + homepage: https://falco.org | |
| 625 | + categories: [cyber, open-source, cloud] | |
| 626 | + tier: D | |
| 627 | + country: US | |
| 628 | + aliases: [falcosecurity] | |
| 629 | + discover: { rss: true } | |
| 630 | + sensors: | |
| 631 | + - { name: releases, url: "https://github.com/falcosecurity/falco/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: falcosecurity/falco, kind: releases } } | |
| 632 | + - id: filigran | |
| 633 | + name: Filigran (OpenCTI) | |
| 634 | + domain: filigran.io | |
| 635 | + homepage: https://filigran.io | |
| 636 | + categories: [cyber, open-source] | |
| 637 | + tier: D | |
| 638 | + country: FR | |
| 639 | + aliases: [opencti, openbas] | |
| 640 | + discover: { rss: true } | |
| 641 | + sensors: | |
| 642 | + - { name: opencti releases, url: "https://github.com/OpenCTI-Platform/opencti/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: OpenCTI-Platform/opencti, kind: releases } } | |
| 643 | + - id: sigmahq | |
| 644 | + name: SigmaHQ | |
| 645 | + domain: sigmahq.io | |
| 646 | + homepage: https://sigmahq.io | |
| 647 | + categories: [cyber, open-source] | |
| 648 | + tier: D | |
| 649 | + country: DE | |
| 650 | + aliases: [sigma rules] | |
| 651 | + discover: { rss: false } | |
| 652 | + sensors: | |
| 653 | + - { name: sigma releases, url: "https://github.com/SigmaHQ/sigma/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: SigmaHQ/sigma, kind: releases } } | |
| 654 | + - id: virustotal | |
| 655 | + name: VirusTotal | |
| 656 | + domain: virustotal.com | |
| 657 | + homepage: https://www.virustotal.com | |
| 658 | + categories: [cyber] | |
| 659 | + tier: C | |
| 660 | + country: US | |
| 661 | + aliases: [yara] | |
| 662 | + products: | |
| 663 | + - { name: YARA, type: software, aliases: [yara rules engine] } | |
| 664 | + discover: { rss: false } | |
| 665 | + sensors: | |
| 666 | + - { name: blog feed, url: "https://blog.virustotal.com/feeds/posts/default", type: ATOM, connector: rss, tier: C } | |
| 667 | + - { name: yara releases, url: "https://github.com/VirusTotal/yara/releases.atom", type: GITHUB_RELEASE, connector: github, tier: D, config: { repo: VirusTotal/yara, kind: releases } } | |
| 668 | + - id: hibp | |
| 669 | + extend: true | |
| 670 | + country: AU | |
| 671 | + aliases: [pwned, haveibeenpwned] | |
| 672 | + - id: exploit-db | |
| 673 | + name: Exploit Database | |
| 674 | + domain: exploit-db.com | |
| 675 | + homepage: https://www.exploit-db.com | |
| 676 | + categories: [cyber] | |
| 677 | + tier: A | |
| 678 | + country: US | |
| 679 | + aliases: [exploit-db, exploitdb, offsec exploit database] | |
| 680 | + llm: false | |
| 681 | + discover: { rss: false } | |
| 682 | + sensors: | |
| 683 | + - { name: latest exploits feed, url: "https://www.exploit-db.com/rss.xml", type: RSS, connector: rss, tier: A, config: { maxItems: 50 } } | |
| 684 | + - { name: exploitdb repository commits, url: "https://gitlab.com/exploit-database/exploitdb/-/commits/main?format=atom", type: ATOM, connector: rss, tier: B, config: { maxItems: 40 } } | |
| 685 | + - id: ransomware-live | |
| 686 | + name: ransomware.live | |
| 687 | + domain: ransomware.live | |
| 688 | + homepage: https://www.ransomware.live | |
| 689 | + categories: [cyber] | |
| 690 | + tier: A | |
| 691 | + country: FR | |
| 692 | + aliases: [ransomware live, ransomware victims tracker] | |
| 693 | + llm: false | |
| 694 | + discover: { rss: false } | |
| 695 | + sensors: | |
| 696 | + - { name: recent victims api, url: "https://api.ransomware.live/v2/recentvictims", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: victim, titleTemplate: "{group} — {victim} ({country})", urlField: url, summaryField: description, dateField: discovered, compareFields: [group, attackdate, ransom], maxItems: 100 } } | |
| 697 | + - { name: victims feed, url: "https://www.ransomware.live/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 698 | + - id: abuse-ch | |
| 699 | + name: abuse.ch | |
| 700 | + domain: abuse.ch | |
| 701 | + homepage: https://abuse.ch | |
| 702 | + categories: [cyber] | |
| 703 | + tier: A | |
| 704 | + country: CH | |
| 705 | + aliases: [urlhaus, threatfox, malwarebazaar, feodo tracker, sslbl] | |
| 706 | + llm: false | |
| 707 | + notes: "URLhaus/ThreatFox/MalwareBazaar APIs require an Auth-Key since 2025; only the Feodo Tracker JSON blocklist is anonymous." | |
| 708 | + discover: { rss: false } | |
| 709 | + sensors: | |
| 710 | + - { name: feodo tracker c2 blocklist, url: "https://feodotracker.abuse.ch/downloads/ipblocklist.json", type: JSON, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: ip_address, titleTemplate: "{malware} C2 {ip_address}:{port} ({country}, {as_name})", dateField: first_seen, urlTemplate: "https://feodotracker.abuse.ch/browse/host/{key}/", compareFields: [status, last_online], maxItems: 500 } } | |
| 711 | + | |
| 712 | + # ───────────────────────── E · National CERTs, CSIRTs & cyber agencies ───────────────────────── | |
| 713 | + - id: ncsc-uk | |
| 714 | + name: NCSC (UK National Cyber Security Centre) | |
| 715 | + domain: ncsc.gov.uk | |
| 716 | + homepage: https://www.ncsc.gov.uk | |
| 717 | + categories: [cyber, government] | |
| 718 | + tier: A | |
| 719 | + weight: 1.3 | |
| 720 | + country: GB | |
| 721 | + aliases: [ncsc, uk ncsc, national cyber security centre] | |
| 722 | + discover: { rss: false } | |
| 723 | + sensors: | |
| 724 | + - { name: all content feed, url: "https://www.ncsc.gov.uk/api/1/services/v1/all-rss-feed.xml", type: RSS, connector: rss, tier: A } | |
| 725 | + - { name: news feed, url: "https://www.ncsc.gov.uk/api/1/services/v1/news-rss-feed.xml", type: RSS, connector: rss, tier: B } | |
| 726 | + - { name: reports & advisories feed, url: "https://www.ncsc.gov.uk/api/1/services/v1/report-rss-feed.xml", type: RSS, connector: rss, tier: A } | |
| 727 | + - { name: gov.uk publications atom, url: "https://www.gov.uk/government/organisations/national-cyber-security-centre.atom", type: ATOM, connector: rss, tier: C } | |
| 728 | + - id: cccs | |
| 729 | + name: Canadian Centre for Cyber Security | |
| 730 | + domain: cyber.gc.ca | |
| 731 | + homepage: https://www.cyber.gc.ca | |
| 732 | + categories: [cyber, government] | |
| 733 | + tier: A | |
| 734 | + weight: 1.2 | |
| 735 | + country: CA | |
| 736 | + aliases: [cccs, cyber centre, centre canadien pour la cybersécurité, cse cyber centre] | |
| 737 | + discover: { rss: false } | |
| 738 | + sensors: | |
| 739 | + - { name: alerts & advisories atom, url: "https://www.cyber.gc.ca/api/cccs/atom/v1/get?feed=alerts_advisories&lang=en", type: ATOM, connector: rss, tier: A, config: { maxItems: 50 } } | |
| 740 | + - { name: alertes et avis (fr), url: "https://www.cyber.gc.ca/api/cccs/atom/v1/get?feed=alerts_advisories&lang=fr", type: ATOM, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 741 | + - id: cert-fr | |
| 742 | + name: CERT-FR (ANSSI) | |
| 743 | + domain: cert.ssi.gouv.fr | |
| 744 | + homepage: https://www.cert.ssi.gouv.fr | |
| 745 | + categories: [cyber, government] | |
| 746 | + tier: A | |
| 747 | + weight: 1.2 | |
| 748 | + country: FR | |
| 749 | + language: fr | |
| 750 | + aliases: [cert-fr, anssi, agence nationale de la sécurité des systèmes d'information] | |
| 751 | + discover: { rss: false } | |
| 752 | + sensors: | |
| 753 | + - { name: avis de sécurité, url: "https://www.cert.ssi.gouv.fr/avis/feed/", type: RSS, connector: rss, tier: A } | |
| 754 | + - { name: alertes, url: "https://www.cert.ssi.gouv.fr/alerte/feed/", type: RSS, connector: rss, tier: S } | |
| 755 | + - { name: menaces et incidents (cti), url: "https://www.cert.ssi.gouv.fr/cti/feed/", type: RSS, connector: rss, tier: B } | |
| 756 | + - { name: indicateurs de compromission, url: "https://www.cert.ssi.gouv.fr/ioc/feed/", type: RSS, connector: rss, tier: B } | |
| 757 | + - { name: actualités, url: "https://www.cert.ssi.gouv.fr/actualite/feed/", type: RSS, connector: rss, tier: C } | |
| 758 | + - id: cert-bund | |
| 759 | + name: CERT-Bund (BSI) | |
| 760 | + domain: cert-bund.de | |
| 761 | + homepage: https://wid.cert-bund.de/portal/wid/kurzinformationen | |
| 762 | + categories: [cyber, government] | |
| 763 | + tier: A | |
| 764 | + weight: 1.2 | |
| 765 | + country: DE | |
| 766 | + language: de | |
| 767 | + aliases: [cert-bund, bsi, bundesamt für sicherheit in der informationstechnik, warn- und informationsdienst] | |
| 768 | + notes: "bsi.bund.de RSS endpoints return 404; the WID portal's 'rss' endpoint returns a JSON envelope (items[])." | |
| 769 | + discover: { rss: false } | |
| 770 | + sensors: | |
| 771 | + - { name: wid security advisories, url: "https://wid.cert-bund.de/content/public/securityAdvisory/rss", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: items, keyField: link, titleField: title, urlField: link, summaryField: description, dateField: pubDate, compareFields: [title], maxItems: 100 } } | |
| 772 | + - id: cert-eu | |
| 773 | + name: CERT-EU | |
| 774 | + domain: cert.europa.eu | |
| 775 | + homepage: https://cert.europa.eu | |
| 776 | + categories: [cyber, government, international] | |
| 777 | + tier: A | |
| 778 | + country: EU | |
| 779 | + aliases: [cert-eu, cybersecurity service for the union institutions] | |
| 780 | + discover: { rss: false } | |
| 781 | + sensors: | |
| 782 | + - { name: security advisories feed, url: "https://cert.europa.eu/publications/security-advisories-rss", type: RSS, connector: rss, tier: A } | |
| 783 | + - { name: threat intelligence feed, url: "https://cert.europa.eu/publications/threat-intelligence-rss", type: RSS, connector: rss, tier: B } | |
| 784 | + - { name: security guidance feed, url: "https://cert.europa.eu/publications/security-guidance-rss", type: RSS, connector: rss, tier: C } | |
| 785 | + - id: enisa | |
| 786 | + extend: true | |
| 787 | + country: EU | |
| 788 | + products: | |
| 789 | + - { name: EUVD, type: dataset, aliases: [european union vulnerability database, eu vulnerability database] } | |
| 790 | + notes: "enisa.europa.eu RSS endpoints return 404; EUVD API (euvdservices) is public JSON." | |
| 791 | + sensors: | |
| 792 | + - { name: euvd latest vulnerabilities, url: "https://euvdservices.enisa.europa.eu/api/lastvulnerabilities", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: id, titleTemplate: "{id} — CVSS {baseScore} ({assigner})", summaryField: description, dateField: datePublished, urlTemplate: "https://euvd.enisa.europa.eu/vulnerability/{key}", compareFields: [baseScore, dateUpdated, exploitedSince], maxItems: 50 } } | |
| 793 | + - { name: euvd exploited vulnerabilities, url: "https://euvdservices.enisa.europa.eu/api/exploitedvulnerabilities", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: id, titleTemplate: "{id} — exploited since {exploitedSince} (CVSS {baseScore})", summaryField: description, dateField: dateUpdated, urlTemplate: "https://euvd.enisa.europa.eu/vulnerability/{key}", compareFields: [baseScore, exploitedSince], maxItems: 50 } } | |
| 794 | + - { name: euvd critical vulnerabilities, url: "https://euvdservices.enisa.europa.eu/api/criticalvulnerabilities", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: id, titleTemplate: "{id} — critical CVSS {baseScore}", summaryField: description, dateField: datePublished, urlTemplate: "https://euvd.enisa.europa.eu/vulnerability/{key}", compareFields: [baseScore, dateUpdated], maxItems: 50 } } | |
| 795 | + - id: europol | |
| 796 | + extend: true | |
| 797 | + country: EU | |
| 798 | + - id: jpcert | |
| 799 | + name: JPCERT/CC | |
| 800 | + domain: jpcert.or.jp | |
| 801 | + homepage: https://www.jpcert.or.jp/english/ | |
| 802 | + categories: [cyber] | |
| 803 | + tier: A | |
| 804 | + country: JP | |
| 805 | + aliases: [jpcert, japan computer emergency response team coordination center] | |
| 806 | + discover: { rss: false } | |
| 807 | + sensors: | |
| 808 | + - { name: english feed, url: "https://www.jpcert.or.jp/english/rss/jpcert-en.rdf", type: RSS, connector: rss, tier: A } | |
| 809 | + - id: jvn | |
| 810 | + name: JVN (Japan Vulnerability Notes) | |
| 811 | + domain: jvn.jp | |
| 812 | + homepage: https://jvn.jp/en/ | |
| 813 | + categories: [cyber, government] | |
| 814 | + tier: A | |
| 815 | + country: JP | |
| 816 | + aliases: [jvn, jvndb, japan vulnerability notes] | |
| 817 | + llm: false | |
| 818 | + discover: { rss: false } | |
| 819 | + sensors: | |
| 820 | + - { name: jvn notes feed, url: "https://jvn.jp/en/rss/jvn.rdf", type: RSS, connector: rss, tier: A } | |
| 821 | + - { name: jvndb new entries feed, url: "https://jvndb.jvn.jp/en/rss/jvndb_new.rdf", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 822 | + - id: ipa-japan | |
| 823 | + name: IPA (Information-technology Promotion Agency, Japan) | |
| 824 | + domain: ipa.go.jp | |
| 825 | + homepage: https://www.ipa.go.jp/security/ | |
| 826 | + categories: [cyber, government] | |
| 827 | + tier: B | |
| 828 | + country: JP | |
| 829 | + language: ja | |
| 830 | + aliases: [ipa, 情報処理推進機構] | |
| 831 | + discover: { rss: false } | |
| 832 | + sensors: | |
| 833 | + - { name: security alerts feed, url: "https://www.ipa.go.jp/security/rss/alert.rdf", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 834 | + - id: ncsc-nl | |
| 835 | + name: NCSC-NL | |
| 836 | + domain: ncsc.nl | |
| 837 | + homepage: https://www.ncsc.nl | |
| 838 | + categories: [cyber, government] | |
| 839 | + tier: A | |
| 840 | + country: NL | |
| 841 | + aliases: [ncsc-nl, nationaal cyber security centrum] | |
| 842 | + discover: { rss: false } | |
| 843 | + sensors: | |
| 844 | + - { name: security advisories feed, url: "https://advisories.ncsc.nl/rss/advisories", type: RSS, connector: rss, tier: A } | |
| 845 | + - id: incibe | |
| 846 | + name: INCIBE-CERT | |
| 847 | + domain: incibe.es | |
| 848 | + homepage: https://www.incibe.es/en/incibe-cert | |
| 849 | + categories: [cyber, government] | |
| 850 | + tier: A | |
| 851 | + country: ES | |
| 852 | + aliases: [incibe, incibe-cert, instituto nacional de ciberseguridad] | |
| 853 | + discover: { rss: false } | |
| 854 | + sensors: | |
| 855 | + - { name: security advisories feed (en), url: "https://www.incibe.es/en/incibe-cert/early-warning/security-advisories/feed", type: RSS, connector: rss, tier: A } | |
| 856 | + - { name: avisos sci (ics), url: "https://www.incibe.es/incibe-cert/alerta-temprana/avisos-sci/feed", type: RSS, connector: rss, tier: B } | |
| 857 | + - { name: blog feed, url: "https://www.incibe.es/incibe-cert/blog/feed", type: RSS, connector: rss, tier: C } | |
| 858 | + - id: cert-pl | |
| 859 | + name: CERT Polska | |
| 860 | + domain: cert.pl | |
| 861 | + homepage: https://cert.pl/en/ | |
| 862 | + categories: [cyber, government] | |
| 863 | + tier: B | |
| 864 | + country: PL | |
| 865 | + aliases: [cert polska, cert.pl, nask] | |
| 866 | + discover: { rss: false } | |
| 867 | + sensors: | |
| 868 | + - { name: english feed, url: "https://cert.pl/en/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 869 | + - id: cert-at | |
| 870 | + name: CERT.at | |
| 871 | + domain: cert.at | |
| 872 | + homepage: https://www.cert.at | |
| 873 | + categories: [cyber, government] | |
| 874 | + tier: A | |
| 875 | + country: AT | |
| 876 | + language: de | |
| 877 | + aliases: [cert.at, govcert austria] | |
| 878 | + discover: { rss: false } | |
| 879 | + sensors: | |
| 880 | + - { name: warnungen, url: "https://www.cert.at/cert-at.de.warnings.rss_2.0.xml", type: RSS, connector: rss, tier: A, config: { maxItems: 50 } } | |
| 881 | + - { name: alle meldungen, url: "https://www.cert.at/cert-at.de.all.rss_2.0.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 882 | + - id: ccb-belgium | |
| 883 | + name: Centre for Cybersecurity Belgium | |
| 884 | + domain: ccb.belgium.be | |
| 885 | + homepage: https://ccb.belgium.be | |
| 886 | + categories: [cyber, government] | |
| 887 | + tier: A | |
| 888 | + country: BE | |
| 889 | + aliases: [ccb, cert.be, centre for cyber security belgium] | |
| 890 | + discover: { rss: false } | |
| 891 | + sensors: | |
| 892 | + - { name: advisories feed, url: "https://ccb.belgium.be/advisories.xml", type: RSS, connector: rss, tier: A } | |
| 893 | + - { name: news feed, url: "https://ccb.belgium.be/news.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 894 | + - id: cert-se | |
| 895 | + name: CERT-SE | |
| 896 | + domain: cert.se | |
| 897 | + homepage: https://www.cert.se | |
| 898 | + categories: [cyber, government] | |
| 899 | + tier: B | |
| 900 | + country: SE | |
| 901 | + language: sv | |
| 902 | + aliases: [cert-se, msb cert] | |
| 903 | + discover: { rss: false } | |
| 904 | + sensors: | |
| 905 | + - { name: feed, url: "https://www.cert.se/feed/rss.xml", type: RSS, connector: rss, tier: B } | |
| 906 | + - id: si-cert | |
| 907 | + name: SI-CERT | |
| 908 | + domain: cert.si | |
| 909 | + homepage: https://www.cert.si/en/ | |
| 910 | + categories: [cyber, government] | |
| 911 | + tier: C | |
| 912 | + country: SI | |
| 913 | + aliases: [si-cert, slovenian computer emergency response team] | |
| 914 | + discover: { rss: false } | |
| 915 | + sensors: | |
| 916 | + - { name: english feed, url: "https://www.cert.si/en/feed/", type: RSS, connector: rss, tier: C } | |
| 917 | + - id: cert-hr | |
| 918 | + name: CERT.hr (CARNET) | |
| 919 | + domain: cert.hr | |
| 920 | + homepage: https://www.cert.hr | |
| 921 | + categories: [cyber, government] | |
| 922 | + tier: C | |
| 923 | + country: HR | |
| 924 | + language: hr | |
| 925 | + aliases: [cert.hr, nacionalni cert] | |
| 926 | + discover: { rss: false } | |
| 927 | + sensors: | |
| 928 | + - { name: feed, url: "https://www.cert.hr/feed/", type: RSS, connector: rss, tier: C } | |
| 929 | + - id: csirt-sk | |
| 930 | + name: CSIRT.SK | |
| 931 | + domain: csirt.sk | |
| 932 | + homepage: https://www.csirt.sk | |
| 933 | + categories: [cyber, government] | |
| 934 | + tier: C | |
| 935 | + country: SK | |
| 936 | + language: sk | |
| 937 | + aliases: [csirt.sk, sk-cert] | |
| 938 | + llm: false | |
| 939 | + discover: { rss: false } | |
| 940 | + sensors: | |
| 941 | + - { name: feed, url: "https://www.csirt.sk/feed", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 942 | + - id: dnsc-romania | |
| 943 | + name: DNSC (Romanian National Cyber Security Directorate) | |
| 944 | + domain: dnsc.ro | |
| 945 | + homepage: https://www.dnsc.ro | |
| 946 | + categories: [cyber, government] | |
| 947 | + tier: C | |
| 948 | + country: RO | |
| 949 | + language: ro | |
| 950 | + aliases: [dnsc, cert-ro, directoratul național de securitate cibernetică] | |
| 951 | + discover: { rss: false } | |
| 952 | + sensors: | |
| 953 | + - { name: feed, url: "https://www.dnsc.ro/feed", type: RSS, connector: rss, tier: C } | |
| 954 | + - id: cert-ua | |
| 955 | + name: CERT-UA | |
| 956 | + domain: cert.gov.ua | |
| 957 | + homepage: https://cert.gov.ua | |
| 958 | + categories: [cyber, government] | |
| 959 | + tier: A | |
| 960 | + country: UA | |
| 961 | + language: uk | |
| 962 | + aliases: [cert-ua, урядова команда реагування на комп'ютерні надзвичайні події україни] | |
| 963 | + discover: { rss: false } | |
| 964 | + sensors: | |
| 965 | + - { name: articles feed, url: "https://cert.gov.ua/api/articles/rss", type: RSS, connector: rss, tier: A } | |
| 966 | + - id: kisa-krcert | |
| 967 | + name: KISA KrCERT/CC | |
| 968 | + domain: boho.or.kr | |
| 969 | + homepage: https://www.boho.or.kr | |
| 970 | + categories: [cyber, government] | |
| 971 | + tier: B | |
| 972 | + country: KR | |
| 973 | + language: ko | |
| 974 | + aliases: [krcert, kisa, korea internet & security agency] | |
| 975 | + discover: { rss: false } | |
| 976 | + sensors: | |
| 977 | + - { name: security notices feed, url: "https://www.boho.or.kr/kr/rss.do?bbsId=B0000133", type: RSS, connector: rss, tier: B } | |
| 978 | + - id: cert-br | |
| 979 | + name: CERT.br | |
| 980 | + domain: cert.br | |
| 981 | + homepage: https://www.cert.br | |
| 982 | + categories: [cyber] | |
| 983 | + tier: C | |
| 984 | + country: BR | |
| 985 | + language: pt | |
| 986 | + aliases: [cert.br, nic.br cert] | |
| 987 | + discover: { rss: false } | |
| 988 | + sensors: | |
| 989 | + - { name: feed, url: "https://cert.br/rss/certbr-rss.xml", type: RSS, connector: rss, tier: C } | |
| 990 | + - id: cert-ae | |
| 991 | + name: UAE Cyber Security Council (aeCERT) | |
| 992 | + domain: cert.ae | |
| 993 | + homepage: https://www.cert.ae | |
| 994 | + categories: [cyber, government] | |
| 995 | + tier: C | |
| 996 | + country: AE | |
| 997 | + aliases: [aecert, uae cert] | |
| 998 | + discover: { rss: false } | |
| 999 | + sensors: | |
| 1000 | + - { name: feed, url: "https://www.cert.ae/feed/", type: RSS, connector: rss, tier: C } | |
| 1001 | + - id: traficom-ncsc-fi | |
| 1002 | + name: NCSC-FI (Traficom) | |
| 1003 | + domain: kyberturvallisuuskeskus.fi | |
| 1004 | + homepage: https://www.kyberturvallisuuskeskus.fi/en/ | |
| 1005 | + categories: [cyber, government] | |
| 1006 | + tier: B | |
| 1007 | + country: FI | |
| 1008 | + aliases: [ncsc-fi, kyberturvallisuuskeskus, finnish transport and communications agency cyber security centre] | |
| 1009 | + discover: { rss: false } | |
| 1010 | + sensors: | |
| 1011 | + - { name: english feed, url: "https://www.kyberturvallisuuskeskus.fi/feed/rss/en", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1012 | + - id: cnil | |
| 1013 | + extend: true | |
| 1014 | + sensors: | |
| 1015 | + - { name: english feed, url: "https://www.cnil.fr/en/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1016 | + | |
| 1017 | + # ───────────────────────── F · Threat-intelligence & security research vendors ───────────────────────── | |
| 1018 | + - id: kaspersky | |
| 1019 | + name: Kaspersky | |
| 1020 | + domain: kaspersky.com | |
| 1021 | + homepage: https://www.kaspersky.com | |
| 1022 | + categories: [cyber] | |
| 1023 | + tier: B | |
| 1024 | + country: RU | |
| 1025 | + aliases: [securelist, kaspersky lab, great] | |
| 1026 | + products: | |
| 1027 | + - { name: Securelist, type: service, aliases: [securelist blog] } | |
| 1028 | + - { name: Kaspersky ICS CERT, type: service, aliases: [ics cert kaspersky] } | |
| 1029 | + discover: { rss: false } | |
| 1030 | + sensors: | |
| 1031 | + - { name: securelist feed, url: "https://securelist.com/feed/", type: RSS, connector: rss, tier: B } | |
| 1032 | + - { name: securelist apt reports, url: "https://securelist.com/category/apt-reports/feed/", type: RSS, connector: rss, tier: B } | |
| 1033 | + - { name: ics cert feed, url: "https://ics-cert.kaspersky.com/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1034 | + - { name: daily blog feed, url: "https://www.kaspersky.com/blog/feed/", type: RSS, connector: rss, tier: C } | |
| 1035 | + - id: eset | |
| 1036 | + name: ESET | |
| 1037 | + domain: eset.com | |
| 1038 | + homepage: https://www.eset.com | |
| 1039 | + categories: [cyber] | |
| 1040 | + tier: B | |
| 1041 | + country: SK | |
| 1042 | + aliases: [welivesecurity, eset research] | |
| 1043 | + products: | |
| 1044 | + - { name: WeLiveSecurity, type: service, aliases: [we live security] } | |
| 1045 | + discover: { rss: false } | |
| 1046 | + sensors: | |
| 1047 | + - { name: welivesecurity feed, url: "https://www.welivesecurity.com/en/rss/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1048 | + - id: check-point | |
| 1049 | + name: Check Point Software | |
| 1050 | + domain: checkpoint.com | |
| 1051 | + homepage: https://www.checkpoint.com | |
| 1052 | + categories: [cyber] | |
| 1053 | + tier: B | |
| 1054 | + country: IL | |
| 1055 | + aliases: [check point research, cpr, checkpoint] | |
| 1056 | + products: | |
| 1057 | + - { name: Check Point Research, type: service, aliases: [cpr] } | |
| 1058 | + discover: { rss: false } | |
| 1059 | + sensors: | |
| 1060 | + - { name: research feed, url: "https://research.checkpoint.com/feed/", type: RSS, connector: rss, tier: B } | |
| 1061 | + - { name: blog feed, url: "https://blog.checkpoint.com/feed/", type: RSS, connector: rss, tier: C } | |
| 1062 | + - id: recorded-future | |
| 1063 | + name: Recorded Future | |
| 1064 | + domain: recordedfuture.com | |
| 1065 | + homepage: https://www.recordedfuture.com | |
| 1066 | + categories: [cyber] | |
| 1067 | + tier: B | |
| 1068 | + country: US | |
| 1069 | + aliases: [insikt group] | |
| 1070 | + discover: { rss: false } | |
| 1071 | + sensors: | |
| 1072 | + - { name: blog & research feed, url: "https://www.recordedfuture.com/feed", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1073 | + - id: greynoise | |
| 1074 | + name: GreyNoise Intelligence | |
| 1075 | + domain: greynoise.io | |
| 1076 | + homepage: https://www.greynoise.io | |
| 1077 | + categories: [cyber] | |
| 1078 | + tier: B | |
| 1079 | + country: US | |
| 1080 | + aliases: [greynoise] | |
| 1081 | + discover: { rss: false } | |
| 1082 | + sensors: | |
| 1083 | + - { name: blog feed, url: "https://www.greynoise.io/blog/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1084 | + - id: huntress | |
| 1085 | + name: Huntress | |
| 1086 | + domain: huntress.com | |
| 1087 | + homepage: https://www.huntress.com | |
| 1088 | + categories: [cyber] | |
| 1089 | + tier: B | |
| 1090 | + country: US | |
| 1091 | + discover: { rss: false } | |
| 1092 | + sensors: | |
| 1093 | + - { name: blog feed, url: "https://www.huntress.com/blog/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1094 | + - id: datadog | |
| 1095 | + extend: true | |
| 1096 | + categories: [cyber] | |
| 1097 | + products: | |
| 1098 | + - { name: Datadog Security Labs, type: service, aliases: [security labs] } | |
| 1099 | + sensors: | |
| 1100 | + - { name: security labs feed, url: "https://securitylabs.datadoghq.com/rss/feed.xml", type: RSS, connector: rss, tier: B } | |
| 1101 | + - id: orca-security | |
| 1102 | + name: Orca Security | |
| 1103 | + domain: orca.security | |
| 1104 | + homepage: https://orca.security | |
| 1105 | + categories: [cyber, cloud] | |
| 1106 | + tier: C | |
| 1107 | + country: US | |
| 1108 | + aliases: [orca] | |
| 1109 | + discover: { rss: false } | |
| 1110 | + sensors: | |
| 1111 | + - { name: blog feed, url: "https://orca.security/resources/blog/feed/", type: RSS, connector: rss, tier: C } | |
| 1112 | + - id: qualys | |
| 1113 | + name: Qualys | |
| 1114 | + domain: qualys.com | |
| 1115 | + homepage: https://www.qualys.com | |
| 1116 | + categories: [cyber, enterprise] | |
| 1117 | + tier: B | |
| 1118 | + country: US | |
| 1119 | + aliases: [qualys tru, threat research unit] | |
| 1120 | + discover: { rss: false } | |
| 1121 | + sensors: | |
| 1122 | + - { name: blog feed, url: "https://blog.qualys.com/feed", type: RSS, connector: rss, tier: B } | |
| 1123 | + - { name: threatprotect feed, url: "https://threatprotect.qualys.com/feed/", type: RSS, connector: rss, tier: A } | |
| 1124 | + - id: bitdefender | |
| 1125 | + name: Bitdefender | |
| 1126 | + domain: bitdefender.com | |
| 1127 | + homepage: https://www.bitdefender.com | |
| 1128 | + categories: [cyber, consumer-tech] | |
| 1129 | + tier: C | |
| 1130 | + country: RO | |
| 1131 | + aliases: [bitdefender labs, hot for security] | |
| 1132 | + discover: { rss: false } | |
| 1133 | + sensors: | |
| 1134 | + - { name: labs feed, url: "https://www.bitdefender.com/nuxt/api/en-us/rss/labs/", type: RSS, connector: rss, tier: C } | |
| 1135 | + - { name: hot for security feed, url: "https://www.bitdefender.com/nuxt/api/en-us/rss/hotforsecurity/", type: RSS, connector: rss, tier: C } | |
| 1136 | + - id: avast | |
| 1137 | + name: Avast (Gen Digital) | |
| 1138 | + domain: avast.com | |
| 1139 | + homepage: https://www.avast.com | |
| 1140 | + categories: [cyber, consumer-tech] | |
| 1141 | + tier: C | |
| 1142 | + country: CZ | |
| 1143 | + aliases: [gen digital, avast threat labs, norton lifelock] | |
| 1144 | + discover: { rss: false } | |
| 1145 | + sensors: | |
| 1146 | + - { name: blog feed, url: "https://blog.avast.com/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1147 | + - id: cybereason | |
| 1148 | + name: Cybereason | |
| 1149 | + domain: cybereason.com | |
| 1150 | + homepage: https://www.cybereason.com | |
| 1151 | + categories: [cyber] | |
| 1152 | + tier: C | |
| 1153 | + country: US | |
| 1154 | + discover: { rss: false } | |
| 1155 | + sensors: | |
| 1156 | + - { name: blog feed, url: "https://www.cybereason.com/blog/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1157 | + - id: arctic-wolf | |
| 1158 | + name: Arctic Wolf | |
| 1159 | + domain: arcticwolf.com | |
| 1160 | + homepage: https://arcticwolf.com | |
| 1161 | + categories: [cyber] | |
| 1162 | + tier: C | |
| 1163 | + country: US | |
| 1164 | + aliases: [arctic wolf labs] | |
| 1165 | + discover: { rss: false } | |
| 1166 | + sensors: | |
| 1167 | + - { name: feed, url: "https://arcticwolf.com/feed/", type: RSS, connector: rss, tier: C } | |
| 1168 | + - id: forescout | |
| 1169 | + name: Forescout | |
| 1170 | + domain: forescout.com | |
| 1171 | + homepage: https://www.forescout.com | |
| 1172 | + categories: [cyber] | |
| 1173 | + tier: C | |
| 1174 | + country: US | |
| 1175 | + aliases: [vedere labs] | |
| 1176 | + discover: { rss: false } | |
| 1177 | + sensors: | |
| 1178 | + - { name: blog feed, url: "https://www.forescout.com/feed/", type: RSS, connector: rss, tier: C } | |
| 1179 | + - id: censys | |
| 1180 | + name: Censys | |
| 1181 | + domain: censys.com | |
| 1182 | + homepage: https://censys.com | |
| 1183 | + categories: [cyber, internet] | |
| 1184 | + tier: C | |
| 1185 | + country: US | |
| 1186 | + discover: { rss: false } | |
| 1187 | + sensors: | |
| 1188 | + - { name: blog feed, url: "https://censys.com/feed/", type: RSS, connector: rss, tier: C } | |
| 1189 | + - id: shodan | |
| 1190 | + name: Shodan | |
| 1191 | + domain: shodan.io | |
| 1192 | + homepage: https://www.shodan.io | |
| 1193 | + categories: [cyber, internet] | |
| 1194 | + tier: C | |
| 1195 | + country: US | |
| 1196 | + discover: { rss: false } | |
| 1197 | + sensors: | |
| 1198 | + - { name: blog feed, url: "https://blog.shodan.io/rss/", type: RSS, connector: rss, tier: C } | |
| 1199 | + - id: cloudsek | |
| 1200 | + name: CloudSEK | |
| 1201 | + domain: cloudsek.com | |
| 1202 | + homepage: https://www.cloudsek.com | |
| 1203 | + categories: [cyber] | |
| 1204 | + tier: C | |
| 1205 | + country: IN | |
| 1206 | + discover: { rss: false } | |
| 1207 | + sensors: | |
| 1208 | + - { name: blog feed, url: "https://www.cloudsek.com/blog/rss.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 1209 | + - id: intel471 | |
| 1210 | + name: Intel 471 | |
| 1211 | + domain: intel471.com | |
| 1212 | + homepage: https://www.intel471.com | |
| 1213 | + categories: [cyber] | |
| 1214 | + tier: C | |
| 1215 | + country: US | |
| 1216 | + discover: { rss: false } | |
| 1217 | + sensors: | |
| 1218 | + - { name: blog feed, url: "https://www.intel471.com/blog/feed", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 1219 | + - id: flashpoint | |
| 1220 | + name: Flashpoint | |
| 1221 | + domain: flashpoint.io | |
| 1222 | + homepage: https://flashpoint.io | |
| 1223 | + categories: [cyber] | |
| 1224 | + tier: C | |
| 1225 | + country: US | |
| 1226 | + discover: { rss: false } | |
| 1227 | + sensors: | |
| 1228 | + - { name: blog feed, url: "https://flashpoint.io/blog/feed/", type: RSS, connector: rss, tier: C } | |
| 1229 | + - id: socradar | |
| 1230 | + name: SOCRadar | |
| 1231 | + domain: socradar.io | |
| 1232 | + homepage: https://socradar.io | |
| 1233 | + categories: [cyber] | |
| 1234 | + tier: C | |
| 1235 | + country: US | |
| 1236 | + discover: { rss: false } | |
| 1237 | + sensors: | |
| 1238 | + - { name: blog feed, url: "https://socradar.io/feed/", type: RSS, connector: rss, tier: C } | |
| 1239 | + - id: eclecticiq | |
| 1240 | + name: EclecticIQ | |
| 1241 | + domain: eclecticiq.com | |
| 1242 | + homepage: https://www.eclecticiq.com | |
| 1243 | + categories: [cyber] | |
| 1244 | + tier: C | |
| 1245 | + country: NL | |
| 1246 | + discover: { rss: false } | |
| 1247 | + sensors: | |
| 1248 | + - { name: blog feed, url: "https://blog.eclecticiq.com/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1249 | + - id: sygnia | |
| 1250 | + name: Sygnia | |
| 1251 | + domain: sygnia.co | |
| 1252 | + homepage: https://www.sygnia.co | |
| 1253 | + categories: [cyber] | |
| 1254 | + tier: C | |
| 1255 | + country: IL | |
| 1256 | + discover: { rss: false } | |
| 1257 | + sensors: | |
| 1258 | + - { name: feed, url: "https://www.sygnia.co/feed/", type: RSS, connector: rss, tier: C } | |
| 1259 | + - id: resecurity | |
| 1260 | + name: Resecurity | |
| 1261 | + domain: resecurity.com | |
| 1262 | + homepage: https://www.resecurity.com | |
| 1263 | + categories: [cyber] | |
| 1264 | + tier: C | |
| 1265 | + country: US | |
| 1266 | + discover: { rss: false } | |
| 1267 | + sensors: | |
| 1268 | + - { name: feed, url: "https://www.resecurity.com/feed", type: RSS, connector: rss, tier: C } | |
| 1269 | + - id: ahnlab | |
| 1270 | + name: AhnLab | |
| 1271 | + domain: ahnlab.com | |
| 1272 | + homepage: https://asec.ahnlab.com/en/ | |
| 1273 | + categories: [cyber] | |
| 1274 | + tier: C | |
| 1275 | + country: KR | |
| 1276 | + aliases: [asec, ahnlab security intelligence center] | |
| 1277 | + discover: { rss: false } | |
| 1278 | + sensors: | |
| 1279 | + - { name: asec english feed, url: "https://asec.ahnlab.com/en/feed/", type: RSS, connector: rss, tier: C } | |
| 1280 | + - id: any-run | |
| 1281 | + name: ANY.RUN | |
| 1282 | + domain: any.run | |
| 1283 | + homepage: https://any.run | |
| 1284 | + categories: [cyber] | |
| 1285 | + tier: C | |
| 1286 | + country: AE | |
| 1287 | + aliases: [anyrun sandbox] | |
| 1288 | + discover: { rss: false } | |
| 1289 | + sensors: | |
| 1290 | + - { name: blog feed, url: "https://any.run/cybersecurity-blog/feed/", type: RSS, connector: rss, tier: C } | |
| 1291 | + - id: infoblox | |
| 1292 | + name: Infoblox | |
| 1293 | + domain: infoblox.com | |
| 1294 | + homepage: https://www.infoblox.com | |
| 1295 | + categories: [cyber, internet] | |
| 1296 | + tier: C | |
| 1297 | + country: US | |
| 1298 | + aliases: [infoblox threat intel] | |
| 1299 | + discover: { rss: false } | |
| 1300 | + sensors: | |
| 1301 | + - { name: blog feed, url: "https://www.infoblox.com/blog/feed/", type: RSS, connector: rss, tier: C } | |
| 1302 | + - id: exodus-intelligence | |
| 1303 | + name: Exodus Intelligence | |
| 1304 | + domain: exodusintel.com | |
| 1305 | + homepage: https://www.exodusintel.com | |
| 1306 | + categories: [cyber] | |
| 1307 | + tier: C | |
| 1308 | + country: US | |
| 1309 | + discover: { rss: false } | |
| 1310 | + sensors: | |
| 1311 | + - { name: blog feed, url: "https://blog.exodusintel.com/feed/", type: RSS, connector: rss, tier: C } | |
| 1312 | + - id: reversinglabs | |
| 1313 | + name: ReversingLabs | |
| 1314 | + domain: reversinglabs.com | |
| 1315 | + homepage: https://www.reversinglabs.com | |
| 1316 | + categories: [cyber, developer] | |
| 1317 | + tier: C | |
| 1318 | + country: US | |
| 1319 | + discover: { rss: false } | |
| 1320 | + sensors: | |
| 1321 | + - { name: blog feed, url: "https://www.reversinglabs.com/blog/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1322 | + - id: endor-labs | |
| 1323 | + name: Endor Labs | |
| 1324 | + domain: endorlabs.com | |
| 1325 | + homepage: https://www.endorlabs.com | |
| 1326 | + categories: [cyber, developer] | |
| 1327 | + tier: C | |
| 1328 | + country: US | |
| 1329 | + discover: { rss: false } | |
| 1330 | + sensors: | |
| 1331 | + - { name: learn feed, url: "https://www.endorlabs.com/learn/rss.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 1332 | + - id: legit-security | |
| 1333 | + name: Legit Security | |
| 1334 | + domain: legitsecurity.com | |
| 1335 | + homepage: https://www.legitsecurity.com | |
| 1336 | + categories: [cyber, developer] | |
| 1337 | + tier: D | |
| 1338 | + country: US | |
| 1339 | + discover: { rss: false } | |
| 1340 | + sensors: | |
| 1341 | + - { name: blog feed, url: "https://www.legitsecurity.com/blog/rss.xml", type: RSS, connector: rss, tier: D } | |
| 1342 | + - id: cycode | |
| 1343 | + name: Cycode | |
| 1344 | + domain: cycode.com | |
| 1345 | + homepage: https://cycode.com | |
| 1346 | + categories: [cyber, developer] | |
| 1347 | + tier: D | |
| 1348 | + country: US | |
| 1349 | + discover: { rss: false } | |
| 1350 | + sensors: | |
| 1351 | + - { name: feed, url: "https://cycode.com/feed/", type: RSS, connector: rss, tier: D } | |
| 1352 | + - id: mend | |
| 1353 | + name: Mend.io | |
| 1354 | + domain: mend.io | |
| 1355 | + homepage: https://www.mend.io | |
| 1356 | + categories: [cyber, developer] | |
| 1357 | + tier: D | |
| 1358 | + country: IL | |
| 1359 | + aliases: [whitesource, renovate] | |
| 1360 | + discover: { rss: false } | |
| 1361 | + sensors: | |
| 1362 | + - { name: blog feed, url: "https://www.mend.io/blog/feed/", type: RSS, connector: rss, tier: D } | |
| 1363 | + - id: checkmarx | |
| 1364 | + name: Checkmarx | |
| 1365 | + domain: checkmarx.com | |
| 1366 | + homepage: https://checkmarx.com | |
| 1367 | + categories: [cyber, developer] | |
| 1368 | + tier: D | |
| 1369 | + country: US | |
| 1370 | + discover: { rss: false } | |
| 1371 | + sensors: | |
| 1372 | + - { name: feed, url: "https://checkmarx.com/feed/", type: RSS, connector: rss, tier: D } | |
| 1373 | + - id: trail-of-bits | |
| 1374 | + name: Trail of Bits | |
| 1375 | + domain: trailofbits.com | |
| 1376 | + homepage: https://www.trailofbits.com | |
| 1377 | + categories: [cyber, developer] | |
| 1378 | + tier: C | |
| 1379 | + country: US | |
| 1380 | + discover: { rss: false } | |
| 1381 | + sensors: | |
| 1382 | + - { name: blog feed, url: "https://blog.trailofbits.com/index.xml", type: RSS, connector: rss, tier: C } | |
| 1383 | + - id: bishop-fox | |
| 1384 | + name: Bishop Fox | |
| 1385 | + domain: bishopfox.com | |
| 1386 | + homepage: https://bishopfox.com | |
| 1387 | + categories: [cyber] | |
| 1388 | + tier: C | |
| 1389 | + country: US | |
| 1390 | + discover: { rss: false } | |
| 1391 | + sensors: | |
| 1392 | + - { name: blog feed, url: "https://bishopfox.com/feeds/blog.rss", type: RSS, connector: rss, tier: C } | |
| 1393 | + - id: praetorian | |
| 1394 | + name: Praetorian | |
| 1395 | + domain: praetorian.com | |
| 1396 | + homepage: https://www.praetorian.com | |
| 1397 | + categories: [cyber] | |
| 1398 | + tier: D | |
| 1399 | + country: US | |
| 1400 | + discover: { rss: false } | |
| 1401 | + sensors: | |
| 1402 | + - { name: blog feed, url: "https://www.praetorian.com/blog/feed/", type: RSS, connector: rss, tier: D } | |
| 1403 | + - id: synacktiv | |
| 1404 | + name: Synacktiv | |
| 1405 | + domain: synacktiv.com | |
| 1406 | + homepage: https://www.synacktiv.com | |
| 1407 | + categories: [cyber] | |
| 1408 | + tier: C | |
| 1409 | + country: FR | |
| 1410 | + discover: { rss: false } | |
| 1411 | + sensors: | |
| 1412 | + - { name: english blog feed, url: "https://www.synacktiv.com/en/feed/lastblog.xml", type: RSS, connector: rss, tier: C } | |
| 1413 | + - id: assetnote | |
| 1414 | + name: Assetnote (Searchlight Cyber) | |
| 1415 | + domain: assetnote.io | |
| 1416 | + homepage: https://www.assetnote.io | |
| 1417 | + categories: [cyber] | |
| 1418 | + tier: C | |
| 1419 | + country: AU | |
| 1420 | + discover: { rss: false } | |
| 1421 | + sensors: | |
| 1422 | + - { name: research blog feed, url: "https://blog.assetnote.io/feed.xml", type: RSS, connector: rss, tier: C } | |
| 1423 | + - id: horizon3 | |
| 1424 | + name: Horizon3.ai | |
| 1425 | + domain: horizon3.ai | |
| 1426 | + homepage: https://horizon3.ai | |
| 1427 | + categories: [cyber] | |
| 1428 | + tier: C | |
| 1429 | + country: US | |
| 1430 | + aliases: [horizon3 attack team, nodezero] | |
| 1431 | + discover: { rss: false } | |
| 1432 | + sensors: | |
| 1433 | + - { name: feed, url: "https://horizon3.ai/feed/", type: RSS, connector: rss, tier: C } | |
| 1434 | + - id: watchtowr | |
| 1435 | + name: watchTowr | |
| 1436 | + domain: watchtowr.com | |
| 1437 | + homepage: https://watchtowr.com | |
| 1438 | + categories: [cyber] | |
| 1439 | + tier: B | |
| 1440 | + country: SG | |
| 1441 | + aliases: [watchtowr labs] | |
| 1442 | + discover: { rss: false } | |
| 1443 | + sensors: | |
| 1444 | + - { name: labs feed, url: "https://labs.watchtowr.com/rss/", type: RSS, connector: rss, tier: B } | |
| 1445 | + - id: intigriti | |
| 1446 | + name: Intigriti | |
| 1447 | + domain: intigriti.com | |
| 1448 | + homepage: https://www.intigriti.com | |
| 1449 | + categories: [cyber] | |
| 1450 | + tier: D | |
| 1451 | + country: BE | |
| 1452 | + discover: { rss: false } | |
| 1453 | + sensors: | |
| 1454 | + - { name: blog feed, url: "https://www.intigriti.com/blog/feed", type: ATOM, connector: rss, tier: D } | |
| 1455 | + - id: mdsec | |
| 1456 | + name: MDSec | |
| 1457 | + domain: mdsec.co.uk | |
| 1458 | + homepage: https://www.mdsec.co.uk | |
| 1459 | + categories: [cyber] | |
| 1460 | + tier: C | |
| 1461 | + country: GB | |
| 1462 | + discover: { rss: false } | |
| 1463 | + sensors: | |
| 1464 | + - { name: feed, url: "https://www.mdsec.co.uk/feed/", type: RSS, connector: rss, tier: C } | |
| 1465 | + - id: pentest-partners | |
| 1466 | + name: Pen Test Partners | |
| 1467 | + domain: pentestpartners.com | |
| 1468 | + homepage: https://www.pentestpartners.com | |
| 1469 | + categories: [cyber] | |
| 1470 | + tier: D | |
| 1471 | + country: GB | |
| 1472 | + discover: { rss: false } | |
| 1473 | + sensors: | |
| 1474 | + - { name: feed, url: "https://www.pentestpartners.com/feed/", type: RSS, connector: rss, tier: D } | |
| 1475 | + - id: portswigger | |
| 1476 | + name: PortSwigger | |
| 1477 | + domain: portswigger.net | |
| 1478 | + homepage: https://portswigger.net | |
| 1479 | + categories: [cyber, developer] | |
| 1480 | + tier: C | |
| 1481 | + country: GB | |
| 1482 | + aliases: [burp suite, portswigger research] | |
| 1483 | + discover: { rss: false } | |
| 1484 | + sensors: | |
| 1485 | + - { name: research feed, url: "https://portswigger.net/research/rss", type: RSS, connector: rss, tier: C } | |
| 1486 | + - id: cloudflare | |
| 1487 | + extend: true | |
| 1488 | + sensors: | |
| 1489 | + - { name: security tag feed, url: "https://blog.cloudflare.com/tag/security/rss/", type: RSS, connector: rss, tier: B } | |
| 1490 | + - { name: vulnerabilities tag feed, url: "https://blog.cloudflare.com/tag/vulnerabilities/rss/", type: RSS, connector: rss, tier: A } | |
| 1491 | + - { name: post-mortem tag feed, url: "https://blog.cloudflare.com/tag/post-mortem/rss/", type: RSS, connector: rss, tier: A } | |
| 1492 | + - id: proofpoint | |
| 1493 | + extend: true | |
| 1494 | + country: US | |
| 1495 | + sensors: | |
| 1496 | + - { name: threat insight blog feed, url: "https://www.proofpoint.com/us/threat-insight-blog.xml", type: RSS, connector: rss, tier: B } | |
| 1497 | + - id: zscaler | |
| 1498 | + extend: true | |
| 1499 | + products: | |
| 1500 | + - { name: ThreatLabz, type: service, aliases: [zscaler threatlabz] } | |
| 1501 | + sensors: | |
| 1502 | + - { name: security research blog feed, url: "https://www.zscaler.com/blogs/feeds/security-research", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1503 | + - id: akamai | |
| 1504 | + extend: true | |
| 1505 | + notes: "akamai.com blog RSS paths return 403 (Akamai bot manager); the FeedBurner mirror is the only working feed." | |
| 1506 | + - id: sans-isc | |
| 1507 | + extend: true | |
| 1508 | + country: US | |
| 1509 | + sensors: | |
| 1510 | + - { name: diaries feed (short), url: "https://isc.sans.edu/rssfeed.xml", type: RSS, connector: rss, tier: A } | |
| 1511 | + - id: seclists | |
| 1512 | + name: SecLists.org (Full Disclosure / oss-security) | |
| 1513 | + domain: seclists.org | |
| 1514 | + homepage: https://seclists.org | |
| 1515 | + categories: [cyber, open-source] | |
| 1516 | + tier: B | |
| 1517 | + country: US | |
| 1518 | + aliases: [full disclosure, oss-security, seclists] | |
| 1519 | + first_party: false | |
| 1520 | + llm: false | |
| 1521 | + discover: { rss: false } | |
| 1522 | + sensors: | |
| 1523 | + - { name: full disclosure list feed, url: "https://seclists.org/rss/fulldisclosure.rss", type: RSS, connector: rss, tier: B } | |
| 1524 | + - { name: oss-security list feed, url: "https://seclists.org/rss/oss-sec.rss", type: RSS, connector: rss, tier: A } | |
| 1525 | + | |
| 1526 | + # ───────────────────────── G · Security media & independent researchers (first_party: false) ───────────────────────── | |
| 1527 | + - id: bleepingcomputer | |
| 1528 | + extend: true | |
| 1529 | + country: US | |
| 1530 | + first_party: false | |
| 1531 | + - id: krebs-on-security | |
| 1532 | + extend: true | |
| 1533 | + country: US | |
| 1534 | + first_party: false | |
| 1535 | + - id: the-hacker-news | |
| 1536 | + extend: true | |
| 1537 | + country: IN | |
| 1538 | + first_party: false | |
| 1539 | + - id: the-record | |
| 1540 | + extend: true | |
| 1541 | + country: US | |
| 1542 | + first_party: false | |
| 1543 | + - id: securityweek | |
| 1544 | + name: SecurityWeek | |
| 1545 | + domain: securityweek.com | |
| 1546 | + homepage: https://www.securityweek.com | |
| 1547 | + categories: [news, media, cyber] | |
| 1548 | + tier: A | |
| 1549 | + weight: 0.8 | |
| 1550 | + country: US | |
| 1551 | + first_party: false | |
| 1552 | + llm: false | |
| 1553 | + discover: { rss: false } | |
| 1554 | + sensors: | |
| 1555 | + - { name: feed, url: "https://www.securityweek.com/feed/", type: RSS, connector: rss, tier: A } | |
| 1556 | + - id: dark-reading | |
| 1557 | + name: Dark Reading | |
| 1558 | + domain: darkreading.com | |
| 1559 | + homepage: https://www.darkreading.com | |
| 1560 | + categories: [news, media, cyber] | |
| 1561 | + tier: A | |
| 1562 | + weight: 0.8 | |
| 1563 | + country: US | |
| 1564 | + first_party: false | |
| 1565 | + llm: false | |
| 1566 | + discover: { rss: false } | |
| 1567 | + sensors: | |
| 1568 | + - { name: feed, url: "https://www.darkreading.com/rss.xml", type: RSS, connector: rss, tier: A, config: { maxItems: 50 } } | |
| 1569 | + - id: cyberscoop | |
| 1570 | + name: CyberScoop | |
| 1571 | + domain: cyberscoop.com | |
| 1572 | + homepage: https://cyberscoop.com | |
| 1573 | + categories: [news, media, cyber, politics] | |
| 1574 | + tier: B | |
| 1575 | + weight: 0.8 | |
| 1576 | + country: US | |
| 1577 | + first_party: false | |
| 1578 | + llm: false | |
| 1579 | + discover: { rss: false } | |
| 1580 | + sensors: | |
| 1581 | + - { name: feed, url: "https://cyberscoop.com/feed/", type: RSS, connector: rss, tier: B } | |
| 1582 | + - id: risky-business | |
| 1583 | + name: Risky Business | |
| 1584 | + domain: risky.biz | |
| 1585 | + homepage: https://risky.biz | |
| 1586 | + categories: [news, media, cyber] | |
| 1587 | + tier: B | |
| 1588 | + weight: 0.7 | |
| 1589 | + country: AU | |
| 1590 | + first_party: false | |
| 1591 | + llm: false | |
| 1592 | + aliases: [risky biz, risky business news] | |
| 1593 | + discover: { rss: false } | |
| 1594 | + sensors: | |
| 1595 | + - { name: podcast & newsletter feed, url: "https://risky.biz/rss.xml", type: RSS, connector: rss, tier: B } | |
| 1596 | + - { name: risky business news feed, url: "https://news.risky.biz/rss/", type: RSS, connector: rss, tier: B } | |
| 1597 | + - id: infosecurity-magazine | |
| 1598 | + name: Infosecurity Magazine | |
| 1599 | + domain: infosecurity-magazine.com | |
| 1600 | + homepage: https://www.infosecurity-magazine.com | |
| 1601 | + categories: [news, media, cyber] | |
| 1602 | + tier: B | |
| 1603 | + weight: 0.7 | |
| 1604 | + country: GB | |
| 1605 | + first_party: false | |
| 1606 | + llm: false | |
| 1607 | + discover: { rss: false } | |
| 1608 | + sensors: | |
| 1609 | + - { name: news feed, url: "https://www.infosecurity-magazine.com/rss/news/", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1610 | + - id: help-net-security | |
| 1611 | + name: Help Net Security | |
| 1612 | + domain: helpnetsecurity.com | |
| 1613 | + homepage: https://www.helpnetsecurity.com | |
| 1614 | + categories: [news, media, cyber] | |
| 1615 | + tier: B | |
| 1616 | + weight: 0.6 | |
| 1617 | + country: HR | |
| 1618 | + first_party: false | |
| 1619 | + llm: false | |
| 1620 | + discover: { rss: false } | |
| 1621 | + sensors: | |
| 1622 | + - { name: feed, url: "https://www.helpnetsecurity.com/feed/", type: RSS, connector: rss, tier: B } | |
| 1623 | + - id: security-affairs | |
| 1624 | + name: Security Affairs | |
| 1625 | + domain: securityaffairs.com | |
| 1626 | + homepage: https://securityaffairs.com | |
| 1627 | + categories: [news, media, cyber] | |
| 1628 | + tier: B | |
| 1629 | + weight: 0.6 | |
| 1630 | + country: IT | |
| 1631 | + first_party: false | |
| 1632 | + llm: false | |
| 1633 | + discover: { rss: false } | |
| 1634 | + sensors: | |
| 1635 | + - { name: feed, url: "https://securityaffairs.com/feed", type: RSS, connector: rss, tier: B } | |
| 1636 | + - id: cso-online | |
| 1637 | + name: CSO Online | |
| 1638 | + domain: csoonline.com | |
| 1639 | + homepage: https://www.csoonline.com | |
| 1640 | + categories: [news, media, cyber] | |
| 1641 | + tier: B | |
| 1642 | + weight: 0.7 | |
| 1643 | + country: US | |
| 1644 | + first_party: false | |
| 1645 | + llm: false | |
| 1646 | + aliases: [cso] | |
| 1647 | + discover: { rss: false } | |
| 1648 | + sensors: | |
| 1649 | + - { name: feed, url: "https://www.csoonline.com/feed/", type: RSS, connector: rss, tier: B } | |
| 1650 | + - id: cybersecurity-dive | |
| 1651 | + name: Cybersecurity Dive | |
| 1652 | + domain: cybersecuritydive.com | |
| 1653 | + homepage: https://www.cybersecuritydive.com | |
| 1654 | + categories: [news, media, cyber] | |
| 1655 | + tier: B | |
| 1656 | + weight: 0.7 | |
| 1657 | + country: US | |
| 1658 | + first_party: false | |
| 1659 | + llm: false | |
| 1660 | + discover: { rss: false } | |
| 1661 | + sensors: | |
| 1662 | + - { name: news feed, url: "https://www.cybersecuritydive.com/feeds/news/", type: RSS, connector: rss, tier: B } | |
| 1663 | + - id: databreaches-net | |
| 1664 | + name: DataBreaches.net | |
| 1665 | + domain: databreaches.net | |
| 1666 | + homepage: https://databreaches.net | |
| 1667 | + categories: [news, media, cyber] | |
| 1668 | + tier: B | |
| 1669 | + weight: 0.6 | |
| 1670 | + country: US | |
| 1671 | + first_party: false | |
| 1672 | + llm: false | |
| 1673 | + aliases: [databreaches, dissent doe] | |
| 1674 | + discover: { rss: false } | |
| 1675 | + sensors: | |
| 1676 | + - { name: feed, url: "https://databreaches.net/feed/", type: RSS, connector: rss, tier: B } | |
| 1677 | + - id: schneier-on-security | |
| 1678 | + name: Schneier on Security | |
| 1679 | + domain: schneier.com | |
| 1680 | + homepage: https://www.schneier.com | |
| 1681 | + categories: [media, cyber] | |
| 1682 | + tier: C | |
| 1683 | + weight: 0.7 | |
| 1684 | + country: US | |
| 1685 | + first_party: false | |
| 1686 | + aliases: [bruce schneier] | |
| 1687 | + discover: { rss: false } | |
| 1688 | + sensors: | |
| 1689 | + - { name: feed, url: "https://www.schneier.com/feed/atom/", type: ATOM, connector: rss, tier: C } | |
| 1690 | + - id: troy-hunt | |
| 1691 | + name: Troy Hunt | |
| 1692 | + domain: troyhunt.com | |
| 1693 | + homepage: https://www.troyhunt.com | |
| 1694 | + categories: [media, cyber] | |
| 1695 | + tier: C | |
| 1696 | + weight: 0.6 | |
| 1697 | + country: AU | |
| 1698 | + first_party: false | |
| 1699 | + discover: { rss: false } | |
| 1700 | + sensors: | |
| 1701 | + - { name: feed, url: "https://www.troyhunt.com/rss/", type: RSS, connector: rss, tier: C } | |
| 1702 | + - id: graham-cluley | |
| 1703 | + name: Graham Cluley | |
| 1704 | + domain: grahamcluley.com | |
| 1705 | + homepage: https://grahamcluley.com | |
| 1706 | + categories: [media, cyber] | |
| 1707 | + tier: C | |
| 1708 | + weight: 0.5 | |
| 1709 | + country: GB | |
| 1710 | + first_party: false | |
| 1711 | + aliases: [smashing security] | |
| 1712 | + discover: { rss: false } | |
| 1713 | + sensors: | |
| 1714 | + - { name: feed, url: "https://grahamcluley.com/feed/", type: RSS, connector: rss, tier: C } | |
| 1715 | + - id: heise-security | |
| 1716 | + name: heise Security | |
| 1717 | + domain: heise.de | |
| 1718 | + homepage: https://www.heise.de/security/ | |
| 1719 | + categories: [news, media, cyber] | |
| 1720 | + tier: B | |
| 1721 | + weight: 0.7 | |
| 1722 | + country: DE | |
| 1723 | + language: de | |
| 1724 | + first_party: false | |
| 1725 | + llm: false | |
| 1726 | + aliases: [heise online security, heise] | |
| 1727 | + discover: { rss: false } | |
| 1728 | + sensors: | |
| 1729 | + - { name: security news feed, url: "https://www.heise.de/security/feed.xml", type: ATOM, connector: rss, tier: B } | |
| 1730 | + - { name: security alerts feed, url: "https://www.heise.de/security/Alerts/feed.xml", type: ATOM, connector: rss, tier: A } | |
| 1731 | + - id: golem-security | |
| 1732 | + name: Golem.de Security | |
| 1733 | + domain: golem.de | |
| 1734 | + homepage: https://www.golem.de/specials/security/ | |
| 1735 | + categories: [news, media, cyber] | |
| 1736 | + tier: C | |
| 1737 | + weight: 0.5 | |
| 1738 | + country: DE | |
| 1739 | + language: de | |
| 1740 | + first_party: false | |
| 1741 | + llm: false | |
| 1742 | + discover: { rss: false } | |
| 1743 | + sensors: | |
| 1744 | + - { name: security feed, url: "https://rss.golem.de/rss.php?feed=RSS2.0&ms=security", type: RSS, connector: rss, tier: C } | |
| 1745 | + - id: zataz | |
| 1746 | + name: ZATAZ | |
| 1747 | + domain: zataz.com | |
| 1748 | + homepage: https://www.zataz.com | |
| 1749 | + categories: [news, media, cyber] | |
| 1750 | + tier: C | |
| 1751 | + weight: 0.5 | |
| 1752 | + country: FR | |
| 1753 | + language: fr | |
| 1754 | + first_party: false | |
| 1755 | + llm: false | |
| 1756 | + discover: { rss: false } | |
| 1757 | + sensors: | |
| 1758 | + - { name: feed, url: "https://www.zataz.com/feed/", type: RSS, connector: rss, tier: C } | |
| 1759 | + - id: the-register | |
| 1760 | + extend: true | |
| 1761 | + sensors: | |
| 1762 | + - { name: security headlines, url: "https://www.theregister.com/security/headlines.atom", type: ATOM, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1763 | + - id: ars-technica | |
| 1764 | + extend: true | |
| 1765 | + sensors: | |
| 1766 | + - { name: security feed, url: "https://arstechnica.com/security/feed/", type: RSS, connector: rss, tier: B } | |
| 1767 | + - id: wired | |
| 1768 | + extend: true | |
| 1769 | + sensors: | |
| 1770 | + - { name: security feed, url: "https://www.wired.com/feed/category/security/latest/rss", type: RSS, connector: rss, tier: B } | |
added
config/sources.d/43-finance-markets.yaml
+1278 −0
@@ -0,0 +1,1278 @@ | ||
| 1 | +# config/sources.d/43-finance-markets.yaml — finance depth (authored 2026-09-11): central banks (decisions, data | |
| 2 | +# APIs, release feeds), securities/banking regulators, exchanges & market infrastructure (halts, listings, IPO | |
| 3 | +# calendar, volatility indices), rating agencies & index providers, multilaterals (BIS, IMF, World Bank, OECD), | |
| 4 | +# US Treasury / TreasuryDirect / OFAC, statistics offices (BLS, StatCan, Eurostat, ONS, INSEE, INE, ABS), and | |
| 5 | +# investor relations + SEC EDGAR filings of major financial and Canadian/foreign issuers (earnings, guidance, | |
| 6 | +# dividends, buybacks, M&A, executive changes, capital raises, bankruptcies). Extends organizations declared in | |
| 7 | +# sources.yaml, 11-central-banks-finance.yaml, 30-edgar-filings.yaml and 35-documents-data.yaml; every sensor was | |
| 8 | +# fetched and parsed by apps/engine/src/validate.ts. Blocked or client-rendered endpoints are recorded in `notes:`. | |
| 9 | +# Country codes follow ISO 3166-1 alpha-2 ("EU"/"INT" for supranational bodies); language defaults to English. | |
| 10 | +# Re-checked and found client-rendered/thin on 2026-09-11 (no sensor added): EIOPA filtered press listing, OSC news | |
| 11 | +# listing, LSEG media-centre/press-releases, SGX media-centre, Cboe equities/options notices pages. | |
| 12 | +sources: | |
| 13 | + # ───────────────────────── A · Central banks — decisions, data releases, statistics APIs ───────────────────────── | |
| 14 | + - id: federal-reserve | |
| 15 | + extend: true | |
| 16 | + country: US | |
| 17 | + aliases: [fomc, federal open market committee, the fed, board of governors] | |
| 18 | + notes: "press_* sub-feeds are subsets of press_all (already a sensor) and are not duplicated; FOMC minutes/press-conference/Beige Book pages moved (404); H.4.1 current page is 700 KB and is skipped in favour of the H.4.1 feed + FRED WALCL." | |
| 19 | + sensors: | |
| 20 | + - { name: testimony feed, url: "https://www.federalreserve.gov/feeds/testimony.xml", type: RSS, connector: rss, tier: B } | |
| 21 | + - { name: H.4.1 factors affecting reserve balances feed, url: "https://www.federalreserve.gov/feeds/h41.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 30 } } | |
| 22 | + - { name: H.15 selected interest rates feed, url: "https://www.federalreserve.gov/feeds/h15.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 30 } } | |
| 23 | + - { name: H.10 foreign exchange rates feed, url: "https://www.federalreserve.gov/feeds/h10.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 30 } } | |
| 24 | + - { name: H.8 bank assets and liabilities feed, url: "https://www.federalreserve.gov/feeds/h8.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 30 } } | |
| 25 | + - { name: H.6 money stock feed, url: "https://www.federalreserve.gov/feeds/h6.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 30 } } | |
| 26 | + - { name: H.3 aggregate reserves feed, url: "https://www.federalreserve.gov/feeds/h3.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 30 } } | |
| 27 | + - { name: G.19 consumer credit feed, url: "https://www.federalreserve.gov/feeds/g19.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 30 } } | |
| 28 | + - { name: G.17 industrial production feed, url: "https://www.federalreserve.gov/feeds/g17.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 30 } } | |
| 29 | + - { name: Z.1 financial accounts feed, url: "https://www.federalreserve.gov/feeds/z1.xml", type: RSS, connector: rss, tier: D, config: { maxItems: 30 } } | |
| 30 | + - { name: FEDS working papers feed, url: "https://www.federalreserve.gov/feeds/feds.xml", type: RSS, connector: rss, tier: D } | |
| 31 | + - { name: FEDS notes feed, url: "https://www.federalreserve.gov/feeds/feds_notes.xml", type: RSS, connector: rss, tier: C } | |
| 32 | + - { name: IFDP papers feed, url: "https://www.federalreserve.gov/feeds/ifdp.xml", type: RSS, connector: rss, tier: D } | |
| 33 | + - { name: FOMC calendars and statements, url: "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm", type: HTML, connector: http, tier: A } | |
| 34 | + - { name: FOMC overview page, url: "https://www.federalreserve.gov/monetarypolicy/fomc.htm", type: HTML, connector: http, tier: B } | |
| 35 | + - id: new-york-fed | |
| 36 | + name: Federal Reserve Bank of New York | |
| 37 | + domain: newyorkfed.org | |
| 38 | + homepage: https://www.newyorkfed.org | |
| 39 | + categories: [finance, central-bank, government, open-data] | |
| 40 | + tier: B | |
| 41 | + weight: 1.2 | |
| 42 | + country: US | |
| 43 | + aliases: [new york fed, ny fed, frbny, federal reserve bank of new york, markets group, sofr] | |
| 44 | + products: | |
| 45 | + - { name: SOFR, type: index, aliases: [secured overnight financing rate] } | |
| 46 | + - { name: EFFR, type: index, aliases: [effective federal funds rate] } | |
| 47 | + discover: { rss: false } | |
| 48 | + notes: "newyorkfed.org RSS paths (/rss/feeds/pressreleases, /speeches) redirect to a 404 page; the Markets Data API (markets.newyorkfed.org) is open. SOMA summary is an ascending 1 200-row series (the jsonlist head cap would keep the oldest rows) — use FRED WALCL instead." | |
| 49 | + sensors: | |
| 50 | + - { name: reference rates (SOFR, EFFR, OBFR, TGCR, BGCR), url: "https://markets.newyorkfed.org/api/rates/all/latest.json", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: refRates, keyField: type, titleTemplate: "{type} {effectiveDate}: {percentRate}", dateField: effectiveDate, compareFields: [effectiveDate, percentRate, average30day, index], urlTemplate: "https://www.newyorkfed.org/markets/reference-rates" } } | |
| 51 | + - { name: secured reference rates, url: "https://markets.newyorkfed.org/api/rates/secured/all/latest.json", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: refRates, keyField: type, titleTemplate: "{type} {effectiveDate}: {percentRate}", dateField: effectiveDate, compareFields: [effectiveDate, percentRate, percentPercentile25, percentPercentile75, volumeInBillions], urlTemplate: "https://www.newyorkfed.org/markets/reference-rates/sofr" } } | |
| 52 | + - { name: unsecured reference rates, url: "https://markets.newyorkfed.org/api/rates/unsecured/all/latest.json", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: refRates, keyField: type, titleTemplate: "{type} {effectiveDate}: {percentRate}", dateField: effectiveDate, compareFields: [effectiveDate, percentRate, percentPercentile25, percentPercentile75, volumeInBillions], urlTemplate: "https://www.newyorkfed.org/markets/reference-rates/effr" } } | |
| 53 | + - { name: SOFR page, url: "https://www.newyorkfed.org/markets/reference-rates/sofr", type: HTML, connector: http, tier: C } | |
| 54 | + - id: fred | |
| 55 | + extend: true | |
| 56 | + country: US | |
| 57 | + sensors: | |
| 58 | + - { name: fed funds daily (DFF), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DFF", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DFF], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 59 | + - { name: EFFR (EFFR), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=EFFR", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [EFFR], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 60 | + - { name: interest on reserve balances (IORB), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=IORB", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [IORB], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 61 | + - { name: prime rate (DPRIME), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DPRIME", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DPRIME], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 62 | + - { name: 3-month treasury yield (DGS3MO), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DGS3MO", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DGS3MO], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 63 | + - { name: 30-year treasury yield (DGS30), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DGS30", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DGS30], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 64 | + - { name: 10-year breakeven inflation (T10YIE), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=T10YIE", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [T10YIE], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 65 | + - { name: high-yield OAS (BAMLH0A0HYM2), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=BAMLH0A0HYM2", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [BAMLH0A0HYM2], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 66 | + - { name: broad dollar index (DTWEXBGS), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DTWEXBGS", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DTWEXBGS], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 67 | + - { name: USD/EUR (DEXUSEU), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DEXUSEU", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DEXUSEU], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 68 | + - { name: JPY/USD (DEXJPUS), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DEXJPUS", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DEXJPUS], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 69 | + - { name: CNY/USD (DEXCHUS), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DEXCHUS", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DEXCHUS], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 70 | + - { name: core CPI (CPILFESL), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=CPILFESL", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [CPILFESL], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 71 | + - { name: core PCE price index (PCEPILFE), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=PCEPILFE", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [PCEPILFE], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 72 | + - { name: overnight reverse repo (RRPONTSYD), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=RRPONTSYD", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [RRPONTSYD], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 73 | + - { name: treasury general account (WTREGEN), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=WTREGEN", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [WTREGEN], dateColumn: observation_date, tail: true, maxRows: 26 } } | |
| 74 | + - { name: total reserves (TOTRESNS), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=TOTRESNS", type: FILE, connector: csv, tier: D, config: { keyColumn: observation_date, compareColumns: [TOTRESNS], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 75 | + - { name: M2 money stock (M2SL), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=M2SL", type: FILE, connector: csv, tier: D, config: { keyColumn: observation_date, compareColumns: [M2SL], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 76 | + - { name: federal debt (GFDEBTN), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=GFDEBTN", type: FILE, connector: csv, tier: D, config: { keyColumn: observation_date, compareColumns: [GFDEBTN], dateColumn: observation_date, tail: true, maxRows: 12 } } | |
| 77 | + - { name: consumer sentiment (UMCSENT), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=UMCSENT", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [UMCSENT], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 78 | + - { name: housing starts (HOUST), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=HOUST", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [HOUST], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 79 | + - { name: retail sales (RSAFS), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=RSAFS", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [RSAFS], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 80 | + - { name: industrial production (INDPRO), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=INDPRO", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [INDPRO], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 81 | + - { name: durable goods orders (DGORDER), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DGORDER", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DGORDER], dateColumn: observation_date, tail: true, maxRows: 24 } } | |
| 82 | + - { name: Brent crude spot (DCOILBRENTEU), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DCOILBRENTEU", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DCOILBRENTEU], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 83 | + - { name: Henry Hub natural gas spot (DHHNGSP), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DHHNGSP", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DHHNGSP], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 84 | + - { name: S&P 500 (SP500), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=SP500", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [SP500], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 85 | + - { name: Nasdaq Composite (NASDAQCOM), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=NASDAQCOM", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [NASDAQCOM], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 86 | + - { name: Dow Jones Industrial Average (DJIA), url: "https://fred.stlouisfed.org/graph/fredgraph.csv?id=DJIA", type: FILE, connector: csv, tier: C, config: { keyColumn: observation_date, compareColumns: [DJIA], dateColumn: observation_date, tail: true, maxRows: 30 } } | |
| 87 | + - id: bank-of-canada | |
| 88 | + extend: true | |
| 89 | + country: CA | |
| 90 | + aliases: [boc, banque du canada, valet api] | |
| 91 | + notes: "Valet CSV has a metadata preamble (JSON used instead); /topic/*/feed/ redirects to HTML; the market-notices feed is empty (dropped). Bond-yield, T-bill and CPI groups are keyed by date with several series compared per row." | |
| 92 | + sensors: | |
| 93 | + - { name: publications feed, url: "https://www.bankofcanada.ca/content_type/publications/feed/", type: RSS, connector: rss, tier: B } | |
| 94 | + - { name: staff analytical notes feed, url: "https://www.bankofcanada.ca/content_type/staff-analytical-notes/feed/", type: RSS, connector: rss, tier: C } | |
| 95 | + - { name: all content feed, url: "https://www.bankofcanada.ca/feed/", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 96 | + - { name: USD/CAD daily rate feed (Valet), url: "https://www.bankofcanada.ca/valet/fx_rss/FXUSDCAD", type: RSS, connector: rss, tier: C } | |
| 97 | + - { name: benchmark bond yields (Valet BOND_YIELDS_ALL), url: "https://www.bankofcanada.ca/valet/observations/group/BOND_YIELDS_ALL/json?recent=5", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: observations, keyField: d, titleTemplate: "Canada benchmark yields {d}: 2y {BD.CDN.2YR.DQ.YLD.v} · 5y {BD.CDN.5YR.DQ.YLD.v} · 10y {BD.CDN.10YR.DQ.YLD.v} · long {BD.CDN.LONG.DQ.YLD.v}", dateField: d, compareFields: [BD.CDN.2YR.DQ.YLD.v, BD.CDN.5YR.DQ.YLD.v, BD.CDN.10YR.DQ.YLD.v, BD.CDN.LONG.DQ.YLD.v, BD.CDN.RRB.DQ.YLD.v], urlTemplate: "https://www.bankofcanada.ca/rates/interest-rates/canadian-bonds/" } } | |
| 98 | + - { name: treasury bill yields (Valet tbill_all), url: "https://www.bankofcanada.ca/valet/observations/group/tbill_all/json?recent=5", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: observations, keyField: d, titleTemplate: "Canada T-bill yields {d}: 1m {V80691342.v} · 3m {V80691344.v} · 6m {V80691345.v} · 1y {V80691346.v}", dateField: d, compareFields: [V80691342.v, V80691344.v, V80691345.v, V80691346.v], urlTemplate: "https://www.bankofcanada.ca/rates/interest-rates/t-bill-yields/" } } | |
| 99 | + - { name: CPI and core measures (Valet CPI_MONTHLY), url: "https://www.bankofcanada.ca/valet/observations/group/CPI_MONTHLY/json?recent=3", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: observations, keyField: d, titleTemplate: "Canada CPI {d}: total {STATIC_TOTALCPICHANGE.v} % · trim {CPI_TRIM.v} · median {CPI_MEDIAN.v} · common {CPI_COMMON.v}", dateField: d, compareFields: [V41690973.v, STATIC_TOTALCPICHANGE.v, CPI_TRIM.v, CPI_MEDIAN.v, CPI_COMMON.v], urlTemplate: "https://www.bankofcanada.ca/rates/price-indexes/cpi/" } } | |
| 100 | + - { name: prime rate (Valet V80691311), url: "https://www.bankofcanada.ca/valet/observations/V80691311/json?recent=6", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: observations, keyField: d, titleTemplate: "Canadian prime rate {d}: {V80691311.v} %", dateField: d, compareFields: [V80691311.v], urlTemplate: "https://www.bankofcanada.ca/rates/daily-digest/" } } | |
| 101 | + - { name: bank rate monthly (Valet V122530), url: "https://www.bankofcanada.ca/valet/observations/V122530/json?recent=6", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: observations, keyField: d, titleTemplate: "Bank rate {d}: {V122530.v} %", dateField: d, compareFields: [V122530.v], urlTemplate: "https://www.bankofcanada.ca/rates/interest-rates/" } } | |
| 102 | + - { name: 5-year conventional mortgage rate (Valet V80691335), url: "https://www.bankofcanada.ca/valet/observations/V80691335/json?recent=6", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: observations, keyField: d, titleTemplate: "5-year conventional mortgage rate {d}: {V80691335.v} %", dateField: d, compareFields: [V80691335.v], urlTemplate: "https://www.bankofcanada.ca/rates/daily-digest/" } } | |
| 103 | + - { name: chartered bank interest rates (Valet group), url: "https://www.bankofcanada.ca/valet/observations/group/chartered_bank_interest/json?recent=3", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: observations, keyField: d, titleTemplate: "Chartered bank rates {d}: prime {V80691311.v} · 5y mortgage {V80691335.v} · 5y GIC {V80691336.v}", dateField: d, compareFields: [V80691311.v, V80691335.v, V80691336.v], urlTemplate: "https://www.bankofcanada.ca/rates/daily-digest/" } } | |
| 104 | + - { name: monetary policy report page, url: "https://www.bankofcanada.ca/publications/mpr/", type: HTML, connector: http, tier: B } | |
| 105 | + - id: ecb | |
| 106 | + extend: true | |
| 107 | + country: EU | |
| 108 | + aliases: [european central bank, banque centrale européenne, europäische zentralbank, governing council] | |
| 109 | + notes: "Only press, pub, blog and wppub RSS exist (speeches/mp/fie/podcast feeds 404). Data Portal (csvdata) series carry a TITLE column." | |
| 110 | + sensors: | |
| 111 | + - { name: publications feed, url: "https://www.ecb.europa.eu/rss/pub.html", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 112 | + - { name: blog feed, url: "https://www.ecb.europa.eu/rss/blog.html", type: RSS, connector: rss, tier: C } | |
| 113 | + - { name: working papers feed, url: "https://www.ecb.europa.eu/rss/wppub.html", type: RSS, connector: rss, tier: D, config: { maxItems: 50 } } | |
| 114 | + - { name: governing council decisions, url: "https://www.ecb.europa.eu/press/govcdec/html/index.en.html", type: HTML, connector: http, tier: A } | |
| 115 | + - { name: monetary policy statements, url: "https://www.ecb.europa.eu/press/press_conference/monetary-policy-statement/html/index.en.html", type: HTML, connector: http, tier: A } | |
| 116 | + - { name: key ECB interest rates, url: "https://www.ecb.europa.eu/stats/policy_and_exchange_rates/key_ecb_interest_rates/html/index.en.html", type: HTML, connector: http, tier: B } | |
| 117 | + - { name: main refinancing rate (FM MRR_FR), url: "https://data-api.ecb.europa.eu/service/data/FM/B.U2.EUR.4F.KR.MRR_FR.LEV?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: B, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 118 | + - { name: marginal lending facility rate (FM MLFR), url: "https://data-api.ecb.europa.eu/service/data/FM/B.U2.EUR.4F.KR.MLFR.LEV?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 119 | + - { name: euro short-term rate €STR (EST), url: "https://data-api.ecb.europa.eu/service/data/EST/B.EU000A2X2A25.WT?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 120 | + - { name: euro area 10-year AAA yield (YC), url: "https://data-api.ecb.europa.eu/service/data/YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 121 | + - { name: GBP/EUR reference rate (EXR), url: "https://data-api.ecb.europa.eu/service/data/EXR/D.GBP.EUR.SP00.A?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 122 | + - { name: JPY/EUR reference rate (EXR), url: "https://data-api.ecb.europa.eu/service/data/EXR/D.JPY.EUR.SP00.A?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 123 | + - { name: CHF/EUR reference rate (EXR), url: "https://data-api.ecb.europa.eu/service/data/EXR/D.CHF.EUR.SP00.A?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 124 | + - { name: CAD/EUR reference rate (EXR), url: "https://data-api.ecb.europa.eu/service/data/EXR/D.CAD.EUR.SP00.A?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 125 | + - { name: CNY/EUR reference rate (EXR), url: "https://data-api.ecb.europa.eu/service/data/EXR/D.CNY.EUR.SP00.A?format=csvdata&lastNObservations=10", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 126 | + - { name: core HICP ex energy and food (ICP XEF000), url: "https://data-api.ecb.europa.eu/service/data/ICP/M.U2.N.XEF000.4.ANR?format=csvdata&lastNObservations=6", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 127 | + - { name: M3 annual growth (BSI), url: "https://data-api.ecb.europa.eu/service/data/BSI/M.U2.Y.V.M30.X.I.U2.2300.Z01.A?format=csvdata&lastNObservations=6", type: FILE, connector: csv, tier: D, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 128 | + - { name: bank lending rate to corporations (MIR), url: "https://data-api.ecb.europa.eu/service/data/MIR/M.U2.B.A2C.AM.R.A.2250.EUR.N?format=csvdata&lastNObservations=6", type: FILE, connector: csv, tier: D, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, titleColumn: TITLE } } | |
| 129 | + - id: bank-of-england | |
| 130 | + extend: true | |
| 131 | + country: GB | |
| 132 | + aliases: [boe, monetary policy committee, mpc, bank rate] | |
| 133 | + notes: "rss/prudential-regulation and rss/research 404; the monetary-policy-summary page moved. Bank Rate comes from the IADB CSV export (series IUDBEDR, daily since 2025)." | |
| 134 | + sensors: | |
| 135 | + - { name: statistics feed, url: "https://www.bankofengland.co.uk/rss/statistics", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 136 | + - { name: events feed, url: "https://www.bankofengland.co.uk/rss/events", type: RSS, connector: rss, tier: D, config: { maxItems: 40 } } | |
| 137 | + - { name: bank rate history page, url: "https://www.bankofengland.co.uk/boeapps/database/Bank-Rate.asp", type: HTML, connector: http, tier: B } | |
| 138 | + - { name: bank rate daily (IADB IUDBEDR), url: "https://www.bankofengland.co.uk/boeapps/database/_iadb-fromshowcolumns.asp?csv.x=yes&Datefrom=01/Jan/2025&Dateto=now&SeriesCodes=IUDBEDR&CSVF=TN&UsingCodes=Y&VPD=Y&VFD=N", type: FILE, connector: csv, tier: B, config: { keyColumn: DATE, compareColumns: [IUDBEDR], dateColumn: DATE, tail: true, maxRows: 30 } } | |
| 139 | + - id: bank-of-japan | |
| 140 | + extend: true | |
| 141 | + country: JP | |
| 142 | + aliases: [boj, nichigin, 日本銀行, monetary policy meeting] | |
| 143 | + notes: "Only whatsnew and statistics RSS exist (release/mopo/research 404); the MPM schedule/minutes page is server-rendered but the decisions index is client-rendered (185 chars, dropped)." | |
| 144 | + sensors: | |
| 145 | + - { name: statistics feed, url: "https://www.boj.or.jp/en/rss/statistics.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 146 | + - { name: MPM schedule and minutes, url: "https://www.boj.or.jp/en/mopo/mpmsche_minu/index.htm", type: HTML, connector: http, tier: B } | |
| 147 | + - { name: call rate statistics page, url: "https://www.boj.or.jp/en/statistics/market/short/mutan/index.htm", type: HTML, connector: http, tier: C } | |
| 148 | + - id: snb | |
| 149 | + extend: true | |
| 150 | + country: CH | |
| 151 | + notes: "data.snb.ch cube API: snboffzisa = official interest rates (semicolon CSV with a 2-line preamble)." | |
| 152 | + sensors: | |
| 153 | + - { name: speeches feed, url: "https://www.snb.ch/public/rss/en/speeches", type: RSS, connector: rss, tier: C, config: { maxItems: 40 } } | |
| 154 | + - { name: official interest rates (data.snb.ch snboffzisa), url: "https://data.snb.ch/api/cube/snboffzisa/data/csv/en", type: FILE, connector: csv, tier: B, config: { delimiter: ";", skipRows: 2, keyColumn: [Date, D0], compareColumns: [Value], dateColumn: Date, tail: true, maxRows: 40 } } | |
| 155 | + - id: rba | |
| 156 | + extend: true | |
| 157 | + country: AU | |
| 158 | + notes: "rss-cb-statistics 404. F1.1 money-market CSV has an 8-line metadata preamble; header row is 'Series ID' (FIRMMCRT = cash rate target, monthly)." | |
| 159 | + sensors: | |
| 160 | + - { name: statement on monetary policy feed, url: "https://www.rba.gov.au/rss/rss-cb-smp.xml", type: RSS, connector: rss, tier: B } | |
| 161 | + - { name: financial stability review feed, url: "https://www.rba.gov.au/rss/rss-cb-fsr.xml", type: RSS, connector: rss, tier: C } | |
| 162 | + - { name: cash rate target page, url: "https://www.rba.gov.au/statistics/cash-rate/", type: HTML, connector: http, tier: A } | |
| 163 | + - { name: money market rates monthly (F1.1 CSV), url: "https://www.rba.gov.au/statistics/tables/csv/f1.1-data.csv", type: FILE, connector: csv, tier: C, config: { skipRows: 8, keyColumn: "Series ID", compareColumns: [FIRMMCRT, FIRMMCRI, FIRMMBAB90], dateColumn: "Series ID", tail: true, maxRows: 24 } } | |
| 164 | + - id: riksbank | |
| 165 | + extend: true | |
| 166 | + country: SE | |
| 167 | + notes: "Only press-release and speeches RSS exist (news/monetary-policy/publications feeds 404). SWEA API 'Latest' returns a single object — fingerprinted on the value only." | |
| 168 | + sensors: | |
| 169 | + - { name: policy rate latest (SWEA SECBREPOEFF), url: "https://api.riksbank.se/swea/v1/Observations/Latest/SECBREPOEFF", type: JSON, connector: http, tier: B, config: { jsonPath: value } } | |
| 170 | + - id: norges-bank | |
| 171 | + extend: true | |
| 172 | + country: "NO" | |
| 173 | + notes: "News/publications/monetary-policy RSS paths 404; the Norges Bank Data API (SDMX CSV, semicolon-separated) is open." | |
| 174 | + sensors: | |
| 175 | + - { name: policy rate daily (IR KPRA), url: "https://data.norges-bank.no/api/data/IR/B.KPRA.SD.R?format=csv&lastNObservations=10&locale=en", type: FILE, connector: csv, tier: B, config: { delimiter: ";", keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, tail: true } } | |
| 176 | + - { name: USD/NOK daily (EXR), url: "https://data.norges-bank.no/api/data/EXR/B.USD.NOK.SP?format=csv&lastNObservations=10&locale=en", type: FILE, connector: csv, tier: C, config: { delimiter: ";", keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD, tail: true } } | |
| 177 | + - id: banco-central-do-brasil | |
| 178 | + extend: true | |
| 179 | + country: BR | |
| 180 | + language: pt | |
| 181 | + sensors: | |
| 182 | + - { name: Copom statements api, url: "https://www.bcb.gov.br/api/servico/sitebcb/copom/comunicados?quantidade=10", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: conteudo, keyField: nro_reuniao, titleField: titulo, dateField: dataReferencia, urlTemplate: "https://www.bcb.gov.br/en/publications/copomstatements" } } | |
| 183 | + - id: pboc | |
| 184 | + extend: true | |
| 185 | + country: CN | |
| 186 | + sensors: | |
| 187 | + - { name: speeches, url: "https://www.pbc.gov.cn/en/3688110/3688175/index.html", type: HTML, connector: http, tier: B } | |
| 188 | + - { name: announcements, url: "https://www.pbc.gov.cn/en/3688110/3688181/index.html", type: HTML, connector: http, tier: A } | |
| 189 | + - id: dnb | |
| 190 | + extend: true | |
| 191 | + country: NL | |
| 192 | + sensors: | |
| 193 | + - { name: supervision publications feed, url: "https://www.dnb.nl/en/rss/13039/4613", type: RSS, connector: rss, tier: B } | |
| 194 | + - id: banque-de-france | |
| 195 | + extend: true | |
| 196 | + country: FR | |
| 197 | + sensors: | |
| 198 | + - { name: press releases, url: "https://www.banque-france.fr/en/press-release", type: HTML, connector: http, tier: A } | |
| 199 | + # ───────────────────────── B · US market regulators & EDGAR live streams ───────────────────────── | |
| 200 | + - id: sec | |
| 201 | + extend: true | |
| 202 | + country: US | |
| 203 | + aliases: [securities and exchange commission, u.s. securities and exchange commission, edgar] | |
| 204 | + notes: "Rulemaking, statements, investor-alert and whistleblower RSS paths 404 after the 2025 site redesign; rss/rules/final.xml is gone (proposed.xml remains). EDGAR full-text search (efts) rejects date-range queries (HTTP 500) and sorts by relevance, so it is not usable as a new-filing detector." | |
| 205 | + sensors: | |
| 206 | + - { name: speeches and statements feed, url: "https://www.sec.gov/news/speeches.rss", type: RSS, connector: rss, tier: B } | |
| 207 | + - { name: administrative proceedings feed, url: "https://www.sec.gov/enforcement-litigation/administrative-proceedings/rss", type: RSS, connector: rss, tier: B } | |
| 208 | + - { name: trading suspensions feed, url: "https://www.sec.gov/enforcement-litigation/trading-suspensions/rss", type: RSS, connector: rss, tier: A } | |
| 209 | + - { name: corporation finance updates feed, url: "https://www.sec.gov/rss/divisions/corpfin/cfnew.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 40 } } | |
| 210 | + - { name: proposed rules feed, url: "https://www.sec.gov/rss/rules/proposed.xml", type: RSS, connector: rss, tier: B } | |
| 211 | + - id: edgar-live | |
| 212 | + name: SEC EDGAR — latest filings | |
| 213 | + domain: sec.gov | |
| 214 | + homepage: https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent | |
| 215 | + categories: [filings, finance, open-data] | |
| 216 | + tier: B | |
| 217 | + weight: 1.1 | |
| 218 | + country: US | |
| 219 | + aliases: [edgar latest filings, edgar getcurrent, edgar full index] | |
| 220 | + discover: { rss: false } | |
| 221 | + llm: false | |
| 222 | + notes: "Market-wide EDGAR streams (hundreds of filings a day): heuristics only, capped. The SC 13D 'getcurrent' feed is empty outside filing hours and is therefore not a sensor. Company-level filings live in 30-edgar-filings.yaml and in the `edgar filings` sensors below." | |
| 223 | + sensors: | |
| 224 | + - { name: latest 8-K filings (all issuers), url: "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=8-K&count=40&output=atom", type: ATOM, connector: rss, tier: A, config: { maxItems: 40 } } | |
| 225 | + - { name: latest 10-K filings (all issuers), url: "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=10-K&count=40&output=atom", type: ATOM, connector: rss, tier: B, config: { maxItems: 40 } } | |
| 226 | + - { name: latest S-1 registration statements (IPOs), url: "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=S-1&count=40&output=atom", type: ATOM, connector: rss, tier: A, config: { maxItems: 40 } } | |
| 227 | + - { name: latest 13F-HR holdings reports, url: "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=13F-HR&count=40&output=atom", type: ATOM, connector: rss, tier: C, config: { maxItems: 40 } } | |
| 228 | + - { name: inline XBRL filings feed, url: "https://www.sec.gov/Archives/edgar/xbrl-inline.rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 229 | + - id: cftc | |
| 230 | + extend: true | |
| 231 | + country: US | |
| 232 | + aliases: [commodity futures trading commission] | |
| 233 | + notes: "Speeches and letters RSS 404. Commitments-of-Traders text files (dea/newcot/*.txt) have no header row and cannot be keyed by the csv connector." | |
| 234 | + sensors: | |
| 235 | + - { name: enforcement actions feed, url: "https://www.cftc.gov/RSS/RSSENF/rssenf.xml", type: RSS, connector: rss, tier: A } | |
| 236 | + - { name: all site updates feed, url: "https://www.cftc.gov/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 237 | + - { name: commitments of traders page, url: "https://www.cftc.gov/MarketReports/CommitmentsofTraders/index.htm", type: HTML, connector: http, tier: C } | |
| 238 | + - id: finra | |
| 239 | + extend: true | |
| 240 | + country: US | |
| 241 | + aliases: [financial industry regulatory authority] | |
| 242 | + sensors: | |
| 243 | + - { name: news releases feed, url: "https://www.finra.org/media-center/newsreleases/rss", type: RSS, connector: rss, tier: A } | |
| 244 | + - { name: notices feed, url: "https://www.finra.org/rules-guidance/notices/rss", type: RSS, connector: rss, tier: B } | |
| 245 | + - { name: disciplinary actions online, url: "https://www.finra.org/rules-guidance/oversight-enforcement/finra-disciplinary-actions-online", type: HTML, connector: http, tier: C } | |
| 246 | + - id: nyse-market-data | |
| 247 | + name: NYSE market data (halts & IPO calendar) | |
| 248 | + domain: nyse.com | |
| 249 | + homepage: https://www.nyse.com/trade/trading-halts | |
| 250 | + categories: [finance, exchange, open-data] | |
| 251 | + tier: A | |
| 252 | + country: US | |
| 253 | + aliases: [nyse trading halts, nyse ipo calendar, new york stock exchange halts] | |
| 254 | + discover: { rss: false } | |
| 255 | + llm: false | |
| 256 | + notes: "Data endpoints of nyse.com that the founding `nyse` source does not carry (the source itself has no feed: /news, /market-status and /api/marketstatus 404). Halt rows disappear when trading resumes — removals are expected." | |
| 257 | + sensors: | |
| 258 | + - { name: current trading halts (CSV), url: "https://www.nyse.com/api/trade-halts/current/download", type: FILE, connector: csv, tier: A, config: { keyColumn: ["Halt Date", "Halt Time", Symbol], compareColumns: [Reason, "Resume Date", "NYSE Resume Time"], titleColumn: Name, dateColumn: "Halt Date", maxRows: 500 } } | |
| 259 | + - { name: IPO calendar api, url: "https://www.nyse.com/api/ipo-center/calendar", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: calendarList, keyField: symbol, titleTemplate: "{issuer_nm} ({symbol}) — {deal_status_desc} on {custom_group_exchange_nm}, expected {expected_dt_report}", compareFields: [deal_status_desc, expected_dt_report, current_file_price_range_usd, offer_px_usd, withdrawn_postponed_txt], urlTemplate: "https://www.nyse.com/ipo-center/filings" } } | |
| 260 | + - id: nasdaq-trader | |
| 261 | + name: Nasdaq Trader (market operations) | |
| 262 | + domain: nasdaqtrader.com | |
| 263 | + homepage: https://www.nasdaqtrader.com | |
| 264 | + categories: [finance, exchange, open-data] | |
| 265 | + tier: A | |
| 266 | + country: US | |
| 267 | + aliases: [nasdaqtrader, nasdaq trade halts, nasdaq listed securities, nasdaq equity trader alerts] | |
| 268 | + discover: { rss: false } | |
| 269 | + llm: false | |
| 270 | + notes: "Only tradehalts and currentheadlines RSS exist (equities/uto/dailylist feeds redirect to an error page). Symbol directories are pipe-delimited and ~5 000 rows: a new row = listing, a removed row = delisting." | |
| 271 | + sensors: | |
| 272 | + - { name: trading halts feed, url: "https://www.nasdaqtrader.com/rss.aspx?feed=tradehalts", type: RSS, connector: rss, tier: A, config: { maxItems: 60 } } | |
| 273 | + - { name: equity trader alerts feed, url: "https://www.nasdaqtrader.com/rss.aspx?feed=currentheadlines", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 274 | + - { name: Nasdaq-listed securities directory, url: "https://www.nasdaqtrader.com/dynamic/SymDir/nasdaqlisted.txt", type: FILE, connector: csv, tier: B, config: { delimiter: "|", keyColumn: Symbol, compareColumns: ["Security Name", "Market Category", "Financial Status", "Test Issue"], titleColumn: "Security Name", maxRows: 7000 } } | |
| 275 | + - { name: other-listed securities directory (NYSE, Arca, Cboe), url: "https://www.nasdaqtrader.com/dynamic/SymDir/otherlisted.txt", type: FILE, connector: csv, tier: C, config: { delimiter: "|", keyColumn: "ACT Symbol", compareColumns: ["Security Name", Exchange, ETF, "Test Issue"], titleColumn: "Security Name", maxRows: 9000 } } | |
| 276 | + - id: cboe-indices | |
| 277 | + extend: true | |
| 278 | + country: US | |
| 279 | + products: | |
| 280 | + - { name: VVIX, type: index, aliases: [vix of vix] } | |
| 281 | + - { name: SKEW, type: index, aliases: [cboe skew index] } | |
| 282 | + sensors: | |
| 283 | + - { name: VIX3M 3-month volatility (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX3M_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [CLOSE], tail: true, maxRows: 30 } } | |
| 284 | + - { name: VIX9D 9-day volatility (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX9D_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [CLOSE], tail: true, maxRows: 30 } } | |
| 285 | + - { name: VVIX (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/VVIX_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [VVIX], tail: true, maxRows: 30 } } | |
| 286 | + - { name: SKEW (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/SKEW_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [SKEW], tail: true, maxRows: 30 } } | |
| 287 | + - { name: VXN Nasdaq-100 volatility (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/VXN_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [CLOSE], tail: true, maxRows: 30 } } | |
| 288 | + - { name: OVX crude oil volatility (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/OVX_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [OVX], tail: true, maxRows: 30 } } | |
| 289 | + - { name: GVZ gold volatility (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/GVZ_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [GVZ], tail: true, maxRows: 30 } } | |
| 290 | + - { name: VXTLT treasury volatility (CSV), url: "https://cdn.cboe.com/api/global/us_indices/daily_prices/VXTLT_History.csv", type: FILE, connector: csv, tier: C, config: { keyColumn: DATE, compareColumns: [VXTLT], tail: true, maxRows: 30 } } | |
| 291 | + # ───────────────────────── C · Exchanges abroad ───────────────────────── | |
| 292 | + - id: asx | |
| 293 | + extend: true | |
| 294 | + country: AU | |
| 295 | + notes: "ASX company announcements API (asx.api.markitdigital.com) is open — used here for ASX Ltd itself." | |
| 296 | + sensors: | |
| 297 | + - { name: ASX Ltd company announcements api, url: "https://asx.api.markitdigital.com/asx-research/1.0/companies/asx/announcements", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: data.items, keyField: documentKey, titleField: headline, dateField: date, compareFields: [isPriceSensitive, announcementType], urlTemplate: "https://www.asx.com.au/markets/trade-our-cash-market/announcements.asx" } } | |
| 298 | + - id: hkex | |
| 299 | + extend: true | |
| 300 | + country: HK | |
| 301 | + sensors: | |
| 302 | + - { name: regulatory announcements, url: "https://www.hkex.com.hk/News/Regulatory-Announcements?sc_lang=en", type: HTML, connector: http, tier: B } | |
| 303 | + - { name: market communications, url: "https://www.hkex.com.hk/News/Market-Communications?sc_lang=en", type: HTML, connector: http, tier: B } | |
| 304 | + - id: deutsche-boerse | |
| 305 | + extend: true | |
| 306 | + country: DE | |
| 307 | + sensors: | |
| 308 | + - { name: Eurex news center, url: "https://www.eurex.com/ex-en/find/news-center", type: HTML, connector: http, tier: B } | |
| 309 | + - id: fca | |
| 310 | + extend: true | |
| 311 | + country: GB | |
| 312 | + notes: "Press-release/news-story sub-feeds 404; the warnings feed (unauthorised firms) is high-volume." | |
| 313 | + sensors: | |
| 314 | + - { name: warnings feed (unauthorised firms), url: "https://www.fca.org.uk/news/warnings/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 315 | + - { name: publications feed, url: "https://www.fca.org.uk/publications/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 316 | + - id: amf-france | |
| 317 | + extend: true | |
| 318 | + country: FR | |
| 319 | + sensors: | |
| 320 | + - { name: news feed (actualités), url: "https://www.amf-france.org/en/flux-rss/display/30", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 321 | + - { name: publications feed, url: "https://www.amf-france.org/en/flux-rss/display/25", type: RSS, connector: rss, tier: C, config: { maxItems: 60 } } | |
| 322 | + - { name: public offers feed (sociétés concernées par une offre), url: "https://www.amf-france.org/en/flux-rss/display/29", type: RSS, connector: rss, tier: B } | |
| 323 | + - id: afm | |
| 324 | + name: Autoriteit Financiële Markten | |
| 325 | + domain: afm.nl | |
| 326 | + homepage: https://www.afm.nl/en | |
| 327 | + categories: [finance, regulator, government] | |
| 328 | + tier: B | |
| 329 | + country: NL | |
| 330 | + aliases: [afm, autoriteit financiële markten, dutch authority for the financial markets, netherlands authority for the financial markets] | |
| 331 | + discover: { rss: false } | |
| 332 | + sensors: | |
| 333 | + - { name: news feed, url: "https://www.afm.nl/en/rss", type: RSS, connector: rss, tier: B } | |
| 334 | + - id: cssf | |
| 335 | + name: CSSF Luxembourg | |
| 336 | + domain: cssf.lu | |
| 337 | + homepage: https://www.cssf.lu/en/ | |
| 338 | + categories: [finance, regulator, government] | |
| 339 | + tier: B | |
| 340 | + country: LU | |
| 341 | + aliases: [cssf, commission de surveillance du secteur financier] | |
| 342 | + discover: { rss: false } | |
| 343 | + sensors: | |
| 344 | + - { name: news feed, url: "https://www.cssf.lu/en/feed/", type: RSS, connector: rss, tier: B } | |
| 345 | + - id: sfc-hk | |
| 346 | + extend: true | |
| 347 | + country: HK | |
| 348 | + sensors: | |
| 349 | + - { name: enforcement news feed, url: "https://www.sfc.hk/en/RSS-Feeds/Enforcement-news", type: RSS, connector: rss, tier: A } | |
| 350 | + - id: cdic | |
| 351 | + name: Canada Deposit Insurance Corporation | |
| 352 | + domain: cdic.ca | |
| 353 | + homepage: https://www.cdic.ca | |
| 354 | + categories: [finance, regulator, government] | |
| 355 | + tier: B | |
| 356 | + country: CA | |
| 357 | + aliases: [cdic, canada deposit insurance corporation, sadc, société d'assurance-dépôts du canada] | |
| 358 | + discover: { rss: false } | |
| 359 | + sensors: | |
| 360 | + - { name: news feed, url: "https://www.cdic.ca/feed/", type: RSS, connector: rss, tier: B } | |
| 361 | + - id: fsrao | |
| 362 | + name: Financial Services Regulatory Authority of Ontario | |
| 363 | + domain: fsrao.ca | |
| 364 | + homepage: https://www.fsrao.ca | |
| 365 | + categories: [finance, regulator, government] | |
| 366 | + tier: C | |
| 367 | + country: CA | |
| 368 | + aliases: [fsra, fsrao, financial services regulatory authority of ontario] | |
| 369 | + discover: { rss: false } | |
| 370 | + sensors: | |
| 371 | + - { name: announcements, url: "https://www.fsrao.ca/announcements", type: HTML, connector: http, tier: C } | |
| 372 | + - id: bcsc | |
| 373 | + name: British Columbia Securities Commission | |
| 374 | + domain: bcsc.bc.ca | |
| 375 | + homepage: https://www.bcsc.bc.ca | |
| 376 | + categories: [finance, regulator, government] | |
| 377 | + tier: C | |
| 378 | + country: CA | |
| 379 | + aliases: [bcsc, british columbia securities commission] | |
| 380 | + discover: { rss: false } | |
| 381 | + sensors: | |
| 382 | + - { name: media room, url: "https://www.bcsc.bc.ca/about/media-room", type: HTML, connector: http, tier: C } | |
| 383 | + - id: cfpb | |
| 384 | + extend: true | |
| 385 | + country: US | |
| 386 | + sensors: | |
| 387 | + - { name: enforcement actions feed, url: "https://www.consumerfinance.gov/enforcement/actions/feed/", type: RSS, connector: rss, tier: A } | |
| 388 | + - { name: final rules feed, url: "https://www.consumerfinance.gov/rules-policy/final-rules/feed/", type: RSS, connector: rss, tier: B } | |
| 389 | + - { name: research reports feed, url: "https://www.consumerfinance.gov/data-research/research-reports/feed/", type: RSS, connector: rss, tier: C } | |
| 390 | + - id: occ | |
| 391 | + extend: true | |
| 392 | + country: US | |
| 393 | + aliases: [office of the comptroller of the currency] | |
| 394 | + sensors: | |
| 395 | + - { name: bulletins feed, url: "https://www.occ.gov/rss/occ_bulletins.xml", type: RSS, connector: rss, tier: B } | |
| 396 | + - { name: enforcement actions index, url: "https://www.occ.gov/topics/laws-and-regulations/enforcement-actions/index-enforcement-actions.html", type: HTML, connector: http, tier: B } | |
| 397 | + - id: fdic | |
| 398 | + extend: true | |
| 399 | + country: US | |
| 400 | + aliases: [federal deposit insurance corporation, bankfind] | |
| 401 | + notes: "banklist.csv now serves the HTML page; the BankFind Suite API (banks.data.fdic.gov) provides failures as JSON." | |
| 402 | + sensors: | |
| 403 | + - { name: bank failures api (BankFind), url: "https://banks.data.fdic.gov/api/failures?sort_by=FAILDATE&sort_order=DESC&limit=25&fields=NAME,CITYST,FAILDATE,SAVR,RESTYPE,QBFASSET,QBFDEP,CERT&format=json", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: data, keyField: data.CERT, titleTemplate: "{data.NAME} ({data.CITYST}) failed {data.FAILDATE} — acquirer {data.SAVR}", dateField: data.FAILDATE, compareFields: [data.SAVR, data.RESTYPE, data.QBFASSET, data.QBFDEP], urlTemplate: "https://www.fdic.gov/bank-failures/failed-bank-list" } } | |
| 404 | + - { name: financial institution letters, url: "https://www.fdic.gov/news/financial-institution-letters", type: HTML, connector: http, tier: C } | |
| 405 | + - { name: quarterly banking profile, url: "https://www.fdic.gov/quarterly-banking-profile", type: HTML, connector: http, tier: D } | |
| 406 | + - id: irs | |
| 407 | + extend: true | |
| 408 | + country: US | |
| 409 | + sensors: | |
| 410 | + - { name: news releases for current month, url: "https://www.irs.gov/newsroom/news-releases-for-current-month", type: HTML, connector: http, tier: B } | |
| 411 | + - { name: internal revenue bulletin, url: "https://www.irs.gov/irb", type: HTML, connector: http, tier: C } | |
| 412 | + - id: pcaob | |
| 413 | + name: PCAOB | |
| 414 | + domain: pcaobus.org | |
| 415 | + homepage: https://pcaobus.org | |
| 416 | + categories: [finance, regulator] | |
| 417 | + tier: C | |
| 418 | + country: US | |
| 419 | + aliases: [pcaob, public company accounting oversight board] | |
| 420 | + discover: { rss: false } | |
| 421 | + notes: "NOT COVERED 2026-09-11: no RSS (rss paths 404) and the news-release listing is client-rendered (185 chars of text). Listed for entity linking only." | |
| 422 | + - id: sipc | |
| 423 | + name: SIPC | |
| 424 | + domain: sipc.org | |
| 425 | + homepage: https://www.sipc.org | |
| 426 | + categories: [finance, regulator] | |
| 427 | + tier: C | |
| 428 | + country: US | |
| 429 | + aliases: [sipc, securities investor protection corporation] | |
| 430 | + discover: { rss: false } | |
| 431 | + sensors: | |
| 432 | + - { name: news releases, url: "https://www.sipc.org/news-and-media/news-releases/", type: HTML, connector: http, tier: C } | |
| 433 | + - id: msrb | |
| 434 | + name: MSRB | |
| 435 | + domain: msrb.org | |
| 436 | + homepage: https://www.msrb.org | |
| 437 | + categories: [finance, regulator] | |
| 438 | + tier: C | |
| 439 | + country: US | |
| 440 | + aliases: [msrb, municipal securities rulemaking board, emma] | |
| 441 | + discover: { rss: false } | |
| 442 | + sensors: | |
| 443 | + - { name: press releases, url: "https://www.msrb.org/Press-Releases", type: HTML, connector: http, tier: C } | |
| 444 | + # ───────────────────────── E · Multilaterals & international standard setters ───────────────────────── | |
| 445 | + - id: bis | |
| 446 | + extend: true | |
| 447 | + country: INT | |
| 448 | + notes: "Most /doclist/*.rss feeds (press releases, BCBS, CPMI, papers) 404 — only rss.xml, cbspeeches, bisbulletins and bis_fsi_publs are live. The BIS Stats API (stats.bis.org) serves central-bank policy rates as CSV." | |
| 449 | + sensors: | |
| 450 | + - { name: BIS bulletins feed, url: "https://www.bis.org/doclist/bisbulletins.rss", type: RSS, connector: rss, tier: C } | |
| 451 | + - { name: FSI publications feed, url: "https://www.bis.org/doclist/bis_fsi_publs.rss", type: RSS, connector: rss, tier: D, config: { maxItems: 40 } } | |
| 452 | + - { name: central bank policy rates (WS_CBPOL US/CA/EA/GB/JP), url: "https://stats.bis.org/api/v2/data/dataflow/BIS/WS_CBPOL/1.0/M.US+CA+XM+GB+JP?lastNObservations=3&format=csv", type: FILE, connector: csv, tier: C, config: { keyColumn: [REF_AREA, TIME_PERIOD], compareColumns: [OBS_VALUE], titleColumn: TITLE, dateColumn: TIME_PERIOD } } | |
| 453 | + - id: imf | |
| 454 | + extend: true | |
| 455 | + country: INT | |
| 456 | + aliases: [international monetary fund, world economic outlook, weo] | |
| 457 | + products: | |
| 458 | + - { name: World Economic Outlook, type: product, aliases: [weo] } | |
| 459 | + notes: "imf.org news/publications/RSS paths answer 403 to bots; the DataMapper API (WEO series) is open and is fingerprinted per indicator — any WEO update is one change event." | |
| 460 | + sensors: | |
| 461 | + - { name: WEO real GDP growth (DataMapper NGDP_RPCH), url: "https://www.imf.org/external/datamapper/api/v1/NGDP_RPCH", type: JSON, connector: http, tier: C, config: { jsonPath: values.NGDP_RPCH } } | |
| 462 | + - { name: WEO inflation (DataMapper PCPIPCH), url: "https://www.imf.org/external/datamapper/api/v1/PCPIPCH", type: JSON, connector: http, tier: C, config: { jsonPath: values.PCPIPCH } } | |
| 463 | + - { name: WEO unemployment rate (DataMapper LUR), url: "https://www.imf.org/external/datamapper/api/v1/LUR", type: JSON, connector: http, tier: D, config: { jsonPath: values.LUR } } | |
| 464 | + - { name: WEO gross debt to GDP (DataMapper GGXWDG_NGDP), url: "https://www.imf.org/external/datamapper/api/v1/GGXWDG_NGDP", type: JSON, connector: http, tier: D, config: { jsonPath: values.GGXWDG_NGDP } } | |
| 465 | + - id: world-bank | |
| 466 | + extend: true | |
| 467 | + country: INT | |
| 468 | + notes: "No RSS (news rss paths 404; the search API returns an object keyed by document id, not a list). Indicators API v2 is open but slow — one country per sensor so rows can be keyed by year." | |
| 469 | + sensors: | |
| 470 | + - { name: world GDP growth (API NY.GDP.MKTP.KD.ZG WLD), url: "https://api.worldbank.org/v2/country/WLD/indicator/NY.GDP.MKTP.KD.ZG?format=json&date=2020:2030", type: REST_API, connector: jsonlist, tier: D, config: { itemsPath: "[1]", keyField: date, titleTemplate: "World GDP growth {date}: {value} %", dateField: date, compareFields: [value], urlTemplate: "https://data.worldbank.org/indicator/NY.GDP.MKTP.KD.ZG", timeoutMs: 60000 } } | |
| 471 | + - { name: world inflation (API FP.CPI.TOTL.ZG WLD), url: "https://api.worldbank.org/v2/country/WLD/indicator/FP.CPI.TOTL.ZG?format=json&date=2020:2030", type: REST_API, connector: jsonlist, tier: D, config: { itemsPath: "[1]", keyField: date, titleTemplate: "World inflation {date}: {value} %", dateField: date, compareFields: [value], urlTemplate: "https://data.worldbank.org/indicator/FP.CPI.TOTL.ZG", timeoutMs: 60000 } } | |
| 472 | + - { name: US GDP growth (API NY.GDP.MKTP.KD.ZG USA), url: "https://api.worldbank.org/v2/country/USA/indicator/NY.GDP.MKTP.KD.ZG?format=json&date=2020:2030", type: REST_API, connector: jsonlist, tier: D, config: { itemsPath: "[1]", keyField: date, titleTemplate: "US GDP growth {date}: {value} %", dateField: date, compareFields: [value], urlTemplate: "https://data.worldbank.org/indicator/NY.GDP.MKTP.KD.ZG?locations=US", timeoutMs: 60000 } } | |
| 473 | + - id: oecd | |
| 474 | + extend: true | |
| 475 | + country: INT | |
| 476 | + notes: "oecd.org news/press/RSS answer 403 to bots; the SDMX Data Explorer API (sdmx.oecd.org, csvfilewithlabels) is open." | |
| 477 | + sensors: | |
| 478 | + - { name: CPI inflation G7/OECD (SDMX DF_PRICES_ALL), url: "https://sdmx.oecd.org/public/rest/data/OECD.SDD.TPS,DSD_PRICES@DF_PRICES_ALL,1.0/G7+OECD+USA+CAN+GBR+DEU+FRA+JPN+ITA.M.N.CPI.PA._T.N.GY?lastNObservations=2&format=csvfilewithlabels", type: FILE, connector: csv, tier: C, config: { keyColumn: [REF_AREA, TIME_PERIOD], compareColumns: [OBS_VALUE], titleColumn: "Reference area", dateColumn: TIME_PERIOD } } | |
| 479 | + - { name: unemployment rates (SDMX DF_IALFS_UNE_M), url: "https://sdmx.oecd.org/public/rest/data/OECD.SDD.TPS,DSD_LFS@DF_IALFS_UNE_M,1.0/USA+CAN+GBR+DEU+FRA+JPN+ITA+G7+OECD..._Z.Y._T.Y_GE15..M?lastNObservations=2&format=csvfilewithlabels", type: FILE, connector: csv, tier: C, config: { keyColumn: [REF_AREA, TIME_PERIOD], compareColumns: [OBS_VALUE], titleColumn: "Reference area", dateColumn: TIME_PERIOD } } | |
| 480 | + - { name: composite leading indicators (SDMX DF_CLI), url: "https://sdmx.oecd.org/public/rest/data/OECD.SDD.STES,DSD_STES@DF_CLI,4.1/USA+CAN+GBR+DEU+FRA+JPN.M.LI...AA...H?lastNObservations=2&format=csvfilewithlabels", type: FILE, connector: csv, tier: C, config: { keyColumn: [REF_AREA, TIME_PERIOD], compareColumns: [OBS_VALUE], titleColumn: "Reference area", dateColumn: TIME_PERIOD } } | |
| 481 | + - { name: short-term interest rates (SDMX KEI IRSTCI), url: "https://sdmx.oecd.org/public/rest/data/OECD.SDD.STES,DSD_KEI@DF_KEI,4.0/USA+CAN+GBR+DEU+FRA+JPN.M.IRSTCI.._Z.._Z...?lastNObservations=2&format=csvfilewithlabels", type: FILE, connector: csv, tier: C, config: { keyColumn: [REF_AREA, TIME_PERIOD], compareColumns: [OBS_VALUE], titleColumn: "Reference area", dateColumn: TIME_PERIOD } } | |
| 482 | + # ───────────────────────── F · US Treasury, TreasuryDirect, OFAC ───────────────────────── | |
| 483 | + - id: us-treasury | |
| 484 | + extend: true | |
| 485 | + country: US | |
| 486 | + aliases: [department of the treasury, ofac, office of foreign assets control, fiscal data] | |
| 487 | + notes: "home.treasury.gov RSS paths never answer (connection stalls). The daily yield-curve CSV carries the calendar year in its path (…/2026/all…): roll the segment each January. FiscalData API rows with several records per date (average rates, cash balance, MSPD) cannot be keyed by the jsonlist connector and are skipped." | |
| 488 | + sensors: | |
| 489 | + - { name: daily treasury par yield curve (CSV, current year), url: "https://home.treasury.gov/resource-center/data-chart-center/interest-rates/daily-treasury-rates.csv/2026/all?type=daily_treasury_yield_curve&field_tdr_date_value=2026&page&_format=csv", type: FILE, connector: csv, tier: B, config: { keyColumn: Date, compareColumns: ["1 Mo", "3 Mo", "6 Mo", "1 Yr", "2 Yr", "5 Yr", "10 Yr", "30 Yr"], dateColumn: Date, maxRows: 30 } } | |
| 490 | + - { name: debt to the penny (FiscalData), url: "https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v2/accounting/od/debt_to_penny?sort=-record_date&page[size]=10", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: data, keyField: record_date, titleTemplate: "Total public debt outstanding {record_date}: ${tot_pub_debt_out_amt}", dateField: record_date, compareFields: [tot_pub_debt_out_amt, debt_held_public_amt, intragov_hold_amt], urlTemplate: "https://fiscaldata.treasury.gov/datasets/debt-to-the-penny/debt-to-the-penny" } } | |
| 491 | + - { name: OFAC sanctions list latest changes (XML), url: "https://sanctionslistservice.ofac.treas.gov/changes/latest", type: XML, connector: http, tier: A } | |
| 492 | + - { name: OFAC recent actions, url: "https://ofac.treasury.gov/recent-actions", type: HTML, connector: http, tier: A } | |
| 493 | + - id: treasurydirect | |
| 494 | + name: TreasuryDirect (Treasury auctions) | |
| 495 | + domain: treasurydirect.gov | |
| 496 | + homepage: https://www.treasurydirect.gov/auctions/ | |
| 497 | + categories: [finance, government, open-data] | |
| 498 | + tier: B | |
| 499 | + country: US | |
| 500 | + aliases: [treasurydirect, treasury auctions, bureau of the fiscal service auctions] | |
| 501 | + discover: { rss: false } | |
| 502 | + notes: "TA_WS securities web service: announced / auctioned / upcoming auctions as JSON arrays keyed by CUSIP." | |
| 503 | + sensors: | |
| 504 | + - { name: announced auctions api, url: "https://www.treasurydirect.gov/TA_WS/securities/announced?format=json&pagesize=20", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "", keyField: cusip, titleTemplate: "{securityType} {securityTerm} — auction {auctionDate}, offering {offeringAmount}", dateField: announcementDate, compareFields: [auctionDate, issueDate, offeringAmount, reopening], urlTemplate: "https://www.treasurydirect.gov/auctions/announcements-data-results/" } } | |
| 505 | + - { name: auction results api, url: "https://www.treasurydirect.gov/TA_WS/securities/auctioned?format=json&pagesize=20", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "", keyField: cusip, titleTemplate: "{securityType} {securityTerm} auction {auctionDate}: high yield {highYield} · discount rate {highDiscountRate} · bid-to-cover {bidToCoverRatio}", dateField: auctionDate, compareFields: [highYield, highDiscountRate, bidToCoverRatio, totalAccepted, pricePer100], urlTemplate: "https://www.treasurydirect.gov/auctions/announcements-data-results/" } } | |
| 506 | + - { name: upcoming auctions api, url: "https://www.treasurydirect.gov/TA_WS/securities/upcoming?format=json", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "", keyField: cusip, titleTemplate: "Upcoming: {securityType} {securityTerm} on {auctionDate}", dateField: auctionDate, compareFields: [auctionDate, offeringAmount, issueDate], urlTemplate: "https://www.treasurydirect.gov/auctions/upcoming/" } } | |
| 507 | + # ───────────────────────── G · Statistics offices — release feeds & data APIs ───────────────────────── | |
| 508 | + - id: bls | |
| 509 | + extend: true | |
| 510 | + country: US | |
| 511 | + aliases: [bureau of labor statistics, employment situation, jobs report] | |
| 512 | + notes: "Per-release Atom feeds (one per major indicator). The public API v2 returns year+period rows that the jsonlist connector cannot key — FRED series cover the same values." | |
| 513 | + sensors: | |
| 514 | + - { name: employment situation feed, url: "https://www.bls.gov/feed/empsit.rss", type: ATOM, connector: rss, tier: A } | |
| 515 | + - { name: consumer price index feed, url: "https://www.bls.gov/feed/cpi.rss", type: ATOM, connector: rss, tier: A } | |
| 516 | + - { name: producer price index feed, url: "https://www.bls.gov/feed/ppi.rss", type: ATOM, connector: rss, tier: B } | |
| 517 | + - { name: JOLTS feed, url: "https://www.bls.gov/feed/jolts.rss", type: ATOM, connector: rss, tier: B } | |
| 518 | + - { name: employment cost index feed, url: "https://www.bls.gov/feed/eci.rss", type: ATOM, connector: rss, tier: C } | |
| 519 | + - { name: real earnings feed, url: "https://www.bls.gov/feed/realer.rss", type: ATOM, connector: rss, tier: C } | |
| 520 | + - { name: productivity and costs feed, url: "https://www.bls.gov/feed/prod2.rss", type: ATOM, connector: rss, tier: C } | |
| 521 | + - id: statcan | |
| 522 | + extend: true | |
| 523 | + country: CA | |
| 524 | + aliases: [statistics canada, statistique canada, the daily, wds] | |
| 525 | + notes: "Web Data Service vectors from 2025-01 to 2030-12 (≤ 72 monthly rows, safely under the jsonlist head cap); getChangedCubeList needs a YYYY-MM-DD path segment the placeholder engine cannot produce." | |
| 526 | + sensors: | |
| 527 | + - { name: CPI all-items (WDS v41690973), url: "https://www150.statcan.gc.ca/t1/wds/rest/getDataFromVectorByReferencePeriodRange?vectorIds=%2241690973%22&startRefPeriod=2025-01-01&endReferencePeriod=2030-12-31", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "[0].object.vectorDataPoint", keyField: refPer, titleTemplate: "Canada CPI all-items {refPer}: {value}", dateField: releaseTime, compareFields: [value], urlTemplate: "https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1810000401" } } | |
| 528 | + - { name: unemployment rate (WDS v2062815), url: "https://www150.statcan.gc.ca/t1/wds/rest/getDataFromVectorByReferencePeriodRange?vectorIds=%222062815%22&startRefPeriod=2025-01-01&endReferencePeriod=2030-12-31", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "[0].object.vectorDataPoint", keyField: refPer, titleTemplate: "Canada unemployment rate {refPer}: {value} %", dateField: releaseTime, compareFields: [value], urlTemplate: "https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1410028701" } } | |
| 529 | + - { name: employment (WDS v2062811), url: "https://www150.statcan.gc.ca/t1/wds/rest/getDataFromVectorByReferencePeriodRange?vectorIds=%222062811%22&startRefPeriod=2025-01-01&endReferencePeriod=2030-12-31", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "[0].object.vectorDataPoint", keyField: refPer, titleTemplate: "Canada employment {refPer}: {value} thousand", dateField: releaseTime, compareFields: [value], urlTemplate: "https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1410028701" } } | |
| 530 | + - { name: monthly real GDP (WDS v65201210), url: "https://www150.statcan.gc.ca/t1/wds/rest/getDataFromVectorByReferencePeriodRange?vectorIds=%2265201210%22&startRefPeriod=2025-01-01&endReferencePeriod=2030-12-31", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: "[0].object.vectorDataPoint", keyField: refPer, titleTemplate: "Canada monthly GDP {refPer}: {value}", dateField: releaseTime, compareFields: [value], urlTemplate: "https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=3610043401" } } | |
| 531 | + - id: eurostat | |
| 532 | + extend: true | |
| 533 | + country: EU | |
| 534 | + notes: "Catalogue RSS (euro-indicators, news-releases) 404; statistics-update.rss lists every dataset update (capped). SDMX 2.1 CSV works for HICP/GDP; the unemployment SDMX query returns 400 so the JSON-stat statistics API is fingerprinted instead." | |
| 535 | + sensors: | |
| 536 | + - { name: statistics update feed, url: "https://ec.europa.eu/eurostat/api/dissemination/catalogue/rss/en/statistics-update.rss", type: RSS, connector: rss, tier: B, config: { maxItems: 40 } } | |
| 537 | + - { name: HICP annual rate (SDMX prc_hicp_manr), url: "https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/prc_hicp_manr/M.RCH_A.CP00.EA+EU+DE+FR+IT+ES?lastNObservations=3&format=SDMX-CSV", type: FILE, connector: csv, tier: B, config: { keyColumn: [geo, TIME_PERIOD], compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD } } | |
| 538 | + - { name: HICP index (SDMX prc_hicp_midx), url: "https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/prc_hicp_midx/M.I15.CP00.EA+EU+DE+FR?lastNObservations=3&format=SDMX-CSV", type: FILE, connector: csv, tier: C, config: { keyColumn: [geo, TIME_PERIOD], compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD } } | |
| 539 | + - { name: quarterly GDP growth (SDMX namq_10_gdp), url: "https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/namq_10_gdp/Q.CLV_PCH_PRE.SCA.B1GQ.EA20+EU27_2020+DE+FR+IT+ES?lastNObservations=3&format=SDMX-CSV", type: FILE, connector: csv, tier: B, config: { keyColumn: [geo, TIME_PERIOD], compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD } } | |
| 540 | + - { name: unemployment rate (JSON-stat une_rt_m), url: "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/une_rt_m?geo=EA20&geo=EU27_2020&s_adj=SA&age=TOTAL&unit=PC_ACT&sex=T&lastTimePeriod=3", type: JSON, connector: http, tier: B, config: { jsonPath: value } } | |
| 541 | + - id: ons | |
| 542 | + extend: true | |
| 543 | + country: GB | |
| 544 | + aliases: [office for national statistics, uk statistics] | |
| 545 | + notes: "Time-series /data JSON is an ascending full history (hundreds of rows) — fingerprinted on the months/quarters array rather than keyed (the jsonlist head cap would keep the oldest rows). The generator CSV export has a variable metadata preamble. Bulletin ?rss pages rate-limit (429)." | |
| 546 | + sensors: | |
| 547 | + - { name: CPI annual rate (D7G7), url: "https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/d7g7/mm23/data", type: JSON, connector: http, tier: B, config: { jsonPath: months } } | |
| 548 | + - { name: CPIH annual rate (L55O), url: "https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/l55o/mm23/data", type: JSON, connector: http, tier: C, config: { jsonPath: months } } | |
| 549 | + - { name: unemployment rate (MGSX), url: "https://www.ons.gov.uk/employmentandlabourmarket/peoplenotinwork/unemployment/timeseries/mgsx/lms/data", type: JSON, connector: http, tier: B, config: { jsonPath: months } } | |
| 550 | + - { name: average weekly earnings growth (KAC3), url: "https://www.ons.gov.uk/employmentandlabourmarket/peopleinwork/earningsandworkinghours/timeseries/kac3/lms/data", type: JSON, connector: http, tier: C, config: { jsonPath: months } } | |
| 551 | + - { name: GDP quarter-on-quarter growth (IHYQ), url: "https://www.ons.gov.uk/economy/grossdomesticproductgdp/timeseries/ihyq/pn2/data", type: JSON, connector: http, tier: B, config: { jsonPath: quarters } } | |
| 552 | + - id: insee | |
| 553 | + extend: true | |
| 554 | + country: FR | |
| 555 | + language: fr | |
| 556 | + notes: "insee.fr RSS paths 404/500; the BDM SDMX API (api.insee.fr) is open without a key for series data — fingerprinted as XML." | |
| 557 | + sensors: | |
| 558 | + - { name: CPI series 001759970 (BDM SDMX), url: "https://api.insee.fr/series/BDM/V1/data/SERIES_BDM/001759970?lastNObservations=3", type: XML, connector: http, tier: C } | |
| 559 | + - id: ine-spain | |
| 560 | + extend: true | |
| 561 | + country: ES | |
| 562 | + notes: "No RSS (all rss paths 404); the Tempus3 JSON API (servicios.ine.es/wstempus) is open." | |
| 563 | + sensors: | |
| 564 | + - { name: CPI monthly change (Tempus IPC206449), url: "https://servicios.ine.es/wstempus/js/EN/DATOS_SERIE/IPC206449?nult=3", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: Data, keyField: Fecha, titleTemplate: "Spain CPI monthly change {Anyo}-{FK_Periodo}: {Valor} %", compareFields: [Valor], urlTemplate: "https://www.ine.es/dyngs/INEbase/en/operacion.htm?c=Estadistica_C&cid=1254736176802&menu=ultiDatos&idp=1254735976607" } } | |
| 565 | + - { name: CPI index (Tempus IPC206446), url: "https://servicios.ine.es/wstempus/js/EN/DATOS_SERIE/IPC206446?nult=3", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: Data, keyField: Fecha, titleTemplate: "Spain CPI index {Anyo}-{FK_Periodo}: {Valor}", compareFields: [Valor], urlTemplate: "https://www.ine.es/dyngs/INEbase/en/operacion.htm?c=Estadistica_C&cid=1254736176802&menu=ultiDatos&idp=1254735976607" } } | |
| 566 | + - id: abs | |
| 567 | + extend: true | |
| 568 | + country: AU | |
| 569 | + notes: "No RSS; the ABS Data API (SDMX csvfile) is open — the CPI dataflow key tried returned 404, labour force works." | |
| 570 | + sensors: | |
| 571 | + - { name: unemployment rate (Data API LF), url: "https://data.api.abs.gov.au/rest/data/ABS,LF,1.0.0/M13.3.1599.20.AUS.M?lastNObservations=3&format=csvfile", type: FILE, connector: csv, tier: C, config: { keyColumn: TIME_PERIOD, compareColumns: [OBS_VALUE], dateColumn: TIME_PERIOD } } | |
| 572 | + # ───────────────────────── H · Investor relations & filings — finance sector ───────────────────────── | |
| 573 | + - id: citigroup | |
| 574 | + extend: true | |
| 575 | + country: US | |
| 576 | + sensors: | |
| 577 | + - { name: press releases, url: "https://www.citigroup.com/global/news/press-release", type: HTML, connector: http, tier: B } | |
| 578 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000831001.json", type: REST_API, connector: edgar, tier: B } | |
| 579 | + - id: charles-schwab | |
| 580 | + extend: true | |
| 581 | + country: US | |
| 582 | + sensors: | |
| 583 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000316709.json", type: REST_API, connector: edgar, tier: B } | |
| 584 | + - id: state-street | |
| 585 | + extend: true | |
| 586 | + country: US | |
| 587 | + sensors: | |
| 588 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000093751.json", type: REST_API, connector: edgar, tier: C } | |
| 589 | + - id: interactive-brokers | |
| 590 | + extend: true | |
| 591 | + country: US | |
| 592 | + sensors: | |
| 593 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001381197.json", type: REST_API, connector: edgar, tier: C } | |
| 594 | + - id: block | |
| 595 | + extend: true | |
| 596 | + country: US | |
| 597 | + sensors: | |
| 598 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001512673.json", type: REST_API, connector: edgar, tier: C } | |
| 599 | + - id: pnc | |
| 600 | + name: PNC Financial Services | |
| 601 | + domain: pnc.com | |
| 602 | + homepage: https://www.pnc.com | |
| 603 | + categories: [finance, banking] | |
| 604 | + tier: B | |
| 605 | + country: US | |
| 606 | + aliases: [pnc, pnc financial services group, pnc bank] | |
| 607 | + discover: { rss: false } | |
| 608 | + sensors: | |
| 609 | + - { name: news releases feed, url: "https://pnc.mediaroom.com/news-releases?pagetemplate=rss", type: RSS, connector: rss, tier: B } | |
| 610 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000713676.json", type: REST_API, connector: edgar, tier: C } | |
| 611 | + - id: us-bancorp | |
| 612 | + name: U.S. Bancorp | |
| 613 | + domain: usbank.com | |
| 614 | + homepage: https://www.usbank.com | |
| 615 | + categories: [finance, banking] | |
| 616 | + tier: B | |
| 617 | + country: US | |
| 618 | + aliases: [u.s. bancorp, us bancorp, u.s. bank, us bank] | |
| 619 | + discover: { rss: false } | |
| 620 | + notes: "ir.usbank.com (Q4) news listing is client-rendered; filings only." | |
| 621 | + sensors: | |
| 622 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000036104.json", type: REST_API, connector: edgar, tier: C } | |
| 623 | + - id: capital-one | |
| 624 | + name: Capital One | |
| 625 | + domain: capitalone.com | |
| 626 | + homepage: https://www.capitalone.com | |
| 627 | + categories: [finance, banking, payments] | |
| 628 | + tier: B | |
| 629 | + country: US | |
| 630 | + aliases: [capital one, capital one financial, discover financial] | |
| 631 | + discover: { rss: false } | |
| 632 | + sensors: | |
| 633 | + - { name: newsroom, url: "https://www.capitalone.com/about/newsroom/", type: HTML, connector: http, tier: B } | |
| 634 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000927628.json", type: REST_API, connector: edgar, tier: C } | |
| 635 | + - id: fannie-mae | |
| 636 | + extend: true | |
| 637 | + country: US | |
| 638 | + sensors: | |
| 639 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000310522.json", type: REST_API, connector: edgar, tier: C } | |
| 640 | + - id: freddie-mac | |
| 641 | + extend: true | |
| 642 | + country: US | |
| 643 | + sensors: | |
| 644 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001026214.json", type: REST_API, connector: edgar, tier: C } | |
| 645 | + - id: blackstone | |
| 646 | + name: Blackstone | |
| 647 | + domain: blackstone.com | |
| 648 | + homepage: https://www.blackstone.com | |
| 649 | + categories: [finance] | |
| 650 | + tier: B | |
| 651 | + weight: 1.1 | |
| 652 | + country: US | |
| 653 | + aliases: [blackstone, blackstone inc, blackstone group, bx] | |
| 654 | + discover: { rss: false } | |
| 655 | + notes: "BLOCKED 2026-09-11: blackstone.com answers 403 to the connector on /news/feed/ and /insights/feed/ (200 to a plain client — UA-based bot management); SEC filings are the open channel." | |
| 656 | + sensors: | |
| 657 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001393818.json", type: REST_API, connector: edgar, tier: B } | |
| 658 | + - id: kkr | |
| 659 | + name: KKR | |
| 660 | + domain: kkr.com | |
| 661 | + homepage: https://www.kkr.com | |
| 662 | + categories: [finance] | |
| 663 | + tier: B | |
| 664 | + country: US | |
| 665 | + aliases: [kkr, kkr & co, kohlberg kravis roberts] | |
| 666 | + discover: { rss: false } | |
| 667 | + notes: "ir.kkr.com press-release page is a 4 KB client-rendered shell; filings only." | |
| 668 | + sensors: | |
| 669 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001404912.json", type: REST_API, connector: edgar, tier: B } | |
| 670 | + - id: apollo-global | |
| 671 | + name: Apollo Global Management | |
| 672 | + domain: apollo.com | |
| 673 | + homepage: https://www.apollo.com | |
| 674 | + categories: [finance] | |
| 675 | + tier: B | |
| 676 | + country: US | |
| 677 | + aliases: [apollo global management, apollo global, apollo, athene] | |
| 678 | + discover: { rss: false } | |
| 679 | + sensors: | |
| 680 | + - { name: press releases, url: "https://www.apollo.com/insights-news/pressreleases", type: HTML, connector: http, tier: B } | |
| 681 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001858681.json", type: REST_API, connector: edgar, tier: B } | |
| 682 | + - id: carlyle | |
| 683 | + name: The Carlyle Group | |
| 684 | + domain: carlyle.com | |
| 685 | + homepage: https://www.carlyle.com | |
| 686 | + categories: [finance] | |
| 687 | + tier: C | |
| 688 | + country: US | |
| 689 | + aliases: [carlyle, carlyle group] | |
| 690 | + discover: { rss: true } | |
| 691 | + sensors: | |
| 692 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001527166.json", type: REST_API, connector: edgar, tier: C } | |
| 693 | + - id: ares-management | |
| 694 | + name: Ares Management | |
| 695 | + domain: aresmgmt.com | |
| 696 | + homepage: https://www.aresmgmt.com | |
| 697 | + categories: [finance] | |
| 698 | + tier: C | |
| 699 | + country: US | |
| 700 | + aliases: [ares management, ares] | |
| 701 | + discover: { rss: true } | |
| 702 | + sensors: | |
| 703 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001176948.json", type: REST_API, connector: edgar, tier: C } | |
| 704 | + - id: t-rowe-price | |
| 705 | + name: T. Rowe Price | |
| 706 | + domain: troweprice.com | |
| 707 | + homepage: https://www.troweprice.com | |
| 708 | + categories: [finance] | |
| 709 | + tier: C | |
| 710 | + country: US | |
| 711 | + aliases: [t. rowe price, t rowe price, troweprice] | |
| 712 | + discover: { rss: true } | |
| 713 | + sensors: | |
| 714 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001113169.json", type: REST_API, connector: edgar, tier: C } | |
| 715 | + - id: franklin-templeton | |
| 716 | + name: Franklin Templeton | |
| 717 | + domain: franklintempleton.com | |
| 718 | + homepage: https://www.franklintempleton.com | |
| 719 | + categories: [finance] | |
| 720 | + tier: C | |
| 721 | + country: US | |
| 722 | + aliases: [franklin templeton, franklin resources] | |
| 723 | + discover: { rss: true } | |
| 724 | + notes: "franklintempleton.com/press-releases is client-rendered (thin); filings only." | |
| 725 | + sensors: | |
| 726 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000038777.json", type: REST_API, connector: edgar, tier: C } | |
| 727 | + - id: invesco | |
| 728 | + name: Invesco | |
| 729 | + domain: invesco.com | |
| 730 | + homepage: https://www.invesco.com/corporate/en/home.html | |
| 731 | + categories: [finance] | |
| 732 | + tier: C | |
| 733 | + country: US | |
| 734 | + aliases: [invesco, invesco ltd] | |
| 735 | + discover: { rss: true } | |
| 736 | + notes: "ir.invesco.com RSS paths redirect to the corporate homepage; filings only." | |
| 737 | + sensors: | |
| 738 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000914208.json", type: REST_API, connector: edgar, tier: C } | |
| 739 | + - id: northern-trust | |
| 740 | + name: Northern Trust | |
| 741 | + domain: northerntrust.com | |
| 742 | + homepage: https://www.northerntrust.com | |
| 743 | + categories: [finance, banking] | |
| 744 | + tier: C | |
| 745 | + country: US | |
| 746 | + aliases: [northern trust] | |
| 747 | + discover: { rss: true } | |
| 748 | + sensors: | |
| 749 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000073124.json", type: REST_API, connector: edgar, tier: C } | |
| 750 | + - id: ameriprise | |
| 751 | + name: Ameriprise Financial | |
| 752 | + domain: ameriprise.com | |
| 753 | + homepage: https://www.ameriprise.com | |
| 754 | + categories: [finance] | |
| 755 | + tier: C | |
| 756 | + country: US | |
| 757 | + aliases: [ameriprise, ameriprise financial] | |
| 758 | + discover: { rss: true } | |
| 759 | + sensors: | |
| 760 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000820027.json", type: REST_API, connector: edgar, tier: C } | |
| 761 | + - id: raymond-james | |
| 762 | + name: Raymond James Financial | |
| 763 | + domain: raymondjames.com | |
| 764 | + homepage: https://www.raymondjames.com | |
| 765 | + categories: [finance] | |
| 766 | + tier: C | |
| 767 | + country: US | |
| 768 | + aliases: [raymond james, raymond james financial] | |
| 769 | + discover: { rss: true } | |
| 770 | + sensors: | |
| 771 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000720005.json", type: REST_API, connector: edgar, tier: C } | |
| 772 | + - id: allstate | |
| 773 | + name: Allstate | |
| 774 | + domain: allstate.com | |
| 775 | + homepage: https://www.allstate.com | |
| 776 | + categories: [finance, insurance] | |
| 777 | + tier: B | |
| 778 | + country: US | |
| 779 | + aliases: [allstate, the allstate corporation] | |
| 780 | + discover: { rss: false } | |
| 781 | + sensors: | |
| 782 | + - { name: newsroom feed, url: "https://www.allstatenewsroom.com/feed/", type: RSS, connector: rss, tier: B } | |
| 783 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000899051.json", type: REST_API, connector: edgar, tier: C } | |
| 784 | + - id: progressive | |
| 785 | + name: Progressive | |
| 786 | + domain: progressive.com | |
| 787 | + homepage: https://www.progressive.com | |
| 788 | + categories: [finance, insurance] | |
| 789 | + tier: C | |
| 790 | + country: US | |
| 791 | + aliases: [progressive, progressive corporation, progressive insurance] | |
| 792 | + discover: { rss: true } | |
| 793 | + sensors: | |
| 794 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000080661.json", type: REST_API, connector: edgar, tier: C } | |
| 795 | + - id: travelers | |
| 796 | + name: Travelers | |
| 797 | + domain: travelers.com | |
| 798 | + homepage: https://www.travelers.com | |
| 799 | + categories: [finance, insurance] | |
| 800 | + tier: C | |
| 801 | + country: US | |
| 802 | + aliases: [travelers, the travelers companies] | |
| 803 | + discover: { rss: true } | |
| 804 | + sensors: | |
| 805 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000086312.json", type: REST_API, connector: edgar, tier: C } | |
| 806 | + - id: hartford | |
| 807 | + name: The Hartford | |
| 808 | + domain: thehartford.com | |
| 809 | + homepage: https://www.thehartford.com | |
| 810 | + categories: [finance, insurance] | |
| 811 | + tier: C | |
| 812 | + country: US | |
| 813 | + aliases: [the hartford, hartford insurance group, hartford financial] | |
| 814 | + discover: { rss: true } | |
| 815 | + sensors: | |
| 816 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000874766.json", type: REST_API, connector: edgar, tier: C } | |
| 817 | + - id: aon | |
| 818 | + name: Aon | |
| 819 | + domain: aon.com | |
| 820 | + homepage: https://www.aon.com | |
| 821 | + categories: [finance, insurance] | |
| 822 | + tier: C | |
| 823 | + country: GB | |
| 824 | + aliases: [aon, aon plc] | |
| 825 | + discover: { rss: true } | |
| 826 | + sensors: | |
| 827 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000315293.json", type: REST_API, connector: edgar, tier: C } | |
| 828 | + - id: chubb | |
| 829 | + extend: true | |
| 830 | + country: CH | |
| 831 | + sensors: | |
| 832 | + - { name: news releases feed, url: "https://news.chubb.com/news-releases?pagetemplate=rss", type: RSS, connector: rss, tier: B } | |
| 833 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000896159.json", type: REST_API, connector: edgar, tier: C } | |
| 834 | + - id: aig | |
| 835 | + extend: true | |
| 836 | + country: US | |
| 837 | + sensors: | |
| 838 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000005272.json", type: REST_API, connector: edgar, tier: C } | |
| 839 | + - id: metlife | |
| 840 | + extend: true | |
| 841 | + country: US | |
| 842 | + sensors: | |
| 843 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001099219.json", type: REST_API, connector: edgar, tier: C } | |
| 844 | + - id: prudential-financial | |
| 845 | + extend: true | |
| 846 | + country: US | |
| 847 | + sensors: | |
| 848 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001137774.json", type: REST_API, connector: edgar, tier: C } | |
| 849 | + # ── Exchanges, rating agencies & index providers as issuers ── | |
| 850 | + - id: moodys | |
| 851 | + extend: true | |
| 852 | + country: US | |
| 853 | + notes: "ratings.moodys.com/rss is an HTML shell and ir.moodys.com (Q4) is blocked — filings are the only open channel." | |
| 854 | + sensors: | |
| 855 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001059556.json", type: REST_API, connector: edgar, tier: B } | |
| 856 | + - id: msci | |
| 857 | + extend: true | |
| 858 | + country: US | |
| 859 | + sensors: | |
| 860 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001408198.json", type: REST_API, connector: edgar, tier: C } | |
| 861 | + - id: ice | |
| 862 | + extend: true | |
| 863 | + country: US | |
| 864 | + sensors: | |
| 865 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001571949.json", type: REST_API, connector: edgar, tier: B } | |
| 866 | + - id: cme-group | |
| 867 | + extend: true | |
| 868 | + country: US | |
| 869 | + sensors: | |
| 870 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001156375.json", type: REST_API, connector: edgar, tier: B } | |
| 871 | + - id: nasdaq | |
| 872 | + extend: true | |
| 873 | + country: US | |
| 874 | + sensors: | |
| 875 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001120193.json", type: REST_API, connector: edgar, tier: C } | |
| 876 | + - id: factset | |
| 877 | + name: FactSet | |
| 878 | + domain: factset.com | |
| 879 | + homepage: https://www.factset.com | |
| 880 | + categories: [finance, enterprise] | |
| 881 | + tier: C | |
| 882 | + country: US | |
| 883 | + aliases: [factset, factset research systems] | |
| 884 | + discover: { rss: false } | |
| 885 | + sensors: | |
| 886 | + - { name: insight blog feed, url: "https://insight.factset.com/rss.xml", type: RSS, connector: rss, tier: C } | |
| 887 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001013237.json", type: REST_API, connector: edgar, tier: C } | |
| 888 | + - id: dow-jones | |
| 889 | + name: Dow Jones | |
| 890 | + domain: dowjones.com | |
| 891 | + homepage: https://www.dowjones.com | |
| 892 | + categories: [finance, media] | |
| 893 | + tier: C | |
| 894 | + country: US | |
| 895 | + aliases: [dow jones, dow jones & company, dow jones newswires] | |
| 896 | + discover: { rss: false } | |
| 897 | + sensors: | |
| 898 | + - { name: press room feed, url: "https://www.dowjones.com/press-room/rss", type: RSS, connector: rss, tier: C } | |
| 899 | + - id: thomson-reuters | |
| 900 | + name: Thomson Reuters | |
| 901 | + domain: thomsonreuters.com | |
| 902 | + homepage: https://www.thomsonreuters.com | |
| 903 | + categories: [finance, media, enterprise] | |
| 904 | + tier: C | |
| 905 | + country: CA | |
| 906 | + aliases: [thomson reuters, thomson reuters corporation, tri] | |
| 907 | + discover: { rss: false } | |
| 908 | + notes: "thomsonreuters.com press-release listing is client-rendered (thin); filings only." | |
| 909 | + sensors: | |
| 910 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001075124.json", type: REST_API, connector: edgar, tier: C } | |
| 911 | + # ── Canadian financials, pension funds & large caps (40-F/6-K filers) ── | |
| 912 | + - id: rbc | |
| 913 | + extend: true | |
| 914 | + country: CA | |
| 915 | + sensors: | |
| 916 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001000275.json", type: REST_API, connector: edgar, tier: B } | |
| 917 | + - id: td-bank | |
| 918 | + extend: true | |
| 919 | + country: CA | |
| 920 | + sensors: | |
| 921 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0000947263.json", type: REST_API, connector: edgar, tier: B } | |
| 922 | + - id: bmo | |
| 923 | + extend: true | |
| 924 | + country: CA | |
| 925 | + sensors: | |
| 926 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0000927971.json", type: REST_API, connector: edgar, tier: B } | |
| 927 | + - id: scotiabank | |
| 928 | + extend: true | |
| 929 | + country: CA | |
| 930 | + sensors: | |
| 931 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0000009631.json", type: REST_API, connector: edgar, tier: B } | |
| 932 | + - id: cibc | |
| 933 | + extend: true | |
| 934 | + country: CA | |
| 935 | + sensors: | |
| 936 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001045520.json", type: REST_API, connector: edgar, tier: B } | |
| 937 | + - id: manulife | |
| 938 | + extend: true | |
| 939 | + country: CA | |
| 940 | + notes: "manulife.com blocks bots; SEC filings (40-F/6-K) are the open channel." | |
| 941 | + sensors: | |
| 942 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001086888.json", type: REST_API, connector: edgar, tier: B } | |
| 943 | + - id: sun-life | |
| 944 | + extend: true | |
| 945 | + country: CA | |
| 946 | + sensors: | |
| 947 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001097362.json", type: REST_API, connector: edgar, tier: C } | |
| 948 | + - id: brookfield | |
| 949 | + extend: true | |
| 950 | + country: CA | |
| 951 | + aliases: [brookfield corporation, brookfield asset management, bam, bn] | |
| 952 | + products: | |
| 953 | + - { name: Brookfield Asset Management, type: product, aliases: [bam] } | |
| 954 | + sensors: | |
| 955 | + - { name: Brookfield Corporation press releases feed, url: "https://bn.brookfield.com/rss/press-releases.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 40 } } | |
| 956 | + - { name: Brookfield Asset Management press releases feed, url: "https://bam.brookfield.com/rss/press-releases.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 40 } } | |
| 957 | + - { name: edgar filings — Brookfield Corporation (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001001085.json", type: REST_API, connector: edgar, tier: C } | |
| 958 | + - { name: edgar filings — Brookfield Asset Management (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001937926.json", type: REST_API, connector: edgar, tier: C } | |
| 959 | + - id: power-corporation | |
| 960 | + name: Power Corporation of Canada | |
| 961 | + domain: powercorporation.com | |
| 962 | + homepage: https://www.powercorporation.com/en/ | |
| 963 | + categories: [finance, insurance] | |
| 964 | + tier: C | |
| 965 | + country: CA | |
| 966 | + aliases: [power corporation, power corporation of canada, power corp, power financial] | |
| 967 | + discover: { rss: false, sitemap: false } | |
| 968 | + notes: "BLOCKED 2026-09-11: /en/news/press-releases/ answers 403 to the connector (no RSS). Listed for entity linking only." | |
| 969 | + - id: bci | |
| 970 | + name: British Columbia Investment Management Corporation | |
| 971 | + domain: bci.ca | |
| 972 | + homepage: https://www.bci.ca | |
| 973 | + categories: [finance] | |
| 974 | + tier: C | |
| 975 | + country: CA | |
| 976 | + aliases: [bci, british columbia investment management corporation, bcimc] | |
| 977 | + discover: { rss: false } | |
| 978 | + sensors: | |
| 979 | + - { name: news feed, url: "https://www.bci.ca/feed/", type: RSS, connector: rss, tier: C } | |
| 980 | + - id: hoopp | |
| 981 | + name: HOOPP | |
| 982 | + domain: hoopp.com | |
| 983 | + homepage: https://hoopp.com | |
| 984 | + categories: [finance] | |
| 985 | + tier: C | |
| 986 | + country: CA | |
| 987 | + aliases: [hoopp, healthcare of ontario pension plan] | |
| 988 | + discover: { rss: false } | |
| 989 | + sensors: | |
| 990 | + - { name: news, url: "https://hoopp.com/news", type: HTML, connector: http, tier: C } | |
| 991 | + - id: aimco | |
| 992 | + name: AIMCo | |
| 993 | + domain: aimco.ca | |
| 994 | + homepage: https://www.aimco.ca | |
| 995 | + categories: [finance] | |
| 996 | + tier: C | |
| 997 | + country: CA | |
| 998 | + aliases: [aimco, alberta investment management corporation] | |
| 999 | + discover: { rss: false } | |
| 1000 | + sensors: | |
| 1001 | + - { name: insights and news, url: "https://www.aimco.ca/insights", type: HTML, connector: http, tier: C } | |
| 1002 | + - id: cpkc | |
| 1003 | + extend: true | |
| 1004 | + country: CA | |
| 1005 | + sensors: | |
| 1006 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0000016875.json", type: REST_API, connector: edgar, tier: C } | |
| 1007 | + - id: cn-rail | |
| 1008 | + extend: true | |
| 1009 | + country: CA | |
| 1010 | + sensors: | |
| 1011 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0000016868.json", type: REST_API, connector: edgar, tier: C } | |
| 1012 | + - id: cnrl | |
| 1013 | + extend: true | |
| 1014 | + country: CA | |
| 1015 | + sensors: | |
| 1016 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001017413.json", type: REST_API, connector: edgar, tier: C } | |
| 1017 | + - id: suncor | |
| 1018 | + extend: true | |
| 1019 | + country: CA | |
| 1020 | + sensors: | |
| 1021 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0000311337.json", type: REST_API, connector: edgar, tier: C } | |
| 1022 | + - id: enbridge | |
| 1023 | + extend: true | |
| 1024 | + country: CA | |
| 1025 | + sensors: | |
| 1026 | + - { name: edgar filings (10-K/8-K), url: "https://data.sec.gov/submissions/CIK0000895728.json", type: REST_API, connector: edgar, tier: C } | |
| 1027 | + - id: tc-energy | |
| 1028 | + extend: true | |
| 1029 | + country: CA | |
| 1030 | + sensors: | |
| 1031 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001232384.json", type: REST_API, connector: edgar, tier: C } | |
| 1032 | + - id: bell-canada | |
| 1033 | + extend: true | |
| 1034 | + country: CA | |
| 1035 | + sensors: | |
| 1036 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0000718940.json", type: REST_API, connector: edgar, tier: C } | |
| 1037 | + - id: nutrien | |
| 1038 | + extend: true | |
| 1039 | + country: CA | |
| 1040 | + sensors: | |
| 1041 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001725964.json", type: REST_API, connector: edgar, tier: C } | |
| 1042 | + - id: cgi | |
| 1043 | + extend: true | |
| 1044 | + country: CA | |
| 1045 | + sensors: | |
| 1046 | + - { name: edgar filings (40-F/6-K), url: "https://data.sec.gov/submissions/CIK0001061574.json", type: REST_API, connector: edgar, tier: C } | |
| 1047 | + # ── Foreign private issuers (20-F/6-K) and other large caps with existing entities ── | |
| 1048 | + - id: meta-ai | |
| 1049 | + extend: true | |
| 1050 | + country: US | |
| 1051 | + aliases: [meta platforms, meta newsroom] | |
| 1052 | + sensors: | |
| 1053 | + - { name: Meta newsroom feed, url: "https://about.fb.com/news/feed/", type: RSS, connector: rss, tier: A, config: { maxItems: 40 } } | |
| 1054 | + - id: warner-bros-discovery | |
| 1055 | + extend: true | |
| 1056 | + country: US | |
| 1057 | + sensors: | |
| 1058 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001437107.json", type: REST_API, connector: edgar, tier: C } | |
| 1059 | + - id: novo-nordisk | |
| 1060 | + extend: true | |
| 1061 | + country: DK | |
| 1062 | + sensors: | |
| 1063 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0000353278.json", type: REST_API, connector: edgar, tier: B } | |
| 1064 | + - id: novartis | |
| 1065 | + extend: true | |
| 1066 | + country: CH | |
| 1067 | + sensors: | |
| 1068 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001114448.json", type: REST_API, connector: edgar, tier: C } | |
| 1069 | + - id: astrazeneca | |
| 1070 | + extend: true | |
| 1071 | + country: GB | |
| 1072 | + sensors: | |
| 1073 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0000901832.json", type: REST_API, connector: edgar, tier: C } | |
| 1074 | + - id: sap | |
| 1075 | + extend: true | |
| 1076 | + country: DE | |
| 1077 | + sensors: | |
| 1078 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001000184.json", type: REST_API, connector: edgar, tier: C } | |
| 1079 | + - id: sony | |
| 1080 | + extend: true | |
| 1081 | + country: JP | |
| 1082 | + sensors: | |
| 1083 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0000313838.json", type: REST_API, connector: edgar, tier: C } | |
| 1084 | + - id: hsbc | |
| 1085 | + extend: true | |
| 1086 | + country: GB | |
| 1087 | + sensors: | |
| 1088 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001089113.json", type: REST_API, connector: edgar, tier: B } | |
| 1089 | + - id: unilever | |
| 1090 | + extend: true | |
| 1091 | + country: GB | |
| 1092 | + sensors: | |
| 1093 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0000217410.json", type: REST_API, connector: edgar, tier: C } | |
| 1094 | + - id: bp | |
| 1095 | + extend: true | |
| 1096 | + country: GB | |
| 1097 | + sensors: | |
| 1098 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0000313807.json", type: REST_API, connector: edgar, tier: C } | |
| 1099 | + - id: shell | |
| 1100 | + extend: true | |
| 1101 | + country: GB | |
| 1102 | + sensors: | |
| 1103 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001306965.json", type: REST_API, connector: edgar, tier: C } | |
| 1104 | + - id: totalenergies | |
| 1105 | + extend: true | |
| 1106 | + country: FR | |
| 1107 | + sensors: | |
| 1108 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0000879764.json", type: REST_API, connector: edgar, tier: C } | |
| 1109 | + - id: spotify | |
| 1110 | + extend: true | |
| 1111 | + country: SE | |
| 1112 | + sensors: | |
| 1113 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001639920.json", type: REST_API, connector: edgar, tier: C } | |
| 1114 | + - id: mercadolibre | |
| 1115 | + extend: true | |
| 1116 | + country: UY | |
| 1117 | + sensors: | |
| 1118 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001099590.json", type: REST_API, connector: edgar, tier: C } | |
| 1119 | + - id: pdd-temu | |
| 1120 | + extend: true | |
| 1121 | + country: IE | |
| 1122 | + sensors: | |
| 1123 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001737806.json", type: REST_API, connector: edgar, tier: C } | |
| 1124 | + - id: jd-com | |
| 1125 | + extend: true | |
| 1126 | + country: CN | |
| 1127 | + sensors: | |
| 1128 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001549802.json", type: REST_API, connector: edgar, tier: C } | |
| 1129 | + - id: alibaba | |
| 1130 | + extend: true | |
| 1131 | + country: CN | |
| 1132 | + sensors: | |
| 1133 | + - { name: edgar filings (20-F/6-K), url: "https://data.sec.gov/submissions/CIK0001577552.json", type: REST_API, connector: edgar, tier: B } | |
| 1134 | + - id: okta | |
| 1135 | + extend: true | |
| 1136 | + country: US | |
| 1137 | + sensors: | |
| 1138 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001660134.json", type: REST_API, connector: edgar, tier: C } | |
| 1139 | + - id: twilio | |
| 1140 | + extend: true | |
| 1141 | + country: US | |
| 1142 | + sensors: | |
| 1143 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001447669.json", type: REST_API, connector: edgar, tier: C } | |
| 1144 | + - id: reddit | |
| 1145 | + extend: true | |
| 1146 | + country: US | |
| 1147 | + sensors: | |
| 1148 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001713445.json", type: REST_API, connector: edgar, tier: C } | |
| 1149 | + - id: chipotle | |
| 1150 | + extend: true | |
| 1151 | + country: US | |
| 1152 | + sensors: | |
| 1153 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001058090.json", type: REST_API, connector: edgar, tier: C } | |
| 1154 | + - id: yum-brands | |
| 1155 | + extend: true | |
| 1156 | + country: US | |
| 1157 | + sensors: | |
| 1158 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001041061.json", type: REST_API, connector: edgar, tier: C } | |
| 1159 | + - id: dominos | |
| 1160 | + extend: true | |
| 1161 | + country: US | |
| 1162 | + sensors: | |
| 1163 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001286681.json", type: REST_API, connector: edgar, tier: C } | |
| 1164 | + - id: centene | |
| 1165 | + extend: true | |
| 1166 | + country: US | |
| 1167 | + sensors: | |
| 1168 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001071739.json", type: REST_API, connector: edgar, tier: C } | |
| 1169 | + - id: molina-healthcare | |
| 1170 | + extend: true | |
| 1171 | + country: US | |
| 1172 | + sensors: | |
| 1173 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001179929.json", type: REST_API, connector: edgar, tier: C } | |
| 1174 | + - id: hca-healthcare | |
| 1175 | + extend: true | |
| 1176 | + country: US | |
| 1177 | + sensors: | |
| 1178 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000860730.json", type: REST_API, connector: edgar, tier: C } | |
| 1179 | + - id: danaher | |
| 1180 | + extend: true | |
| 1181 | + country: US | |
| 1182 | + sensors: | |
| 1183 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000313616.json", type: REST_API, connector: edgar, tier: C } | |
| 1184 | + - id: agilent | |
| 1185 | + extend: true | |
| 1186 | + country: US | |
| 1187 | + sensors: | |
| 1188 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001090872.json", type: REST_API, connector: edgar, tier: C } | |
| 1189 | + - id: illumina | |
| 1190 | + extend: true | |
| 1191 | + country: US | |
| 1192 | + sensors: | |
| 1193 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001110803.json", type: REST_API, connector: edgar, tier: C } | |
| 1194 | + - id: biogen | |
| 1195 | + extend: true | |
| 1196 | + country: US | |
| 1197 | + sensors: | |
| 1198 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000875045.json", type: REST_API, connector: edgar, tier: C } | |
| 1199 | + - id: waters | |
| 1200 | + extend: true | |
| 1201 | + country: US | |
| 1202 | + sensors: | |
| 1203 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001000697.json", type: REST_API, connector: edgar, tier: C } | |
| 1204 | + - id: bio-rad | |
| 1205 | + extend: true | |
| 1206 | + country: US | |
| 1207 | + sensors: | |
| 1208 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000012208.json", type: REST_API, connector: edgar, tier: C } | |
| 1209 | + - id: baxter | |
| 1210 | + extend: true | |
| 1211 | + country: US | |
| 1212 | + sensors: | |
| 1213 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000010456.json", type: REST_API, connector: edgar, tier: C } | |
| 1214 | + - id: insulet | |
| 1215 | + extend: true | |
| 1216 | + country: US | |
| 1217 | + sensors: | |
| 1218 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001145197.json", type: REST_API, connector: edgar, tier: C } | |
| 1219 | + - id: tandem-diabetes-care | |
| 1220 | + extend: true | |
| 1221 | + country: US | |
| 1222 | + sensors: | |
| 1223 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001438133.json", type: REST_API, connector: edgar, tier: C } | |
| 1224 | + - id: align-technology | |
| 1225 | + extend: true | |
| 1226 | + country: US | |
| 1227 | + sensors: | |
| 1228 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001097149.json", type: REST_API, connector: edgar, tier: C } | |
| 1229 | + - id: dentsply-sirona | |
| 1230 | + extend: true | |
| 1231 | + country: US | |
| 1232 | + sensors: | |
| 1233 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000818479.json", type: REST_API, connector: edgar, tier: C } | |
| 1234 | + - id: wabtec | |
| 1235 | + extend: true | |
| 1236 | + country: US | |
| 1237 | + sensors: | |
| 1238 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000943452.json", type: REST_API, connector: edgar, tier: C } | |
| 1239 | + - id: ups | |
| 1240 | + extend: true | |
| 1241 | + country: US | |
| 1242 | + sensors: | |
| 1243 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001090727.json", type: REST_API, connector: edgar, tier: B } | |
| 1244 | + - id: fedex | |
| 1245 | + extend: true | |
| 1246 | + country: US | |
| 1247 | + sensors: | |
| 1248 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001048911.json", type: REST_API, connector: edgar, tier: B } | |
| 1249 | + - id: csx | |
| 1250 | + extend: true | |
| 1251 | + country: US | |
| 1252 | + sensors: | |
| 1253 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000277948.json", type: REST_API, connector: edgar, tier: C } | |
| 1254 | + - id: norfolk-southern | |
| 1255 | + extend: true | |
| 1256 | + country: US | |
| 1257 | + sensors: | |
| 1258 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000702165.json", type: REST_API, connector: edgar, tier: C } | |
| 1259 | + - id: jb-hunt | |
| 1260 | + extend: true | |
| 1261 | + country: US | |
| 1262 | + sensors: | |
| 1263 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000728535.json", type: REST_API, connector: edgar, tier: C } | |
| 1264 | + - id: xpo | |
| 1265 | + extend: true | |
| 1266 | + country: US | |
| 1267 | + sensors: | |
| 1268 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001166003.json", type: REST_API, connector: edgar, tier: C } | |
| 1269 | + - id: ch-robinson | |
| 1270 | + extend: true | |
| 1271 | + country: US | |
| 1272 | + sensors: | |
| 1273 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0001043277.json", type: REST_API, connector: edgar, tier: C } | |
| 1274 | + - id: ryder | |
| 1275 | + extend: true | |
| 1276 | + country: US | |
| 1277 | + sensors: | |
| 1278 | + - { name: edgar filings, url: "https://data.sec.gov/submissions/CIK0000085961.json", type: REST_API, connector: edgar, tier: C } | |
added
config/sources.d/44-governments.yaml
+1660 −0
@@ -0,0 +1,2539 @@ | ||
| 1 | +# config/sources.d/44-governments.yaml — governments (2026-09-11): legislation, regulations, gazettes, press | |
| 2 | +# releases, budgets, procurement, open-data catalogues, appointments, sanctions, courts and emergency notices for | |
| 3 | +# Canada (federal + QC/ON/BC/AB/NS/NB/MB/SK/PE/NL/NT), the United States, the European Union, the United Kingdom, | |
| 4 | +# France, Germany, Italy, Spain, Japan, South Korea, Australia, India, Brazil, Mexico and a few international bodies. | |
| 5 | +# Every entry — new or `extend: true` — carries `country:` (ISO alpha-2, EU/INT) and `language:` when not English so | |
| 6 | +# country pages work. Blocked/JS-only endpoints are recorded in `notes:` instead of being pretended. | |
| 7 | +sources: | |
| 8 | + # ───────────────────────────── CANADA — federal ───────────────────────────── | |
| 9 | + - id: canada | |
| 10 | + extend: true | |
| 11 | + country: CA | |
| 12 | + sensors: | |
| 13 | + - { name: statements (all departments), url: "https://api.io.canada.ca/io-server/gc/news/en/v2?type=statements&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Statements", type: ATOM, connector: rss, tier: A } | |
| 14 | + - { name: speeches (all departments), url: "https://api.io.canada.ca/io-server/gc/news/en/v2?type=speeches&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Speeches", type: ATOM, connector: rss, tier: B } | |
| 15 | + - { name: backgrounders (all departments), url: "https://api.io.canada.ca/io-server/gc/news/en/v2?type=backgrounders&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Backgrounders", type: ATOM, connector: rss, tier: B } | |
| 16 | + - id: statcan | |
| 17 | + extend: true | |
| 18 | + country: CA | |
| 19 | + sensors: | |
| 20 | + - { name: news releases (Canada.ca), url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=statisticscanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Statistics%20Canada", type: ATOM, connector: rss, tier: B } | |
| 21 | + - id: finance-canada | |
| 22 | + extend: true | |
| 23 | + country: CA | |
| 24 | + sensors: | |
| 25 | + - { name: consultations, url: "https://www.canada.ca/en/department-finance/programs/consultations.html", type: HTML, connector: http, tier: C } | |
| 26 | + - id: ircc | |
| 27 | + extend: true | |
| 28 | + country: CA | |
| 29 | + sensors: | |
| 30 | + - { name: notices, url: "https://www.canada.ca/en/immigration-refugees-citizenship/news/notices.html", type: HTML, connector: http, tier: B } | |
| 31 | + - { name: ministerial instructions, url: "https://www.canada.ca/en/immigration-refugees-citizenship/corporate/mandate/policies-operational-instructions-agreements/ministerial-instructions.html", type: HTML, connector: http, tier: C } | |
| 32 | + - id: global-affairs-canada | |
| 33 | + extend: true | |
| 34 | + country: CA | |
| 35 | + sensors: | |
| 36 | + - { name: current sanctions (regimes), url: "https://www.international.gc.ca/world-monde/international_relations-relations_internationales/sanctions/current-actuelles.aspx?lang=eng", type: HTML, connector: http, tier: C } | |
| 37 | + - { name: travel advisories (all destinations), url: "https://travel.gc.ca/travelling/advisories", type: HTML, connector: http, tier: B } | |
| 38 | + notes: "Consolidated Canadian Autonomous Sanctions List XML (sema-lmes.xml, 2.9 MB) not monitored; travel-advisory JSON (data.international.gc.ca) is an object keyed by ISO code, not a list." | |
| 39 | + - id: canadabuys | |
| 40 | + name: CanadaBuys (PSPC tender notices) | |
| 41 | + domain: canadabuys.canada.ca | |
| 42 | + categories: [government, procurement] | |
| 43 | + tier: B | |
| 44 | + aliases: [canadabuys, achatscanada, buyandsell] | |
| 45 | + llm: false | |
| 46 | + country: CA | |
| 47 | + sensors: | |
| 48 | + - { name: new tender notices (CSV), url: "https://canadabuys.canada.ca/opendata/pub/newTenderNotice-nouvelAvisAppelOffres.csv", type: FILE, connector: csv, tier: B, config: { keyColumn: "referenceNumber-numeroReference", titleColumn: "title-titre-eng", dateColumn: "publicationDate-datePublication", compareColumns: ["amendmentNumber-numeroModification", "tenderStatus-appelOffresStatut-eng", "tenderClosingDate-appelOffresDateCloture"], maxRows: 500, tail: true } } | |
| 49 | + - { name: tender opportunities, url: "https://canadabuys.canada.ca/en/tender-opportunities", type: HTML, connector: http, tier: B } | |
| 50 | + - id: justice-canada | |
| 51 | + name: Department of Justice Canada | |
| 52 | + domain: justice.gc.ca | |
| 53 | + categories: [government, legal] | |
| 54 | + tier: B | |
| 55 | + aliases: [justice canada, department of justice canada] | |
| 56 | + country: CA | |
| 57 | + sensors: | |
| 58 | + - { name: news, url: "https://www.justice.gc.ca/eng/news-nouv/index.html", type: HTML, connector: http, tier: B } | |
| 59 | + notes: "No working Canada.ca news-API department id; laws-lois.justice.gc.ca RSS feeds are gone (404)." | |
| 60 | + - id: privy-council-office | |
| 61 | + name: Privy Council Office (Canada) | |
| 62 | + domain: canada.ca | |
| 63 | + homepage: https://www.canada.ca/en/privy-council.html | |
| 64 | + categories: [government, politics] | |
| 65 | + tier: B | |
| 66 | + aliases: [pco, privy council office, bureau du conseil privé] | |
| 67 | + country: CA | |
| 68 | + sensors: | |
| 69 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=privycouncil&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=PCO", type: ATOM, connector: rss, tier: B } | |
| 70 | + - { name: intergovernmental affairs news, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=intergovernmentalaffairs&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Intergovernmental%20Affairs", type: ATOM, connector: rss, tier: C } | |
| 71 | + - { name: democratic institutions news, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=democraticinstitutions&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Democratic%20Institutions", type: ATOM, connector: rss, tier: C } | |
| 72 | + - { name: orders in council, url: "https://orders-in-council.canada.ca/index.php?lang=en", type: HTML, connector: http, tier: C } | |
| 73 | + - id: treasury-board-secretariat | |
| 74 | + name: Treasury Board of Canada Secretariat | |
| 75 | + domain: tbs-sct.canada.ca | |
| 76 | + homepage: https://www.canada.ca/en/treasury-board-secretariat.html | |
| 77 | + categories: [government] | |
| 78 | + tier: B | |
| 79 | + aliases: [tbs, treasury board secretariat, secrétariat du conseil du trésor] | |
| 80 | + country: CA | |
| 81 | + sensors: | |
| 82 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=treasuryboardsecretariat&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=TBS", type: ATOM, connector: rss, tier: B } | |
| 83 | + - id: natural-resources-canada | |
| 84 | + name: Natural Resources Canada | |
| 85 | + domain: natural-resources.canada.ca | |
| 86 | + categories: [government, energy] | |
| 87 | + tier: B | |
| 88 | + aliases: [nrcan, natural resources canada, ressources naturelles canada] | |
| 89 | + country: CA | |
| 90 | + sensors: | |
| 91 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=naturalresourcescanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=NRCan", type: ATOM, connector: rss, tier: B } | |
| 92 | + - id: veterans-affairs-canada | |
| 93 | + name: Veterans Affairs Canada | |
| 94 | + domain: veterans.gc.ca | |
| 95 | + categories: [government] | |
| 96 | + tier: C | |
| 97 | + aliases: [vac, veterans affairs canada, anciens combattants canada] | |
| 98 | + country: CA | |
| 99 | + sensors: | |
| 100 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=veteransaffairscanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=VAC", type: ATOM, connector: rss, tier: C } | |
| 101 | + - id: crown-indigenous-relations | |
| 102 | + name: Crown-Indigenous Relations and Northern Affairs Canada | |
| 103 | + domain: rcaanc-cirnac.gc.ca | |
| 104 | + categories: [government] | |
| 105 | + tier: B | |
| 106 | + aliases: [cirnac, crown-indigenous relations, relations couronne-autochtones] | |
| 107 | + country: CA | |
| 108 | + sensors: | |
| 109 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=crownindigenousrelationsandnorthernaffairscanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=CIRNAC", type: ATOM, connector: rss, tier: B } | |
| 110 | + - id: indigenous-services-canada | |
| 111 | + name: Indigenous Services Canada | |
| 112 | + domain: sac-isc.gc.ca | |
| 113 | + categories: [government] | |
| 114 | + tier: B | |
| 115 | + aliases: [isc, indigenous services canada, services aux autochtones canada] | |
| 116 | + country: CA | |
| 117 | + sensors: | |
| 118 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=indigenousservicescanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=ISC", type: ATOM, connector: rss, tier: B } | |
| 119 | + - id: parks-canada | |
| 120 | + name: Parks Canada | |
| 121 | + domain: parks.canada.ca | |
| 122 | + categories: [government, climate] | |
| 123 | + tier: C | |
| 124 | + aliases: [parks canada, parcs canada] | |
| 125 | + country: CA | |
| 126 | + sensors: | |
| 127 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=parkscanada&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Parks%20Canada", type: ATOM, connector: rss, tier: C } | |
| 128 | + - id: canadian-heritage | |
| 129 | + name: Canadian Heritage | |
| 130 | + domain: canada.ca | |
| 131 | + homepage: https://www.canada.ca/en/canadian-heritage.html | |
| 132 | + categories: [government, media] | |
| 133 | + tier: C | |
| 134 | + aliases: [pch, canadian heritage, patrimoine canadien] | |
| 135 | + country: CA | |
| 136 | + sensors: | |
| 137 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=canadianheritage&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=Canadian%20Heritage", type: ATOM, connector: rss, tier: C } | |
| 138 | + - id: csis-canada | |
| 139 | + name: Canadian Security Intelligence Service | |
| 140 | + domain: canada.ca | |
| 141 | + homepage: https://www.canada.ca/en/security-intelligence-service.html | |
| 142 | + categories: [government, cyber] | |
| 143 | + tier: B | |
| 144 | + aliases: [csis, canadian security intelligence service, scrs] | |
| 145 | + country: CA | |
| 146 | + sensors: | |
| 147 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=canadiansecurityintelligenceservice&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=CSIS", type: ATOM, connector: rss, tier: B } | |
| 148 | + - id: iaac | |
| 149 | + name: Impact Assessment Agency of Canada | |
| 150 | + domain: canada.ca | |
| 151 | + homepage: https://www.canada.ca/en/impact-assessment-agency.html | |
| 152 | + categories: [government, climate, energy] | |
| 153 | + tier: C | |
| 154 | + aliases: [iaac, impact assessment agency, agence d'évaluation d'impact] | |
| 155 | + country: CA | |
| 156 | + sensors: | |
| 157 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=impactassessmentagency&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=IAAC", type: ATOM, connector: rss, tier: C } | |
| 158 | + - id: fcac | |
| 159 | + name: Financial Consumer Agency of Canada | |
| 160 | + domain: canada.ca | |
| 161 | + homepage: https://www.canada.ca/en/financial-consumer-agency.html | |
| 162 | + categories: [government, finance, consumer-safety] | |
| 163 | + tier: C | |
| 164 | + aliases: [fcac, financial consumer agency of canada, acfc] | |
| 165 | + country: CA | |
| 166 | + sensors: | |
| 167 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=financialconsumeragency&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=FCAC", type: ATOM, connector: rss, tier: C } | |
| 168 | + - id: citt | |
| 169 | + name: Canadian International Trade Tribunal | |
| 170 | + domain: citt-tcce.gc.ca | |
| 171 | + categories: [government, legal, commerce] | |
| 172 | + tier: C | |
| 173 | + aliases: [citt, canadian international trade tribunal, tcce] | |
| 174 | + country: CA | |
| 175 | + sensors: | |
| 176 | + - { name: news releases, url: "https://api.io.canada.ca/io-server/gc/news/en/v2?dept=canadianinternationaltradetribunal&sort=publishedDate&orderBy=desc&pick=50&format=atom&atomtitle=CITT", type: ATOM, connector: rss, tier: C } | |
| 177 | + - id: fintrac | |
| 178 | + name: FINTRAC | |
| 179 | + domain: fintrac-canafe.canada.ca | |
| 180 | + categories: [government, finance] | |
| 181 | + tier: B | |
| 182 | + aliases: [fintrac, canafe, financial transactions and reports analysis centre] | |
| 183 | + country: CA | |
| 184 | + sensors: | |
| 185 | + - { name: news and notices, url: "https://fintrac-canafe.canada.ca/rss/rss-eng.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 186 | + - id: canlii | |
| 187 | + name: CanLII | |
| 188 | + domain: canlii.org | |
| 189 | + categories: [legal, open-data] | |
| 190 | + tier: B | |
| 191 | + aliases: [canlii, canadian legal information institute] | |
| 192 | + first_party: false | |
| 193 | + country: CA | |
| 194 | + sensors: | |
| 195 | + - { name: new decisions — Federal Court of Appeal, url: "https://www.canlii.org/en/ca/fca/rss_new.xml", type: RSS, connector: rss, tier: B } | |
| 196 | + - { name: new decisions — Court of Appeal for Ontario, url: "https://www.canlii.org/en/on/onca/rss_new.xml", type: RSS, connector: rss, tier: B } | |
| 197 | + - { name: new decisions — Cour d'appel du Québec, url: "https://www.canlii.org/fr/qc/qcca/rss_new.xml", type: RSS, connector: rss, tier: B } | |
| 198 | + - { name: new decisions — Court of Appeal for British Columbia, url: "https://www.canlii.org/en/bc/bcca/rss_new.xml", type: RSS, connector: rss, tier: B } | |
| 199 | + - { name: new decisions — Court of Appeal of Alberta, url: "https://www.canlii.org/en/ab/abca/rss_new.xml", type: RSS, connector: rss, tier: B } | |
| 200 | + - { name: new decisions — Ontario Superior Court of Justice, url: "https://www.canlii.org/en/on/onsc/rss_new.xml", type: RSS, connector: rss, tier: C } | |
| 201 | + - id: openparliament | |
| 202 | + name: openparliament.ca | |
| 203 | + domain: openparliament.ca | |
| 204 | + categories: [politics, open-data] | |
| 205 | + tier: B | |
| 206 | + aliases: [openparliament] | |
| 207 | + first_party: false | |
| 208 | + country: CA | |
| 209 | + sensors: | |
| 210 | + - { name: bills in the House of Commons, url: "https://openparliament.ca/bills/rss/", type: RSS, connector: rss, tier: B } | |
| 211 | + - { name: House votes (API), url: "https://api.openparliament.ca/votes/?format=json&limit=40", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: objects, keyField: url, titleTemplate: "Vote {number} ({session}) — {result}: {description.en}", dateField: date, urlTemplate: "https://openparliament.ca{key}", compareFields: [result, yea_total, nay_total], maxItems: 40 } } | |
| 212 | + # ───────────────────────────── CANADA — Québec ───────────────────────────── | |
| 213 | + - id: assemblee-nationale-quebec | |
| 214 | + extend: true | |
| 215 | + country: CA | |
| 216 | + language: fr | |
| 217 | + sensors: | |
| 218 | + - { name: projets de loi, url: "https://www.assnat.qc.ca/fr/rss/SyndicationRSS-210.html", type: RSS, connector: rss, tier: B } | |
| 219 | + - { name: actualités, url: "https://www.assnat.qc.ca/fr/rss/SyndicationRSS-214.html", type: RSS, connector: rss, tier: B } | |
| 220 | + - { name: mandats des commissions parlementaires, url: "https://www.assnat.qc.ca/fr/rss/SyndicationRSS-120.html", type: RSS, connector: rss, tier: C } | |
| 221 | + - id: elections-quebec | |
| 222 | + extend: true | |
| 223 | + country: CA | |
| 224 | + language: fr | |
| 225 | + sensors: | |
| 226 | + - { name: actualités (RSS), url: "https://www.electionsquebec.qc.ca/actualites/feed/", type: RSS, connector: rss, tier: B } | |
| 227 | + - id: surete-du-quebec | |
| 228 | + name: Sûreté du Québec | |
| 229 | + domain: sq.gouv.qc.ca | |
| 230 | + categories: [government, politics] | |
| 231 | + tier: B | |
| 232 | + aliases: [sq, sûreté du québec, surete du quebec] | |
| 233 | + country: CA | |
| 234 | + language: fr | |
| 235 | + sensors: | |
| 236 | + - { name: communiqués, url: "https://www.sq.gouv.qc.ca/communiques/feed/", type: RSS, connector: rss, tier: B } | |
| 237 | + - id: ville-de-quebec | |
| 238 | + name: Ville de Québec | |
| 239 | + domain: ville.quebec.qc.ca | |
| 240 | + categories: [government] | |
| 241 | + tier: C | |
| 242 | + aliases: [ville de québec, quebec city] | |
| 243 | + country: CA | |
| 244 | + language: fr | |
| 245 | + sensors: | |
| 246 | + - { name: actualités — affaires urbaines (RSS), url: "https://www.ville.quebec.qc.ca/Rss/rss.aspx?f=gen", type: RSS, connector: rss, tier: C } | |
| 247 | + # ───────────────────────────── CANADA — Ontario ───────────────────────────── | |
| 248 | + - id: ontario | |
| 249 | + extend: true | |
| 250 | + country: CA | |
| 251 | + sensors: | |
| 252 | + - { name: Office of the Premier news, url: "https://news.ontario.ca/opo/en/rss/news.rss", type: RSS, connector: rss, tier: A } | |
| 253 | + - { name: Ministry of Finance news, url: "https://news.ontario.ca/mof/en/rss/news.rss", type: RSS, connector: rss, tier: B } | |
| 254 | + - { name: Ministry of the Attorney General news, url: "https://news.ontario.ca/mag/en/rss/news.rss", type: RSS, connector: rss, tier: B } | |
| 255 | + - { name: Ministry of Environment news, url: "https://news.ontario.ca/mecp/en/rss/news.rss", type: RSS, connector: rss, tier: B } | |
| 256 | + notes: "IPC Ontario (ipc.on.ca) and Ottawa (Incapsula) block bots; Ontario Gazette search is a Drupal page without a feed." | |
| 257 | + - id: oeb | |
| 258 | + name: Ontario Energy Board | |
| 259 | + domain: oeb.ca | |
| 260 | + categories: [government, energy] | |
| 261 | + tier: C | |
| 262 | + aliases: [oeb, ontario energy board] | |
| 263 | + country: CA | |
| 264 | + sensors: | |
| 265 | + - { name: newsroom, url: "https://www.oeb.ca/newsroom", type: HTML, connector: http, tier: C } | |
| 266 | + - id: hamilton | |
| 267 | + name: City of Hamilton | |
| 268 | + domain: hamilton.ca | |
| 269 | + categories: [government] | |
| 270 | + tier: C | |
| 271 | + aliases: [city of hamilton, hamilton ontario] | |
| 272 | + country: CA | |
| 273 | + sensors: | |
| 274 | + - { name: news releases, url: "https://www.hamilton.ca/rss.xml", type: RSS, connector: rss, tier: C } | |
| 275 | + # ───────────────────────────── CANADA — British Columbia ───────────────────────────── | |
| 276 | + - id: british-columbia | |
| 277 | + extend: true | |
| 278 | + country: CA | |
| 279 | + sensors: | |
| 280 | + - { name: Office of the Premier news, url: "https://news.gov.bc.ca/ministries/office-of-the-premier/feed", type: RSS, connector: rss, tier: A } | |
| 281 | + - { name: Ministry of Finance news, url: "https://news.gov.bc.ca/ministries/finance/feed", type: RSS, connector: rss, tier: B } | |
| 282 | + - { name: Emergency Management and Climate Readiness news, url: "https://news.gov.bc.ca/ministries/emergency-management-and-climate-readiness/feed", type: RSS, connector: rss, tier: B } | |
| 283 | + - { name: Attorney General news, url: "https://news.gov.bc.ca/ministries/attorney-general/feed", type: RSS, connector: rss, tier: B } | |
| 284 | + notes: "vancouver.ca (Akamai) and BCUC (JS) are not monitorable." | |
| 285 | + - id: emergency-info-bc | |
| 286 | + name: EmergencyInfoBC | |
| 287 | + domain: emergencyinfobc.gov.bc.ca | |
| 288 | + categories: [government, weather] | |
| 289 | + tier: A | |
| 290 | + aliases: [emergencyinfobc, emergency info bc] | |
| 291 | + country: CA | |
| 292 | + sensors: | |
| 293 | + - { name: emergency notices, url: "https://www.emergencyinfobc.gov.bc.ca/feed/", type: RSS, connector: rss, tier: A } | |
| 294 | + - id: drivebc | |
| 295 | + name: DriveBC (BC Ministry of Transportation Open511) | |
| 296 | + domain: drivebc.ca | |
| 297 | + categories: [government, transport, open-data] | |
| 298 | + tier: A | |
| 299 | + aliases: [drivebc, open511 bc] | |
| 300 | + llm: false | |
| 301 | + country: CA | |
| 302 | + sensors: | |
| 303 | + - { name: road events (Open511 API), url: "https://api.open511.gov.bc.ca/events?format=json&limit=50", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: events, keyField: id, titleTemplate: "{event_type} — {headline}", summaryField: description, dateField: updated, urlField: url, compareFields: [updated, status, severity], maxItems: 50 } } | |
| 304 | + - id: bc-laws | |
| 305 | + name: BC Laws (King's Printer) | |
| 306 | + domain: bclaws.gov.bc.ca | |
| 307 | + categories: [government, legal] | |
| 308 | + tier: C | |
| 309 | + aliases: [bc laws, bclaws] | |
| 310 | + country: CA | |
| 311 | + sensors: | |
| 312 | + - { name: recently added or updated, url: "https://www.bclaws.gov.bc.ca/recent.html", type: HTML, connector: http, tier: C } | |
| 313 | + # ───────────────────────────── CANADA — Alberta ───────────────────────────── | |
| 314 | + - id: auc | |
| 315 | + name: Alberta Utilities Commission | |
| 316 | + domain: auc.ab.ca | |
| 317 | + categories: [government, energy] | |
| 318 | + tier: C | |
| 319 | + aliases: [auc, alberta utilities commission] | |
| 320 | + country: CA | |
| 321 | + sensors: | |
| 322 | + - { name: news, url: "https://www.auc.ab.ca/feed/", type: RSS, connector: rss, tier: C } | |
| 323 | + - id: alberta-auditor-general | |
| 324 | + name: Office of the Auditor General of Alberta | |
| 325 | + domain: oag.ab.ca | |
| 326 | + categories: [government] | |
| 327 | + tier: C | |
| 328 | + aliases: [oag alberta, auditor general of alberta] | |
| 329 | + country: CA | |
| 330 | + sensors: | |
| 331 | + - { name: news, url: "https://www.oag.ab.ca/news/", type: HTML, connector: http, tier: C } | |
| 332 | + - id: oipc-alberta | |
| 333 | + name: Office of the Information and Privacy Commissioner of Alberta | |
| 334 | + domain: oipc.ab.ca | |
| 335 | + categories: [government, web-policy] | |
| 336 | + tier: C | |
| 337 | + aliases: [oipc alberta] | |
| 338 | + country: CA | |
| 339 | + sensors: | |
| 340 | + - { name: news releases, url: "https://www.oipc.ab.ca/news-and-events/news-releases", type: HTML, connector: http, tier: C } | |
| 341 | + - id: edmonton | |
| 342 | + name: City of Edmonton | |
| 343 | + domain: edmonton.ca | |
| 344 | + categories: [government] | |
| 345 | + tier: C | |
| 346 | + aliases: [city of edmonton] | |
| 347 | + country: CA | |
| 348 | + sensors: | |
| 349 | + - { name: news releases, url: "https://www.edmonton.ca/rss.xml", type: RSS, connector: rss, tier: C } | |
| 350 | + - id: nova-scotia | |
| 351 | + extend: true | |
| 352 | + country: CA | |
| 353 | + sensors: | |
| 354 | + - { name: traffic advisories, url: "https://novascotia.ca/news/rss/traffic.asp", type: RSS, connector: rss, tier: B } | |
| 355 | + - { name: open data catalogue (Socrata), url: "https://api.us.socrata.com/api/catalog/v1?domains=data.novascotia.ca&order=updatedAt&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: results, keyField: resource.id, titleField: resource.name, summaryField: resource.description, dateField: resource.updatedAt, urlField: link, compareFields: [resource.updatedAt], maxItems: 50 } } | |
| 356 | + notes: "novascotia.ca/news/rss/rss.asp?dept=<id> department feeds return an empty document." | |
| 357 | + - id: ns-legislature | |
| 358 | + name: Nova Scotia Legislature | |
| 359 | + domain: nslegislature.ca | |
| 360 | + categories: [government, politics] | |
| 361 | + tier: C | |
| 362 | + aliases: [nova scotia house of assembly, ns legislature] | |
| 363 | + country: CA | |
| 364 | + sensors: | |
| 365 | + - { name: bills, url: "https://nslegislature.ca/legislative-business/bills-statutes/bills", type: HTML, connector: http, tier: C } | |
| 366 | + - id: nb-legislature | |
| 367 | + name: Legislative Assembly of New Brunswick | |
| 368 | + domain: legnb.ca | |
| 369 | + categories: [government, politics] | |
| 370 | + tier: C | |
| 371 | + aliases: [legislative assembly of new brunswick, legnb] | |
| 372 | + country: CA | |
| 373 | + sensors: | |
| 374 | + - { name: bills, url: "https://legnb.ca/en/legislation/bills", type: HTML, connector: http, tier: C } | |
| 375 | + - id: manitoba | |
| 376 | + extend: true | |
| 377 | + country: CA | |
| 378 | + sensors: | |
| 379 | + - { name: laws — what's new, url: "https://web2.gov.mb.ca/laws/whats_new.php", type: HTML, connector: http, tier: C } | |
| 380 | + - id: manitoba-legislature | |
| 381 | + name: Legislative Assembly of Manitoba | |
| 382 | + domain: gov.mb.ca | |
| 383 | + homepage: https://www.gov.mb.ca/legislature/ | |
| 384 | + categories: [government, politics] | |
| 385 | + tier: C | |
| 386 | + aliases: [legislative assembly of manitoba] | |
| 387 | + country: CA | |
| 388 | + sensors: | |
| 389 | + - { name: bills (43rd legislature, 2nd session), url: "https://web2.gov.mb.ca/bills/43-2/index.php", type: HTML, connector: http, tier: C } | |
| 390 | + - id: sk-legislature | |
| 391 | + name: Legislative Assembly of Saskatchewan | |
| 392 | + domain: legassembly.sk.ca | |
| 393 | + categories: [government, politics] | |
| 394 | + tier: C | |
| 395 | + aliases: [legislative assembly of saskatchewan] | |
| 396 | + country: CA | |
| 397 | + sensors: | |
| 398 | + - { name: bills, url: "https://www.legassembly.sk.ca/legislative-business/bills/", type: HTML, connector: http, tier: C } | |
| 399 | + - id: prince-edward-island | |
| 400 | + name: Government of Prince Edward Island | |
| 401 | + domain: princeedwardisland.ca | |
| 402 | + categories: [government] | |
| 403 | + tier: B | |
| 404 | + aliases: [pei, prince edward island, government of pei] | |
| 405 | + country: CA | |
| 406 | + sensors: | |
| 407 | + - { name: news releases, url: "https://www.princeedwardisland.ca/en/rss.xml", type: RSS, connector: rss, tier: B } | |
| 408 | + - id: federal-register | |
| 409 | + extend: true | |
| 410 | + country: US | |
| 411 | + sensors: | |
| 412 | + - { name: significant documents, url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=50&conditions[significant]=1", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, maxItems: 50 } } | |
| 413 | + - { name: FCC documents, url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=50&conditions[agencies][]=federal-communications-commission", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, maxItems: 50 } } | |
| 414 | + - id: white-house | |
| 415 | + extend: true | |
| 416 | + country: US | |
| 417 | + sensors: | |
| 418 | + - { name: briefings and statements, url: "https://www.whitehouse.gov/briefings-statements/feed/", type: RSS, connector: rss, tier: A } | |
| 419 | + - { name: fact sheets, url: "https://www.whitehouse.gov/fact-sheets/feed/", type: RSS, connector: rss, tier: A } | |
| 420 | + - id: govinfo | |
| 421 | + extend: true | |
| 422 | + country: US | |
| 423 | + sensors: | |
| 424 | + - { name: Federal Register (new items), url: "https://www.govinfo.gov/rss/fr.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 425 | + - { name: Compilation of Presidential Documents, url: "https://www.govinfo.gov/rss/dcpd.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 426 | + - { name: Congressional reports, url: "https://www.govinfo.gov/rss/crpt.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 427 | + - { name: enrolled bills, url: "https://www.govinfo.gov/rss/bills-enr.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 428 | + - id: us-treasury | |
| 429 | + extend: true | |
| 430 | + country: US | |
| 431 | + sensors: | |
| 432 | + - { name: press releases (RSS), url: "https://home.treasury.gov/rss.xml", type: RSS, connector: rss, tier: A } | |
| 433 | + - { name: OFAC documents in the Federal Register, url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=50&conditions[agencies][]=foreign-assets-control-office", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, maxItems: 50 } } | |
| 434 | + notes: "OFAC SDN CSV (sdn.csv, 5.7 MB, no header row) and sanctionslistservice exports are too large/unkeyed for the csv connector; ofac.treasury.gov/recent-actions is JS-rendered." | |
| 435 | + - id: us-state-department | |
| 436 | + extend: true | |
| 437 | + country: US | |
| 438 | + sensors: | |
| 439 | + - { name: travel advisories, url: "https://travel.state.gov/_res/rss/TAsTWs.xml", type: RSS, connector: rss, tier: A, config: { maxItems: 100 } } | |
| 440 | + - { name: department press briefings, url: "https://www.state.gov/rss-feed/department-press-briefings/feed/", type: RSS, connector: rss, tier: B } | |
| 441 | + - id: us-doj | |
| 442 | + extend: true | |
| 443 | + country: US | |
| 444 | + sensors: | |
| 445 | + - { name: speeches, url: "https://www.justice.gov/news/rss?type=speech", type: RSS, connector: rss, tier: C } | |
| 446 | + - id: ftc | |
| 447 | + extend: true | |
| 448 | + country: US | |
| 449 | + sensors: | |
| 450 | + - { name: competition press releases, url: "https://www.ftc.gov/feeds/press-release-competition.xml", type: RSS, connector: rss, tier: B } | |
| 451 | + - { name: consumer protection press releases, url: "https://www.ftc.gov/feeds/press-release-consumer-protection.xml", type: RSS, connector: rss, tier: B } | |
| 452 | + - id: fema | |
| 453 | + name: Federal Emergency Management Agency | |
| 454 | + domain: fema.gov | |
| 455 | + categories: [government, weather, climate] | |
| 456 | + tier: A | |
| 457 | + weight: 1.2 | |
| 458 | + aliases: [fema, federal emergency management agency] | |
| 459 | + country: US | |
| 460 | + sensors: | |
| 461 | + - { name: disaster declarations (OpenFEMA), url: "https://www.fema.gov/api/open/v2/DisasterDeclarationsSummaries?$orderby=declarationDate%20desc&$top=50", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: DisasterDeclarationsSummaries, keyField: id, titleTemplate: "{femaDeclarationString} — {declarationTitle} ({designatedArea}, {state})", dateField: declarationDate, urlTemplate: "https://www.fema.gov/disaster/{disasterNumber}", compareFields: [incidentEndDate, disasterCloseoutDate, lastRefresh], maxItems: 50 } } | |
| 462 | + - { name: press releases, url: "https://www.fema.gov/about/news-multimedia/press-releases", type: HTML, connector: http, tier: B } | |
| 463 | + notes: "fema.gov/feeds/news.rss and fema.gov/rss.xml are WAF-blocked (403)." | |
| 464 | + - id: dhs | |
| 465 | + name: U.S. Department of Homeland Security | |
| 466 | + domain: dhs.gov | |
| 467 | + categories: [government, cyber] | |
| 468 | + tier: A | |
| 469 | + aliases: [dhs, department of homeland security, homeland security] | |
| 470 | + country: US | |
| 471 | + sensors: | |
| 472 | + - { name: news, url: "https://www.dhs.gov/news", type: HTML, connector: http, tier: B } | |
| 473 | + - { name: DHS documents in the Federal Register, url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=50&conditions[agencies][]=homeland-security-department", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, maxItems: 50 } } | |
| 474 | + notes: "dhs.gov RSS endpoints (news-releases/rss.xml, rss.xml) are gone." | |
| 475 | + - id: bis-commerce | |
| 476 | + name: Bureau of Industry and Security (U.S. Commerce) | |
| 477 | + domain: bis.gov | |
| 478 | + homepage: https://www.bis.gov | |
| 479 | + categories: [government, commerce, semiconductors] | |
| 480 | + tier: A | |
| 481 | + weight: 1.2 | |
| 482 | + aliases: [bis, bureau of industry and security, entity list, export controls] | |
| 483 | + country: US | |
| 484 | + sensors: | |
| 485 | + - { name: BIS rules and notices in the Federal Register, url: "https://www.federalregister.gov/api/v1/documents.json?order=newest&per_page=50&conditions[agencies][]=industry-and-security-bureau", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: results, keyField: document_number, titleField: title, urlField: html_url, summaryField: abstract, dateField: publication_date, maxItems: 50 } } | |
| 486 | + - { name: Federal Register notices (BIS site), url: "https://www.bis.doc.gov/index.php/all-articles/17-regulations/federal-register-notices", type: HTML, connector: http, tier: C } | |
| 487 | + notes: "bis.gov press pages are JS-rendered; commerce.gov returns 403." | |
| 488 | + - id: fbi | |
| 489 | + name: Federal Bureau of Investigation | |
| 490 | + domain: fbi.gov | |
| 491 | + categories: [government, cyber] | |
| 492 | + tier: B | |
| 493 | + aliases: [fbi, federal bureau of investigation] | |
| 494 | + llm: false | |
| 495 | + country: US | |
| 496 | + sensors: | |
| 497 | + - { name: national press releases, url: "https://www.fbi.gov/feeds/national-press-releases/rss.xml", type: ATOM, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 498 | + - id: us-ice | |
| 499 | + name: U.S. Immigration and Customs Enforcement | |
| 500 | + domain: ice.gov | |
| 501 | + categories: [government] | |
| 502 | + tier: B | |
| 503 | + aliases: [ice, immigration and customs enforcement] | |
| 504 | + country: US | |
| 505 | + sensors: | |
| 506 | + - { name: news releases, url: "https://www.ice.gov/rss.xml", type: RSS, connector: rss, tier: B } | |
| 507 | + - id: uscis | |
| 508 | + name: U.S. Citizenship and Immigration Services | |
| 509 | + domain: uscis.gov | |
| 510 | + categories: [government] | |
| 511 | + tier: B | |
| 512 | + aliases: [uscis, citizenship and immigration services] | |
| 513 | + country: US | |
| 514 | + sensors: | |
| 515 | + - { name: news releases, url: "https://www.uscis.gov/news/news-releases", type: HTML, connector: http, tier: B } | |
| 516 | + - { name: alerts, url: "https://www.uscis.gov/news/alerts", type: HTML, connector: http, tier: B } | |
| 517 | + notes: "uscis.gov/rss.xml has not been updated since 2015." | |
| 518 | + - id: us-va | |
| 519 | + name: U.S. Department of Veterans Affairs | |
| 520 | + domain: va.gov | |
| 521 | + categories: [government, health] | |
| 522 | + tier: C | |
| 523 | + aliases: [va, department of veterans affairs, veterans affairs] | |
| 524 | + country: US | |
| 525 | + sensors: | |
| 526 | + - { name: VA news, url: "https://news.va.gov/feed/", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 527 | + - id: sba | |
| 528 | + name: U.S. Small Business Administration | |
| 529 | + domain: sba.gov | |
| 530 | + categories: [government, commerce] | |
| 531 | + tier: C | |
| 532 | + aliases: [sba, small business administration] | |
| 533 | + country: US | |
| 534 | + sensors: | |
| 535 | + - { name: press releases, url: "https://www.sba.gov/rss", type: RSS, connector: rss, tier: C } | |
| 536 | + - id: samhsa | |
| 537 | + name: SAMHSA | |
| 538 | + domain: samhsa.gov | |
| 539 | + categories: [government, health] | |
| 540 | + tier: C | |
| 541 | + aliases: [samhsa, substance abuse and mental health services administration] | |
| 542 | + country: US | |
| 543 | + sensors: | |
| 544 | + - { name: press announcements, url: "https://www.samhsa.gov/newsroom/press-announcements/rss", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 545 | + - id: us-copyright-office | |
| 546 | + name: U.S. Copyright Office | |
| 547 | + domain: copyright.gov | |
| 548 | + categories: [government, legal, media] | |
| 549 | + tier: C | |
| 550 | + aliases: [copyright office, us copyright office] | |
| 551 | + country: US | |
| 552 | + sensors: | |
| 553 | + - { name: NewsNet, url: "https://www.copyright.gov/newsnet/rss.xml", type: RSS, connector: rss, tier: C } | |
| 554 | + - { name: rulemaking, url: "https://www.copyright.gov/rulemaking/", type: HTML, connector: http, tier: C } | |
| 555 | + - id: everycrsreport | |
| 556 | + name: EveryCRSReport (Congressional Research Service reports) | |
| 557 | + domain: everycrsreport.com | |
| 558 | + categories: [politics, research, open-data] | |
| 559 | + tier: B | |
| 560 | + aliases: [everycrsreport, crs reports, congressional research service] | |
| 561 | + first_party: false | |
| 562 | + country: US | |
| 563 | + sensors: | |
| 564 | + - { name: new CRS reports, url: "https://www.everycrsreport.com/rss.xml", type: RSS, connector: rss, tier: B } | |
| 565 | + notes: "crsreports.congress.gov is Akamai-protected (403)." | |
| 566 | + - id: california-open-data | |
| 567 | + name: California Open Data Portal | |
| 568 | + domain: data.ca.gov | |
| 569 | + categories: [government, open-data] | |
| 570 | + tier: C | |
| 571 | + aliases: [california open data, data.ca.gov] | |
| 572 | + llm: false | |
| 573 | + country: US | |
| 574 | + sensors: | |
| 575 | + - { name: dataset catalogue (CKAN), url: "https://data.ca.gov/api/3/action/package_search?sort=metadata_modified+desc&rows=50", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: result.results, keyField: id, titleField: title, summaryField: notes, dateField: metadata_modified, compareFields: [metadata_modified], maxItems: 50, urlTemplate: "https://data.ca.gov/dataset/{name}" } } | |
| 576 | + notes: "catalog.data.gov CKAN API (api/3/action/package_search) now returns 404." | |
| 577 | + - id: new-york-state-open-data | |
| 578 | + name: New York State Open Data | |
| 579 | + domain: data.ny.gov | |
| 580 | + categories: [government, open-data] | |
| 581 | + tier: C | |
| 582 | + aliases: [data.ny.gov, new york state open data] | |
| 583 | + llm: false | |
| 584 | + country: US | |
| 585 | + sensors: | |
| 586 | + - { name: dataset catalogue (Socrata), url: "https://api.us.socrata.com/api/catalog/v1?domains=data.ny.gov&order=updatedAt&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: results, keyField: resource.id, titleField: resource.name, summaryField: resource.description, dateField: resource.updatedAt, urlField: link, compareFields: [resource.updatedAt], maxItems: 50 } } | |
| 587 | + - id: texas-open-data | |
| 588 | + name: Texas Open Data Portal | |
| 589 | + domain: data.texas.gov | |
| 590 | + categories: [government, open-data] | |
| 591 | + tier: C | |
| 592 | + aliases: [data.texas.gov, texas open data] | |
| 593 | + llm: false | |
| 594 | + country: US | |
| 595 | + sensors: | |
| 596 | + - { name: dataset catalogue (Socrata), url: "https://api.us.socrata.com/api/catalog/v1?domains=data.texas.gov&order=updatedAt&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: results, keyField: resource.id, titleField: resource.name, summaryField: resource.description, dateField: resource.updatedAt, urlField: link, compareFields: [resource.updatedAt], maxItems: 50 } } | |
| 597 | + - id: los-angeles-open-data | |
| 598 | + name: Los Angeles Open Data | |
| 599 | + domain: data.lacity.org | |
| 600 | + categories: [government, open-data] | |
| 601 | + tier: C | |
| 602 | + aliases: [data.lacity.org, los angeles open data] | |
| 603 | + llm: false | |
| 604 | + country: US | |
| 605 | + sensors: | |
| 606 | + - { name: dataset catalogue (Socrata), url: "https://api.us.socrata.com/api/catalog/v1?domains=data.lacity.org&order=updatedAt&limit=50", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: results, keyField: resource.id, titleField: resource.name, summaryField: resource.description, dateField: resource.updatedAt, urlField: link, compareFields: [resource.updatedAt], maxItems: 50 } } | |
| 607 | + # ───────────────────────────── EUROPEAN UNION ───────────────────────────── | |
| 608 | + - id: european-commission | |
| 609 | + extend: true | |
| 610 | + country: EU | |
| 611 | + sensors: | |
| 612 | + - { name: digital strategy news, url: "https://digital-strategy.ec.europa.eu/rss.xml", type: RSS, connector: rss, tier: B } | |
| 613 | + notes: "Presscorner RSS accepts only ?language=; documenttype/policyarea filters return 400. Have-your-say (brpapi) initiatives use float-like ids; DG sites (*.ec.europa.eu/rss_en) rate-limit (429)." | |
| 614 | + - id: cjeu | |
| 615 | + extend: true | |
| 616 | + country: EU | |
| 617 | + sensors: | |
| 618 | + - { name: press releases, url: "https://curia.europa.eu/jcms/jcms/Jo2_7052/en/", type: HTML, connector: http, tier: B } | |
| 619 | + - id: efsa | |
| 620 | + extend: true | |
| 621 | + country: EU | |
| 622 | + sensors: | |
| 623 | + - { name: news (RSS), url: "https://www.efsa.europa.eu/en/news/rss", type: RSS, connector: rss, tier: B } | |
| 624 | + - id: eib | |
| 625 | + extend: true | |
| 626 | + country: EU | |
| 627 | + sensors: | |
| 628 | + - { name: press releases (RSS), url: "https://www.eib.org/en/press/all/index.rss", type: RSS, connector: rss, tier: B } | |
| 629 | + - id: eu-sanctions-map | |
| 630 | + name: EU Sanctions Map | |
| 631 | + domain: sanctionsmap.eu | |
| 632 | + categories: [government, international, legal] | |
| 633 | + tier: B | |
| 634 | + weight: 1.2 | |
| 635 | + aliases: [eu sanctions map, sanctionsmap, eu restrictive measures] | |
| 636 | + country: EU | |
| 637 | + sensors: | |
| 638 | + - { name: sanctions regimes (API), url: "https://www.sanctionsmap.eu/api/v1/regime", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: data, keyField: id, titleTemplate: "{specification} ({country.data.title})", compareFields: [amendment, expiration, under_construction], urlTemplate: "https://www.sanctionsmap.eu/#/main/details/{key}", maxItems: 100 } } | |
| 639 | + notes: "The consolidated financial sanctions list (webgate.ec.europa.eu/fsd, XML 3 MB / CSV via token) is not keyed for the csv connector." | |
| 640 | + - id: council-of-the-eu | |
| 641 | + name: Council of the European Union | |
| 642 | + domain: consilium.europa.eu | |
| 643 | + categories: [government, politics, international] | |
| 644 | + tier: A | |
| 645 | + aliases: [council of the eu, european council, consilium] | |
| 646 | + country: EU | |
| 647 | + discover: { rss: true, sitemap: true } | |
| 648 | + notes: "consilium.europa.eu answers 403 to every non-browser request (press releases, RSS); listed for discovery only." | |
| 649 | + # ───────────────────────────── UNITED KINGDOM ───────────────────────────── | |
| 650 | + - id: uk-government | |
| 651 | + extend: true | |
| 652 | + country: GB | |
| 653 | + sensors: | |
| 654 | + - { name: policy papers and consultations, url: "https://www.gov.uk/search/policy-papers-and-consultations.atom", type: ATOM, connector: rss, tier: B } | |
| 655 | + - { name: guidance and regulation, url: "https://www.gov.uk/search/guidance-and-regulation.atom", type: ATOM, connector: rss, tier: C } | |
| 656 | + - { name: transparency and FOI releases, url: "https://www.gov.uk/search/transparency-and-freedom-of-information-releases.atom", type: ATOM, connector: rss, tier: C } | |
| 657 | + notes: "The UK Sanctions List / OFSI consolidated list CSV (16 MB) is too large; northernireland.gov.uk, royal.uk, parliament.uk news and electoralcommission.org.uk return 403." | |
| 658 | + - id: uk-parliament | |
| 659 | + extend: true | |
| 660 | + country: GB | |
| 661 | + sensors: | |
| 662 | + - { name: bills (Bills API, recently updated), url: "https://bills-api.parliament.uk/api/v1/Bills?SortOrder=DateUpdatedDescending&Take=50", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: items, keyField: billId, titleField: shortTitle, dateField: lastUpdate, urlTemplate: "https://bills.parliament.uk/bills/{key}", compareFields: [currentHouse, currentStage.description, isAct, isDefeated, billWithdrawn], maxItems: 50 } } | |
| 663 | + - { name: written ministerial statements (API), url: "https://questions-statements-api.parliament.uk/api/writtenstatements/statements?take=50", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: results, keyField: value.id, titleTemplate: "{value.title} — {value.answeringBodyName} ({value.uin})", summaryField: value.text, dateField: value.dateMade, urlTemplate: "https://questions-statements.parliament.uk/written-statements/detail/{value.dateMade}/{value.uin}", maxItems: 50 } } | |
| 664 | + - { name: Commons divisions (votes API), url: "https://commonsvotes-api.parliament.uk/data/divisions.json/search?take=40", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: "", keyField: DivisionId, titleTemplate: "Division {Number}: {Title} (Ayes {AyeCount} · Noes {NoCount})", dateField: Date, urlTemplate: "https://votes.parliament.uk/Votes/Commons/Division/{key}", compareFields: [AyeCount, NoCount, PublicationUpdated], maxItems: 40 } } | |
| 665 | + - id: legislation-gov-uk | |
| 666 | + extend: true | |
| 667 | + country: GB | |
| 668 | + sensors: | |
| 669 | + - { name: new UK statutory instruments, url: "https://www.legislation.gov.uk/new/uksi/data.feed", type: ATOM, connector: rss, tier: B } | |
| 670 | + - { name: new UK public general acts, url: "https://www.legislation.gov.uk/new/ukpga/data.feed", type: ATOM, connector: rss, tier: B } | |
| 671 | + - id: ico-uk | |
| 672 | + extend: true | |
| 673 | + country: GB | |
| 674 | + sensors: | |
| 675 | + - { name: ICO on GOV.UK, url: "https://www.gov.uk/government/organisations/information-commissioner-s-office.atom", type: ATOM, connector: rss, tier: B } | |
| 676 | + - id: ons | |
| 677 | + extend: true | |
| 678 | + country: GB | |
| 679 | + sensors: | |
| 680 | + - { name: ONS on GOV.UK, url: "https://www.gov.uk/government/organisations/office-for-national-statistics.atom", type: ATOM, connector: rss, tier: B } | |
| 681 | + - id: hm-treasury | |
| 682 | + name: HM Treasury | |
| 683 | + domain: gov.uk | |
| 684 | + homepage: https://www.gov.uk/government/organisations/hm-treasury | |
| 685 | + categories: [government, finance] | |
| 686 | + tier: A | |
| 687 | + weight: 1.2 | |
| 688 | + aliases: [hm treasury, hmt, the treasury] | |
| 689 | + country: GB | |
| 690 | + sensors: | |
| 691 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/hm-treasury.atom", type: ATOM, connector: rss, tier: A } | |
| 692 | + - { name: news and communications, url: "https://www.gov.uk/search/news-and-communications.atom?organisations[]=hm-treasury", type: ATOM, connector: rss, tier: A } | |
| 693 | + - id: uk-cabinet-office | |
| 694 | + name: Cabinet Office | |
| 695 | + domain: gov.uk | |
| 696 | + homepage: https://www.gov.uk/government/organisations/cabinet-office | |
| 697 | + categories: [government, politics] | |
| 698 | + tier: B | |
| 699 | + aliases: [cabinet office] | |
| 700 | + country: GB | |
| 701 | + sensors: | |
| 702 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/cabinet-office.atom", type: ATOM, connector: rss, tier: B } | |
| 703 | + - id: number-10 | |
| 704 | + name: Prime Minister's Office, 10 Downing Street | |
| 705 | + domain: gov.uk | |
| 706 | + homepage: https://www.gov.uk/government/organisations/prime-ministers-office-10-downing-street | |
| 707 | + categories: [government, politics] | |
| 708 | + tier: A | |
| 709 | + weight: 1.3 | |
| 710 | + aliases: [number 10, 10 downing street, downing street, prime minister's office, uk prime minister] | |
| 711 | + country: GB | |
| 712 | + sensors: | |
| 713 | + - { name: announcements, url: "https://www.gov.uk/government/organisations/prime-ministers-office-10-downing-street.atom", type: ATOM, connector: rss, tier: A } | |
| 714 | + - id: dsit | |
| 715 | + name: Department for Science, Innovation and Technology | |
| 716 | + domain: gov.uk | |
| 717 | + homepage: https://www.gov.uk/government/organisations/department-for-science-innovation-and-technology | |
| 718 | + categories: [government, technology, ai] | |
| 719 | + tier: A | |
| 720 | + aliases: [dsit, department for science innovation and technology] | |
| 721 | + country: GB | |
| 722 | + sensors: | |
| 723 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/department-for-science-innovation-and-technology.atom", type: ATOM, connector: rss, tier: A } | |
| 724 | + - id: uk-home-office | |
| 725 | + name: Home Office | |
| 726 | + domain: gov.uk | |
| 727 | + homepage: https://www.gov.uk/government/organisations/home-office | |
| 728 | + categories: [government] | |
| 729 | + tier: B | |
| 730 | + aliases: [home office, uk home office] | |
| 731 | + country: GB | |
| 732 | + sensors: | |
| 733 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/home-office.atom", type: ATOM, connector: rss, tier: B } | |
| 734 | + - id: uk-mod | |
| 735 | + name: Ministry of Defence (UK) | |
| 736 | + domain: gov.uk | |
| 737 | + homepage: https://www.gov.uk/government/organisations/ministry-of-defence | |
| 738 | + categories: [government] | |
| 739 | + tier: B | |
| 740 | + aliases: [mod, ministry of defence, uk mod] | |
| 741 | + country: GB | |
| 742 | + sensors: | |
| 743 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/ministry-of-defence.atom", type: ATOM, connector: rss, tier: B } | |
| 744 | + - id: fcdo | |
| 745 | + name: Foreign, Commonwealth & Development Office | |
| 746 | + domain: gov.uk | |
| 747 | + homepage: https://www.gov.uk/government/organisations/foreign-commonwealth-development-office | |
| 748 | + categories: [government, international] | |
| 749 | + tier: A | |
| 750 | + aliases: [fcdo, foreign office, foreign commonwealth and development office] | |
| 751 | + country: GB | |
| 752 | + sensors: | |
| 753 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/foreign-commonwealth-development-office.atom", type: ATOM, connector: rss, tier: A } | |
| 754 | + - { name: the UK Sanctions List (publication page), url: "https://www.gov.uk/government/publications/the-uk-sanctions-list", type: HTML, connector: http, tier: C } | |
| 755 | + - id: dhsc | |
| 756 | + name: Department of Health and Social Care | |
| 757 | + domain: gov.uk | |
| 758 | + homepage: https://www.gov.uk/government/organisations/department-of-health-and-social-care | |
| 759 | + categories: [government, health] | |
| 760 | + tier: B | |
| 761 | + aliases: [dhsc, department of health and social care] | |
| 762 | + country: GB | |
| 763 | + sensors: | |
| 764 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/department-of-health-and-social-care.atom", type: ATOM, connector: rss, tier: B } | |
| 765 | + - id: uk-dbt | |
| 766 | + name: Department for Business and Trade | |
| 767 | + domain: gov.uk | |
| 768 | + homepage: https://www.gov.uk/government/organisations/department-for-business-and-trade | |
| 769 | + categories: [government, commerce] | |
| 770 | + tier: B | |
| 771 | + aliases: [dbt, department for business and trade] | |
| 772 | + country: GB | |
| 773 | + sensors: | |
| 774 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/department-for-business-and-trade.atom", type: ATOM, connector: rss, tier: B } | |
| 775 | + - id: desnz | |
| 776 | + name: Department for Energy Security and Net Zero | |
| 777 | + domain: gov.uk | |
| 778 | + homepage: https://www.gov.uk/government/organisations/department-for-energy-security-and-net-zero | |
| 779 | + categories: [government, energy, climate] | |
| 780 | + tier: B | |
| 781 | + aliases: [desnz, department for energy security and net zero] | |
| 782 | + country: GB | |
| 783 | + sensors: | |
| 784 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/department-for-energy-security-and-net-zero.atom", type: ATOM, connector: rss, tier: B } | |
| 785 | + - id: uk-moj | |
| 786 | + name: Ministry of Justice (UK) | |
| 787 | + domain: gov.uk | |
| 788 | + homepage: https://www.gov.uk/government/organisations/ministry-of-justice | |
| 789 | + categories: [government, legal] | |
| 790 | + tier: B | |
| 791 | + aliases: [moj, ministry of justice] | |
| 792 | + country: GB | |
| 793 | + sensors: | |
| 794 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/ministry-of-justice.atom", type: ATOM, connector: rss, tier: B } | |
| 795 | + - id: dfe | |
| 796 | + name: Department for Education (UK) | |
| 797 | + domain: gov.uk | |
| 798 | + homepage: https://www.gov.uk/government/organisations/department-for-education | |
| 799 | + categories: [government, education] | |
| 800 | + tier: C | |
| 801 | + aliases: [dfe, department for education] | |
| 802 | + country: GB | |
| 803 | + sensors: | |
| 804 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/department-for-education.atom", type: ATOM, connector: rss, tier: C } | |
| 805 | + - id: dwp | |
| 806 | + name: Department for Work and Pensions | |
| 807 | + domain: gov.uk | |
| 808 | + homepage: https://www.gov.uk/government/organisations/department-for-work-pensions | |
| 809 | + categories: [government, labour] | |
| 810 | + tier: C | |
| 811 | + aliases: [dwp, department for work and pensions] | |
| 812 | + country: GB | |
| 813 | + sensors: | |
| 814 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/department-for-work-pensions.atom", type: ATOM, connector: rss, tier: C } | |
| 815 | + - id: hmrc | |
| 816 | + name: HM Revenue & Customs | |
| 817 | + domain: gov.uk | |
| 818 | + homepage: https://www.gov.uk/government/organisations/hm-revenue-customs | |
| 819 | + categories: [government, finance] | |
| 820 | + tier: B | |
| 821 | + aliases: [hmrc, hm revenue and customs] | |
| 822 | + country: GB | |
| 823 | + sensors: | |
| 824 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/hm-revenue-customs.atom", type: ATOM, connector: rss, tier: B } | |
| 825 | + - id: ofsi | |
| 826 | + name: Office of Financial Sanctions Implementation | |
| 827 | + domain: gov.uk | |
| 828 | + homepage: https://www.gov.uk/government/organisations/office-of-financial-sanctions-implementation | |
| 829 | + categories: [government, finance, international] | |
| 830 | + tier: A | |
| 831 | + aliases: [ofsi, financial sanctions implementation, uk financial sanctions] | |
| 832 | + country: GB | |
| 833 | + sensors: | |
| 834 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/office-of-financial-sanctions-implementation.atom", type: ATOM, connector: rss, tier: A } | |
| 835 | + - { name: consolidated list of financial sanctions targets (page), url: "https://www.gov.uk/government/publications/financial-sanctions-consolidated-list-of-targets", type: HTML, connector: http, tier: C } | |
| 836 | + - id: uk-attorney-general | |
| 837 | + name: Attorney General's Office (UK) | |
| 838 | + domain: gov.uk | |
| 839 | + homepage: https://www.gov.uk/government/organisations/attorney-generals-office | |
| 840 | + categories: [government, legal] | |
| 841 | + tier: C | |
| 842 | + aliases: [attorney general's office, uk attorney general] | |
| 843 | + country: GB | |
| 844 | + sensors: | |
| 845 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/attorney-generals-office.atom", type: ATOM, connector: rss, tier: C } | |
| 846 | + - id: dcms | |
| 847 | + name: Department for Culture, Media and Sport | |
| 848 | + domain: gov.uk | |
| 849 | + homepage: https://www.gov.uk/government/organisations/department-for-culture-media-and-sport | |
| 850 | + categories: [government, media] | |
| 851 | + tier: C | |
| 852 | + aliases: [dcms, culture media and sport] | |
| 853 | + country: GB | |
| 854 | + sensors: | |
| 855 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/department-for-culture-media-and-sport.atom", type: ATOM, connector: rss, tier: C } | |
| 856 | + - id: ukef | |
| 857 | + name: UK Export Finance | |
| 858 | + domain: gov.uk | |
| 859 | + homepage: https://www.gov.uk/government/organisations/uk-export-finance | |
| 860 | + categories: [government, finance, commerce] | |
| 861 | + tier: C | |
| 862 | + aliases: [ukef, uk export finance] | |
| 863 | + country: GB | |
| 864 | + sensors: | |
| 865 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/uk-export-finance.atom", type: ATOM, connector: rss, tier: C } | |
| 866 | + - id: crown-commercial-service | |
| 867 | + name: Crown Commercial Service | |
| 868 | + domain: gov.uk | |
| 869 | + homepage: https://www.gov.uk/government/organisations/crown-commercial-service | |
| 870 | + categories: [government, procurement] | |
| 871 | + tier: C | |
| 872 | + aliases: [ccs, crown commercial service] | |
| 873 | + country: GB | |
| 874 | + sensors: | |
| 875 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/crown-commercial-service.atom", type: ATOM, connector: rss, tier: C } | |
| 876 | + - id: gds | |
| 877 | + name: Government Digital Service | |
| 878 | + domain: gov.uk | |
| 879 | + homepage: https://www.gov.uk/government/organisations/government-digital-service | |
| 880 | + categories: [government, technology] | |
| 881 | + tier: C | |
| 882 | + aliases: [gds, government digital service] | |
| 883 | + country: GB | |
| 884 | + sensors: | |
| 885 | + - { name: announcements and publications, url: "https://www.gov.uk/government/organisations/government-digital-service.atom", type: ATOM, connector: rss, tier: C } | |
| 886 | + - id: find-a-tender | |
| 887 | + name: Find a Tender (UK public procurement notices) | |
| 888 | + domain: find-tender.service.gov.uk | |
| 889 | + categories: [government, procurement, open-data] | |
| 890 | + tier: B | |
| 891 | + aliases: [find a tender, fts, uk tenders] | |
| 892 | + llm: false | |
| 893 | + country: GB | |
| 894 | + sensors: | |
| 895 | + - { name: OCDS release packages (latest notices), url: "https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages?limit=50", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: releases, keyField: id, titleTemplate: "{buyer.name} — {tender.title}", summaryField: tender.description, dateField: date, urlTemplate: "https://www.find-tender.service.gov.uk/Notice/{key}", compareFields: [tender.status, tag], maxItems: 50 } } | |
| 896 | + - id: contracts-finder | |
| 897 | + name: Contracts Finder (UK) | |
| 898 | + domain: contractsfinder.service.gov.uk | |
| 899 | + categories: [government, procurement, open-data] | |
| 900 | + tier: B | |
| 901 | + aliases: [contracts finder] | |
| 902 | + llm: false | |
| 903 | + country: GB | |
| 904 | + sensors: | |
| 905 | + - { name: tender notices published in the last 3 days (OCDS), url: "https://www.contractsfinder.service.gov.uk/Published/Notices/OCDS/Search?publishedFrom={now-3d}&stages=tender&size=50", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: releases, keyField: id, titleTemplate: "{buyer.name} — {tender.title}", summaryField: tender.description, dateField: date, urlTemplate: "https://www.contractsfinder.service.gov.uk/Notice/{key}", compareFields: [tender.status], maxItems: 50, noConditional: true } } | |
| 906 | + - id: the-gazette-uk | |
| 907 | + name: The Gazette (UK official public record) | |
| 908 | + domain: thegazette.co.uk | |
| 909 | + categories: [government, legal] | |
| 910 | + tier: B | |
| 911 | + aliases: [the gazette, london gazette, edinburgh gazette, belfast gazette] | |
| 912 | + llm: false | |
| 913 | + country: GB | |
| 914 | + sensors: | |
| 915 | + - { name: all notices, url: "https://www.thegazette.co.uk/all-notices/notice/data.feed?results-page-size=50", type: ATOM, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 916 | + - { name: insolvency notices, url: "https://www.thegazette.co.uk/insolvency/notice/data.feed", type: ATOM, connector: rss, tier: C } | |
| 917 | + - id: national-archives-uk | |
| 918 | + name: The National Archives (Find Case Law) | |
| 919 | + domain: nationalarchives.gov.uk | |
| 920 | + categories: [government, legal, open-data] | |
| 921 | + tier: B | |
| 922 | + aliases: [national archives, find case law, caselaw.nationalarchives.gov.uk] | |
| 923 | + country: GB | |
| 924 | + sensors: | |
| 925 | + - { name: Find Case Law — latest judgments, url: "https://caselaw.nationalarchives.gov.uk/atom.xml", type: ATOM, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 926 | + - id: nao-uk | |
| 927 | + name: National Audit Office (UK) | |
| 928 | + domain: nao.org.uk | |
| 929 | + categories: [government, finance] | |
| 930 | + tier: C | |
| 931 | + aliases: [nao, national audit office] | |
| 932 | + country: GB | |
| 933 | + sensors: | |
| 934 | + - { name: reports and news, url: "https://www.nao.org.uk/feed/", type: ATOM, connector: rss, tier: C } | |
| 935 | + - id: hse-uk | |
| 936 | + name: Health and Safety Executive | |
| 937 | + domain: hse.gov.uk | |
| 938 | + categories: [government, labour, consumer-safety] | |
| 939 | + tier: C | |
| 940 | + aliases: [hse, health and safety executive] | |
| 941 | + country: GB | |
| 942 | + sensors: | |
| 943 | + - { name: press releases, url: "https://press.hse.gov.uk/feed/", type: RSS, connector: rss, tier: C } | |
| 944 | + - id: scottish-government | |
| 945 | + name: Scottish Government | |
| 946 | + domain: gov.scot | |
| 947 | + categories: [government, politics] | |
| 948 | + tier: B | |
| 949 | + aliases: [scottish government, riaghaltas na h-alba] | |
| 950 | + country: GB | |
| 951 | + sensors: | |
| 952 | + - { name: news, url: "https://www.gov.scot/news/", type: HTML, connector: http, tier: B } | |
| 953 | + notes: "gov.scot publishes no RSS; the GOV.UK organisation feed for the Scottish Government is gone." | |
| 954 | + - id: scottish-parliament | |
| 955 | + name: Scottish Parliament | |
| 956 | + domain: parliament.scot | |
| 957 | + categories: [government, politics] | |
| 958 | + tier: C | |
| 959 | + aliases: [scottish parliament, holyrood] | |
| 960 | + country: GB | |
| 961 | + sensors: | |
| 962 | + - { name: bills, url: "https://www.parliament.scot/bills-and-laws/bills", type: HTML, connector: http, tier: C } | |
| 963 | + - id: welsh-government | |
| 964 | + name: Welsh Government | |
| 965 | + domain: gov.wales | |
| 966 | + categories: [government, politics] | |
| 967 | + tier: B | |
| 968 | + aliases: [welsh government, llywodraeth cymru] | |
| 969 | + country: GB | |
| 970 | + sensors: | |
| 971 | + - { name: announcements, url: "https://www.gov.wales/announcements/rss", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 972 | + - id: senedd | |
| 973 | + name: Senedd Cymru (Welsh Parliament) | |
| 974 | + domain: senedd.wales | |
| 975 | + categories: [government, politics] | |
| 976 | + tier: C | |
| 977 | + aliases: [senedd, welsh parliament] | |
| 978 | + country: GB | |
| 979 | + sensors: | |
| 980 | + - { name: business — what's new, url: "https://business.senedd.wales/mgRss.aspx", type: RSS, connector: rss, tier: C } | |
| 981 | + # ───────────────────────────── FRANCE ───────────────────────────── | |
| 982 | + - id: elysee | |
| 983 | + extend: true | |
| 984 | + country: FR | |
| 985 | + language: fr | |
| 986 | + sensors: | |
| 987 | + - { name: actualités (RSS), url: "https://www.elysee.fr/feed", type: RSS, connector: rss, tier: A, config: { maxItems: 50 } } | |
| 988 | + - id: senat-fr | |
| 989 | + extend: true | |
| 990 | + country: FR | |
| 991 | + language: fr | |
| 992 | + sensors: | |
| 993 | + - { name: rapports, url: "https://www.senat.fr/rss/rapports.rss", type: RSS, connector: rss, tier: C } | |
| 994 | + - id: conseil-etat | |
| 995 | + extend: true | |
| 996 | + country: FR | |
| 997 | + language: fr | |
| 998 | + sensors: | |
| 999 | + - { name: avis, url: "https://www.conseil-etat.fr/rss/avis-rss", type: RSS, connector: rss, tier: C } | |
| 1000 | + - id: cnil | |
| 1001 | + extend: true | |
| 1002 | + country: FR | |
| 1003 | + language: fr | |
| 1004 | + sensors: | |
| 1005 | + - { name: sanctions prononcées, url: "https://www.cnil.fr/fr/les-sanctions-prononcees-par-la-cnil", type: HTML, connector: http, tier: C } | |
| 1006 | + - id: autorite-de-la-concurrence | |
| 1007 | + extend: true | |
| 1008 | + country: FR | |
| 1009 | + language: fr | |
| 1010 | + sensors: | |
| 1011 | + - { name: actualités (page), url: "https://www.autoritedelaconcurrence.fr/fr/actualites", type: HTML, connector: http, tier: B } | |
| 1012 | + - id: gouvernement-fr | |
| 1013 | + name: Gouvernement français (info.gouv.fr) | |
| 1014 | + domain: info.gouv.fr | |
| 1015 | + categories: [government, politics] | |
| 1016 | + tier: A | |
| 1017 | + aliases: [gouvernement, info.gouv.fr, gouvernement.fr, matignon, premier ministre] | |
| 1018 | + country: FR | |
| 1019 | + language: fr | |
| 1020 | + discover: { rss: true, sitemap: true } | |
| 1021 | + notes: "info.gouv.fr / gouvernement.fr, legifrance.gouv.fr (JORF), interieur.gouv.fr, diplomatie.gouv.fr (SPIP backend) and education.gouv.fr all answer 403 to bots; vie-publique.fr is JS-rendered. Listed for discovery only." | |
| 1022 | + - id: ministere-justice-fr | |
| 1023 | + name: Ministère de la Justice (France) | |
| 1024 | + domain: justice.gouv.fr | |
| 1025 | + categories: [government, legal] | |
| 1026 | + tier: B | |
| 1027 | + aliases: [ministère de la justice, place vendôme, garde des sceaux] | |
| 1028 | + country: FR | |
| 1029 | + language: fr | |
| 1030 | + sensors: | |
| 1031 | + - { name: actualités, url: "https://www.justice.gouv.fr/rss.xml", type: RSS, connector: rss, tier: B } | |
| 1032 | + - id: ministere-agriculture-fr | |
| 1033 | + name: Ministère de l'Agriculture et de la Souveraineté alimentaire | |
| 1034 | + domain: agriculture.gouv.fr | |
| 1035 | + categories: [government, agriculture] | |
| 1036 | + tier: C | |
| 1037 | + aliases: [ministère de l'agriculture, agriculture.gouv.fr] | |
| 1038 | + country: FR | |
| 1039 | + language: fr | |
| 1040 | + sensors: | |
| 1041 | + - { name: actualités, url: "https://agriculture.gouv.fr/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1042 | + - id: ministere-travail-fr | |
| 1043 | + name: Ministère du Travail (France) | |
| 1044 | + domain: travail-emploi.gouv.fr | |
| 1045 | + categories: [government, labour] | |
| 1046 | + tier: C | |
| 1047 | + aliases: [ministère du travail, travail-emploi.gouv.fr] | |
| 1048 | + country: FR | |
| 1049 | + language: fr | |
| 1050 | + sensors: | |
| 1051 | + - { name: actualités, url: "https://travail-emploi.gouv.fr/rss.xml", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 1052 | + - id: ministere-economie-fr | |
| 1053 | + name: Ministère de l'Économie, des Finances et de la Souveraineté industrielle | |
| 1054 | + domain: economie.gouv.fr | |
| 1055 | + categories: [government, finance] | |
| 1056 | + tier: B | |
| 1057 | + aliases: [ministère de l'économie, bercy, minefi] | |
| 1058 | + country: FR | |
| 1059 | + language: fr | |
| 1060 | + sensors: | |
| 1061 | + - { name: toutes les actualités, url: "https://www.economie.gouv.fr/rss/toutesactualites", type: RSS, connector: rss, tier: B } | |
| 1062 | + notes: "presse.economie.gouv.fr feeds return 403." | |
| 1063 | + - id: douane-fr | |
| 1064 | + name: Douane française (DGDDI) | |
| 1065 | + domain: douane.gouv.fr | |
| 1066 | + categories: [government, commerce] | |
| 1067 | + tier: C | |
| 1068 | + aliases: [douane, dgddi, douanes françaises] | |
| 1069 | + country: FR | |
| 1070 | + language: fr | |
| 1071 | + sensors: | |
| 1072 | + - { name: actualités, url: "https://www.douane.gouv.fr/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1073 | + - id: anssi | |
| 1074 | + name: ANSSI | |
| 1075 | + domain: cyber.gouv.fr | |
| 1076 | + categories: [government, cyber] | |
| 1077 | + tier: B | |
| 1078 | + aliases: [anssi, agence nationale de la sécurité des systèmes d'information] | |
| 1079 | + country: FR | |
| 1080 | + language: fr | |
| 1081 | + sensors: | |
| 1082 | + - { name: actualités, url: "https://www.cyber.gouv.fr/actualites/rss", type: RSS, connector: rss, tier: B } | |
| 1083 | + - id: arcom | |
| 1084 | + name: Arcom | |
| 1085 | + domain: arcom.fr | |
| 1086 | + categories: [government, media, telecom] | |
| 1087 | + tier: C | |
| 1088 | + aliases: [arcom, autorité de régulation de la communication audiovisuelle et numérique, csa] | |
| 1089 | + country: FR | |
| 1090 | + language: fr | |
| 1091 | + sensors: | |
| 1092 | + - { name: actualités, url: "https://www.arcom.fr/actualites", type: HTML, connector: http, tier: C } | |
| 1093 | + notes: "arcom.fr/rss.xml stopped in 2021." | |
| 1094 | + - id: haut-commissariat-strategie-plan | |
| 1095 | + name: Haut-commissariat à la Stratégie et au Plan | |
| 1096 | + domain: strategie-plan.gouv.fr | |
| 1097 | + categories: [government, research] | |
| 1098 | + tier: C | |
| 1099 | + aliases: [france stratégie, haut-commissariat au plan, hcsp] | |
| 1100 | + country: FR | |
| 1101 | + language: fr | |
| 1102 | + sensors: | |
| 1103 | + - { name: publications et actualités, url: "https://www.strategie-plan.gouv.fr/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1104 | + - id: boamp | |
| 1105 | + name: BOAMP (Bulletin officiel des annonces de marchés publics) | |
| 1106 | + domain: boamp.fr | |
| 1107 | + categories: [government, procurement, open-data] | |
| 1108 | + tier: B | |
| 1109 | + aliases: [boamp, marchés publics, annonces de marchés publics] | |
| 1110 | + llm: false | |
| 1111 | + country: FR | |
| 1112 | + language: fr | |
| 1113 | + sensors: | |
| 1114 | + - { name: dernières annonces (Opendatasoft API), url: "https://boamp-datadila.opendatasoft.com/api/explore/v2.1/catalog/datasets/boamp/records?order_by=dateparution%20desc&limit=50", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: results, keyField: idweb, titleTemplate: "{nomacheteur} — {objet}", dateField: dateparution, urlTemplate: "https://www.boamp.fr/pages/avis/?q=idweb:%22{key}%22", compareFields: [etat, datelimitereponse], maxItems: 50 } } | |
| 1115 | + # ───────────────────────────── GERMANY ───────────────────────────── | |
| 1116 | + - id: bundesregierung | |
| 1117 | + extend: true | |
| 1118 | + country: DE | |
| 1119 | + language: de | |
| 1120 | + sensors: | |
| 1121 | + - { name: Pressemitteilungen, url: "https://www.bundesregierung.de/service/rss/breg-de/1151244/feed.xml", type: RSS, connector: rss, tier: A } | |
| 1122 | + notes: "Bundesrat RSS, recht.bund.de (BGBl) feeds, bundesanzeiger.de and most BM* ministry GSB feeds (BMF, BMI, BMJ, BMWK/BMWE, BMDV, BMEL, BMZ) return 404/400." | |
| 1123 | + - id: bundestag | |
| 1124 | + extend: true | |
| 1125 | + country: DE | |
| 1126 | + language: de | |
| 1127 | + sensors: | |
| 1128 | + - { name: Drucksachen, url: "https://www.bundestag.de/static/appdata/includes/rss/drucksachen.rss", type: RSS, connector: rss, tier: B } | |
| 1129 | + - { name: Plenarprotokolle, url: "https://www.bundestag.de/static/appdata/includes/rss/plenarprotokolle.rss", type: RSS, connector: rss, tier: C } | |
| 1130 | + - id: destatis | |
| 1131 | + extend: true | |
| 1132 | + country: DE | |
| 1133 | + sensors: | |
| 1134 | + - { name: Aktuell (RSS), url: "https://www.destatis.de/SiteGlobals/Functions/RSSFeed/DE/RSSNewsfeed/Aktuell.xml", type: RSS, connector: rss, tier: B } | |
| 1135 | + - id: bmas | |
| 1136 | + name: Bundesministerium für Arbeit und Soziales | |
| 1137 | + domain: bmas.de | |
| 1138 | + categories: [government, labour] | |
| 1139 | + tier: C | |
| 1140 | + aliases: [bmas, arbeitsministerium] | |
| 1141 | + country: DE | |
| 1142 | + language: de | |
| 1143 | + sensors: | |
| 1144 | + - { name: Newsfeed, url: "https://www.bmas.de/SiteGlobals/Functions/RSSFeed/DE/RSSNewsfeed/RSSNewsfeed.xml", type: RSS, connector: rss, tier: C } | |
| 1145 | + - id: bmg | |
| 1146 | + name: Bundesministerium für Gesundheit | |
| 1147 | + domain: bundesgesundheitsministerium.de | |
| 1148 | + categories: [government, health] | |
| 1149 | + tier: B | |
| 1150 | + aliases: [bmg, gesundheitsministerium] | |
| 1151 | + country: DE | |
| 1152 | + language: de | |
| 1153 | + sensors: | |
| 1154 | + - { name: Pressemitteilungen, url: "https://www.bundesgesundheitsministerium.de/pressemitteilungen.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 1155 | + - id: bmukn | |
| 1156 | + name: Bundesministerium für Umwelt, Klimaschutz, Naturschutz und nukleare Sicherheit | |
| 1157 | + domain: bundesumweltministerium.de | |
| 1158 | + categories: [government, climate, energy] | |
| 1159 | + tier: C | |
| 1160 | + aliases: [bmukn, bmuv, umweltministerium] | |
| 1161 | + country: DE | |
| 1162 | + language: de | |
| 1163 | + sensors: | |
| 1164 | + - { name: Meldungen Umwelt, url: "https://www.bundesumweltministerium.de/umwelt.rss", type: RSS, connector: rss, tier: C } | |
| 1165 | + - { name: Meldungen Klimaschutz, url: "https://www.bundesumweltministerium.de/klimaschutz.rss", type: RSS, connector: rss, tier: C } | |
| 1166 | + - id: bbk | |
| 1167 | + name: Bundesamt für Bevölkerungsschutz und Katastrophenhilfe | |
| 1168 | + domain: bbk.bund.de | |
| 1169 | + categories: [government, weather] | |
| 1170 | + tier: A | |
| 1171 | + aliases: [bbk, warnung.bund.de, nina warn-app, mowas] | |
| 1172 | + country: DE | |
| 1173 | + language: de | |
| 1174 | + sensors: | |
| 1175 | + - { name: MoWaS warnings (warnung.bund.de), url: "https://warnung.bund.de/api31/mowas/mapData.json", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: id, titleField: i18nTitle.de, dateField: startDate, urlTemplate: "https://warnung.bund.de/meldungen", compareFields: [version, severity, urgency], maxItems: 100 } } | |
| 1176 | + - id: bayern | |
| 1177 | + name: Bayerische Staatsregierung | |
| 1178 | + domain: bayern.de | |
| 1179 | + categories: [government, politics] | |
| 1180 | + tier: C | |
| 1181 | + aliases: [bayern, freistaat bayern, bayerische staatsregierung] | |
| 1182 | + country: DE | |
| 1183 | + language: de | |
| 1184 | + sensors: | |
| 1185 | + - { name: Meldungen, url: "https://www.bayern.de/feed/", type: RSS, connector: rss, tier: C } | |
| 1186 | + - id: niedersachsen | |
| 1187 | + name: Land Niedersachsen | |
| 1188 | + domain: niedersachsen.de | |
| 1189 | + categories: [government, politics] | |
| 1190 | + tier: C | |
| 1191 | + aliases: [niedersachsen, landesregierung niedersachsen] | |
| 1192 | + country: DE | |
| 1193 | + language: de | |
| 1194 | + sensors: | |
| 1195 | + - { name: Presseinformationen, url: "https://www.niedersachsen.de/rss", type: RSS, connector: rss, tier: C } | |
| 1196 | + - id: baden-wuerttemberg | |
| 1197 | + name: Land Baden-Württemberg | |
| 1198 | + domain: baden-wuerttemberg.de | |
| 1199 | + categories: [government, politics] | |
| 1200 | + tier: C | |
| 1201 | + aliases: [baden-württemberg, landesregierung baden-württemberg] | |
| 1202 | + country: DE | |
| 1203 | + language: de | |
| 1204 | + sensors: | |
| 1205 | + - { name: alle Meldungen, url: "https://www.baden-wuerttemberg.de/de/service/rss/xml/rss-alle-meldungen", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 1206 | + - id: berlin | |
| 1207 | + name: Land Berlin (Senatskanzlei) | |
| 1208 | + domain: berlin.de | |
| 1209 | + categories: [government, politics] | |
| 1210 | + tier: C | |
| 1211 | + aliases: [berlin, senat von berlin, senatskanzlei] | |
| 1212 | + country: DE | |
| 1213 | + language: de | |
| 1214 | + sensors: | |
| 1215 | + - { name: Pressemitteilungen des Regierenden Bürgermeisters, url: "https://www.berlin.de/rbmskzl/aktuelles/pressemitteilungen/index.php/rss", type: RSS, connector: rss, tier: C } | |
| 1216 | + - id: govdata | |
| 1217 | + name: GovData (German open data portal) | |
| 1218 | + domain: govdata.de | |
| 1219 | + categories: [government, open-data] | |
| 1220 | + tier: C | |
| 1221 | + aliases: [govdata, govdata.de] | |
| 1222 | + llm: false | |
| 1223 | + country: DE | |
| 1224 | + language: de | |
| 1225 | + sensors: | |
| 1226 | + - { name: dataset catalogue (CKAN), url: "https://ckan.govdata.de/api/3/action/package_search?sort=metadata_modified+desc&rows=50", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: result.results, keyField: id, titleField: title, summaryField: notes, dateField: metadata_modified, compareFields: [metadata_modified], maxItems: 50, urlTemplate: "https://www.govdata.de/suche/daten/{name}" } } | |
| 1227 | + # ───────────────────────────── ITALY ───────────────────────────── | |
| 1228 | + - id: agcom | |
| 1229 | + name: AGCOM | |
| 1230 | + domain: agcom.it | |
| 1231 | + categories: [government, telecom, media] | |
| 1232 | + tier: C | |
| 1233 | + aliases: [agcom, autorità per le garanzie nelle comunicazioni] | |
| 1234 | + country: IT | |
| 1235 | + language: it | |
| 1236 | + sensors: | |
| 1237 | + - { name: news, url: "https://www.agcom.it/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1238 | + - id: gazzetta-ufficiale | |
| 1239 | + name: Gazzetta Ufficiale della Repubblica Italiana | |
| 1240 | + domain: gazzettaufficiale.it | |
| 1241 | + categories: [government, legal] | |
| 1242 | + tier: B | |
| 1243 | + aliases: [gazzetta ufficiale, gu, gurI] | |
| 1244 | + llm: false | |
| 1245 | + country: IT | |
| 1246 | + language: it | |
| 1247 | + sensors: | |
| 1248 | + - { name: Serie Generale — sommario, url: "https://www.gazzettaufficiale.it/rss/SG.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 60 } } | |
| 1249 | + - { name: 1a Serie Speciale — Corte Costituzionale, url: "https://www.gazzettaufficiale.it/rss/S1.xml", type: RSS, connector: rss, tier: C } | |
| 1250 | + - { name: 3a Serie Speciale — Regioni, url: "https://www.gazzettaufficiale.it/rss/S3.xml", type: RSS, connector: rss, tier: C } | |
| 1251 | + - id: maeci | |
| 1252 | + name: Ministero degli Affari Esteri e della Cooperazione Internazionale | |
| 1253 | + domain: esteri.it | |
| 1254 | + categories: [government, international] | |
| 1255 | + tier: B | |
| 1256 | + aliases: [maeci, farnesina, ministero degli esteri] | |
| 1257 | + country: IT | |
| 1258 | + language: it | |
| 1259 | + sensors: | |
| 1260 | + - { name: sala stampa, url: "https://www.esteri.it/it/feed/", type: RSS, connector: rss, tier: B } | |
| 1261 | + - id: ministero-interno | |
| 1262 | + name: Ministero dell'Interno | |
| 1263 | + domain: interno.gov.it | |
| 1264 | + categories: [government] | |
| 1265 | + tier: C | |
| 1266 | + aliases: [ministero dell'interno, viminale] | |
| 1267 | + country: IT | |
| 1268 | + language: it | |
| 1269 | + sensors: | |
| 1270 | + - { name: notizie, url: "https://www.interno.gov.it/it/rss/news.xml", type: RSS, connector: rss, tier: C } | |
| 1271 | + - id: protezione-civile | |
| 1272 | + name: Dipartimento della Protezione Civile | |
| 1273 | + domain: protezionecivile.gov.it | |
| 1274 | + categories: [government, weather] | |
| 1275 | + tier: A | |
| 1276 | + aliases: [protezione civile, dipartimento della protezione civile, dpc] | |
| 1277 | + country: IT | |
| 1278 | + language: it | |
| 1279 | + sensors: | |
| 1280 | + - { name: comunicati stampa, url: "https://api.protezionecivile.it/default/dpcPortalGenerateRss?categoria=comunicato_stampa", type: RSS, connector: rss, tier: A, config: { maxItems: 50 } } | |
| 1281 | + - { name: bollettino di criticità, url: "https://api.protezionecivile.it/default/dpcPortalGenerateRss?categoria=bollettino_criticita", type: RSS, connector: rss, tier: A } | |
| 1282 | + - { name: notizie, url: "https://api.protezionecivile.it/default/dpcPortalGenerateRss?categoria=notizia", type: RSS, connector: rss, tier: B, config: { maxItems: 50 } } | |
| 1283 | + - id: mef | |
| 1284 | + name: Ministero dell'Economia e delle Finanze | |
| 1285 | + domain: mef.gov.it | |
| 1286 | + categories: [government, finance] | |
| 1287 | + tier: B | |
| 1288 | + aliases: [mef, ministero dell'economia e delle finanze, tesoro] | |
| 1289 | + country: IT | |
| 1290 | + language: it | |
| 1291 | + sensors: | |
| 1292 | + - { name: comunicati stampa, url: "https://www.mef.gov.it/ufficio-stampa/comunicati/index.html", type: HTML, connector: http, tier: B } | |
| 1293 | + - id: dati-gov-it | |
| 1294 | + name: dati.gov.it (Italian open data catalogue) | |
| 1295 | + domain: dati.gov.it | |
| 1296 | + categories: [government, open-data] | |
| 1297 | + tier: C | |
| 1298 | + aliases: [dati.gov.it, dati gov] | |
| 1299 | + llm: false | |
| 1300 | + country: IT | |
| 1301 | + language: it | |
| 1302 | + sensors: | |
| 1303 | + - { name: dataset catalogue (CKAN), url: "https://dati.gov.it/opendata/api/3/action/package_search?sort=metadata_modified+desc&rows=50", type: REST_API, connector: jsonlist, tier: C, config: { itemsPath: result.results, keyField: id, titleField: title, summaryField: notes, dateField: metadata_modified, compareFields: [metadata_modified], maxItems: 50, urlTemplate: "https://dati.gov.it/view-dataset/dataset?id={key}" } } | |
| 1304 | + # ───────────────────────────── SPAIN ───────────────────────────── | |
| 1305 | + - id: spain-government | |
| 1306 | + extend: true | |
| 1307 | + country: ES | |
| 1308 | + language: es | |
| 1309 | + sensors: | |
| 1310 | + - { name: news (English), url: "https://www.lamoncloa.gob.es/lang/en/Paginas/rss.aspx", type: RSS, connector: rss, tier: B } | |
| 1311 | + notes: "Senado, Tribunal Constitucional, Ministerio del Interior and AEMET feeds return 403/400." | |
| 1312 | + - id: boe | |
| 1313 | + name: Boletín Oficial del Estado | |
| 1314 | + domain: boe.es | |
| 1315 | + categories: [government, legal] | |
| 1316 | + tier: A | |
| 1317 | + weight: 1.2 | |
| 1318 | + aliases: [boe, boletín oficial del estado, borme] | |
| 1319 | + llm: false | |
| 1320 | + country: ES | |
| 1321 | + language: es | |
| 1322 | + sensors: | |
| 1323 | + - { name: sumario del día (BOE), url: "https://www.boe.es/rss/boe.php", type: RSS, connector: rss, tier: A, config: { maxItems: 100 } } | |
| 1324 | + - { name: disposiciones generales (sección I), url: "https://www.boe.es/rss/boe.php?s=1", type: RSS, connector: rss, tier: A } | |
| 1325 | + - { name: BORME (registro mercantil), url: "https://www.boe.es/rss/borme.php", type: RSS, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 1326 | + - id: gobierno-vasco | |
| 1327 | + name: Gobierno Vasco / Eusko Jaurlaritza (Irekia) | |
| 1328 | + domain: euskadi.eus | |
| 1329 | + categories: [government, politics] | |
| 1330 | + tier: C | |
| 1331 | + aliases: [gobierno vasco, eusko jaurlaritza, irekia] | |
| 1332 | + country: ES | |
| 1333 | + language: es | |
| 1334 | + sensors: | |
| 1335 | + - { name: noticias (Irekia), url: "https://www.irekia.euskadi.eus/es/news.rss", type: RSS, connector: rss, tier: C } | |
| 1336 | + - id: barcelona | |
| 1337 | + name: Ajuntament de Barcelona | |
| 1338 | + domain: barcelona.cat | |
| 1339 | + categories: [government] | |
| 1340 | + tier: C | |
| 1341 | + aliases: [ajuntament de barcelona, barcelona city council] | |
| 1342 | + country: ES | |
| 1343 | + language: es | |
| 1344 | + sensors: | |
| 1345 | + - { name: servei de premsa, url: "https://ajuntament.barcelona.cat/premsa/rss", type: RSS, connector: rss, tier: C } | |
| 1346 | + - id: aepd | |
| 1347 | + name: Agencia Española de Protección de Datos | |
| 1348 | + domain: aepd.es | |
| 1349 | + categories: [government, web-policy] | |
| 1350 | + tier: C | |
| 1351 | + aliases: [aepd, agencia española de protección de datos] | |
| 1352 | + country: ES | |
| 1353 | + language: es | |
| 1354 | + sensors: | |
| 1355 | + - { name: notas de prensa, url: "https://www.aepd.es/prensa-y-comunicacion/notas-de-prensa", type: HTML, connector: http, tier: C } | |
| 1356 | + - id: congreso | |
| 1357 | + name: Congreso de los Diputados | |
| 1358 | + domain: congreso.es | |
| 1359 | + categories: [government, politics] | |
| 1360 | + tier: B | |
| 1361 | + aliases: [congreso de los diputados, congreso] | |
| 1362 | + country: ES | |
| 1363 | + language: es | |
| 1364 | + sensors: | |
| 1365 | + - { name: notas de prensa, url: "https://www.congreso.es/es/notas-de-prensa", type: HTML, connector: http, tier: B } | |
| 1366 | + - id: cgpj | |
| 1367 | + name: Consejo General del Poder Judicial / Tribunal Supremo | |
| 1368 | + domain: poderjudicial.es | |
| 1369 | + categories: [government, legal] | |
| 1370 | + tier: C | |
| 1371 | + aliases: [cgpj, poder judicial, tribunal supremo] | |
| 1372 | + country: ES | |
| 1373 | + language: es | |
| 1374 | + sensors: | |
| 1375 | + - { name: noticias judiciales — Tribunal Supremo, url: "https://www.poderjudicial.es/cgpj/es/Poder-Judicial/Tribunal-Supremo/Noticias-Judiciales/", type: HTML, connector: http, tier: C } | |
| 1376 | + - id: maec | |
| 1377 | + name: Ministerio de Asuntos Exteriores, Unión Europea y Cooperación | |
| 1378 | + domain: exteriores.gob.es | |
| 1379 | + categories: [government, international] | |
| 1380 | + tier: C | |
| 1381 | + aliases: [maec, ministerio de asuntos exteriores, exteriores] | |
| 1382 | + country: ES | |
| 1383 | + language: es | |
| 1384 | + sensors: | |
| 1385 | + - { name: comunicados, url: "https://www.exteriores.gob.es/es/Comunicacion/Comunicados/Paginas/index.aspx", type: HTML, connector: http, tier: C } | |
| 1386 | + - id: ministerio-defensa-es | |
| 1387 | + name: Ministerio de Defensa (España) | |
| 1388 | + domain: defensa.gob.es | |
| 1389 | + categories: [government] | |
| 1390 | + tier: C | |
| 1391 | + aliases: [ministerio de defensa] | |
| 1392 | + country: ES | |
| 1393 | + language: es | |
| 1394 | + sensors: | |
| 1395 | + - { name: notas de prensa, url: "https://www.defensa.gob.es/gabinete/notasPrensa/index.html", type: HTML, connector: http, tier: C } | |
| 1396 | + # ───────────────────────────── JAPAN ───────────────────────────── | |
| 1397 | + - id: kantei | |
| 1398 | + name: Prime Minister's Office of Japan (Kantei) | |
| 1399 | + domain: kantei.go.jp | |
| 1400 | + categories: [government, politics] | |
| 1401 | + tier: A | |
| 1402 | + weight: 1.2 | |
| 1403 | + aliases: [kantei, prime minister of japan, cabinet of japan, 首相官邸] | |
| 1404 | + country: JP | |
| 1405 | + sensors: | |
| 1406 | + - { name: new information (English), url: "https://japan.kantei.go.jp/index-e2.rdf", type: RSS, connector: rss, tier: A } | |
| 1407 | + - { name: prime minister's actions, url: "https://japan.kantei.go.jp/103/actions/index.html", type: HTML, connector: http, tier: B } | |
| 1408 | + notes: "METI (403/202), MOFA (403), MOD (403), Consumer Affairs Agency (403) and NISC (JS) publish no reachable feeds." | |
| 1409 | + - id: digital-agency-japan | |
| 1410 | + name: Digital Agency (Japan) | |
| 1411 | + domain: digital.go.jp | |
| 1412 | + categories: [government, technology] | |
| 1413 | + tier: B | |
| 1414 | + aliases: [digital agency, デジタル庁] | |
| 1415 | + country: JP | |
| 1416 | + language: ja | |
| 1417 | + sensors: | |
| 1418 | + - { name: news (English), url: "https://www.digital.go.jp/en/rss/news.xml", type: RSS, connector: rss, tier: B } | |
| 1419 | + - { name: 新着・更新, url: "https://www.digital.go.jp/rss/news.xml", type: RSS, connector: rss, tier: B } | |
| 1420 | + - id: e-gov-japan | |
| 1421 | + name: e-Gov public comments (Japan) | |
| 1422 | + domain: e-gov.go.jp | |
| 1423 | + categories: [government, legal] | |
| 1424 | + tier: B | |
| 1425 | + aliases: [e-gov, パブリックコメント, public comment japan] | |
| 1426 | + country: JP | |
| 1427 | + language: ja | |
| 1428 | + sensors: | |
| 1429 | + - { name: 意見募集案件一覧, url: "https://public-comment.e-gov.go.jp/rss/pcm_list.xml", type: RSS, connector: rss, tier: B } | |
| 1430 | + - { name: 結果公示案件一覧, url: "https://public-comment.e-gov.go.jp/rss/pcm_result.xml", type: RSS, connector: rss, tier: C } | |
| 1431 | + - id: jma | |
| 1432 | + extend: true | |
| 1433 | + country: JP | |
| 1434 | + language: ja | |
| 1435 | + sensors: | |
| 1436 | + - { name: earthquake list (bosai JSON), url: "https://www.jma.go.jp/bosai/quake/data/list.json", type: REST_API, connector: jsonlist, tier: A, config: { itemsPath: "", keyField: eid, titleTemplate: "{en_ttl}: {en_anm} M{mag} (max intensity {maxi})", dateField: at, urlTemplate: "https://www.jma.go.jp/bosai/map.html#contents=earthquake_map&lang=en", compareFields: [mag, maxi, ser], maxItems: 50 } } | |
| 1437 | + - { name: 高頻度フィード(警報・注意報・地震・火山), url: "https://www.data.jma.go.jp/developer/xml/feed/extra.xml", type: ATOM, connector: rss, tier: A, config: { maxItems: 100 } } | |
| 1438 | + - id: jftc | |
| 1439 | + extend: true | |
| 1440 | + country: JP | |
| 1441 | + sensors: | |
| 1442 | + - { name: press releases (English), url: "https://www.jftc.go.jp/en/pressreleases/index.html", type: HTML, connector: http, tier: C } | |
| 1443 | + - id: moe-japan | |
| 1444 | + name: Ministry of the Environment (Japan) | |
| 1445 | + domain: env.go.jp | |
| 1446 | + categories: [government, climate] | |
| 1447 | + tier: C | |
| 1448 | + aliases: [moe japan, 環境省] | |
| 1449 | + country: JP | |
| 1450 | + language: ja | |
| 1451 | + sensors: | |
| 1452 | + - { name: 報道発表資料, url: "https://www.env.go.jp/press/index.html", type: HTML, connector: http, tier: C } | |
| 1453 | + - id: soumu | |
| 1454 | + name: Ministry of Internal Affairs and Communications (Japan) | |
| 1455 | + domain: soumu.go.jp | |
| 1456 | + categories: [government, telecom] | |
| 1457 | + tier: C | |
| 1458 | + aliases: [mic japan, soumu, 総務省] | |
| 1459 | + country: JP | |
| 1460 | + language: ja | |
| 1461 | + sensors: | |
| 1462 | + - { name: 報道資料, url: "https://www.soumu.go.jp/menu_news/s-news/index.html", type: HTML, connector: http, tier: C } | |
| 1463 | + - id: kanpou | |
| 1464 | + name: Kanpō (Official Gazette of Japan) | |
| 1465 | + domain: kanpou.npb.go.jp | |
| 1466 | + categories: [government, legal] | |
| 1467 | + tier: C | |
| 1468 | + aliases: [kanpou, kanpo, 官報] | |
| 1469 | + country: JP | |
| 1470 | + language: ja | |
| 1471 | + sensors: | |
| 1472 | + - { name: 本日の官報, url: "https://kanpou.npb.go.jp/", type: HTML, connector: http, tier: C } | |
| 1473 | + # ───────────────────────────── SOUTH KOREA ───────────────────────────── | |
| 1474 | + - id: korea-policy-briefing | |
| 1475 | + extend: true | |
| 1476 | + country: KR | |
| 1477 | + language: ko | |
| 1478 | + sensors: | |
| 1479 | + - { name: 보도자료 (press releases), url: "https://www.korea.kr/briefing/pressReleaseList.do", type: HTML, connector: http, tier: B } | |
| 1480 | + notes: "korea.kr /rss/*.xml feeds are stale columns from 2017; ministry sites (MOEF, MOFA, MSIT, MOTIE, MOIS, KDCA, PIPC), National Assembly and KMA expose no working RSS without an API key." | |
| 1481 | + - id: pmc-australia | |
| 1482 | + name: Department of the Prime Minister and Cabinet (Australia) | |
| 1483 | + domain: pmc.gov.au | |
| 1484 | + categories: [government, politics] | |
| 1485 | + tier: A | |
| 1486 | + aliases: [pm&c, pmc, ministers' media centre] | |
| 1487 | + country: AU | |
| 1488 | + sensors: | |
| 1489 | + - { name: ministers' media centre, url: "https://ministers.pmc.gov.au/rss.xml", type: RSS, connector: rss, tier: A } | |
| 1490 | + notes: "pmc.gov.au/rss.xml itself returns 403." | |
| 1491 | + - id: australian-treasury | |
| 1492 | + name: Australian Treasury | |
| 1493 | + domain: treasury.gov.au | |
| 1494 | + categories: [government, finance] | |
| 1495 | + tier: B | |
| 1496 | + aliases: [australian treasury, the treasury australia] | |
| 1497 | + country: AU | |
| 1498 | + sensors: | |
| 1499 | + - { name: media releases, url: "https://treasury.gov.au/media-release", type: HTML, connector: http, tier: B } | |
| 1500 | + - id: dfat | |
| 1501 | + name: Department of Foreign Affairs and Trade (Australia) | |
| 1502 | + domain: dfat.gov.au | |
| 1503 | + categories: [government, international] | |
| 1504 | + tier: B | |
| 1505 | + aliases: [dfat, department of foreign affairs and trade] | |
| 1506 | + country: AU | |
| 1507 | + sensors: | |
| 1508 | + - { name: news, url: "https://www.dfat.gov.au/rss.xml", type: RSS, connector: rss, tier: B } | |
| 1509 | + notes: "smartraveller.gov.au is unreachable from bots (connection reset)." | |
| 1510 | + - id: austender | |
| 1511 | + name: AusTender | |
| 1512 | + domain: tenders.gov.au | |
| 1513 | + categories: [government, procurement] | |
| 1514 | + tier: B | |
| 1515 | + aliases: [austender, australian government tenders] | |
| 1516 | + llm: false | |
| 1517 | + country: AU | |
| 1518 | + sensors: | |
| 1519 | + - { name: current approaches to market, url: "https://www.tenders.gov.au/public_data/rss/rss.xml", type: RSS, connector: rss, tier: B, config: { maxItems: 100 } } | |
| 1520 | + - id: australian-parliament | |
| 1521 | + name: Parliament of Australia | |
| 1522 | + domain: aph.gov.au | |
| 1523 | + categories: [government, politics] | |
| 1524 | + tier: B | |
| 1525 | + aliases: [parliament of australia, aph, federal parliament australia] | |
| 1526 | + country: AU | |
| 1527 | + sensors: | |
| 1528 | + - { name: bills before Parliament, url: "https://www.aph.gov.au/Parliamentary_Business/Bills_Legislation/Bills_before_Parliament", type: HTML, connector: http, tier: B } | |
| 1529 | + notes: "ParlInfo RSS (rss.w3p) is behind an Azure WAF that returns 403 after a few requests." | |
| 1530 | + - id: nsw-government | |
| 1531 | + name: NSW Government | |
| 1532 | + domain: nsw.gov.au | |
| 1533 | + categories: [government, politics] | |
| 1534 | + tier: B | |
| 1535 | + aliases: [nsw government, new south wales government] | |
| 1536 | + country: AU | |
| 1537 | + sensors: | |
| 1538 | + - { name: media releases, url: "https://www.nsw.gov.au/media-releases", type: HTML, connector: http, tier: B } | |
| 1539 | + - id: wa-government | |
| 1540 | + name: Government of Western Australia | |
| 1541 | + domain: wa.gov.au | |
| 1542 | + categories: [government, politics] | |
| 1543 | + tier: C | |
| 1544 | + aliases: [wa government, western australian government] | |
| 1545 | + country: AU | |
| 1546 | + sensors: | |
| 1547 | + - { name: media statements, url: "https://www.wa.gov.au/government/media-statements", type: HTML, connector: http, tier: C } | |
| 1548 | + notes: "SA, Tasmania, ACT and NT government sites sit behind Cloudflare challenges." | |
| 1549 | + # ───────────────────────────── INDIA ───────────────────────────── | |
| 1550 | + - id: camara-dos-deputados | |
| 1551 | + name: Câmara dos Deputados | |
| 1552 | + domain: camara.leg.br | |
| 1553 | + categories: [government, politics] | |
| 1554 | + tier: B | |
| 1555 | + aliases: [câmara dos deputados, camara dos deputados, chamber of deputies brazil] | |
| 1556 | + country: BR | |
| 1557 | + language: pt | |
| 1558 | + sensors: | |
| 1559 | + - { name: proposições apresentadas (Dados Abertos), url: "https://dadosabertos.camara.leg.br/api/v2/proposicoes?ordem=DESC&ordenarPor=id&itens=50", type: REST_API, connector: jsonlist, tier: B, config: { itemsPath: dados, keyField: id, titleTemplate: "{siglaTipo} {numero}/{ano} — {ementa}", dateField: dataApresentacao, urlTemplate: "https://www.camara.leg.br/propostas-legislativas/{key}", maxItems: 50 } } | |
| 1560 | + - { name: notícias — Plenário, url: "https://www.camara.leg.br/noticias/rss/dinamico/PLENARIO", type: RSS, connector: rss, tier: B } | |
| 1561 | + - id: senado-federal | |
| 1562 | + name: Senado Federal | |
| 1563 | + domain: senado.leg.br | |
| 1564 | + categories: [government, politics] | |
| 1565 | + tier: B | |
| 1566 | + aliases: [senado federal, senado brasil, federal senate brazil] | |
| 1567 | + country: BR | |
| 1568 | + language: pt | |
| 1569 | + sensors: | |
| 1570 | + - { name: notícias, url: "https://www12.senado.leg.br/noticias/rss.xml", type: ATOM, connector: rss, tier: B } | |
| 1571 | + - id: stf | |
| 1572 | + name: Supremo Tribunal Federal | |
| 1573 | + domain: stf.jus.br | |
| 1574 | + categories: [government, legal] | |
| 1575 | + tier: B | |
| 1576 | + aliases: [stf, supremo tribunal federal, supreme court brazil] | |
| 1577 | + country: BR | |
| 1578 | + language: pt | |
| 1579 | + sensors: | |
| 1580 | + - { name: notícias, url: "https://noticias.stf.jus.br/rss", type: RSS, connector: rss, tier: B } | |
| 1581 | + - id: ministerio-da-defesa | |
| 1582 | + name: Ministério da Defesa (Brasil) | |
| 1583 | + domain: gov.br | |
| 1584 | + homepage: https://www.gov.br/defesa/pt-br | |
| 1585 | + categories: [government] | |
| 1586 | + tier: C | |
| 1587 | + aliases: [ministério da defesa, defesa.gov.br] | |
| 1588 | + country: BR | |
| 1589 | + language: pt | |
| 1590 | + sensors: | |
| 1591 | + - { name: notícias, url: "https://www.gov.br/defesa/pt-br/centrais-de-conteudo/noticias/RSS", type: ATOM, connector: rss, tier: C, config: { maxItems: 50 } } | |
| 1592 | + - id: receita-federal | |
| 1593 | + name: Receita Federal do Brasil | |
| 1594 | + domain: gov.br | |
| 1595 | + homepage: https://www.gov.br/receitafederal/pt-br | |
| 1596 | + categories: [government, finance] | |
| 1597 | + tier: C | |
| 1598 | + aliases: [receita federal, rfb] | |
| 1599 | + country: BR | |
| 1600 | + language: pt | |
| 1601 | + sensors: | |
| 1602 | + - { name: notícias, url: "https://www.gov.br/receitafederal/pt-br/assuntos/noticias/RSS", type: ATOM, connector: rss, tier: C } | |
| 1603 | + - id: mdic | |
| 1604 | + name: Ministério do Desenvolvimento, Indústria, Comércio e Serviços | |
| 1605 | + domain: gov.br | |
| 1606 | + homepage: https://www.gov.br/mdic/pt-br | |
| 1607 | + categories: [government, commerce] | |
| 1608 | + tier: C | |
| 1609 | + aliases: [mdic] | |
| 1610 | + country: BR | |
| 1611 | + language: pt | |
| 1612 | + sensors: | |
| 1613 | + - { name: notícias, url: "https://www.gov.br/mdic/pt-br/assuntos/noticias/RSS", type: ATOM, connector: rss, tier: C } | |
| 1614 | + # ───────────────────────────── MEXICO ───────────────────────────── | |
| 1615 | + - id: ine-mexico | |
| 1616 | + name: Instituto Nacional Electoral | |
| 1617 | + domain: ine.mx | |
| 1618 | + categories: [government, elections] | |
| 1619 | + tier: B | |
| 1620 | + aliases: [ine, instituto nacional electoral, central electoral] | |
| 1621 | + country: MX | |
| 1622 | + language: es | |
| 1623 | + sensors: | |
| 1624 | + - { name: Central Electoral (boletines), url: "https://centralelectoral.ine.mx/feed/", type: RSS, connector: rss, tier: B } | |
| 1625 | + - id: ift-mexico | |
| 1626 | + name: Instituto Federal de Telecomunicaciones / CRT | |
| 1627 | + domain: ift.org.mx | |
| 1628 | + categories: [government, telecom] | |
| 1629 | + tier: C | |
| 1630 | + aliases: [ift, instituto federal de telecomunicaciones, comisión reguladora de telecomunicaciones] | |
| 1631 | + country: MX | |
| 1632 | + language: es | |
| 1633 | + sensors: | |
| 1634 | + - { name: comunicados, url: "https://www.ift.org.mx/rss.xml", type: RSS, connector: rss, tier: C } | |
| 1635 | + - id: scjn | |
| 1636 | + name: Suprema Corte de Justicia de la Nación | |
| 1637 | + domain: scjn.gob.mx | |
| 1638 | + categories: [government, legal] | |
| 1639 | + tier: B | |
| 1640 | + aliases: [scjn, suprema corte de justicia de la nación, supreme court mexico] | |
| 1641 | + country: MX | |
| 1642 | + language: es | |
| 1643 | + sensors: | |
| 1644 | + - { name: comunicados, url: "https://www.scjn.gob.mx/rss.xml", type: RSS, connector: rss, tier: B } | |
| 1645 | + - id: senado-mexico | |
| 1646 | + name: Senado de la República (México) | |
| 1647 | + domain: senado.gob.mx | |
| 1648 | + categories: [government, politics] | |
| 1649 | + tier: C | |
| 1650 | + aliases: [senado de la república, senado mexico] | |
| 1651 | + country: MX | |
| 1652 | + language: es | |
| 1653 | + sensors: | |
| 1654 | + - { name: Gaceta del Senado, url: "https://www.senado.gob.mx/66/gaceta_del_senado", type: HTML, connector: http, tier: C } | |
| 1655 | + - id: united-nations | |
| 1656 | + extend: true | |
| 1657 | + country: INT | |
| 1658 | + sensors: | |
| 1659 | + - { name: Security Council news, url: "https://main.un.org/securitycouncil/en/rss.xml", type: RSS, connector: rss, tier: B } | |
| 1660 | + - id: g20 | |
Diff truncated — file too large.