Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature: - Add graniteLoaderConfig.js with all 41 columns, groupings, and operation-type requirements (Change/Add/Delete/Move) - Add graniteLoaderExport.js for client-side xlsx generation using the xlsx library - Add LoaderModal component with operation type selection, column checkboxes, bulk defaults with per-row overrides, editable preview table, CARD enrichment integration, and standalone paste-IPs mode - Add POST /api/card/enrich-batch endpoint for batch IP lookup in CARD returning EQUIP_INST_ID, hostname, site, ASN, team - Integrate 'Generate Loader Sheet' button in Ivanti Queue floating action bar (visible when CARD/GRANITE/DECOM items selected) - Add card-connectivity-test.js script for verifying CARD API access
This commit is contained in:
574
frontend/src/components/LoaderModal.js
Normal file
574
frontend/src/components/LoaderModal.js
Normal file
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* 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 when operation type changes
|
||||
useEffect(() => {
|
||||
const required = getRequiredColumns(operationType);
|
||||
setSelectedColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
required.forEach(id => next.add(id));
|
||||
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.240.78.111 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ConsolidationModal from '../ConsolidationModal';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
||||
@@ -300,6 +301,7 @@ export default function IvantiTodoQueuePage() {
|
||||
|
||||
// Single-item Jira creation modal state (Requirement 2.4)
|
||||
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
||||
const [showLoaderModal, setShowLoaderModal] = useState(false);
|
||||
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
||||
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
|
||||
const [singleJiraError, setSingleJiraError] = useState(null);
|
||||
@@ -908,6 +910,20 @@ export default function IvantiTodoQueuePage() {
|
||||
<Plus style={{ width: '14px', height: '14px' }} />
|
||||
Create Jira Ticket
|
||||
</button>
|
||||
{(() => {
|
||||
const selectedItems = queueItems.filter(i => selectedIds.has(i.id));
|
||||
const hasCardGranite = selectedItems.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||||
return hasCardGranite ? (
|
||||
<button
|
||||
onClick={() => setShowLoaderModal(true)}
|
||||
style={STYLES.btnSuccess}
|
||||
title="Generate Granite Team_Device Loader Sheet from selected items"
|
||||
>
|
||||
<FileSpreadsheet style={{ width: '14px', height: '14px' }} />
|
||||
Generate Loader Sheet
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
<button
|
||||
onClick={cancelSelection}
|
||||
style={STYLES.btnCancel}
|
||||
@@ -1014,6 +1030,13 @@ export default function IvantiTodoQueuePage() {
|
||||
onSuccess={handleConsolidationSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Granite Loader Sheet Modal */}
|
||||
<LoaderModal
|
||||
isOpen={showLoaderModal}
|
||||
onClose={() => setShowLoaderModal(false)}
|
||||
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
77
frontend/src/utils/graniteLoaderConfig.js
Normal file
77
frontend/src/utils/graniteLoaderConfig.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Granite Team_Device Loader column configuration.
|
||||
* Defines all 41 columns in canonical order, their groupings,
|
||||
* and which are required for each operation type.
|
||||
*/
|
||||
|
||||
export const LOADER_COLUMNS = [
|
||||
{ id: 'DELETE', label: 'DELETE', group: 'Identification', requiredFor: ['Delete'] },
|
||||
{ id: 'SET_CONFIRMED', label: 'SET_CONFIRMED', group: 'Identification', requiredFor: [] },
|
||||
{ id: 'EQUIPMENT_CLASS', label: 'EQUIPMENT CLASS', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_INST_ID', label: 'EQUIP_INST_ID', group: 'Identification', requiredFor: ['Change', 'Move', 'Delete'] },
|
||||
{ id: 'SITE_NAME', label: 'SITE_NAME', group: 'Identification', requiredFor: ['Add', 'Move'] },
|
||||
{ id: 'EQUIP_NAME', label: 'EQUIP_NAME', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_TEMPLATE', label: 'EQUIP_TEMPLATE', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_STATUS', label: 'EQUIP_STATUS', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'RESPONSIBLE_TEAM', label: 'UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM', group: 'Responsible Org', requiredFor: ['Add'] },
|
||||
{ id: 'IPV4_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ADDRESS', group: 'IP Addressing', requiredFor: ['Add'] },
|
||||
{ id: 'MAC_ADDRESS', label: 'UDA#IP_ADDRESSING#MAC ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'MGMT_IP_ASN', label: 'UDA#IP_ADDRESSING#MGMT_IP_ASN', group: 'IP Addressing', requiredFor: ['Add'] },
|
||||
{ id: 'SERIALNUMBER', label: 'SERIALNUMBER', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EXCLUDED_DISCOVERY', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY', group: 'Discovery', requiredFor: [] },
|
||||
{ id: 'EXCLUDED_REASON', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY REASON', group: 'Discovery', requiredFor: [] },
|
||||
{ id: 'IPV6_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV6_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'ILOM_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ILOM_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'APP_ID_ASSET_TAG', label: 'UDA#CHERWELL_CMDB#APP ID ASSET TAG', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'DEVICE_FUNCTION', label: 'UDA#CHERWELL_CMDB#DEVICE_FUNCTION', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'ENVIRONMENT', label: 'UDA#CHERWELL_CMDB#ENVIRONMENT', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'SECONDARY_MGMT_IP', label: 'UDA#IP_ADDRESSING#SECONDARY_MGMT_IP_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'VIP', label: 'UDA#IP_ADDRESSING#VIP', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'FLOATING_IP', label: 'UDA#IP_ADDRESSING#FLOATING IP ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_1', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 1', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_2', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 2', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_3', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 3', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'EQUIP_MODEL', label: 'EQUIP_MODEL', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EQUIP_COMMENTS', label: 'EQUIP_COMMENTS', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EQUIP_PARTNUMBER', label: 'EQUIP_PARTNUMBER', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'OS', label: 'UDA#CONTROLLER CONFIG#OS', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'OS_VERSION', label: 'UDA#CONTROLLER CONFIG#OS VERSION', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'CPU_CORES', label: 'UDA#CONTROLLER CONFIG#TOTAL CPU CORES', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'RAM_GB', label: 'UDA#CONTROLLER CONFIG#RAM IN GB', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'STORAGE_GB', label: 'UDA#CONTROLLER CONFIG#STORAGE IN GB', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'ARCHER_ID', label: 'UDA#WIFI EQUIP INFO#ARCHER ID', group: 'Other', requiredFor: [] },
|
||||
{ id: 'INSTALL_LOCATION', label: 'UDA#WIFI EQUIP INFO#INSTALL LOCATION', group: 'Other', requiredFor: [] },
|
||||
{ id: 'SYSNAME', label: 'UDA#EQUIPMENT_INFO#SYSNAME', group: 'Other', requiredFor: [] },
|
||||
{ id: 'LATITUDE', label: 'UDA#WIFI EQUIP INFO#LATITUDE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'LONGITUDE', label: 'UDA#WIFI EQUIP INFO#LONGITUDE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'OSTYPE', label: 'UDA#EQUIP MIGRATION#OSTYPE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'OSVERSION', label: 'UDA#EQUIP MIGRATION#OSVERSION', group: 'Other', requiredFor: [] },
|
||||
];
|
||||
|
||||
export const COLUMN_GROUPS = [
|
||||
'Identification',
|
||||
'Responsible Org',
|
||||
'IP Addressing',
|
||||
'Discovery',
|
||||
'Cyber Metrics',
|
||||
'Equipment Info',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const OPERATION_TYPES = ['Change', 'Add', 'Delete', 'Move'];
|
||||
|
||||
/**
|
||||
* Returns column IDs required for the given operation type.
|
||||
*/
|
||||
export function getRequiredColumns(operationType) {
|
||||
return LOADER_COLUMNS
|
||||
.filter(col => col.requiredFor.includes(operationType))
|
||||
.map(col => col.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns columns belonging to a specific group.
|
||||
*/
|
||||
export function getColumnsByGroup(group) {
|
||||
return LOADER_COLUMNS.filter(col => col.group === group);
|
||||
}
|
||||
82
frontend/src/utils/graniteLoaderExport.js
Normal file
82
frontend/src/utils/graniteLoaderExport.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Granite Team_Device Loader xlsx generation.
|
||||
* Produces a properly formatted xlsx file for upload to SNIP XperLoad.
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { LOADER_COLUMNS } from './graniteLoaderConfig';
|
||||
|
||||
/**
|
||||
* Generate a Granite Loader Sheet xlsx file.
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.operationType - 'Change' | 'Add' | 'Delete' | 'Move'
|
||||
* @param {Array<string>} config.columnIds - selected column IDs (in any order; output uses canonical order)
|
||||
* @param {Array<Object>} config.rows - device rows, each keyed by column ID with string values
|
||||
* @returns {Blob} xlsx file as a Blob for browser download
|
||||
*/
|
||||
export function generateLoaderXlsx(config) {
|
||||
const { operationType, columnIds, rows } = config;
|
||||
|
||||
// Filter LOADER_COLUMNS to only selected columns, preserving canonical order
|
||||
const selectedColumns = LOADER_COLUMNS.filter(col => columnIds.includes(col.id));
|
||||
|
||||
// Build header row from canonical labels
|
||||
const headers = selectedColumns.map(col => col.label);
|
||||
|
||||
// Build data rows
|
||||
const dataRows = rows.map(row => {
|
||||
return selectedColumns.map(col => {
|
||||
// DELETE column auto-fill for Delete operations
|
||||
if (col.id === 'DELETE' && operationType === 'Delete') {
|
||||
return 'X';
|
||||
}
|
||||
|
||||
// Get the value from the row
|
||||
let value = row[col.id];
|
||||
|
||||
// EQUIPMENT CLASS defaults to "S" if not explicitly set
|
||||
if (col.id === 'EQUIPMENT_CLASS' && (value === undefined || value === null || value === '')) {
|
||||
value = 'S';
|
||||
}
|
||||
|
||||
// Convert null/undefined to empty string (not "null" or "undefined")
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
});
|
||||
});
|
||||
|
||||
// Combine headers + data into array-of-arrays
|
||||
const aoa = [headers, ...dataRows];
|
||||
|
||||
// Create workbook and worksheet
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Load_Sheet');
|
||||
|
||||
// Write to array buffer
|
||||
const wbOut = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||
|
||||
// Return as Blob
|
||||
return new Blob([wbOut], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a descriptive filename for the loader sheet.
|
||||
*
|
||||
* @param {string} operationType - 'Change' | 'Add' | 'Delete' | 'Move'
|
||||
* @param {string} [teamName] - optional team name to include in filename
|
||||
* @returns {string} filename like "Loader_Change_NTS-AEO-STEAM_2026-05-27.xlsx"
|
||||
*/
|
||||
export function generateFilename(operationType, teamName) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const parts = ['Loader', operationType];
|
||||
if (teamName) {
|
||||
parts.push(teamName.replace(/[^a-zA-Z0-9_-]/g, '-'));
|
||||
}
|
||||
parts.push(date);
|
||||
return parts.join('_') + '.xlsx';
|
||||
}
|
||||
Reference in New Issue
Block a user