Files
cve-dashboard/frontend/src/components/LoaderModal.js
Jordan Ramos a6e455311e Improve CARD action error messages and default loader columns
Show actual CARD API error messages (e.g., 'Cannot redirect asset
because Team is neither confirmed nor pending owner') instead of
generic 'Redirect failed.' or 'confirm failed.' messages.

Also auto-select IPV4_ADDRESS, EQUIP_NAME, and RESPONSIBLE_TEAM
columns by default in the Loader Modal for better initial UX.
2026-05-28 14:25:37 -06:00

579 lines
24 KiB
JavaScript

/**
* LoaderModal — Granite Team_Device Loader Sheet Generator
*
* Generates a properly formatted xlsx for upload to SNIP XperLoad.
* Supports queue-initiated mode (pre-populated devices) and standalone mode (paste IPs).
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FileSpreadsheet, Download, X, RefreshCw, Plus, Trash2, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react';
import {
LOADER_COLUMNS,
COLUMN_GROUPS,
OPERATION_TYPES,
getRequiredColumns,
getColumnsByGroup,
} from '../utils/graniteLoaderConfig';
import { generateLoaderXlsx, generateFilename } from '../utils/graniteLoaderExport';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const OVERLAY = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const MODAL = {
background: '#1E293B', borderRadius: '0.75rem', border: '1px solid #334155',
width: '90vw', maxWidth: '1100px', maxHeight: '90vh', display: 'flex', flexDirection: 'column',
overflow: 'hidden',
};
const HEADER = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem', borderBottom: '1px solid #334155',
};
const BODY = { flex: 1, overflow: 'auto', padding: '1.25rem' };
const FOOTER = {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem', borderTop: '1px solid #334155',
};
const INPUT = {
background: '#0F172A', border: '1px solid #334155', borderRadius: '0.375rem',
color: '#E2E8F0', padding: '0.4rem 0.6rem', fontSize: '0.75rem', width: '100%',
};
const BTN = {
padding: '0.5rem 1rem', borderRadius: '0.375rem', border: 'none',
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer',
};
const BTN_PRIMARY = { ...BTN, background: '#7C3AED', color: '#fff' };
const BTN_SECONDARY = { ...BTN, background: '#334155', color: '#E2E8F0' };
const BTN_SUCCESS = { ...BTN, background: '#10B981', color: '#fff' };
export default function LoaderModal({ isOpen, onClose, initialDevices }) {
// --- State ---
const [operationType, setOperationType] = useState('Change');
const [selectedColumns, setSelectedColumns] = useState(new Set());
const [devices, setDevices] = useState([]);
const [bulkDefaults, setBulkDefaults] = useState({});
const [overrides, setOverrides] = useState({});
const [editingCell, setEditingCell] = useState(null);
const [editValue, setEditValue] = useState('');
const [enriching, setEnriching] = useState(false);
const [enrichErrors, setEnrichErrors] = useState([]);
const [cardConfigured, setCardConfigured] = useState(false);
const [pasteInput, setPasteInput] = useState('');
const [expandedGroups, setExpandedGroups] = useState(new Set(['Identification', 'Responsible Org']));
const [validationWarnings, setValidationWarnings] = useState([]);
// --- Initialize ---
useEffect(() => {
if (!isOpen) return;
// Check CARD status
fetch(`${API_BASE}/card/status`, { credentials: 'include' })
.then(r => r.json())
.then(d => setCardConfigured(d.configured === true))
.catch(() => setCardConfigured(false));
}, [isOpen]);
// Populate devices from initialDevices or reset
useEffect(() => {
if (!isOpen) return;
if (initialDevices && initialDevices.length > 0) {
setDevices(initialDevices.map(d => ({
IPV4_ADDRESS: d.ip_address || '',
EQUIP_NAME: d.hostname || '',
})));
} else {
setDevices([]);
}
setOverrides({});
setBulkDefaults({});
setEnrichErrors([]);
setValidationWarnings([]);
}, [isOpen, initialDevices]);
// Auto-select required columns + useful defaults when operation type changes
useEffect(() => {
const required = getRequiredColumns(operationType);
setSelectedColumns(prev => {
const next = new Set(prev);
required.forEach(id => next.add(id));
// Always include these useful columns by default
next.add('IPV4_ADDRESS');
next.add('EQUIP_NAME');
next.add('RESPONSIBLE_TEAM');
return next;
});
}, [operationType]);
// --- Column selection ---
const toggleColumn = useCallback((colId) => {
const required = getRequiredColumns(operationType);
if (required.includes(colId)) return; // Can't deselect required
setSelectedColumns(prev => {
const next = new Set(prev);
if (next.has(colId)) next.delete(colId);
else next.add(colId);
return next;
});
}, [operationType]);
const toggleGroup = useCallback((group) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(group)) next.delete(group);
else next.add(group);
return next;
});
}, []);
// --- Ordered selected columns (canonical order) ---
const orderedColumns = useMemo(() => {
return LOADER_COLUMNS.filter(col => selectedColumns.has(col.id));
}, [selectedColumns]);
// --- Resolve cell value (override > bulk default > device value > empty) ---
const getCellValue = useCallback((rowIdx, colId) => {
if (overrides[rowIdx] && overrides[rowIdx][colId] !== undefined) {
return overrides[rowIdx][colId];
}
if (bulkDefaults[colId] !== undefined && bulkDefaults[colId] !== '') {
return bulkDefaults[colId];
}
return devices[rowIdx]?.[colId] || '';
}, [overrides, bulkDefaults, devices]);
// --- Cell editing ---
const startEdit = (rowIdx, colId) => {
setEditingCell({ rowIdx, colId });
setEditValue(getCellValue(rowIdx, colId));
};
const commitEdit = () => {
if (!editingCell) return;
const { rowIdx, colId } = editingCell;
const currentBulk = bulkDefaults[colId] || '';
const currentDevice = devices[rowIdx]?.[colId] || '';
// Only store override if different from bulk default and device value
if (editValue !== currentBulk || currentDevice) {
setOverrides(prev => ({
...prev,
[rowIdx]: { ...(prev[rowIdx] || {}), [colId]: editValue },
}));
}
setEditingCell(null);
};
const clearOverride = (rowIdx, colId) => {
setOverrides(prev => {
const next = { ...prev };
if (next[rowIdx]) {
const row = { ...next[rowIdx] };
delete row[colId];
if (Object.keys(row).length === 0) delete next[rowIdx];
else next[rowIdx] = row;
}
return next;
});
};
// --- Bulk default ---
const setBulkDefault = (colId, value) => {
setBulkDefaults(prev => ({ ...prev, [colId]: value }));
};
// --- Paste IPs (standalone mode) ---
const loadPastedIps = () => {
const lines = pasteInput.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
const newDevices = lines.slice(0, 200).map(ip => ({ IPV4_ADDRESS: ip, EQUIP_NAME: '' }));
setDevices(newDevices);
setOverrides({});
setPasteInput('');
};
const addRow = () => {
setDevices(prev => [...prev, { IPV4_ADDRESS: '', EQUIP_NAME: '' }]);
};
const removeRow = (idx) => {
setDevices(prev => prev.filter((_, i) => i !== idx));
setOverrides(prev => {
const next = {};
Object.entries(prev).forEach(([k, v]) => {
const ki = parseInt(k, 10);
if (ki < idx) next[ki] = v;
else if (ki > idx) next[ki - 1] = v;
});
return next;
});
};
// --- CARD Enrichment ---
const enrichFromCard = async () => {
const ips = devices.map(d => d.IPV4_ADDRESS).filter(Boolean);
if (ips.length === 0) return;
setEnriching(true);
setEnrichErrors([]);
try {
const resp = await fetch(`${API_BASE}/card/enrich-batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ips }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
setEnrichErrors([{ ip: 'all', error: err.error || `HTTP ${resp.status}` }]);
setEnriching(false);
return;
}
const data = await resp.json();
const errors = [];
// Map results back to devices
setDevices(prev => prev.map((device, idx) => {
const result = data.results.find(r => r.ip === device.IPV4_ADDRESS);
if (!result || !result.found) {
if (result) errors.push({ ip: result.ip, error: result.error || 'Not found' });
return device;
}
// Only populate fields that aren't already overridden by the user
const updated = { ...device };
const rowOverrides = overrides[idx] || {};
if (result.equip_inst_id && !rowOverrides.EQUIP_INST_ID && !device.EQUIP_INST_ID) {
updated.EQUIP_INST_ID = result.equip_inst_id;
}
if (result.hostname && !rowOverrides.EQUIP_NAME && !device.EQUIP_NAME) {
updated.EQUIP_NAME = result.hostname;
}
if (result.site_name && !rowOverrides.SITE_NAME && !device.SITE_NAME) {
updated.SITE_NAME = result.site_name;
}
if (result.mgmt_ip_asn && !rowOverrides.MGMT_IP_ASN && !device.MGMT_IP_ASN) {
updated.MGMT_IP_ASN = result.mgmt_ip_asn;
}
if (result.responsible_team && !rowOverrides.RESPONSIBLE_TEAM && !device.RESPONSIBLE_TEAM) {
updated.RESPONSIBLE_TEAM = result.responsible_team;
}
if (result.equip_status && !rowOverrides.EQUIP_STATUS && !device.EQUIP_STATUS) {
updated.EQUIP_STATUS = result.equip_status;
}
return updated;
}));
setEnrichErrors(errors);
} catch (err) {
setEnrichErrors([{ ip: 'all', error: err.message }]);
}
setEnriching(false);
};
// --- Validation ---
const validate = () => {
const required = getRequiredColumns(operationType);
const warnings = [];
devices.forEach((_, rowIdx) => {
required.forEach(colId => {
if (colId === 'DELETE') return; // Auto-filled
const val = getCellValue(rowIdx, colId);
if (!val) {
warnings.push({ rowIdx, colId });
}
});
});
setValidationWarnings(warnings);
return warnings;
};
// --- Download ---
const handleDownload = () => {
const warnings = validate();
// Build final rows
const finalRows = devices.map((_, rowIdx) => {
const row = {};
orderedColumns.forEach(col => {
row[col.id] = getCellValue(rowIdx, col.id);
});
return row;
});
// Determine team name for filename (from bulk default or first row)
const teamName = bulkDefaults.RESPONSIBLE_TEAM || finalRows[0]?.RESPONSIBLE_TEAM || '';
const blob = generateLoaderXlsx({
operationType,
columnIds: orderedColumns.map(c => c.id),
rows: finalRows,
});
const filename = generateFilename(operationType, teamName);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Keep warnings visible but don't block download
if (warnings.length > 0) {
// Warnings already displayed in UI
}
};
// --- Render ---
if (!isOpen) return null;
const requiredCols = getRequiredColumns(operationType);
const isStandalone = !initialDevices || initialDevices.length === 0;
const missingCount = validationWarnings.length;
const isCellWarning = (rowIdx, colId) => validationWarnings.some(w => w.rowIdx === rowIdx && w.colId === colId);
const isOverridden = (rowIdx, colId) => overrides[rowIdx] && overrides[rowIdx][colId] !== undefined;
return (
<div style={OVERLAY} onClick={onClose}>
<div style={MODAL} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={HEADER}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FileSpreadsheet style={{ width: '18px', height: '18px', color: '#7C3AED' }} />
<span style={{ fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0' }}>Generate Granite Loader Sheet</span>
<span style={{ fontSize: '0.7rem', color: '#64748B' }}>({devices.length} devices)</span>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Body */}
<div style={BODY}>
{/* Top controls row */}
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1rem', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.65rem', color: '#94A3B8', display: 'block', marginBottom: '0.2rem' }}>Operation</label>
<select style={{ ...INPUT, width: '140px', cursor: 'pointer' }} value={operationType} onChange={e => setOperationType(e.target.value)}>
{OPERATION_TYPES.map(op => <option key={op} value={op}>{op}</option>)}
</select>
</div>
{cardConfigured && (
<button style={{ ...BTN_SECONDARY, display: 'flex', alignItems: 'center', gap: '0.3rem' }} onClick={enrichFromCard} disabled={enriching || devices.length === 0}>
<RefreshCw style={{ width: '12px', height: '12px', animation: enriching ? 'spin 1s linear infinite' : 'none' }} />
{enriching ? 'Enriching...' : 'Enrich from CARD'}
</button>
)}
</div>
{/* Standalone: paste IPs */}
{isStandalone && devices.length === 0 && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
<label style={{ fontSize: '0.7rem', color: '#94A3B8', display: 'block', marginBottom: '0.3rem' }}>Paste IP addresses (one per line or comma-separated)</label>
<textarea
style={{ ...INPUT, height: '80px', resize: 'vertical', fontFamily: 'monospace' }}
value={pasteInput}
onChange={e => setPasteInput(e.target.value)}
placeholder="10.240.78.110&#10;10.240.78.111&#10;172.16.5.20"
/>
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem' }}>
<button style={BTN_PRIMARY} onClick={loadPastedIps} disabled={!pasteInput.trim()}>Load IPs</button>
</div>
</div>
)}
{/* Enrich errors */}
{enrichErrors.length > 0 && (
<div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}>
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
<AlertCircle style={{ width: '12px', height: '12px' }} />
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
? enrichErrors[0].error
: `${enrichErrors.length} device(s) not found in CARD`}
</div>
</div>
)}
{/* Column selection */}
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', marginBottom: '0.5rem', fontWeight: '600' }}>Columns</div>
{COLUMN_GROUPS.map(group => {
const cols = getColumnsByGroup(group);
const selectedInGroup = cols.filter(c => selectedColumns.has(c.id)).length;
const expanded = expandedGroups.has(group);
return (
<div key={group} style={{ marginBottom: '0.25rem' }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', cursor: 'pointer', padding: '0.2rem 0', userSelect: 'none' }}
onClick={() => toggleGroup(group)}
>
{expanded ? <ChevronDown style={{ width: '12px', height: '12px', color: '#64748B' }} /> : <ChevronRight style={{ width: '12px', height: '12px', color: '#64748B' }} />}
<span style={{ fontSize: '0.7rem', color: '#E2E8F0' }}>{group}</span>
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>({selectedInGroup} selected)</span>
</div>
{expanded && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', paddingLeft: '1.2rem', marginTop: '0.2rem' }}>
{cols.map(col => {
const isRequired = requiredCols.includes(col.id);
const isChecked = selectedColumns.has(col.id);
return (
<label key={col.id} style={{ display: 'flex', alignItems: 'center', gap: '0.2rem', fontSize: '0.65rem', color: isRequired ? '#A78BFA' : '#94A3B8', cursor: isRequired ? 'default' : 'pointer' }}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleColumn(col.id)}
disabled={isRequired}
style={{ accentColor: '#7C3AED' }}
/>
{col.label.length > 30 ? col.id : col.label}
{isRequired && <span style={{ fontSize: '0.55rem', color: '#7C3AED' }}>*</span>}
</label>
);
})}
</div>
)}
</div>
);
})}
</div>
{/* Bulk defaults */}
{orderedColumns.length > 0 && devices.length > 0 && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', marginBottom: '0.5rem', fontWeight: '600' }}>Bulk Defaults (applies to all rows)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.5rem' }}>
{orderedColumns.filter(c => c.id !== 'DELETE').map(col => (
<div key={col.id}>
<label style={{ fontSize: '0.6rem', color: '#64748B', display: 'block', marginBottom: '0.15rem' }}>
{col.id}
</label>
<input
style={INPUT}
value={bulkDefaults[col.id] || ''}
onChange={e => setBulkDefault(col.id, e.target.value)}
placeholder={`Default for all rows`}
/>
</div>
))}
</div>
</div>
)}
{/* Preview table */}
{devices.length > 0 && orderedColumns.length > 0 && (
<div style={{ border: '1px solid #334155', borderRadius: '0.5rem', overflow: 'auto', maxHeight: '300px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.7rem' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#0F172A', zIndex: 1 }}>
<th style={{ padding: '0.4rem', borderBottom: '1px solid #334155', color: '#64748B', textAlign: 'center', width: '30px' }}>#</th>
{orderedColumns.map(col => (
<th key={col.id} style={{ padding: '0.4rem 0.5rem', borderBottom: '1px solid #334155', color: '#94A3B8', textAlign: 'left', whiteSpace: 'nowrap' }}>
{col.id}
</th>
))}
<th style={{ padding: '0.4rem', borderBottom: '1px solid #334155', width: '30px' }}></th>
</tr>
</thead>
<tbody>
{devices.map((_, rowIdx) => (
<tr key={rowIdx} style={{ borderBottom: '1px solid rgba(51, 65, 85, 0.5)' }}>
<td style={{ padding: '0.3rem', textAlign: 'center', color: '#475569', fontSize: '0.6rem' }}>{rowIdx + 1}</td>
{orderedColumns.map(col => {
const value = getCellValue(rowIdx, col.id);
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colId === col.id;
const hasOverride = isOverridden(rowIdx, col.id);
const hasWarning = isCellWarning(rowIdx, col.id);
return (
<td
key={col.id}
style={{
padding: '0.2rem 0.4rem',
position: 'relative',
background: hasWarning ? 'rgba(239, 68, 68, 0.08)' : 'transparent',
cursor: 'pointer',
minWidth: '100px',
}}
onClick={() => !isEditing && startEdit(rowIdx, col.id)}
>
{isEditing ? (
<input
style={{ ...INPUT, padding: '0.2rem 0.4rem', fontSize: '0.7rem' }}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
autoFocus
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
{hasOverride && <span style={{ color: '#F59E0B', fontSize: '0.5rem' }}></span>}
<span style={{ color: value ? '#E2E8F0' : '#475569' }}>{value || '—'}</span>
{hasOverride && (
<button
onClick={e => { e.stopPropagation(); clearOverride(rowIdx, col.id); }}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', fontSize: '0.55rem', padding: '0 0.2rem' }}
title="Revert to bulk default"
></button>
)}
</div>
)}
</td>
);
})}
<td style={{ padding: '0.2rem', textAlign: 'center' }}>
<button onClick={() => removeRow(rowIdx)} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<Trash2 style={{ width: '11px', height: '11px' }} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add row button */}
{devices.length > 0 && (
<button style={{ ...BTN_SECONDARY, marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.65rem' }} onClick={addRow}>
<Plus style={{ width: '11px', height: '11px' }} /> Add Row
</button>
)}
</div>
{/* Footer */}
<div style={FOOTER}>
<div style={{ fontSize: '0.7rem', color: '#64748B' }}>
{missingCount > 0 && (
<span style={{ color: '#F59E0B' }}> {missingCount} missing required field{missingCount > 1 ? 's' : ''}</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button style={BTN_SECONDARY} onClick={onClose}>Cancel</button>
<button
style={{ ...BTN_SUCCESS, display: 'flex', alignItems: 'center', gap: '0.3rem' }}
onClick={handleDownload}
disabled={devices.length === 0 || orderedColumns.length === 0}
>
<Download style={{ width: '13px', height: '13px' }} />
Download Loader Sheet
</button>
</div>
</div>
</div>
</div>
);
}