Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature: - Add graniteLoaderConfig.js with all 41 columns, groupings, and operation-type requirements (Change/Add/Delete/Move) - Add graniteLoaderExport.js for client-side xlsx generation using the xlsx library - Add LoaderModal component with operation type selection, column checkboxes, bulk defaults with per-row overrides, editable preview table, CARD enrichment integration, and standalone paste-IPs mode - Add POST /api/card/enrich-batch endpoint for batch IP lookup in CARD returning EQUIP_INST_ID, hostname, site, ASN, team - Integrate 'Generate Loader Sheet' button in Ivanti Queue floating action bar (visible when CARD/GRANITE/DECOM items selected) - Add card-connectivity-test.js script for verifying CARD API access
This commit is contained in:
@@ -52,7 +52,14 @@ function handleCardError(err, res) {
|
||||
function createCardApiRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /status
|
||||
/**
|
||||
* GET /status
|
||||
*
|
||||
* Returns whether the CARD API integration is configured.
|
||||
*
|
||||
* @response 200 - { configured: true }
|
||||
* @response 503 - { configured: false, error: string, missingVars: string[] }
|
||||
*/
|
||||
router.get('/status', requireAuth(), (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
||||
@@ -60,7 +67,14 @@ function createCardApiRouter() {
|
||||
res.json({ configured: true });
|
||||
});
|
||||
|
||||
// GET /teams
|
||||
/**
|
||||
* GET /teams
|
||||
*
|
||||
* Returns the list of teams from the CARD API.
|
||||
*
|
||||
* @response 200 - Array of team objects from CARD
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -82,7 +96,19 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /teams/:teamName/assets
|
||||
/**
|
||||
* GET /teams/:teamName/assets
|
||||
*
|
||||
* Returns paginated assets for a team filtered by disposition.
|
||||
*
|
||||
* @param {string} teamName - Team name (path parameter)
|
||||
* @query {string} disposition - Required. Asset disposition filter.
|
||||
* @query {number} [page] - Page number for pagination.
|
||||
* @query {number} [page_size=50] - Number of results per page.
|
||||
* @response 200 - { assets: object[], total: number, ... } from CARD
|
||||
* @response 400 - { error: string } — missing disposition
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -133,7 +159,15 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /owner/:assetId
|
||||
/**
|
||||
* GET /owner/:assetId
|
||||
*
|
||||
* Returns the CARD owner record for a given asset ID.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier (path parameter)
|
||||
* @response 200 - CARD owner/asset object
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -156,7 +190,23 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /queue/:queueItemId/confirm
|
||||
/**
|
||||
* POST /queue/:queueItemId/confirm
|
||||
*
|
||||
* Confirms ownership of a CARD asset for a queue item. Fetches the owner
|
||||
* record to obtain the update_token, then calls the CARD confirm endpoint.
|
||||
* Marks the queue item as complete on success.
|
||||
*
|
||||
* @param {string} queueItemId - Queue item ID (path parameter)
|
||||
* @body {string} teamName - Team name to confirm ownership for (required)
|
||||
* @body {string} assetId - CARD asset identifier (required)
|
||||
* @body {string} [comment] - Optional comment for the confirmation
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields or item not pending
|
||||
* @response 404 - { error: string } — queue item not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -232,7 +282,23 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /queue/:queueItemId/decline
|
||||
/**
|
||||
* POST /queue/:queueItemId/decline
|
||||
*
|
||||
* Declines ownership of a CARD asset for a queue item. Fetches the owner
|
||||
* record to obtain the update_token, then calls the CARD decline endpoint.
|
||||
* Marks the queue item as complete on success.
|
||||
*
|
||||
* @param {string} queueItemId - Queue item ID (path parameter)
|
||||
* @body {string} teamName - Team name declining ownership (required)
|
||||
* @body {string} assetId - CARD asset identifier (required)
|
||||
* @body {string} [comment] - Optional comment for the decline
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields or item not pending
|
||||
* @response 404 - { error: string } — queue item not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -308,7 +374,23 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /queue/:queueItemId/redirect
|
||||
/**
|
||||
* POST /queue/:queueItemId/redirect
|
||||
*
|
||||
* Redirects a CARD asset from one team to another for a queue item. Fetches
|
||||
* the owner record to obtain the update_token, then calls the CARD redirect
|
||||
* endpoint. Marks the queue item as complete on success.
|
||||
*
|
||||
* @param {string} queueItemId - Queue item ID (path parameter)
|
||||
* @body {string} fromTeam - Current owning team (required)
|
||||
* @body {string} toTeam - Target team to redirect to (required)
|
||||
* @body {string} assetId - CARD asset identifier (required)
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields or item not pending
|
||||
* @response 404 - { error: string } — queue item not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -387,7 +469,163 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /enrich-batch
|
||||
*
|
||||
* Batch lookup IPs in CARD to extract Granite loader fields. Tries each IP
|
||||
* with known asset ID suffixes (CTEC, NATL, CHTR, etc.) and falls back to
|
||||
* bare IP lookup. Returns enrichment results for each IP.
|
||||
*
|
||||
* @body {string[]} ips - Non-empty array of IP address strings (max 200)
|
||||
* @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number }
|
||||
* Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: null, equip_status?: string|null, error?: string }
|
||||
* @response 400 - { error: string } — invalid or empty ips array, or exceeds 200
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { ips } = req.body || {};
|
||||
if (!Array.isArray(ips) || ips.length === 0) {
|
||||
return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' });
|
||||
}
|
||||
if (ips.length > 200) {
|
||||
return res.status(400).json({ error: 'Maximum 200 IPs per request.' });
|
||||
}
|
||||
|
||||
// Known CARD asset ID suffixes to try
|
||||
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
|
||||
const results = [];
|
||||
let enrichedCount = 0;
|
||||
let notFoundCount = 0;
|
||||
|
||||
for (const ip of ips) {
|
||||
if (!ip || typeof ip !== 'string') {
|
||||
results.push({ ip: ip || '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' });
|
||||
notFoundCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmedIp = ip.trim();
|
||||
let found = false;
|
||||
let enriched = null;
|
||||
|
||||
// Try owner lookup with each suffix
|
||||
for (const suffix of SUFFIXES) {
|
||||
try {
|
||||
const assetId = `${trimmedIp}-${suffix}`;
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (ownerResult.ok) {
|
||||
const asset = JSON.parse(ownerResult.body);
|
||||
enriched = extractGraniteFields(asset, trimmedIp);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Continue to next suffix
|
||||
}
|
||||
}
|
||||
|
||||
// Try without suffix (bare IP)
|
||||
if (!found) {
|
||||
try {
|
||||
const ownerResult = await getOwner(trimmedIp);
|
||||
if (ownerResult.ok) {
|
||||
const asset = JSON.parse(ownerResult.body);
|
||||
enriched = extractGraniteFields(asset, trimmedIp);
|
||||
found = true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
if (found && enriched) {
|
||||
results.push({ ip: trimmedIp, found: true, ...enriched });
|
||||
enrichedCount++;
|
||||
} else {
|
||||
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
|
||||
notFoundCount++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Granite-relevant fields from a CARD asset record.
|
||||
*/
|
||||
function extractGraniteFields(asset, ip) {
|
||||
const ncim = asset.ncim_discovery || [];
|
||||
const granite = asset.netops_granite_allips || [];
|
||||
const iseGranite = asset.ise_granite_equipment || [];
|
||||
const flags = (asset.card_flags && asset.card_flags[0]) || {};
|
||||
const qualys = asset.qualys_hosts || [];
|
||||
const ivanti = asset.ivanti_assets || [];
|
||||
|
||||
// EQUIP_INST_ID — primary from ncim_discovery, fallbacks
|
||||
let equip_inst_id = null;
|
||||
let site_name = null;
|
||||
let responsible_team = null;
|
||||
let hostname = null;
|
||||
|
||||
if (ncim.length > 0) {
|
||||
equip_inst_id = ncim[0].EQUIP_INST_ID || null;
|
||||
responsible_team = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
|
||||
site_name = ncim[0].SITE_NAME || ncim[0].SITENAME || null;
|
||||
hostname = ncim[0].HOSTNAME || null;
|
||||
}
|
||||
|
||||
if (!equip_inst_id && Array.isArray(granite) && granite.length > 0) {
|
||||
equip_inst_id = granite[0].EQUIP_INST_ID || null;
|
||||
if (!site_name) site_name = granite[0].SITE_NAME || null;
|
||||
if (!responsible_team) responsible_team = granite[0].RESPONSIBLE_TEAM || null;
|
||||
}
|
||||
|
||||
if (!equip_inst_id && Array.isArray(iseGranite) && iseGranite.length > 0) {
|
||||
equip_inst_id = iseGranite[0].EQUIP_INST_ID || null;
|
||||
}
|
||||
|
||||
// Hostname fallbacks
|
||||
if (!hostname) {
|
||||
hostname = (flags.CARD_HOSTNAME && flags.CARD_HOSTNAME[0])
|
||||
|| (qualys.length > 0 && qualys[0].HOSTNAME)
|
||||
|| (ivanti.length > 0 && ivanti[0].hostName)
|
||||
|| null;
|
||||
}
|
||||
|
||||
// ASN
|
||||
const mgmt_ip_asn = (flags.CARD_ASN) || null;
|
||||
|
||||
// Equipment class — always S (Shelf) from CARD context
|
||||
const equipment_class = 'S';
|
||||
|
||||
// Equip status from flags
|
||||
const equip_status = (flags.status) || null;
|
||||
|
||||
// Equip template — not typically in CARD data, leave null
|
||||
const equip_template = null;
|
||||
|
||||
// Confirmed team from owner record
|
||||
const confirmed_team = asset.owner && asset.owner.confirmed
|
||||
? asset.owner.confirmed.name : null;
|
||||
|
||||
return {
|
||||
equip_inst_id: equip_inst_id ? String(equip_inst_id) : null,
|
||||
hostname,
|
||||
site_name,
|
||||
mgmt_ip_asn: mgmt_ip_asn ? String(mgmt_ip_asn) : null,
|
||||
responsible_team: responsible_team || confirmed_team || null,
|
||||
equipment_class,
|
||||
equip_template,
|
||||
equip_status,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createCardApiRouter;
|
||||
|
||||
68
backend/scripts/card-connectivity-test.js
Normal file
68
backend/scripts/card-connectivity-test.js
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CARD API Connectivity Test
|
||||
* Tests: token acquisition → teams list → sample asset lookup
|
||||
*/
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const { isConfigured, missingVars, testConnection, getTeams } = require('../helpers/cardApi');
|
||||
|
||||
async function main() {
|
||||
console.log('=== CARD API Connectivity Test ===');
|
||||
console.log(`Timestamp: ${new Date().toISOString()}`);
|
||||
console.log(`Target: ${process.env.CARD_API_URL}`);
|
||||
console.log(`User: ${process.env.CARD_API_USER}`);
|
||||
console.log(`TLS Skip: ${process.env.CARD_SKIP_TLS}`);
|
||||
console.log('');
|
||||
|
||||
if (!isConfigured) {
|
||||
console.error('FAIL: CARD API not configured. Missing:', missingVars.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 1: Token acquisition
|
||||
console.log('1. Acquiring Bearer token...');
|
||||
const connResult = await testConnection();
|
||||
if (!connResult.ok) {
|
||||
console.error(' FAIL:', connResult.error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' OK — token acquired:', connResult.token);
|
||||
|
||||
// Step 2: List teams
|
||||
console.log('2. Fetching teams (GET /api/v1/teams)...');
|
||||
const teamsResult = await getTeams();
|
||||
console.log(' Status:', teamsResult.status);
|
||||
if (!teamsResult.ok) {
|
||||
console.error(' FAIL:', teamsResult.body.substring(0, 300));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let teams;
|
||||
try {
|
||||
teams = JSON.parse(teamsResult.body);
|
||||
} catch (e) {
|
||||
console.error(' FAIL: Could not parse response:', teamsResult.body.substring(0, 200));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Array.isArray(teams)) {
|
||||
console.log(` OK — ${teams.length} teams found`);
|
||||
const sample = teams.slice(0, 8);
|
||||
sample.forEach(t => {
|
||||
const name = t.name || t.team_name || t.teamName || JSON.stringify(t).substring(0, 60);
|
||||
console.log(` • ${name}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' Response structure:', Object.keys(teams).join(', '));
|
||||
console.log(' Preview:', JSON.stringify(teams).substring(0, 200));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('=== RESULT: PASS — CARD API is reachable and authenticated ===');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('ERROR:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user