feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services
This commit is contained in:
@@ -1518,7 +1518,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueuePanel — fixed slide-out panel showing the user's Ivanti queue
|
||||
// ---------------------------------------------------------------------------
|
||||
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission }) {
|
||||
function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, onClearCompleted, onCreateFpWorkflow, onRedirectComplete, canWrite, fpSubmissions, onEditSubmission, cardConfigured, cardTeams, onQueueRefresh }) {
|
||||
const pendingCount = items.filter((i) => i.status === 'pending').length;
|
||||
const completedCount = items.filter((i) => i.status === 'complete').length;
|
||||
|
||||
@@ -1526,6 +1526,24 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
const [redirectItem, setRedirectItem] = useState(null);
|
||||
const [redirectSuccess, setRedirectSuccess] = useState(null);
|
||||
|
||||
// CARD action state — tracks which item has an active action form
|
||||
const [cardAction, setCardAction] = useState(null); // { itemId, type: 'confirm'|'decline'|'redirect' }
|
||||
const [cardFormTeam, setCardFormTeam] = useState('');
|
||||
const [cardFormComment, setCardFormComment] = useState('');
|
||||
const [cardFormFromTeam, setCardFormFromTeam] = useState('');
|
||||
const [cardFormToTeam, setCardFormToTeam] = useState('');
|
||||
const [cardActionLoading, setCardActionLoading] = useState(false);
|
||||
const [cardActionError, setCardActionError] = useState(null);
|
||||
|
||||
// CARD Asset Search state
|
||||
const [assetSearchOpen, setAssetSearchOpen] = useState(false);
|
||||
const [assetSearchTeam, setAssetSearchTeam] = useState('');
|
||||
const [assetSearchDisposition, setAssetSearchDisposition] = useState('confirmed');
|
||||
const [assetSearchResults, setAssetSearchResults] = useState(null);
|
||||
const [assetSearchLoading, setAssetSearchLoading] = useState(false);
|
||||
const [assetSearchError, setAssetSearchError] = useState(null);
|
||||
const [assetSearchPage, setAssetSearchPage] = useState(1);
|
||||
|
||||
// Drop any selected IDs that no longer exist in items
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -1556,6 +1574,110 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
setTimeout(() => setRedirectSuccess(null), 3000);
|
||||
};
|
||||
|
||||
// CARD action handlers
|
||||
const openCardAction = (itemId, type) => {
|
||||
setCardAction({ itemId, type });
|
||||
setCardFormTeam('');
|
||||
setCardFormComment('');
|
||||
setCardFormFromTeam('');
|
||||
setCardFormToTeam('');
|
||||
setCardActionError(null);
|
||||
};
|
||||
|
||||
const closeCardAction = () => {
|
||||
setCardAction(null);
|
||||
setCardFormTeam('');
|
||||
setCardFormComment('');
|
||||
setCardFormFromTeam('');
|
||||
setCardFormToTeam('');
|
||||
setCardActionError(null);
|
||||
setCardActionLoading(false);
|
||||
};
|
||||
|
||||
const handleCardConfirmDecline = async (item, actionType) => {
|
||||
if (!cardFormTeam) return;
|
||||
setCardActionLoading(true);
|
||||
setCardActionError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/card/queue/${item.id}/${actionType}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
teamName: cardFormTeam,
|
||||
assetId: item.ip_address,
|
||||
comment: cardFormComment || '',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setCardActionError(data.error || `${actionType} failed.`);
|
||||
setCardActionLoading(false);
|
||||
return;
|
||||
}
|
||||
// Update local state to complete without full refresh
|
||||
onUpdate(item.id, { status: 'complete' });
|
||||
closeCardAction();
|
||||
} catch (err) {
|
||||
setCardActionError(err.message || 'Network error.');
|
||||
setCardActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardRedirect = async (item) => {
|
||||
if (!cardFormFromTeam || !cardFormToTeam) return;
|
||||
setCardActionLoading(true);
|
||||
setCardActionError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/card/queue/${item.id}/redirect`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fromTeam: cardFormFromTeam,
|
||||
toTeam: cardFormToTeam,
|
||||
assetId: item.ip_address,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setCardActionError(data.error || 'Redirect failed.');
|
||||
setCardActionLoading(false);
|
||||
return;
|
||||
}
|
||||
onUpdate(item.id, { status: 'complete' });
|
||||
closeCardAction();
|
||||
} catch (err) {
|
||||
setCardActionError(err.message || 'Network error.');
|
||||
setCardActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// CARD Asset Search handler
|
||||
const handleAssetSearch = async (page = 1) => {
|
||||
if (!assetSearchTeam || !assetSearchDisposition) return;
|
||||
setAssetSearchLoading(true);
|
||||
setAssetSearchError(null);
|
||||
setAssetSearchPage(page);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/card/teams/${encodeURIComponent(assetSearchTeam)}/assets?disposition=${encodeURIComponent(assetSearchDisposition)}&page_size=50&page=${page}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setAssetSearchError(data.error || 'Search failed.');
|
||||
setAssetSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
setAssetSearchResults(data);
|
||||
} catch (err) {
|
||||
setAssetSearchError(err.message || 'Network error.');
|
||||
} finally {
|
||||
setAssetSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render a single queue item row
|
||||
const renderQueueItem = (item, { done, selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }) => {
|
||||
const wfColor = item.workflow_type === 'FP' ? { col: '#F59E0B', rgb: '245,158,11' }
|
||||
@@ -1681,6 +1803,66 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
{item.workflow_type}
|
||||
</span>
|
||||
|
||||
{/* CARD action buttons — pending CARD items only */}
|
||||
{item.workflow_type === 'CARD' && item.status === 'pending' && canWrite && (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openCardAction(item.id, 'confirm')}
|
||||
disabled={!cardConfigured || cardActionLoading}
|
||||
title={!cardConfigured ? 'CARD integration not configured' : 'Confirm asset'}
|
||||
style={{
|
||||
background: cardConfigured ? 'rgba(16,185,129,0.1)' : 'transparent',
|
||||
border: `1px solid ${cardConfigured ? 'rgba(16,185,129,0.3)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.2rem',
|
||||
padding: '0.15rem 0.3rem',
|
||||
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
|
||||
color: cardConfigured ? '#10B981' : '#334155',
|
||||
cursor: cardConfigured ? 'pointer' : 'not-allowed',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCardAction(item.id, 'decline')}
|
||||
disabled={!cardConfigured || cardActionLoading}
|
||||
title={!cardConfigured ? 'CARD integration not configured' : 'Decline asset'}
|
||||
style={{
|
||||
background: cardConfigured ? 'rgba(239,68,68,0.1)' : 'transparent',
|
||||
border: `1px solid ${cardConfigured ? 'rgba(239,68,68,0.3)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.2rem',
|
||||
padding: '0.15rem 0.3rem',
|
||||
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
|
||||
color: cardConfigured ? '#EF4444' : '#334155',
|
||||
cursor: cardConfigured ? 'pointer' : 'not-allowed',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCardAction(item.id, 'redirect')}
|
||||
disabled={!cardConfigured || cardActionLoading}
|
||||
title={!cardConfigured ? 'CARD integration not configured' : 'Redirect asset'}
|
||||
style={{
|
||||
background: cardConfigured ? 'rgba(14,165,233,0.1)' : 'transparent',
|
||||
border: `1px solid ${cardConfigured ? 'rgba(14,165,233,0.3)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.2rem',
|
||||
padding: '0.15rem 0.3rem',
|
||||
fontFamily: 'monospace', fontSize: '0.55rem', fontWeight: '700',
|
||||
color: cardConfigured ? '#0EA5E9' : '#334155',
|
||||
cursor: cardConfigured ? 'pointer' : 'not-allowed',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>
|
||||
Redirect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redirect button — completed items only */}
|
||||
{canWrite && done && (
|
||||
<button
|
||||
@@ -1708,6 +1890,228 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
);
|
||||
};
|
||||
|
||||
// Render CARD action inline form below a queue item
|
||||
const renderCardActionForm = (item) => {
|
||||
if (!cardAction || cardAction.itemId !== item.id) return null;
|
||||
const { type } = cardAction;
|
||||
|
||||
if (type === 'confirm' || type === 'decline') {
|
||||
const accentColor = type === 'confirm' ? '#10B981' : '#EF4444';
|
||||
const accentRgb = type === 'confirm' ? '16,185,129' : '239,68,68';
|
||||
const canSubmit = !cardActionLoading && cardFormTeam.length > 0;
|
||||
return (
|
||||
<div style={{
|
||||
padding: '0.5rem 0.625rem',
|
||||
marginBottom: '0.25rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: `rgba(${accentRgb},0.04)`,
|
||||
border: `1px solid rgba(${accentRgb},0.15)`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: accentColor, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{type === 'confirm' ? 'Confirm Asset' : 'Decline Asset'}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={cardFormTeam}
|
||||
onChange={(e) => setCardFormTeam(e.target.value)}
|
||||
disabled={cardActionLoading}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: `1px solid rgba(${accentRgb}, 0.25)`,
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||||
color: '#E2E8F0',
|
||||
outline: 'none',
|
||||
marginBottom: '0.375rem',
|
||||
}}
|
||||
>
|
||||
<option value="">Select team…</option>
|
||||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={cardFormComment}
|
||||
onChange={(e) => setCardFormComment(e.target.value)}
|
||||
disabled={cardActionLoading}
|
||||
placeholder="Comment (optional)"
|
||||
maxLength={500}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: `1px solid rgba(${accentRgb}, 0.15)`,
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||||
color: '#E2E8F0',
|
||||
outline: 'none',
|
||||
marginBottom: '0.375rem',
|
||||
}}
|
||||
/>
|
||||
{cardActionError && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.3rem 0.5rem',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
borderRadius: '0.25rem',
|
||||
marginBottom: '0.375rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.375rem' }}>
|
||||
<button
|
||||
onClick={() => handleCardConfirmDecline(item, type)}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
flex: 1, padding: '0.3rem',
|
||||
background: canSubmit ? `rgba(${accentRgb},0.12)` : 'transparent',
|
||||
border: `1px solid ${canSubmit ? `rgba(${accentRgb},0.35)` : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.25rem',
|
||||
color: canSubmit ? accentColor : '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
|
||||
{cardActionLoading ? 'Submitting…' : type === 'confirm' ? 'Confirm' : 'Decline'}
|
||||
</button>
|
||||
<button
|
||||
onClick={closeCardAction}
|
||||
disabled={cardActionLoading}
|
||||
style={{
|
||||
padding: '0.3rem 0.5rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#64748B',
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'redirect') {
|
||||
const canSubmit = !cardActionLoading && cardFormFromTeam.length > 0 && cardFormToTeam.length > 0;
|
||||
return (
|
||||
<div style={{
|
||||
padding: '0.5rem 0.625rem',
|
||||
marginBottom: '0.25rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(14,165,233,0.04)',
|
||||
border: '1px solid rgba(14,165,233,0.15)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '0.375rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Redirect Asset
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={cardFormFromTeam}
|
||||
onChange={(e) => setCardFormFromTeam(e.target.value)}
|
||||
disabled={cardActionLoading}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||||
color: '#E2E8F0',
|
||||
outline: 'none',
|
||||
marginBottom: '0.375rem',
|
||||
}}
|
||||
>
|
||||
<option value="">From team…</option>
|
||||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={cardFormToTeam}
|
||||
onChange={(e) => setCardFormToTeam(e.target.value)}
|
||||
disabled={cardActionLoading}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||||
color: '#E2E8F0',
|
||||
outline: 'none',
|
||||
marginBottom: '0.375rem',
|
||||
}}
|
||||
>
|
||||
<option value="">To team…</option>
|
||||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
{cardActionError && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.3rem 0.5rem',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
borderRadius: '0.25rem',
|
||||
marginBottom: '0.375rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{cardActionError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.375rem' }}>
|
||||
<button
|
||||
onClick={() => handleCardRedirect(item)}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
flex: 1, padding: '0.3rem',
|
||||
background: canSubmit ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||||
border: `1px solid ${canSubmit ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.25rem',
|
||||
color: canSubmit ? '#0EA5E9' : '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{cardActionLoading && <Loader style={{ width: '10px', height: '10px', animation: 'spin 1s linear infinite' }} />}
|
||||
{cardActionLoading ? 'Redirecting…' : 'Redirect'}
|
||||
</button>
|
||||
<button
|
||||
onClick={closeCardAction}
|
||||
disabled={cardActionLoading}
|
||||
style={{
|
||||
padding: '0.3rem 0.5rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#64748B',
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Inventory items (CARD + GRANITE) are their own top section; everything else groups by vendor
|
||||
const grouped = useMemo(() => {
|
||||
const inventoryItems = items.filter((i) => i.workflow_type === 'CARD' || i.workflow_type === 'GRANITE');
|
||||
@@ -1819,7 +2223,12 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
{/* Items — Inventory section renders CARD then GRANITE with optional sub-divider */}
|
||||
{isInventory ? (
|
||||
<>
|
||||
{cardItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))}
|
||||
{cardItems.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite })}
|
||||
{renderCardActionForm(item)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{cardItems.length > 0 && graniteItems.length > 0 && (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
@@ -1836,6 +2245,211 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CARD Asset Search section */}
|
||||
{cardConfigured && (
|
||||
<div style={{ padding: '0 1.25rem 0.75rem' }}>
|
||||
<div
|
||||
onClick={() => setAssetSearchOpen(!assetSearchOpen)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||||
borderBottom: '1px solid rgba(14,165,233,0.2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Database style={{ width: '12px', height: '12px', color: '#0EA5E9' }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
CARD Asset Search
|
||||
</span>
|
||||
</div>
|
||||
{assetSearchOpen
|
||||
? <ChevronUp style={{ width: '14px', height: '14px', color: '#475569' }} />
|
||||
: <ChevronDown style={{ width: '14px', height: '14px', color: '#475569' }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{assetSearchOpen && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
<select
|
||||
value={assetSearchTeam}
|
||||
onChange={(e) => { setAssetSearchTeam(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||||
color: '#E2E8F0',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">Select team…</option>
|
||||
{cardTeams.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={assetSearchDisposition}
|
||||
onChange={(e) => { setAssetSearchDisposition(e.target.value); setAssetSearchResults(null); setAssetSearchError(null); }}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.35rem 0.5rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.7rem',
|
||||
color: '#E2E8F0',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="unconfirmed">Unconfirmed</option>
|
||||
<option value="declined">Declined</option>
|
||||
<option value="candidate">Candidate</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleAssetSearch(1)}
|
||||
disabled={!assetSearchTeam || assetSearchLoading}
|
||||
style={{
|
||||
padding: '0.35rem',
|
||||
background: assetSearchTeam ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||||
border: `1px solid ${assetSearchTeam ? 'rgba(14,165,233,0.35)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.25rem',
|
||||
color: assetSearchTeam ? '#0EA5E9' : '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
cursor: assetSearchTeam ? 'pointer' : 'not-allowed',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem',
|
||||
}}
|
||||
>
|
||||
{assetSearchLoading
|
||||
? <Loader style={{ width: '12px', height: '12px', animation: 'spin 1s linear infinite' }} />
|
||||
: <Search style={{ width: '12px', height: '12px' }} />
|
||||
}
|
||||
{assetSearchLoading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
|
||||
{/* Error */}
|
||||
{assetSearchError && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.3rem 0.5rem',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
borderRadius: '0.25rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '12px', height: '12px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#FCA5A5' }}>{assetSearchError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{assetSearchResults && (
|
||||
<div>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.62rem', fontWeight: '600',
|
||||
color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
marginBottom: '0.25rem',
|
||||
}}>
|
||||
{assetSearchResults.total != null ? `${assetSearchResults.total} asset${assetSearchResults.total !== 1 ? 's' : ''} found` : 'Results'}
|
||||
</div>
|
||||
|
||||
{/* Results table */}
|
||||
{Array.isArray(assetSearchResults.assets) && assetSearchResults.assets.length > 0 ? (
|
||||
<div style={{
|
||||
maxHeight: '200px', overflowY: 'auto',
|
||||
border: '1px solid rgba(14,165,233,0.12)',
|
||||
borderRadius: '0.25rem',
|
||||
}}>
|
||||
<table style={{
|
||||
width: '100%', borderCollapse: 'collapse',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.62rem',
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(14,165,233,0.06)' }}>
|
||||
<th style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
|
||||
Asset ID
|
||||
</th>
|
||||
{assetSearchResults.assets[0] && Object.keys(assetSearchResults.assets[0]).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3).map(k => (
|
||||
<th key={k} style={{ padding: '0.3rem 0.5rem', textAlign: 'left', color: '#94A3B8', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.06em', borderBottom: '1px solid rgba(14,165,233,0.12)' }}>
|
||||
{k.replace(/_/g, ' ')}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assetSearchResults.assets.map((asset, idx) => {
|
||||
const extraKeys = Object.keys(asset).filter(k => k !== 'asset_id' && k !== '_id').slice(0, 3);
|
||||
return (
|
||||
<tr key={asset.asset_id || asset._id || idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||||
<td style={{ padding: '0.25rem 0.5rem', color: '#CBD5E1', fontWeight: '600' }}>
|
||||
{asset.asset_id || asset._id || '—'}
|
||||
</td>
|
||||
{extraKeys.map(k => (
|
||||
<td key={k} style={{ padding: '0.25rem 0.5rem', color: '#94A3B8' }}>
|
||||
{typeof asset[k] === 'object' ? JSON.stringify(asset[k]) : String(asset[k] ?? '—')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', padding: '0.5rem 0' }}>
|
||||
No assets found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{assetSearchResults.total != null && assetSearchResults.total > (assetSearchResults.page_size || 50) && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
marginTop: '0.375rem',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleAssetSearch(assetSearchPage - 1)}
|
||||
disabled={assetSearchPage <= 1 || assetSearchLoading}
|
||||
style={{
|
||||
padding: '0.2rem 0.4rem',
|
||||
background: assetSearchPage > 1 ? 'rgba(14,165,233,0.08)' : 'transparent',
|
||||
border: `1px solid ${assetSearchPage > 1 ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.2rem',
|
||||
color: assetSearchPage > 1 ? '#0EA5E9' : '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||||
cursor: assetSearchPage > 1 ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#64748B' }}>
|
||||
Page {assetSearchPage} of {Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50))}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleAssetSearch(assetSearchPage + 1)}
|
||||
disabled={assetSearchPage >= Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) || assetSearchLoading}
|
||||
style={{
|
||||
padding: '0.2rem 0.4rem',
|
||||
background: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.08)' : 'transparent',
|
||||
border: `1px solid ${assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'rgba(14,165,233,0.25)' : 'rgba(255,255,255,0.05)'}`,
|
||||
borderRadius: '0.2rem',
|
||||
color: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? '#0EA5E9' : '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||||
cursor: assetSearchPage < Math.ceil(assetSearchResults.total / (assetSearchResults.page_size || 50)) ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submissions section */}
|
||||
{fpSubmissions && fpSubmissions.length > 0 && (
|
||||
<div style={{ padding: '0 1.25rem 0.75rem' }}>
|
||||
@@ -4344,6 +4958,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const [atlasMetricsLoading, setAtlasMetricsLoading] = useState(false);
|
||||
const [atlasMetricsError, setAtlasMetricsError] = useState(null);
|
||||
|
||||
// CARD API state — session-level caching for teams list
|
||||
const [cardConfigured, setCardConfigured] = useState(false);
|
||||
const [cardTeams, setCardTeams] = useState([]);
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
@@ -4481,6 +5099,31 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// CARD API — fetch status and teams (session-level caching)
|
||||
const cardTeamsFetchedRef = useRef(false);
|
||||
const fetchCardStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCardConfigured(data.configured === true);
|
||||
if (data.configured && !cardTeamsFetchedRef.current) {
|
||||
cardTeamsFetchedRef.current = true;
|
||||
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
|
||||
if (teamsRes.ok) {
|
||||
const teamsData = await teamsRes.json();
|
||||
const teams = Array.isArray(teamsData)
|
||||
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
|
||||
: [];
|
||||
setCardTeams(teams);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[card-api] Failed to fetch CARD status:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -4523,6 +5166,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
fetchFpSubmissions();
|
||||
fetchAtlasStatus();
|
||||
fetchAtlasMetrics();
|
||||
fetchCardStatus();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
@@ -5658,6 +6302,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
canWrite={canWrite}
|
||||
fpSubmissions={fpSubmissions}
|
||||
onEditSubmission={handleEditSubmission}
|
||||
cardConfigured={cardConfigured}
|
||||
cardTeams={cardTeams}
|
||||
onQueueRefresh={fetchQueue}
|
||||
/>
|
||||
<FpWorkflowModal
|
||||
open={fpModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user