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:
@@ -398,6 +398,7 @@ CREATE TABLE IF NOT EXISTS atlas_action_plans_cache (
|
|||||||
has_action_plan BOOLEAN NOT NULL DEFAULT FALSE,
|
has_action_plan BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
plan_count INTEGER NOT NULL DEFAULT 0,
|
plan_count INTEGER NOT NULL DEFAULT 0,
|
||||||
plans_json TEXT NOT NULL DEFAULT '[]',
|
plans_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
atlas_known BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
61
backend/migrations/add_atlas_known_column.js
Normal file
61
backend/migrations/add_atlas_known_column.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -32,6 +32,7 @@ const POSTGRES_MIGRATIONS = [
|
|||||||
'add_notifications_table.js',
|
'add_notifications_table.js',
|
||||||
'add_ivanti_findings_ipv6_columns.js',
|
'add_ivanti_findings_ipv6_columns.js',
|
||||||
'add_user_ivanti_identity.js',
|
'add_user_ivanti_identity.js',
|
||||||
|
'add_atlas_known_column.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
async function runAll() {
|
||||||
|
|||||||
@@ -101,19 +101,20 @@ function createAtlasRouter() {
|
|||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT host_id FROM ivanti_findings
|
SELECT DISTINCT host_id FROM ivanti_findings
|
||||||
WHERE bu_ownership ILIKE ANY($1::text[])
|
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]
|
[patterns]
|
||||||
);
|
);
|
||||||
rows = result.rows;
|
rows = result.rows;
|
||||||
} else {
|
} else {
|
||||||
const result = await pool.query(
|
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;
|
rows = result.rows;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await pool.query(
|
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;
|
rows = result.rows;
|
||||||
}
|
}
|
||||||
@@ -134,7 +135,7 @@ function createAtlasRouter() {
|
|||||||
* belonging to specific BUs (via JOIN on ivanti_findings).
|
* belonging to specific BUs (via JOIN on ivanti_findings).
|
||||||
*
|
*
|
||||||
* @query {string} [teams] - Comma-separated team names (e.g. 'STEAM,ACCESS-ENG')
|
* @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} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on database failure
|
* @returns {Object} 500 - { error } on database failure
|
||||||
*/
|
*/
|
||||||
@@ -152,7 +153,7 @@ function createAtlasRouter() {
|
|||||||
if (teams.length > 0) {
|
if (teams.length > 0) {
|
||||||
const patterns = teams.map(t => `%${t}%`);
|
const patterns = teams.map(t => `%${t}%`);
|
||||||
const result = await pool.query(
|
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
|
FROM atlas_action_plans_cache a
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT host_id FROM ivanti_findings
|
SELECT DISTINCT host_id FROM ivanti_findings
|
||||||
@@ -163,13 +164,13 @@ function createAtlasRouter() {
|
|||||||
rows = result.rows;
|
rows = result.rows;
|
||||||
} else {
|
} else {
|
||||||
const result = await pool.query(
|
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;
|
rows = result.rows;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await pool.query(
|
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;
|
rows = result.rows;
|
||||||
}
|
}
|
||||||
@@ -186,8 +187,12 @@ function createAtlasRouter() {
|
|||||||
*
|
*
|
||||||
* Syncs action plan data from Atlas for all hosts found in ivanti_findings.
|
* 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.
|
* 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.
|
* 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} 200 - { synced, withPlans, failed }
|
||||||
* @returns {Object} 503 - { error } when Atlas API is not configured
|
* @returns {Object} 503 - { error } when Atlas API is not configured
|
||||||
* @returns {Object} 500 - { error } on unexpected failure
|
* @returns {Object} 500 - { error } on unexpected failure
|
||||||
@@ -289,6 +294,9 @@ function createAtlasRouter() {
|
|||||||
|
|
||||||
const planCount = activePlans.length;
|
const planCount = activePlans.length;
|
||||||
const hasActionPlan = planCount > 0;
|
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 {
|
try {
|
||||||
if (!hasActionPlan) {
|
if (!hasActionPlan) {
|
||||||
@@ -314,14 +322,15 @@ function createAtlasRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
ON CONFLICT(host_id) DO UPDATE SET
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
has_action_plan = EXCLUDED.has_action_plan,
|
has_action_plan = EXCLUDED.has_action_plan,
|
||||||
plan_count = EXCLUDED.plan_count,
|
plan_count = EXCLUDED.plan_count,
|
||||||
plans_json = EXCLUDED.plans_json,
|
plans_json = EXCLUDED.plans_json,
|
||||||
|
atlas_known = EXCLUDED.atlas_known,
|
||||||
synced_at = EXCLUDED.synced_at`,
|
synced_at = EXCLUDED.synced_at`,
|
||||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
||||||
);
|
);
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
console.error('[Atlas Sync] DB upsert failed for host', hostId, ':', dbErr.message);
|
||||||
@@ -576,12 +585,13 @@ function createAtlasRouter() {
|
|||||||
const newCount = updatedPlans.length;
|
const newCount = updatedPlans.length;
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
||||||
VALUES ($1, true, $2, $3, NOW())
|
VALUES ($1, true, $2, $3, true, NOW())
|
||||||
ON CONFLICT(host_id) DO UPDATE SET
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
has_action_plan = true,
|
has_action_plan = true,
|
||||||
plan_count = EXCLUDED.plan_count,
|
plan_count = EXCLUDED.plan_count,
|
||||||
plans_json = EXCLUDED.plans_json,
|
plans_json = EXCLUDED.plans_json,
|
||||||
|
atlas_known = true,
|
||||||
synced_at = EXCLUDED.synced_at`,
|
synced_at = EXCLUDED.synced_at`,
|
||||||
[hid, newCount, JSON.stringify(updatedPlans)]
|
[hid, newCount, JSON.stringify(updatedPlans)]
|
||||||
);
|
);
|
||||||
@@ -659,16 +669,18 @@ function createAtlasRouter() {
|
|||||||
|
|
||||||
const planCount = activePlans.length;
|
const planCount = activePlans.length;
|
||||||
const hasActionPlan = planCount > 0;
|
const hasActionPlan = planCount > 0;
|
||||||
|
const atlasKnown = allPlans.length > 0;
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, synced_at)
|
`INSERT INTO atlas_action_plans_cache (host_id, has_action_plan, plan_count, plans_json, atlas_known, synced_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
ON CONFLICT(host_id) DO UPDATE SET
|
ON CONFLICT(host_id) DO UPDATE SET
|
||||||
has_action_plan = EXCLUDED.has_action_plan,
|
has_action_plan = EXCLUDED.has_action_plan,
|
||||||
plan_count = EXCLUDED.plan_count,
|
plan_count = EXCLUDED.plan_count,
|
||||||
plans_json = EXCLUDED.plans_json,
|
plans_json = EXCLUDED.plans_json,
|
||||||
|
atlas_known = EXCLUDED.atlas_known,
|
||||||
synced_at = EXCLUDED.synced_at`,
|
synced_at = EXCLUDED.synced_at`,
|
||||||
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans)]
|
[hostId, hasActionPlan, planCount, JSON.stringify(allPlans), atlasKnown]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export default function AtlasBadge({ hostId, atlasStatus, onClick }) {
|
|||||||
// No status data — render nothing
|
// No status data — render nothing
|
||||||
if (!atlasStatus) return null;
|
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 hasPlan = atlasStatus.plan_count > 0;
|
||||||
const style = hasPlan ? successStyle : warningStyle;
|
const style = hasPlan ? successStyle : warningStyle;
|
||||||
const label = hasPlan ? String(atlasStatus.plan_count) : '0';
|
const label = hasPlan ? String(atlasStatus.plan_count) : '0';
|
||||||
|
|||||||
Reference in New Issue
Block a user