Add CARD ownership tooltip and direct action modal on IP hover
Hover over any IP address in the findings table to see CARD ownership data (confirmed/unconfirmed/candidate teams) in an interactive tooltip. Click 'Actions' to open a full modal for confirm/decline/redirect — no queue item required. Backend: - Add direct /api/card/owner/:assetId/confirm|decline|redirect endpoints - Add quick mode to resolveAssetId (CTEC only, 15s timeout) for tooltip use - owner-lookup supports ?quick=1 query param with 504 on timeout - getOwner accepts options for custom timeout Frontend: - New CardOwnerTooltip component (portal, hover bridge, cached results) - New CardDetailModal for confirm/decline/redirect from tooltip - IP cells show help cursor, trigger tooltip on 400ms hover - Timeouts (504) not cached — retry on re-hover - Teams fetch retries silently up to 3x on failure - Redirect dropdowns show owner-data teams as fallback when teams API fails
This commit is contained in:
@@ -252,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||
/**
|
||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||
*/
|
||||
async function getOwner(assetId) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||
async function getOwner(assetId, options) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
@@ -298,35 +298,54 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||
/**
|
||||
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
|
||||
* Returns the first asset ID that returns a valid owner record, or null if none found.
|
||||
*
|
||||
* @param {string} ip - IP address or existing asset ID
|
||||
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
|
||||
*/
|
||||
async function resolveAssetId(ip) {
|
||||
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
async function resolveAssetId(ip, options) {
|
||||
const quick = options && options.quick;
|
||||
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode
|
||||
const trimmedIp = (ip || '').trim();
|
||||
if (!trimmedIp) return null;
|
||||
|
||||
// If it already has a suffix (contains a dash followed by letters), use as-is
|
||||
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
try {
|
||||
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each suffix
|
||||
for (const suffix of SUFFIXES) {
|
||||
const candidate = `${trimmedIp}-${suffix}`;
|
||||
try {
|
||||
const result = await getOwner(candidate);
|
||||
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return candidate;
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
// Continue to next suffix
|
||||
}
|
||||
}
|
||||
|
||||
// Try bare IP as last resort
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
|
||||
if (!quick) {
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -508,8 +508,11 @@ function createCardApiRouter() {
|
||||
* confirm/decline/redirect operations.
|
||||
*
|
||||
* @param {string} ip - IP address (path parameter)
|
||||
* @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups.
|
||||
* @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token }
|
||||
* @response 400 - { error: string } — missing IP
|
||||
* @response 404 - { error: string } — IP not found in CARD
|
||||
* @response 504 - { error: string, timeout: true } — CARD lookup timed out
|
||||
* @response 503 - { error: string } — CARD not configured
|
||||
*/
|
||||
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
@@ -522,8 +525,19 @@ function createCardApiRouter() {
|
||||
return res.status(400).json({ error: 'IP address is required.' });
|
||||
}
|
||||
|
||||
// Use quick mode (CTEC only, 15s timeout) for tooltip lookups
|
||||
const quick = req.query.quick === '1';
|
||||
|
||||
// Resolve to full asset ID
|
||||
const assetId = await resolveAssetId(ip.trim());
|
||||
let assetId;
|
||||
try {
|
||||
assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined);
|
||||
} catch (err) {
|
||||
if (err.message === 'CARD_TIMEOUT') {
|
||||
return res.status(504).json({ error: 'CARD lookup timed out', timeout: true });
|
||||
}
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
if (!assetId) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
|
||||
}
|
||||
@@ -552,6 +566,208 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/confirm
|
||||
*
|
||||
* Directly confirm ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD confirm.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to confirm ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/decline
|
||||
*
|
||||
* Directly decline ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD decline.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to decline ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/redirect
|
||||
*
|
||||
* Directly redirect a CARD asset between teams (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD redirect.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} fromTeam - Current owning team (required)
|
||||
* @body {string} toTeam - Target team (required)
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { fromTeam, toTeam } = req.body || {};
|
||||
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
}
|
||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||
return res.status(400).json({ error: 'toTeam is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /enrich-batch
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user