Skip to content

feat(devices): fleet migration posture report (#3244) - #3264

Open
ToddHebebrand wants to merge 1 commit into
mainfrom
feat/3244-fleet-posture-report
Open

feat(devices): fleet migration posture report (#3244)#3264
ToddHebebrand wants to merge 1 commit into
mainfrom
feat/3244-fleet-posture-report

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Closes #3244 (epic #3249).

Surfaces the management-posture detection Breeze already collects (agent/internal/mgmtdetect/devices.management_posture jsonb) as a fleet-level migration/decommission report. Surfacing only: no agent changes, no new tables/columns — the jsonb stays the single source of truth.

Spec: docs/superpowers/specs/onboarding-signup/2026-08-08-fleet-migration-posture-report-design.md · Plan: docs/superpowers/plans/open/2026-08-08-fleet-migration-posture-report.md (both on the #3250 docs branch).

API

  • services/managementPostureReport.tstwo aggregate queries, per the plan's critical note:
    • Detections: CROSS JOIN LATERAL jsonb_array_elements(...), count(DISTINCT devices.id) per (org, product, status), plus a fresh-count FILTER on collectedAt.
    • Coverage: per-org denominators with no lateral jointotalDevices, neverScanned (posture IS NULL), stale, scannedNoneDetected (empty array OR absent key), detectedDevices, freshDetectedDevices.
    • Deliberately not one GROUP BY with a LEFT JOIN LATERAL: that collapses never-scanned / category-absent / empty-array into one (NULL, NULL) group, making a never-scanned device read as verified-clean — the exact failure that strands endpoints in Phase 6 of a migration.
    • The summary assembler never emits a detection count without its coverage denominators (throws on the impossible orphan case rather than dropping it).
    • category is validated against the ingest enum (MANAGEMENT_POSTURE_CATEGORIES, now exported from routes/agents/schemas.ts as the single source of truth) and rides a bind param with an explicit ::text cast — never string interpolation.
  • routes/devices/posture.tsGET /devices/management-posture/summary (orgId?, category?=rmm, stalenessDays?=7) and GET /devices/management-posture/devices (product, category?, status?, pagination). Scoping mirrors GET /devices/stats: authMiddleware + requireScope + DEVICES_READ, auth.orgCondition, 403 on inaccessible ?orgId, site-allowlist narrowing (empty allowlist ⇒ empty report), decommissioned + ephemeral devices excluded. Mounted before coreRoutes so /management-posture/* isn't eaten by the /:id matcher.

Web

  • components/devices/FleetPostureReport.tsx + pages/devices/posture.astro + sidebar entry (Reporting → Fleet Posture, gated on devices:read). Org narrowing rides the global org selector (/devices/* is org-or-all; fetchWithAuth injects orgId).
  • Posture age next to every count (fresh of total on every row; stale/never-scanned cards); a bare zero is never rendered — zero detections with unknown/stale devices shows an explicit amber caveat.
  • Orphaned remote-access agents called out as a security finding (separate red callout fed by a remoteAccess summary fetch — ScreenConnect/Splashtop survive their RMM's uninstall).
  • Per-org migration progress: enrolled / running both (mid-migration) / Breeze-only (verified clean) / unknown.
  • CSV export via toCsv + downloadBlob (injection-neutralized), for customer-facing migration evidence.
  • Category selection persists in window.location.hash per the repo's URL-state convention.
  • i18n: fleetPosture.* (devices ns) + nav.fleetPosture added to all 7 locales with real translations; all four i18n guard suites pass.

Tests

  • services/managementPostureReport.test.ts (unit, mocked db.execute): asserts two separate queries (lateral join in detections only, none in coverage), scope propagation, assembly, numeric coercion, orphan-detection throw, category validation.
  • routes/devices/posture.test.ts (unit): defaults, category 400 (incl. SQL-injection-shaped input), inaccessible-org 403, org/site narrowing, empty-allowlist short-circuit, pagination.
  • services/managementPostureReport.integration.test.ts (real Postgres, the load-bearing one): one org seeded with all four populations at once — never-scanned, stale-with-detection, fresh empty-array, fresh absent-key, fresh-detected, duplicate-product, decommissioned, plus a second partner's org. Asserts the partition neverScanned + stale + freshClean + freshDetected == totalDevices, empty-array/absent-key land in scannedNoneDetected (not neverScanned), duplicate product counts once, active/installed/unknown kept separate, fresh <= total, org/partner scoping, drill-down, staleness-window boundary. Registered in vitest.integration.config.ts (and excluded from the unit runner). A single-population fixture passes against the broken one-query form — this fixture is what actually guards the design.
  • components/devices/FleetPostureReport.test.tsx: render, zero-caveat, orphan callout, drill-down fetch, error state.
  • No RLS/cascade/export registration needed: no new table/column; devices is already registered and management_posture is already excludedOpen.

Deviations from the plan (flagged, not silent)

  • Plan SQL used deleted_at IS NULL; devices has no such column — used the repo's actual liveness convention from GET /devices/stats (status != 'decommissioned' AND is_ephemeral = false).
  • Site filter (Task 4): deferred. The spec's §3 endpoint contract has no site parameter and site-restricted users are already auto-narrowed via allowedSiteIds; adding a free-form site dropdown would grow the API surface beyond the spec. Can follow as a small increment if wanted.
  • Task 5 (perf against a 10k seeded fleet): not run — no Postgres (or Docker) on this machine. The query shapes match the spec's cost analysis (per-org rides the org_id index; partner-wide roll-up is an on-demand full scan). Needs a one-off measurement on a seeded stack before/after merge; happy to record numbers on [API][Web] Fleet migration/decommission report — Management Posture already detects 11 competing RMMs and nothing consumes it #3244 from an environment with a DB.
  • Task 6 toolkit Recipe 5 / Known Rough Edges: apps/docs/.../migration/toolkit.mdx only exists on the unmerged docs(migration): RMM-to-Breeze migration guides and toolkit #3250 docs branch, not on main — cannot be edited from this PR. Should be a follow-up commit on docs(migration): RMM-to-Breeze migration guides and toolkit #3250 (or after it merges). features/management-posture.mdx (on main) is updated here with the fleet view + both endpoints.

Verification

  • vitest run (single-fork): service unit 10/10, route unit 10/10, agents security.test.ts + devices stats.test.ts 12/12, web component 5/5, i18n guards + sidebar structural + no-silent-mutations 182/182 — all green.
  • tsc --noEmit: clean for apps/api and apps/web. (astro check cannot run locally — Node 20 vs Astro's >=22.12 — the new 13-line .astro page mirrors compare.astro; CI covers it.)
  • The integration suite runs under the Integration Tests CI job (no local DB available); it is skipped (it.runIf(DATABASE_URL)) elsewhere.

🤖 Generated with Claude Code

Two aggregate queries over devices.management_posture (detections via
CROSS JOIN LATERAL; coverage denominators separately, no lateral join)
behind GET /devices/management-posture/summary and /devices, plus the
Fleet Posture web page, CSV export, orphaned remote-access callout,
docs, and the mixed-fixture integration test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: e921153
Status: ✅  Deploy successful!
Preview URL: https://c592134a.breeze-9te.pages.dev
Branch Preview URL: https://feat-3244-fleet-posture-repo.breeze-9te.pages.dev

View logs

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.

[API][Web] Fleet migration/decommission report — Management Posture already detects 11 competing RMMs and nothing consumes it

1 participant