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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user