New specs: archer-template-library, ccp-metrics-view-restructure, compliance-list-stale-after-sidebar-edit, compliance-metric-estimated-resolution-date, compliance-remediation-display-fix, flexible-jira-ticket-creation, forecast-burndown-chart, granite-loader-export, ivanti-queue-clear-completed-fix, multi-item-jira-ticket, queue-collapsible-sections, vendor-issue-type-dropdown New steering: archer-template-gen.md Updated: migration-registration-check hook, remediation-plan-history spec, gitlab-workflow, tech, versioning steering files
320 lines
16 KiB
Markdown
320 lines
16 KiB
Markdown
# Design Document: Granite Loader Sheet Export
|
||
|
||
## Overview
|
||
|
||
This feature adds a Granite Team_Device Loader xlsx generator to the STEAM Security Dashboard. It integrates with the existing Ivanti Queue (for CARD/GRANITE items) and provides a standalone mode for ad-hoc device lists. The system enriches device data from the CARD API, allows bulk defaults with per-row overrides in an editable preview table, and generates a properly formatted xlsx for upload to SNIP XperLoad.
|
||
|
||
Key design decisions:
|
||
- **Frontend-driven xlsx generation**: The xlsx is generated client-side using the `xlsx` library (already a project dependency) to avoid backend file I/O and temp file cleanup.
|
||
- **Backend CARD enrichment endpoint**: A single batch endpoint accepts an array of IPs and returns enriched Granite fields from CARD. This keeps the frontend simple and centralizes CARD API token management.
|
||
- **Reuse queue data model**: Queue items already have `ip_address`, `hostname`, and `workflow_type` — no schema changes needed.
|
||
- **Modal-based UI**: The loader configuration lives in a modal (like the Consolidation Modal pattern) to avoid adding a new page while keeping the queue page clean.
|
||
|
||
## Architecture
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
subgraph Frontend
|
||
QP[IvantiTodoQueuePage] -->|selected CARD/GRANITE items| LM[LoaderModal]
|
||
NAV[Nav Drawer / Standalone] -->|manual IP list| LM
|
||
LM -->|enrich request| API[POST /api/card/enrich-batch]
|
||
LM -->|generate xlsx| XLSX[xlsx library - client-side]
|
||
XLSX -->|download| FILE[Loader_Change_TEAM_DATE.xlsx]
|
||
end
|
||
|
||
subgraph Backend
|
||
API --> CARD[CARD API Helper]
|
||
CARD -->|per-IP lookup| EXT[card.charter.com]
|
||
end
|
||
```
|
||
|
||
## Components and Interfaces
|
||
|
||
### Backend: Batch Enrichment Endpoint
|
||
|
||
**File:** `backend/routes/cardApi.js` (added to existing router)
|
||
|
||
| Method | Path | Auth | Description |
|
||
|--------|------|------|-------------|
|
||
| POST | `/api/card/enrich-batch` | Admin, Standard_User | Batch lookup IPs in CARD, return Granite-relevant fields |
|
||
|
||
**Request:**
|
||
```json
|
||
{
|
||
"ips": ["10.240.78.110", "10.240.78.111", "172.16.5.20"]
|
||
}
|
||
```
|
||
|
||
**Response:**
|
||
```json
|
||
{
|
||
"results": [
|
||
{
|
||
"ip": "10.240.78.110",
|
||
"found": true,
|
||
"equip_inst_id": "1931008",
|
||
"hostname": "NDCW-SW-CORE-01",
|
||
"site_name": "ENWDCOCD-PEAKVIEW-SRDC",
|
||
"mgmt_ip_asn": "11427",
|
||
"responsible_team": "NTS-AEO-STEAM",
|
||
"equipment_class": "S",
|
||
"equip_template": "DISC-CISCO NEXUS 9300",
|
||
"equip_status": "Active"
|
||
},
|
||
{
|
||
"ip": "172.16.5.20",
|
||
"found": false,
|
||
"equip_inst_id": null,
|
||
"hostname": null,
|
||
"error": "IP not found in CARD"
|
||
}
|
||
],
|
||
"enriched_count": 1,
|
||
"not_found_count": 1,
|
||
"total": 2
|
||
}
|
||
```
|
||
|
||
**Implementation notes:**
|
||
- Accepts up to 200 IPs per request (matches Requirement 1.4 limit).
|
||
- For each IP, constructs asset ID candidates with known suffixes (CTEC, NATL, CHTR, etc.) and queries `GET /api/v1/owner/{assetId}`.
|
||
- Falls back to team asset search if direct owner lookup fails.
|
||
- Extracts fields from `ncim_discovery`, `netops_granite_allips`, `card_flags`, and `owner` on the asset record.
|
||
- Returns partial results on CARD API errors (best-effort enrichment).
|
||
|
||
### Frontend: LoaderModal Component
|
||
|
||
**File:** `frontend/src/components/LoaderModal.js`
|
||
|
||
A modal component that handles the full loader sheet workflow:
|
||
|
||
**Props:**
|
||
```javascript
|
||
{
|
||
isOpen: boolean,
|
||
onClose: () => void,
|
||
// Pre-populated from queue selection (null in standalone mode)
|
||
initialDevices: Array<{ ip_address: string, hostname: string }> | null,
|
||
}
|
||
```
|
||
|
||
**Internal State:**
|
||
```javascript
|
||
{
|
||
operationType: 'Change' | 'Add' | 'Delete' | 'Move',
|
||
selectedColumns: Set<string>, // checked column IDs
|
||
devices: Array<DeviceRow>, // the editable row data
|
||
bulkDefaults: Record<string, string>, // column → default value
|
||
overrides: Record<string, Record<string, string>>, // rowIndex → column → value
|
||
enriching: boolean,
|
||
enrichErrors: Array<{ ip: string, error: string }>,
|
||
}
|
||
```
|
||
|
||
**DeviceRow shape:**
|
||
```javascript
|
||
{
|
||
ip_address: string,
|
||
hostname: string,
|
||
// CARD-enriched fields (populated after enrichment)
|
||
equip_inst_id: string | null,
|
||
site_name: string | null,
|
||
mgmt_ip_asn: string | null,
|
||
responsible_team: string | null,
|
||
equipment_class: string | null,
|
||
equip_template: string | null,
|
||
equip_status: string | null,
|
||
}
|
||
```
|
||
|
||
### Frontend: Column Configuration
|
||
|
||
**File:** `frontend/src/utils/graniteLoaderConfig.js`
|
||
|
||
Pure data module defining the 41 columns, their groupings, and operation-type requirements:
|
||
|
||
```javascript
|
||
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',
|
||
'IP Addressing',
|
||
'Responsible Org',
|
||
'Discovery',
|
||
'Cyber Metrics',
|
||
'Equipment Info',
|
||
'Other',
|
||
];
|
||
|
||
export const OPERATION_TYPES = ['Change', 'Add', 'Delete', 'Move'];
|
||
```
|
||
|
||
### Frontend: XLSX Generation
|
||
|
||
**File:** `frontend/src/utils/graniteLoaderExport.js`
|
||
|
||
Pure function that takes the configured state and produces an xlsx workbook:
|
||
|
||
```javascript
|
||
/**
|
||
* @param {Object} config
|
||
* @param {string} config.operationType - 'Change' | 'Add' | 'Delete' | 'Move'
|
||
* @param {Array<string>} config.columnIds - ordered list of selected column IDs
|
||
* @param {Array<Object>} config.rows - device rows with resolved values (bulk + overrides merged)
|
||
* @returns {Blob} xlsx file as a Blob for download
|
||
*/
|
||
export function generateLoaderXlsx(config) { ... }
|
||
```
|
||
|
||
Uses the `xlsx` library (already in `frontend/package.json`) to create a workbook with a single "Load_Sheet" worksheet.
|
||
|
||
## Data Flow
|
||
|
||
### Queue-Initiated Flow
|
||
|
||
1. User selects CARD/GRANITE items on IvantiTodoQueuePage
|
||
2. User clicks "Generate Loader Sheet" in floating action bar
|
||
3. LoaderModal opens with `initialDevices` populated from selected items
|
||
4. User selects Operation Type (defaults to "Change")
|
||
5. User checks desired columns (required columns pre-checked)
|
||
6. User optionally clicks "Enrich from CARD" → `POST /api/card/enrich-batch`
|
||
7. EQUIP_INST_ID and other fields populate in the preview table
|
||
8. User sets bulk defaults and per-row overrides as needed
|
||
9. User clicks "Download" → client-side xlsx generation → browser download
|
||
|
||
### Standalone Flow
|
||
|
||
1. User navigates to standalone access point (nav drawer link or CARD page section)
|
||
2. LoaderModal opens with empty device list
|
||
3. User pastes IPs (textarea, one per line or comma-separated) → rows populate
|
||
4. Steps 4–9 same as above
|
||
|
||
### CARD Enrichment Flow (Backend)
|
||
|
||
1. Frontend sends `POST /api/card/enrich-batch` with array of IPs
|
||
2. Backend iterates IPs, for each:
|
||
a. Try `GET /api/v1/owner/{ip}-CTEC`, then `-NATL`, then `-CHTR` (known suffixes)
|
||
b. If found, extract fields from the asset record
|
||
c. If not found via owner lookup, search team assets for the IP
|
||
3. Return results array with found/not-found status per IP
|
||
|
||
## Error Handling
|
||
|
||
| Scenario | Behavior |
|
||
|----------|----------|
|
||
| CARD API not configured | "Enrich from CARD" button hidden; tooltip explains why |
|
||
| CARD API timeout on individual IP | Mark that IP as not-found, continue with others |
|
||
| CARD API auth failure | Show error toast, abort enrichment, preserve any already-enriched data |
|
||
| All IPs not found in CARD | Show warning banner "No devices found in CARD — enter EQUIP_INST_ID manually" |
|
||
| Required field missing on download | Highlight cells, show warning count, allow download with acknowledgment |
|
||
| xlsx generation failure | Show error toast with message |
|
||
| More than 200 IPs submitted | Frontend truncates to 200 with warning message |
|
||
|
||
## UI Layout
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Generate Granite Loader Sheet [X] │
|
||
├─────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ Operation: [Change ▾] Devices: 24 items │
|
||
│ │
|
||
│ ┌─ Columns ──────────────────────────────────────────────────┐ │
|
||
│ │ ▸ Identification (4 selected) │ │
|
||
│ │ ▸ IP Addressing (2 selected) │ │
|
||
│ │ ▸ Responsible Org (1 selected) │ │
|
||
│ │ ▸ Cyber Metrics (0 selected) │ │
|
||
│ │ ▸ Equipment Info (0 selected) │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ [Enrich from CARD] │
|
||
│ │
|
||
│ ┌─ Bulk Defaults ────────────────────────────────────────────┐ │
|
||
│ │ RESPONSIBLE TEAM: [NTS-AEO-STEAM ] │ │
|
||
│ │ EQUIP_STATUS: [Active ] │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─ Preview (24 rows) ────────────────────────────────────────┐ │
|
||
│ │ IP Address │ EQUIP_INST_ID │ RESP TEAM │ STATUS │ │
|
||
│ │───────────────┼───────────────┼────────────────┼───────────│ │
|
||
│ │ 10.240.78.110 │ 1931008 │ NTS-AEO-STEAM │ Active │ │
|
||
│ │ 10.240.78.111 │ 1931009 │ NTS-AEO-STEAM │ Active │ │
|
||
│ │ 172.16.5.20 │ ⚠ (not found) │ ACCESS-ENG ● │ Active │ │
|
||
│ │ 172.16.5.21 │ 2045112 │ NTS-AEO-STEAM │ Active │ │
|
||
│ └────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ⚠ 1 row missing EQUIP_INST_ID │
|
||
│ │
|
||
│ [Cancel] [Download Loader Sheet] │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- ● = per-row override indicator (amber dot)
|
||
- ⚠ = missing required field or CARD lookup failure
|
||
|
||
## Testing Strategy
|
||
|
||
### Property-Based Tests
|
||
|
||
**File:** `backend/__tests__/granite-loader-enrichment.property.test.js`
|
||
|
||
- **Property 1: Enrichment result count** — For any array of N IPs (1 ≤ N ≤ 200), the response contains exactly N result objects.
|
||
- **Property 2: Found results have required fields** — For any result where `found === true`, `equip_inst_id` is a non-empty string.
|
||
- **Property 3: Not-found results have null fields** — For any result where `found === false`, `equip_inst_id` is null.
|
||
|
||
### Unit Tests
|
||
|
||
- Column configuration: required columns for each operation type
|
||
- XLSX generation: correct headers, correct row data, empty cells handled
|
||
- Bulk default + override merge logic
|
||
- IP validation
|
||
- EQUIP_INST_ID numeric validation
|
||
|
||
### Integration Tests
|
||
|
||
- `POST /api/card/enrich-batch` with mocked CARD API responses
|
||
- Auth requirement enforcement
|
||
- 200-IP limit enforcement
|