When a CARD action fails with 'update_token not found', display a clear message explaining the asset cannot be actioned via API, with a prominent 'Open in CARD (ID copied)' button that copies the host ID to clipboard and opens card.charter.com/ipn-search in a new tab. Applied to both CardDetailModal (reporting page) and CardActionModal (queue).
420 lines
26 KiB
JavaScript
420 lines
26 KiB
JavaScript
/**
|
|
* CardDetailModal — Full CARD ownership detail view
|
|
*
|
|
* Opens from the CARD tooltip "Actions" button on the reporting page.
|
|
* Shows the full ownership record and allows confirm/decline/redirect
|
|
* directly against the CARD API (no queue item required).
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft, ExternalLink } from 'lucide-react';
|
|
|
|
// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
const OVERLAY = {
|
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)',
|
|
zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
};
|
|
const MODAL = {
|
|
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
|
borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)',
|
|
width: '90vw', maxWidth: '580px', maxHeight: '85vh', overflow: 'auto',
|
|
padding: '1.5rem', position: 'relative',
|
|
};
|
|
const SECTION = {
|
|
background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)',
|
|
borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem',
|
|
};
|
|
const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' };
|
|
const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" };
|
|
const TEAM_BADGE = (color) => ({
|
|
display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem',
|
|
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
|
background: `${color}15`, border: `1px solid ${color}40`, color,
|
|
});
|
|
const INPUT = {
|
|
width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)',
|
|
border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem',
|
|
color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem',
|
|
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
|
};
|
|
const BTN = {
|
|
padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none',
|
|
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s',
|
|
};
|
|
|
|
export default function CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) {
|
|
const [ownerData, setOwnerData] = useState(initialOwnerData || null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [action, setAction] = useState('confirm');
|
|
const [teamName, setTeamName] = useState('');
|
|
const [fromTeam, setFromTeam] = useState('');
|
|
const [toTeam, setToTeam] = useState('');
|
|
const [comment, setComment] = useState('');
|
|
const [executing, setExecuting] = useState(false);
|
|
const [execError, setExecError] = useState(null);
|
|
const [execSuccess, setExecSuccess] = useState(null);
|
|
|
|
// Fetch owner data if not provided or refresh on open
|
|
useEffect(() => {
|
|
if (!isOpen || !ip) return;
|
|
|
|
// If we already have data from the tooltip cache, use it
|
|
if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) {
|
|
setOwnerData(initialOwnerData);
|
|
// Pre-fill team fields
|
|
if (initialOwnerData.confirmed) {
|
|
setTeamName(initialOwnerData.confirmed.name || '');
|
|
setFromTeam(initialOwnerData.confirmed.name || '');
|
|
} else if (initialOwnerData.unconfirmed) {
|
|
setTeamName(initialOwnerData.unconfirmed.name || '');
|
|
setFromTeam(initialOwnerData.unconfirmed.name || '');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fetch fresh
|
|
setLoading(true);
|
|
setError(null);
|
|
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { credentials: 'include' })
|
|
.then(r => {
|
|
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
|
|
return r.json();
|
|
})
|
|
.then(data => {
|
|
setOwnerData(data);
|
|
if (data.confirmed) {
|
|
setTeamName(data.confirmed.name || '');
|
|
setFromTeam(data.confirmed.name || '');
|
|
} else if (data.unconfirmed) {
|
|
setTeamName(data.unconfirmed.name || '');
|
|
setFromTeam(data.unconfirmed.name || '');
|
|
}
|
|
setLoading(false);
|
|
})
|
|
.catch(err => {
|
|
setError(err.message);
|
|
setLoading(false);
|
|
});
|
|
}, [isOpen, ip, initialOwnerData]);
|
|
|
|
// Reset state on close
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setExecError(null);
|
|
setExecSuccess(null);
|
|
setComment('');
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleExecute = useCallback(async () => {
|
|
if (!ownerData?.asset_id) return;
|
|
setExecuting(true);
|
|
setExecError(null);
|
|
setExecSuccess(null);
|
|
|
|
try {
|
|
let url, body;
|
|
const assetId = ownerData.asset_id;
|
|
|
|
if (action === 'confirm') {
|
|
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`;
|
|
body = { teamName: teamName.trim(), comment: comment.trim() };
|
|
} else if (action === 'decline') {
|
|
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`;
|
|
body = { teamName: teamName.trim(), comment: comment.trim() };
|
|
} else if (action === 'redirect') {
|
|
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`;
|
|
body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() };
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setExecError(data.error || data.message || `${action} failed.`);
|
|
} else {
|
|
setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`);
|
|
}
|
|
} catch (err) {
|
|
setExecError(err.message || 'Network error.');
|
|
} finally {
|
|
setExecuting(false);
|
|
}
|
|
}, [ownerData, action, teamName, fromTeam, toTeam, comment]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const canExecute = () => {
|
|
if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0;
|
|
if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0;
|
|
return false;
|
|
};
|
|
|
|
return (
|
|
<div style={OVERLAY} onClick={onClose}>
|
|
<div style={MODAL} onClick={e => e.stopPropagation()}>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
|
<div>
|
|
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Details</h3>
|
|
<div style={{ fontSize: '0.72rem', color: '#0EA5E9', fontFamily: "'JetBrains Mono', monospace", marginTop: '0.2rem' }}>
|
|
{ip}
|
|
</div>
|
|
{ownerData?.asset_id && (
|
|
<div style={{ fontSize: '0.65rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.1rem' }}>
|
|
{ownerData.asset_id}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
|
<X style={{ width: '18px', height: '18px' }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{loading && (
|
|
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
<Loader style={{ width: '20px', height: '20px', color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
|
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading CARD data...</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div style={{ ...SECTION, borderColor: 'rgba(239, 68, 68, 0.4)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444' }} />
|
|
<span style={{ fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Owner data */}
|
|
{ownerData && !loading && (
|
|
<>
|
|
{/* Ownership section */}
|
|
<div style={SECTION}>
|
|
<div style={LABEL}>Ownership</div>
|
|
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Confirmed:</span>
|
|
{ownerData.confirmed ? (
|
|
<>
|
|
<span style={TEAM_BADGE('#10B981')}>{ownerData.confirmed.name}</span>
|
|
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
|
(score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource || 'n/a'})
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Unconfirmed:</span>
|
|
{ownerData.unconfirmed ? (
|
|
<>
|
|
<span style={TEAM_BADGE('#F59E0B')}>{ownerData.unconfirmed.name}</span>
|
|
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
|
(score: {ownerData.unconfirmed.score})
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
|
)}
|
|
</div>
|
|
{ownerData.candidate && ownerData.candidate.length > 0 && (
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Candidates:</span>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
|
{ownerData.candidate.map((c, i) => (
|
|
<span key={i} style={TEAM_BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{ownerData.declined && ownerData.declined.length > 0 && (
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Declined:</span>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
|
{ownerData.declined.map((d, i) => (
|
|
<span key={i} style={TEAM_BADGE('#EF4444')}>{d.name}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action section */}
|
|
<div style={{ ...SECTION, borderColor: 'rgba(124, 58, 237, 0.3)' }}>
|
|
<div style={LABEL}>Action</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', marginBottom: '0.75rem' }}>
|
|
{['confirm', 'decline', 'redirect'].map(a => (
|
|
<button
|
|
key={a}
|
|
onClick={() => setAction(a)}
|
|
style={{
|
|
...BTN,
|
|
padding: '0.35rem 0.75rem',
|
|
background: action === a ? (a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)') : 'transparent',
|
|
border: `1px solid ${action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#334155'}`,
|
|
color: action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#64748B',
|
|
}}
|
|
>
|
|
{a === 'confirm' && <CheckCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
|
{a === 'decline' && <XCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
|
{a === 'redirect' && <ArrowRightLeft style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
|
{a.charAt(0).toUpperCase() + a.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action-specific fields */}
|
|
{(action === 'confirm' || action === 'decline') && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
<div>
|
|
<label style={{ ...LABEL, display: 'block' }}>Team</label>
|
|
<select style={INPUT} value={teamName} onChange={e => setTeamName(e.target.value)}>
|
|
<option value="">Select team...</option>
|
|
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
|
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
|
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
|
<option key={c.name} value={c.name}>{c.name} (candidate, score: {c.score})</option>
|
|
))}
|
|
<option disabled>───────────</option>
|
|
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ ...LABEL, display: 'block' }}>Comment (optional)</label>
|
|
<input style={INPUT} value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional comment..." />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{action === 'redirect' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
<div>
|
|
<label style={{ ...LABEL, display: 'block' }}>From Team</label>
|
|
<select style={INPUT} value={fromTeam} onChange={e => setFromTeam(e.target.value)}>
|
|
<option value="">Select from team...</option>
|
|
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
|
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
|
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
|
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
|
))}
|
|
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ ...LABEL, display: 'block' }}>To Team</label>
|
|
<select style={INPUT} value={toTeam} onChange={e => setToTeam(e.target.value)}>
|
|
<option value="">Select to team...</option>
|
|
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
|
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
|
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
|
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
|
))}
|
|
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Execution error */}
|
|
{execError && (
|
|
<div style={{ padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
|
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>
|
|
{execError.includes('update_token') ? 'Cannot action via API — this asset has no update token.' : execError}
|
|
</span>
|
|
</div>
|
|
{execError.includes('update_token') && (
|
|
<div style={{ marginTop: '0.4rem', paddingTop: '0.4rem', borderTop: '1px solid rgba(239, 68, 68, 0.2)' }}>
|
|
<span style={{ fontSize: '0.65rem', color: '#94A3B8', display: 'block', marginBottom: '0.3rem' }}>
|
|
Action this asset directly in CARD instead:
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
const hostId = ownerData?.asset_id ? ownerData.asset_id.replace(/-[A-Z]+$/i, '') : ip;
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = String(hostId);
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
} catch (_) { /* best effort */ }
|
|
window.open('https://card.charter.com/ipn-search', '_blank');
|
|
}}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
|
padding: '0.35rem 0.75rem',
|
|
background: 'rgba(14, 165, 233, 0.15)',
|
|
border: '1px solid rgba(14, 165, 233, 0.5)',
|
|
borderRadius: '0.3rem',
|
|
color: '#7DD3FC',
|
|
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.12s',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.3)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(14, 165, 233, 0.15)'; }}
|
|
>
|
|
<ExternalLink style={{ width: 12, height: 12 }} />
|
|
Open in CARD (ID copied)
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Success */}
|
|
{execSuccess && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
|
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0 }} />
|
|
<span style={{ fontSize: '0.7rem', color: '#6EE7B7' }}>{execSuccess}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
|
|
<button onClick={onClose} style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}>Close</button>
|
|
<button
|
|
onClick={handleExecute}
|
|
disabled={!canExecute() || executing || !!execSuccess}
|
|
style={{
|
|
...BTN,
|
|
background: canExecute() && !executing && !execSuccess ? '#7C3AED' : '#1E293B',
|
|
color: canExecute() && !executing && !execSuccess ? '#fff' : '#475569',
|
|
cursor: canExecute() && !executing && !execSuccess ? 'pointer' : 'not-allowed',
|
|
}}
|
|
>
|
|
{executing ? 'Executing...' : `Execute ${action.charAt(0).toUpperCase() + action.slice(1)}`}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|