diff --git a/backend/db-schema.sql b/backend/db-schema.sql index 7c8f419..196c7c1 100644 --- a/backend/db-schema.sql +++ b/backend/db-schema.sql @@ -398,6 +398,7 @@ CREATE TABLE IF NOT EXISTS atlas_action_plans_cache ( has_action_plan BOOLEAN NOT NULL DEFAULT FALSE, plan_count INTEGER NOT NULL DEFAULT 0, plans_json TEXT NOT NULL DEFAULT '[]', + atlas_known BOOLEAN NOT NULL DEFAULT FALSE, synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/backend/migrations/add_atlas_known_column.js b/backend/migrations/add_atlas_known_column.js new file mode 100644 index 0000000..5813eab --- /dev/null +++ b/backend/migrations/add_atlas_known_column.js @@ -0,0 +1,61 @@ +// Migration: Add atlas_known column to atlas_action_plans_cache +// +// Distinguishes between hosts Atlas actively tracks (atlas_known = true) +// and hosts that were synced but Atlas has no data for (atlas_known = false). +// The badge only renders for atlas_known hosts, preventing noise from BUs +// not covered by Atlas. +// +// Safe to re-run — uses ADD COLUMN IF NOT EXISTS pattern. +// +// Usage: node backend/migrations/add_atlas_known_column.js + +const pool = require('../db'); + +async function migrate() { + console.log('Starting atlas_known column migration...'); + + // Add column (IF NOT EXISTS not supported for ADD COLUMN in all PG versions, use DO block) + await pool.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'atlas_action_plans_cache' AND column_name = 'atlas_known' + ) THEN + ALTER TABLE atlas_action_plans_cache ADD COLUMN atlas_known BOOLEAN NOT NULL DEFAULT FALSE; + END IF; + END $$; + `); + console.log('✓ atlas_known column ready'); + + // Backfill: mark hosts that have at least one plan as atlas_known = true + const { rowCount } = await pool.query(` + UPDATE atlas_action_plans_cache SET atlas_known = true WHERE has_action_plan = true + `); + console.log(`✓ Backfilled ${rowCount} rows with atlas_known = true (hosts with plans)`); + + // Also mark hosts belonging to managed BUs as atlas_known + // These are the BUs Atlas is supposed to cover + const managedBUs = (process.env.IVANTI_MANAGED_BUS || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM') + .split(',').map(b => b.trim()).filter(Boolean); + const patterns = managedBUs.map(b => `%${b}%`); + + const { rowCount: buCount } = await pool.query(` + UPDATE atlas_action_plans_cache SET atlas_known = true + WHERE host_id IN ( + SELECT DISTINCT host_id FROM ivanti_findings + WHERE bu_ownership ILIKE ANY($1::text[]) + ) + `, [patterns]); + console.log(`✓ Backfilled ${buCount} rows for managed BU hosts as atlas_known = true`); + + console.log('Migration complete.'); +} + +migrate() + .then(() => { pool.end(); }) + .catch((err) => { + console.error('Migration failed:', err); + pool.end(); + process.exit(1); + }); diff --git a/backend/migrations/run-all.js b/backend/migrations/run-all.js index 5952f7e..2d8cdf5 100644 --- a/backend/migrations/run-all.js +++ b/backend/migrations/run-all.js @@ -32,6 +32,7 @@ const POSTGRES_MIGRATIONS = [ 'add_notifications_table.js', 'add_ivanti_findings_ipv6_columns.js', 'add_user_ivanti_identity.js', + 'add_atlas_known_column.js', ]; async function runAll() { diff --git a/backend/routes/atlas.js b/backend/routes/atlas.js index 90df553..3a1d31f 100644 --- a/backend/routes/atlas.js +++ b/backend/routes/atlas.js @@ -101,19 +101,20 @@ function createAtlasRouter() { INNER JOIN ( SELECT DISTINCT host_id FROM ivanti_findings WHERE bu_ownership ILIKE ANY($1::text[]) - ) f ON a.host_id = f.host_id`, + ) f ON a.host_id = f.host_id + WHERE a.atlas_known = true`, [patterns] ); rows = result.rows; } else { const result = await pool.query( - `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` + `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true` ); rows = result.rows; } } else { const result = await pool.query( - `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache` + `SELECT has_action_plan, plans_json FROM atlas_action_plans_cache WHERE atlas_known = true` ); rows = result.rows; } @@ -134,7 +135,7 @@ function createAtlasRouter() { * belonging to specific BUs (via JOIN on ivanti_findings). * * @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG') - * @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, synced_at } + * @returns {Array} 200 - Array of { host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at } * @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 500 - { error } on database failure */ @@ -152,7 +153,7 @@ function createAtlasRouter() { if (teams.length > 0) { const patterns = teams.map(t => `%${t}%`); const result = await pool.query( - `SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.synced_at + `SELECT a.host_id, a.has_action_plan, a.plan_count, a.plans_json, a.atlas_known, a.synced_at FROM atlas_action_plans_cache a INNER JOIN ( SELECT DISTINCT host_id FROM ivanti_findings @@ -163,13 +164,13 @@ function createAtlasRouter() { rows = result.rows; } else { const result = await pool.query( - `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache` + `SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache` ); rows = result.rows; } } else { const result = await pool.query( - `SELECT host_id, has_action_plan, plan_count, plans_json, synced_at FROM atlas_action_plans_cache` + `SELECT host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at FROM atlas_action_plans_cache` ); rows = result.rows; } @@ -186,8 +187,12 @@ function createAtlasRouter() { * * Syncs action plan data from Atlas for all hosts found in ivanti_findings. * Fetches plans per host in batches of 5 and upserts into the local cache. + * Scopes to the provided teams or falls back to IVANTI_MANAGED_BUS. * Requires Admin or Standard_User group. * + * @query {string} [teams] - Comma-separated team names to scope sync (e.g. 'STEAM,ACCESS-ENG') + * @param {Object} [req.body] + * @param {string} [req.body.teams] - Comma-separated team names (alternative to query param) * @returns {Object} 200 - { synced, withPlans, failed } * @returns {Object} 503 - { error } when Atlas API is not configured * @returns {Object} 500 - { error } on unexpected failure @@ -289,6 +294,9 @@ function createAtlasRouter() { const planCount = activePlans.length; const hasActionPlan = planCount > 0; + // Atlas "knows" this host if it returned any plans (active or inactive). + // Hosts with completely empty responses are not tracked by Atlas. + const atlasKnown = allPlans.length > 0; try { if (!hasActionPlan) { @@ -314,14 +322,15 @@ function createAtlasRouter() { } await pool.query( - `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) - VALUES ($1, $2, $3, $4, NOW()) + `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at) + VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT(host_id) DO UPDATE SET has_action_plan = EXCLUDED.has_action_plan, plan_count = EXCLUDED.plan_count, plans_json = EXCLUDED.plans_json, + atlas_known = EXCLUDED.atlas_known, synced_at = EXCLUDED.synced_at`, - [hostId, hasActionPlan, planCount, JSON.stringify(allPlans)] + [hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown] ); } catch (dbErr) { console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message); @@ -576,12 +585,13 @@ function createAtlasRouter() { const newCount = updatedPlans.length; await pool.query( - `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) - VALUES ($1, true, $2, $3, NOW()) + `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at) + VALUES ($1, true, $2, $3, true, NOW()) ON CONFLICT(host_id) DO UPDATE SET has_action_plan = true, plan_count = EXCLUDED.plan_count, plans_json = EXCLUDED.plans_json, + atlas_known = true, synced_at = EXCLUDED.synced_at`, [hid, newCount, JSON.stringify(updatedPlans)] ); @@ -659,16 +669,18 @@ function createAtlasRouter() { const planCount = activePlans.length; const hasActionPlan = planCount > 0; + const atlasKnown = allPlans.length > 0; await pool.query( - `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at) - VALUES ($1, $2, $3, $4, NOW()) + `INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at) + VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT(host_id) DO UPDATE SET has_action_plan = EXCLUDED.has_action_plan, plan_count = EXCLUDED.plan_count, plans_json = EXCLUDED.plans_json, + atlas_known = EXCLUDED.atlas_known, synced_at = EXCLUDED.synced_at`, - [hostId, hasActionPlan, planCount, JSON.stringify(allPlans)] + [hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown] ); } diff --git a/frontend/src/components/AtlasBadge.js b/frontend/src/components/AtlasBadge.js index 7dec67e..732288f 100644 --- a/frontend/src/components/AtlasBadge.js +++ b/frontend/src/components/AtlasBadge.js @@ -49,6 +49,9 @@ export default function AtlasBadge({ hostId, atlasStatus, onClick }) { // No status data — render nothing if (!atlasStatus) return null; + // Host not tracked by Atlas — render nothing (avoids noise from BUs not covered) + if (atlasStatus.atlas_known === false) return null; + const hasPlan = atlasStatus.plan_count > 0; const style = hasPlan ? successStyle : warningStyle; const label = hasPlan ? String(atlasStatus.plan_count) : '0';