Skip to content

Promote development → staging: media→Mongo, IODA recharts, Connections redesign, access control, alert/incident filtering - #146

Open
ronniegross wants to merge 52 commits into
stagingfrom
development
Open

Promote development → staging: media→Mongo, IODA recharts, Connections redesign, access control, alert/incident filtering#146
ronniegross wants to merge 52 commits into
stagingfrom
development

Conversation

@ronniegross

Copy link
Copy Markdown
Collaborator

Promotes everything accumulated on development since it was last 1:1 with staging.

Scope: 52 commits · 112 files · +8,107 / −2,410, bundling five efforts that landed as separate PRs into development.

⚠️ Not code-only — a data migration (media disk→Mongo + report/chart backfills) must run against the staging DB as part of this rollout. See Migrations below. Take a staging DB backup first.

What's changing

  1. Media storage: disk → MongoDB (highest risk)
    • New mediaAsset model stores image/chart bytes in Mongo; socialImageStorage.js rewritten (thumbnails via sharp, replacing the macOS-only sips). Media served at /media/<key>.
    • IODA charts: scraped-SVG → recharts (IodaChart + useReportChartSeries), rendering signal JSON. Legacy image/SVG reports still render via a fallback.
  2. Backfill scripts — added under scripts/backfill/* and backend/scripts/*; inert until run.
  3. API/Settings redesign — "Providers and Feeds" — consolidated Connections page (sources + credentials grouped by provider, one connection/provider, warnings dialog, toasts). Source/credential shape adjusted in sourceController/credentialsController/source.js.
  4. Access control + ongoing-outage detection — teams + source-access policies + permissions; isOutageOngoing + alerts Status filter (All/Ongoing/Ended); "Present" for outages with no end.
  5. Incident/alert filtering (Feat/incident alert filtering #145) — per-user display preferences (12h/24h, DMY/MDY, local/UTC); incident lifecycle-stage filter; compare-from-list + toolbar; ASN/network/geo-scope source column; click-to-expand IODA + Cloudflare charts; fix: date-bound casting in report-query.js (corrects phantom pagination pages when a date range is set).

New dependency: sharp@^0.35.3 → run npm ci (recharts was already present).
New env keys: API_REQUEST_TIMEOUT, API_FETCH_INTERVAL, optional MEDIA_ROOT (leave unset — media is in Mongo).

Migrations

Deploy code. Then, from the repo root, staging .env → staging DB, --dry-run first (all idempotent):

node backend/scripts/backfillMediaToMongo.js --dry-run && node backend/scripts/backfillMediaToMongo.js
node scripts/backfill/backfill-ioda-charts.js --dry-run && node scripts/backfill/backfill-ioda-charts.js
node scripts/backfill/backfill-ioda-null-charts.js --dry-run && node scripts/backfill/backfill-ioda-null-charts.js
node scripts/backfill/backfill-reports-outage-fields.js --dry-run && node scripts/backfill/backfill-reports-outage-fields.js
node scripts/backfill/backfill-reports-entity-level.js --dry-run && node scripts/backfill/backfill-reports-entity-level.js
node scripts/backfill/backfill-reports-eventIdentifier-eventAggKeyBase.js --dry-run && node scripts/backfill/backfill-reports-eventIdentifier-eventAggKeyBase.js

Fold the not-yet-implemented steps from seven completed/superseded plan
docs into docs/claude/plans/todo.md (with file refs and context), then
remove those docs. Keep the still-outstanding plans and the media-storage
architecture reference.

Kept:
- architecture/media-image-storage.md (evergreen reference)
- plans/fix-assign-user-in-operator-crash.md (unfixed live bug)
- plans/cloudflare-chart-caching.md (design-only, not built)

Removed (leftover steps moved to todo.md):
- move-ioda-svg-to-media-storage, fix-reports-timeout-double-send,
  report-dedup-timeout-fix, table-views, table-toolbar-relayout,
  incidents-list-scroll-retention, ioda-v2-chart-investigation
A — Alerts date filter fix
- Cast before/after bounds to Date in report-query so the deduped path's
  aggregate $match total agrees with find() (aggregation doesn't cast),
  fixing phantom/empty pages. Guard invalid dates.
- FilterDateTime gains an optional label prop; alerts label it "Outage start".
  Remove stray console.log.

B — Incident start-time filter
- parseQueryData maps before/after to incidentStartedAt (cast to Date) instead
  of storedAt; add incidentStartedAt to Group.filterAttributes so queryGroups
  copies it into the Mongo filter.
- Add a FilterDateTime control ("Incident start") to IncidentsFilters.

C — Source badges on incident cards
- addReportSourcesToGroups aggregates distinct _media per group and attaches
  reportSources[]; chained after coverage enrichment in group_groups.
- Add reportSources to Group type; render per-source badges via SocialMediaIcon
  on incident cards.
- FilterDropdown: add persistLabel so the trigger keeps its label (and
  fixed size) when a value is set instead of swapping to the long range
  string — stops the filter bar from wrapping/resizing.
- FilterDateTime: pin the dropdown panel to fit-content so it no longer
  grows when dates are picked; label the control clearly.
- DateSelector: fixed-width date buttons; disable future dates; add
  min/max bounds so the "before" date can't precede the "after" date
  (and vice-versa), preventing empty invalid ranges.
When picking the "after" or "before" date, open the calendar on the
other field's month (via defaultMonth) so the user lands near their
target instead of today's month. Falls back to the field's own value,
then today.
Repurpose the redundant alerts "Source" column (which just re-printed the
platform name already shown by the Platform icon) into an "ASN / Network /
Geo Scope" column for IODA/Cloudflare outage alerts. The ASN, network name,
and geo scope are all derived from data already on each report
(report.asn + rawAPIResponse.entityName/entityScope), so no backend change
is needed and it works on existing rows.

Also top-align the DataTable expand caret (items-start) so it lines up with
the first row of text now that outage rows are multi-line.
Shrink the incidents table to text-xs so it matches the alerts table:
pass tableClassName=text-xs (it was falling back to the DataTable default
text-sm), drop two oversized hardcoded sizes (the "alerts" suffix and the
"N reports" subtitle), and set the expanded detail panel to text-xs
(scoped to incidents so the alerts ReportDetail is unaffected).

Also center the DataTable expand caret on a one-line-tall box (items-center
h-4) instead of pinning it to the top of a 30px box, so the chevron lines up
with the first row of text in both the alerts and incidents tables.
Add per-user display preferences (12h/24h clock, MM/DD/YYYY vs DD/MM/YYYY
date order, local vs UTC timezone) surfaced in the account profile.

- User model gains a preferences sub-object with enum defaults (24h/DMY/local);
  user_update whitelists it and the session endpoint returns it app-wide.
- Centralize date/time formatting into utils/dateFormat.ts (Intl-based) plus a
  useFormatters hook bound to the current session's preferences.
- Convert all date/time display sites (incidents, reports, IODA/Cloudflare event
  windows, filters, WebAuthn rows, comments) to honor the preference, removing
  the scattered formatIsoTime/formatStamp/toLocale* helpers.
- Add a self-service Display preferences section that saves via a dedicated
  updateUserPreferences call and refetches the session so dates update live.
Frees space on an over-quota cluster by deleting reports that are both
orphaned (no group) and older than 30 days, keeping all grouped reports
and the last month of everything. Dry-run by default; --apply to delete.
Add ExpandableChart so IODA/Cloudflare outage charts in the alerts table
expanded row can be clicked to enlarge to full width and collapsed again
(click chart or button). Deduplicates the chart-rendering JSX previously
copied in IodaEvent and TrafficEvent.

Also surface the ASN in the alert detail author header (ASN / network /
geo scope) by moving reportNetwork into the shared reportParser.
Replace the incidents list Open/Closed/All radio with a multi-select
"Stage" dropdown (Verification / Confirmation / Published) plus an
"Include closed" toggle, matching the alerts filter pattern.

- backend: add `stages` to Group.filterAttributes and translate it into
  strict-pipeline predicates in Group.queryGroups, OR-combined and pushed
  as an $and clause so it doesn't clobber the assignedTo $or. Existing
  closed handling is unchanged.
- frontend: add `stages` to GroupQueryState; swap the radio for a
  FilterListbox multi-select with an "Include closed" toggle (Entity
  Level pattern); clear the stage filter on title search so search spans
  all stages.
- FilterDropdown: remove hardcoded w-[20em] that collided with each
  caller's width class (unpredictable panel widths); size panels with
  w-max min-w-[150px] so they fit the widest row without wrapping.
- FilterListBox: nowrap option rows; cap the toggle description width so
  long helper text wraps instead of stretching the panel.
- FilterDateTime: drop the now-unneeded !w-fit override.
- Sort By dropdown: show human-readable labels (GROUP_SORTBY_LABELS)
  instead of camelCase keys, mapping label<->key on change.
- Incidents filters: group the Stage dropdown inline with the other
  filter dropdowns.
The side-by-side Compare feature previously only worked in the table view
of the Alerts and Incidents pages. Turn it on in list view too, reusing the
existing page-level compare state, cap, and modals.

Alerts (AllReportsList):
- Show the Compare button in list view (drop the table-only guard).
- Extract toggleReportForCompare (cap + chart prefetch) shared by the table
  and list rows.
- Clicking a list row checkbox now enters compare mode and selects the row,
  while keeping the mark relevant/irrelevant bar available on the same
  selection (relevance bar renders in list view even in compare mode; table
  view stays pure compare).
- "Select all on this page" leaves compare mode, since it's an uncapped
  relevance action incompatible with the 6-item compare cap.

Incidents (index + IncidentListItem):
- Show the Compare button in list view.
- Add selection support to IncidentListItem via the shared MultiSelectListItem
  wrapper (checkbox gutter + select-mode row-click routing).
- Clicking a list row checkbox enters compare mode and selects the incident.

CompareActionBar and the compare modals are unchanged — they already render
off page-level compareMode for both views.
…toolbar

Table view (alerts + incidents):
- Add an always-visible checkbox column to the shared DataTable via a new
  selection.alwaysShow flag; idle checkboxes reveal on row hover.
- Clicking a table checkbox launches compare the same way the list does
  (enters compare mode, capped at 6, prefetch charts for alerts). On alerts it
  also surfaces the mark relevant/irrelevant bar on the same selection; the
  relevance select mode still does a plain toggle.

Compare control:
- Replace the floating CompareActionBar with an inline CompareToolbar rendered
  next to the view toggle: idle shows a Compare button, active turns blue (like
  the view toggle) and becomes the launch + clear + cancel bar, separated from
  the view toggle by a divider.

Bug fix:
- On the alerts table, 'Select all on this page' followed by unchecking it left
  the page stuck in an empty select mode, so a subsequent single check no longer
  launched compare. Unchecking select-all now returns fully to idle.
- Add rerender-frequency-findings.md: writeup of why the Alerts/Incidents
  pages re-render frequently (react-time-ago tickers, socket-listener churn,
  isFetching/StrictMode pulses, whole-list cache replacement) with ranked fixes.
- Update incidents-alerts-filtering.md and todo.md planning notes.
…ection

Two related fixes for the Alerts and Incidents list/table views.

ResizeObserver overlay crash:
- Switching list -> table with rows selected triggered "ResizeObserver loop
  completed with undelivered notifications", which CRA's dev error overlay
  surfaced as a full-screen crash. Both pages measured their sticky filters
  bar with a ResizeObserver whose callback set state synchronously; clearing
  the selection shrank the bar in the same frame, retriggering layout mid-
  delivery.
- Extract a shared useMeasuredHeight hook that defers the measurement to
  requestAnimationFrame and skips no-op updates, breaking the loop. Reuse it
  in AllReportsList and incidents/index, replacing the duplicated inline
  observers.

Persist selection across list/table:
- Selecting rows then toggling the view previously cleared the selection.
  The underlying results are identical across both views, so keep the
  selection instead.
- Remove the [view] reset effect on both pages and re-key the search-params
  reset effect on the non-view params, so a view toggle no longer clears the
  selection (or refetches, on incidents) while real filter/search/page
  changes still do.
Add planning doc for building a dev Aggie environment seeded with real
production data at zero risk to prod. Approach: each VM runs its own local
MongoDB, and a nightly cron on the dev VM overwrites dev's database with a
fresh mongodump/mongorestore snapshot of prod.

Covers the rationale (why not a shared DB, dedicated DB VM, or per-record
origin field), the refresh script design (dump-first ordering, PM2
stop/restore/start, single-instance lock), two connection options
(SSH tunnel vs direct TLS + IP allowlist), the config required for a
full "copy everything" dump (ENCRYPTION_KEY parity, MFA/WebAuthn caveats),
dev-fetching implications, and a verification checklist.
Drop the per-poll headless Chromium (Playwright) that scraped IODA's
dashboard SVG. Fetch IODA's raw signal series instead, store them inline
on the report (metadata.rawAPIResponse.chart), and render them
client-side with recharts.

Backend:
- fetchSignals() replaces the SVG scrape; removes playwright + dompurify
  (jsdom kept for Mastodon); drops the now-dead persistSvgChart.
- Signals use the raw event.location entity so geoasn works (the
  dashboard-stripped form is rejected by the signals API).
- List serializer strips the chart series from rows; detail keeps it and
  the frontend lazy-loads per report.

Frontend:
- New IodaChart (recharts): per-signal normalized lines (BGP, Active
  Probing, Telescope, Mozilla), outage-window shading, and a zoomable
  navigator with a responsive full-range 12-hour time axis.
- New useReportChartSeries hook; IodaEvent and CompareCardBody branch to
  IodaChart, keeping the legacy image path as a fallback.

Also adds dev-only fixture/backfill scripts (gitignored scratchpad) and
updates the plan docs; IODA is now out of scope for the media-bytes
migration.
The signal-JSON/recharts migration removed persistSvgChart, but the
one-off backfill scripts/migrate-ioda-svg-to-storage.js still needs it
to run in production. Reinstate the helper (live channel no longer calls
it), correct the list-projection comment in report.js, and update the
media/deployment docs to describe the current model and the pending
production backfill.
Maps every MongoDB collection, model, and notably-renamed field to its
user-facing UI label (Group=Incident, one reports collection split into
Alerts vs Social Media Posts, SMTCTag=Tags, etc.). Verified against the
live aggie DB. Includes a detailed explanation of the _sources
String-vs-ObjectId caveat.
One-time, idempotent script that converts legacy IODA chart SVGs
(metadata.rawAPIResponse.image) into the compact signal JSON shape
(metadata.rawAPIResponse.chart) by re-fetching from IODA's signals API.
Converted disk SVGs are moved to an out-of-media backup tree; charts with
no usable signals keep the SVG fallback (inline SVGs promoted to disk) for
the later media-bytes->Mongo migration. Supports --dry-run.
- Rework Step 2 backfillMediaToMongo.js spec to migrate only report-referenced
  keys, skip and report orphans, and count dangling refs — avoids ingesting the
  ~322 MB of orphaned IODA chart SVGs into the mediaassets collection.
- Gitignore public/ioda-charts-backup (rollback copies from the SVG→JSON backfill).
Image bytes for Mastodon/Telegram social attachments and residual IODA
chart SVGs now live in a new 'mediaassets' collection (inline BinData)
instead of public/media/, so media travels with the database. The key
namespace and the unauthenticated GET /media/<key> URL contract are
unchanged, so reports, controllers, channels, hooks, and the frontend
are untouched.

- backend/models/mediaAsset.js: new model / mediaassets collection
  (key unique, data Buffer, contentType, byteSize, kind indexed,
  sourcePlatform, timestamps).
- socialImageStorage.js: disk -> Mongo, same public API. Thumbnails are
  built in-memory with sharp (replaces the macOS-only sips shell-out,
  which silently produced full-size copies on the Ubuntu prod VM).
  persistSocialImage inserts social-full + social-thumb docs;
  persistSvgChart upserts an ioda-chart doc; deleteMediaByKey removes the
  doc; normalizeKey is now exported. getMediaRoot/MEDIA_ROOT retained for
  the backfill.
- api.js: serve GET /media/* from Mongo via one streaming route
  (replacing both express.static mounts), with per-kind cache/ETag and a
  Binary->Buffer coercion for .lean() results.
- backfillMediaToMongo.js: one-time, idempotent, reference-aware backfill
  that migrates only report-referenced keys and skips/reports orphans.
- Add sharp dependency; update architecture + plan/runbook docs.
Document how to obtain external credentials and register Mastodon,
Telegram (User and Bot), and Cloudflare sources in Aggie, with
per-source troubleshooting.
The FETCH process writes social images and IODA SVG charts under MEDIA_ROOT,
which defaulted to public/media. CRA's dev server watches public/ and does a
full browser reload on every write, so background fetch cycles reloaded the
app on a loop (full page reload, no 401) on every page while idle.

Default MEDIA_ROOT to media-store/ (outside public/) in dev, keeping
public/media only in production, which has no such watcher. /media is still
served by the backend via getMediaRoot(), so images resolve unchanged with no
frontend or env changes. Gitignore media-store; document the optional
override in .env.example.
Fold the six shipped plan docs (lifecycle-stage filter, ASN/Network column,
compare-in-list-view, date/time preferences, and the alerts reload/re-render
work) into incidents-alerts-filtering.md as a consolidated branch record, and
delete the standalone files. Update todo.md with a branch-record pointer, an
alerts/incidents re-render perf backlog, and a flag for the live assign-user
crash. Leave cloudflare-chart-caching.md and fix-assign-user-in-operator-crash.md
standalone since they did not ship.
ronniegross and others added 22 commits August 19, 2026 11:27
Check in scripts/backfill/ (reports outage fields, entity level,
event keys, incidents import, IODA chart signals). Promote the IODA
chart backfill out of scratchpad/ into scripts/backfill/ and fix its
require paths. Gitignore media-store/, public/ioda-charts-backup/,
and scratchpad/ — runtime media, chart backups, and dev scratch.
…rovider

- New /settings/connections page (ConnectionsIndex + ApiTypeSection) that groups credentials and sources by API type, each with add-credential/add-source actions

- Extract reusable SourcesSection, CredentialsSection, and SourceDetailsView from the standalone pages

- Responsive Settings sidebar (fixed 300px at >=1080, wraps below) and teal AggieButton variant + aggie.secondary.650 token

- Source form: defaultType prop to pre-scope by type, access-policy fields, multi-hashtag Mastodon input; auto-filled credential name

- Backend: credential-name fallback in credential_create; Mastodon multi-hashtag fetch; media storage/env tweaks
…ined details

Frontend
- Feed row: title is a plain bold heading (no longer a link); add
  "View details" to the row's action menu.
- Details popup: "View details" now shows a combined read-only info +
  editable form; warnings pill shows a summary toast (react-hot-toast)
  instead of opening the popup.
- Add-feed form is locked to its provider (no provider dropdown) and the
  provider is emphasized in the dialog title; removed the inline
  "Add connection" button - connections are configured beforehand.
- Cap connections at one per provider via
  ALLOW_MULTIPLE_CONNECTIONS_PER_PROVIDER (flag kept for re-enabling).

Backend
- Compute distinctErrorCount for sources (controller + model), exposed
  on the source list payload.

Docs
- Replace older Connections plan docs with connections-page.md.
…icher details view

- Replace react-hot-toast warnings peek with a Warnings dialog and a
  Recent activity table in the details view; drop react-hot-toast
- Warning badge now counts recent events (last 50) so it matches the
  popup list, instead of distinct-message dedup (backend + types)
- Add a runtime "Allow multiple connections per provider" toggle,
  replacing the compile-time flag; hide/auto-select the connection
  picker when off
- Expand SourceDetailsView into a read-only view with per-provider
  config, access policy, and allowed teams
- FormikInput hint prop; block submit until dirty; dialog/spacing polish
Recover from a full MongoDB (Atlas free-tier) storage quota by keeping
the newest ~N MB of reports and deleting the rest. Walks reports
newest-first by _id, measures real BSON size via $bsonSize, and deletes
everything older than the cutoff. Deletes run before the group back-ref
cleanup so recovery works even while writes are quota-blocked. Defaults
to a 350MB budget (KEEP_BYTES-overridable) and is dry-run unless --yes.
- Rename page to "Providers and Feeds"; rewrite intro to define
  provider → connection → feed; "Reports" → "Alerts" copy; capitalize
  Provider; rework Mastodon hashtag helper text; drop em dashes
- Feed rows: lead with mode/country code, feed name beneath; keep the
  connection key chip
- View details: "View <name> details" header (green name, sized like Add
  feed); static (non-link) connection; collapsible Recent activity with
  inline warnings pill; respect single-connection setting when editing
- Conditional "Hashtag"/"Hashtags" label by tag count
- Smaller "Enable Fetching" heading
- Remove dev-only Style link from the navbar
- Consistent light-grey "more" menu buttons on the feeds list and details
Folds the 5 scripts/backfill/*.js tools onto media-migration. feat/backfill-scripts
carried a merge of development, so this also refreshes media-migration onto development.

Conflict resolution (package.json / package-lock.json / .gitignore):
- Kept development's structure: cross-env in devDependencies + .npmrc (include=dev)
  prod-build fix; dompurify and playwright retained.
- Kept media-migration's sharp dependency; regenerated lockfile (--package-lock-only)
  so sharp's subtree is consistent.
- .gitignore: union of both ignore sets (media-store, public/ioda-charts-backup, scratchpad/).
The canonical IODA backfill is scripts/backfill/backfill-ioda-charts.js
(re-fetches signal series from the IODA API). Add a header banner pointing
there and reserve the SVG->JSON converter for legacy reports the API
re-fetch can't reconstruct.
Brings the landed api-redesign work (Settings/Connections redesign, mastodon
multi-hashtag, distinctErrorCount, trim-reports script) onto media-migration.

Conflict resolution:
- socialImageStorage.js: keep media-migration's Mongo storage. MEDIA_ROOT stays
  the legacy disk path (public/media) used only by backfillMediaToMongo.js;
  api-redesign's dev 'media-store' reload-loop workaround is unnecessary here
  because fetch no longer writes media to disk.
- .env.example: MEDIA_ROOT comment updated to reflect Mongo storage.
- package.json: auto-merged (sharp dependency + trim-reports script both kept).
One-off, idempotent repair that re-fetches signal series for IODA
reports rendering blank — those whose metadata.rawAPIResponse.chart
is null/empty-series with no legacy image fallback (a transient
IODA signals-API error at fetch time stored chart=null with no
retry). Distinct from backfill-ioda-charts.js, which repairs legacy
reports that still carry a scraped SVG image key and no chart field.
Supports --dry-run.
Add backfill-ioda-null-charts.js: repair blank IODA charts
Refresh the branch onto development ahead of its PR (step 3 of the
branch-test-merge-order plan). development had since landed media-migration
(media->Mongo + recharts IODA), the ongoing-outage status filter, and
api-redesign, which the branch predated.

Conflict resolution:
- Media storage: keep development's Mongo path (socialImageStorage.js,
  .env.example); drop the branch's stale on-disk writer.
- report-query.js: keep isOutageOngoing filter AND the branch's Date-cast
  pagination fix for before/after.
- reports/types.ts: keep chart? (IodaChartData) + add entityName/entityScope.
- IODA/Cloudflare charts: recharts IodaChart stays the primary render and is
  now click-to-expand; ExpandableChart handles the legacy image/SVG fallback
  (with loading skeleton). Shared expand/collapse chrome extracted into
  ChartExpander (image = surface click; recharts = corner button only).
- Dates: adopt the branch's display-preferences formatDateTime while restoring
  development's ongoing -> "Present" (SocialMediaListItem, IncidentInfo).
- CompareCardBody: keep recharts + existing zoom button; formatStamp->formatDateTime.
The three backfill-reports-*.js scripts require()d "../database" /
"../models/report" / "../fetching/utils/iodaUtils", which resolve relative to
the script's own dir (scripts/backfill/), not the cwd — so they threw
"Cannot find module '../database'" from every working directory. Repoint them
at "../../backend/…" (matching backfill-incidents.js) so they run from the repo
root.

Also add a --dry-run flag to each (they previously wrote immediately with no
preview): reports counts / would-modify totals without issuing updateMany /
bulkWrite.
The dry-run tally previously printed "Matched: N / Modified: 0", identical to a
real run that changed nothing — impossible to tell apart without the [DRY-RUN]
banner. In dry-run these now say "would write (prepared updates): N" and
"would update=N (no writes made)" instead of Matched/Modified. Real-run output
is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant