Add atlas_known distinction to prevent badge noise for untracked hosts

Atlas sync now distinguishes between hosts Atlas actively tracks (returned
plans, active or inactive) vs hosts with empty responses (not in Atlas).
Only atlas_known hosts show the badge — ACCESS-OPS hosts not covered by
Atlas won't show the amber '0' warning badge anymore.

Changes:
- Migration adds atlas_known BOOLEAN column to atlas_action_plans_cache
- Sync sets atlas_known = true only when Atlas returns at least one plan
- Metrics endpoint only counts atlas_known hosts in its aggregation
- Status endpoint includes atlas_known in response
- AtlasBadge renders nothing when atlas_known = false
- Bulk-create and refresh-cache upserts set atlas_known = true
- Backfill marks existing hosts with plans + managed BU hosts as known
This commit is contained in:
Jordan Ramos
2026-06-12 13:25:00 -06:00
parent 5105ee2ff8
commit 150a534943
5 changed files with 93 additions and 15 deletions

View File

@@ -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()
);

View File

@@ -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);
});

View File

@@ -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() {

View File

@@ -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]
);
}