feat(reporting): inline editable hostname and DNS with persistent overrides

Backend:
- New ivanti_finding_overrides table (finding_id, field, value) with
  UNIQUE(finding_id, field) — same survival-across-sync pattern as notes
- PUT /api/ivanti/findings/:id/override (editor/admin only) — saves or
  clears a field override; empty value = revert to Ivanti
- Overrides merged into findings at read time via readOverrides()
- Whitelisted fields: hostName, dns

Frontend:
- OverrideCell component — click to edit inline (editor/admin only),
  Enter/blur to save, Escape to cancel
- Amber dot indicator on cells with an active local override
- Hover tooltip shows original Ivanti value when overridden
- RotateCcw button reverts cell back to Ivanti value in one click
- canWrite() gating via useAuth — viewers see the value, can't edit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:39:37 -06:00
parent 071aef96a1
commit 07894709ba
2 changed files with 209 additions and 11 deletions

View File

@@ -4,6 +4,7 @@
const express = require('express');
const https = require('https');
const { requireRole } = require('../middleware/auth');
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -151,9 +152,25 @@ function initTables(db) {
VALUES (1, 0, 0)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(finding_id, field)
)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
ON ivanti_finding_notes(finding_id)
`, (err) => { if (err) return reject(err); });
db.run(`
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
ON ivanti_finding_overrides(finding_id)
`, (err) => {
if (err) reject(err);
else resolve();
@@ -395,9 +412,28 @@ function readCounts(db) {
});
}
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
function readOverrides(db) {
return new Promise((resolve, reject) => {
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
if (err) return reject(err);
const map = {};
(rows || []).forEach((r) => {
if (!map[r.finding_id]) map[r.finding_id] = {};
map[r.finding_id][r.field] = r.value;
});
resolve(map);
});
});
}
async function readStateWithNotes(db) {
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
state.findings = state.findings.map((f) => ({
...f,
note: notes[f.id] || '',
overrides: overrides[f.id] || {},
}));
return state;
}
@@ -441,6 +477,42 @@ function createIvantiFindingsRouter(db, requireAuth) {
}
});
// PUT /:findingId/override — save or clear a field override (editor/admin only)
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
const { findingId } = req.params;
const { field, value } = req.body;
if (!OVERRIDE_ALLOWED.includes(field)) {
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
}
const val = String(value ?? '').trim();
if (val === '') {
// Empty value = clear the override (revert to Ivanti)
db.run(
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
[findingId, field],
(err) => {
if (err) return res.status(500).json({ error: 'Failed to clear override' });
res.json({ finding_id: findingId, field, value: null });
}
);
} else {
db.run(
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
[findingId, field, val],
(err) => {
if (err) return res.status(500).json({ error: 'Failed to save override' });
res.json({ finding_id: findingId, field, value: val });
}
);
}
});
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
router.put('/:findingId/note', (req, res) => {
const { findingId } = req.params;

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download } from 'lucide-react';
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw } from 'lucide-react';
import * as XLSX from 'xlsx';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const STORAGE_KEY = 'steam_findings_columns_v2';
@@ -391,6 +392,119 @@ function SortIcon({ colKey, sort }) {
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
}
// ---------------------------------------------------------------------------
// OverrideCell — inline editable hostname/dns with amber dot when overridden
// ---------------------------------------------------------------------------
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) {
const effective = initialOverride ?? originalValue ?? '';
const [value, setValue] = useState(effective);
const [isOverridden, setOverridden] = useState(!!initialOverride);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const lastSaved = useRef(effective);
const inputRef = useRef(null);
// Sync when the finding updates (e.g. after a full sync)
useEffect(() => {
const eff = initialOverride ?? originalValue ?? '';
setValue(eff);
setOverridden(!!initialOverride);
lastSaved.current = eff;
}, [initialOverride, originalValue]);
useEffect(() => {
if (editing && inputRef.current) inputRef.current.focus();
}, [editing]);
const persist = useCallback(async (newVal) => {
const trimmed = newVal.trim();
if (trimmed === lastSaved.current) return;
setSaving(true);
try {
const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field, value: trimmed }),
});
if (res.ok) {
const data = await res.json();
const cleared = data.value === null;
const displayed = cleared ? (originalValue ?? '') : trimmed;
setValue(displayed);
setOverridden(!cleared);
lastSaved.current = displayed;
} else {
setValue(lastSaved.current); // revert on error
}
} catch {
setValue(lastSaved.current);
} finally {
setSaving(false);
}
}, [findingId, field, originalValue]);
const handleBlur = () => { setEditing(false); persist(value); };
const handleKeyDown = (e) => {
if (e.key === 'Enter') { e.target.blur(); }
if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); }
};
const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); };
if (editing) {
return (
<td style={{ padding: '0.3rem 0.5rem' }}>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
style={{
width: '100%', minWidth: '120px',
background: 'rgba(14,165,233,0.08)',
border: '1px solid rgba(14,165,233,0.4)',
borderRadius: '0.25rem',
padding: '0.2rem 0.4rem',
color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.72rem',
outline: 'none',
}}
/>
</td>
);
}
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
<span
onClick={canWrite ? () => setEditing(true) : undefined}
title={isOverridden ? `Ivanti value: ${originalValue || '—'}\nClick to edit` : canWrite ? 'Click to edit' : undefined}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
color: isOverridden ? '#E2E8F0' : '#94A3B8',
fontFamily: 'monospace', fontSize: '0.72rem',
cursor: canWrite ? 'text' : 'default',
}}
>
{isOverridden && (
<span title="Local override active" style={{ width: '5px', height: '5px', borderRadius: '50%', background: '#F59E0B', flexShrink: 0, marginRight: '1px' }} />
)}
{value || '—'}
{saving && <Loader style={{ width: '10px', height: '10px', color: '#475569', animation: 'spin 1s linear infinite', flexShrink: 0 }} />}
{isOverridden && canWrite && !saving && (
<button
onClick={handleRevert}
title="Revert to Ivanti value"
style={{ background: 'none', border: 'none', padding: '0 1px', cursor: 'pointer', color: '#475569', lineHeight: 1, flexShrink: 0, display: 'inline-flex', alignItems: 'center' }}
>
<RotateCcw style={{ width: '10px', height: '10px' }} />
</button>
)}
</span>
</td>
);
}
// ---------------------------------------------------------------------------
// NoteCell — inline editable, saves on blur
// ---------------------------------------------------------------------------
@@ -713,7 +827,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
// ---------------------------------------------------------------------------
// Render a single table cell by column key
// ---------------------------------------------------------------------------
function TableCell({ colKey, finding }) {
function TableCell({ colKey, finding, canWrite }) {
switch (colKey) {
case 'findingId':
return (
@@ -763,19 +877,30 @@ function TableCell({ colKey, finding }) {
);
}
case 'hostName':
return (
<OverrideCell
findingId={finding.id}
field="hostName"
originalValue={finding.hostName}
initialOverride={finding.overrides?.hostName ?? null}
canWrite={canWrite}
/>
);
case 'ipAddress':
return (
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
{finding[colKey] || '—'}
{finding.ipAddress || '—'}
</td>
);
case 'dns':
return (
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '200px' }}>
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
{finding.dns || '—'}
</span>
</td>
<OverrideCell
findingId={finding.id}
field="dns"
originalValue={finding.dns}
initialOverride={finding.overrides?.dns ?? null}
canWrite={canWrite}
/>
);
case 'dueDate': {
const color = dueDateColor(finding.dueDate);
@@ -861,6 +986,7 @@ function TableCell({ colKey, finding }) {
// Main ReportingPage
// ---------------------------------------------------------------------------
export default function ReportingPage({ filterDate, filterEXC }) {
const { canWrite } = useAuth();
const [findings, setFindings] = useState([]);
const [total, setTotal] = useState(null);
const [syncedAt, setSyncedAt] = useState(null);
@@ -1384,7 +1510,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
>
{visibleCols.map((col) => (
<TableCell key={col.key} colKey={col.key} finding={finding} />
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
))}
</tr>
);