Add CARD Action Modal with full owner context

Replace inline CARD action form with a centered modal that:
- Fetches and displays the full CARD owner record (confirmed,
  unconfirmed, candidates, declined teams with scores/sources)
- Shows queue item info (hostname, IP, finding, CVEs)
- Lets user switch between Confirm/Decline/Redirect actions
- Pre-fills team dropdowns from the actual owner data
- Shows CARD API errors inline with full detail

Add GET /api/card/owner-lookup/:ip endpoint that resolves a bare
IP to a CARD asset ID and returns the structured owner record.
This commit is contained in:
Jordan Ramos
2026-05-28 14:58:27 -06:00
parent a6e455311e
commit 8224183679
3 changed files with 427 additions and 11 deletions

View File

@@ -500,16 +500,69 @@ function createCardApiRouter() {
}
});
/**
* GET /owner-lookup/:ip
*
* Resolve an IP to a CARD asset ID and return the full owner record.
* Used by the CARD Action Modal to display ownership state before
* confirm/decline/redirect operations.
*
* @param {string} ip - IP address (path parameter)
* @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token }
* @response 404 - { error: string } — IP not found in CARD
* @response 503 - { error: string } — CARD not configured
*/
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const ip = req.params.ip;
if (!ip || !ip.trim()) {
return res.status(400).json({ error: 'IP address is required.' });
}
// Resolve to full asset ID
const assetId = await resolveAssetId(ip.trim());
if (!assetId) {
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
}
// Fetch full owner record
try {
const ownerResult = await getOwner(assetId);
if (!ownerResult.ok) {
return res.status(ownerResult.status).json({ error: `Failed to fetch owner: HTTP ${ownerResult.status}` });
}
const data = JSON.parse(ownerResult.body);
const owner = data.owner || {};
res.json({
asset_id: assetId,
ip: ip.trim(),
confirmed: owner.confirmed || null,
unconfirmed: owner.unconfirmed || null,
declined: owner.declined || [],
candidate: owner.candidate || [],
update_token: owner.update_token || null,
});
} catch (err) {
return handleCardError(err, res);
}
});
/**
* 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.
* Batch lookup IPs in CARD to extract Granite loader fields. Fetches team
* assets (paginated, across confirmed and unconfirmed dispositions) and
* matches against the provided IPs. Returns enrichment results for each IP.
*
* @body {string[]} ips - Non-empty array of IP address strings (max 200)
* @body {string} [team="NTS-AEO-STEAM"] - Team name to search assets under
* @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 }
* 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?: string|null, equip_status?: string|null, serial_number?: 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
*/