Fix Ivanti panel bugs: Invalid Date, wrong workflow count, crash on archive click, BU scope filtering
This commit is contained in:
27
.kiro/steering/workflow.md
Normal file
27
.kiro/steering/workflow.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Workflow & Context Gathering
|
||||||
|
|
||||||
|
## Specs First
|
||||||
|
|
||||||
|
Before making changes to any feature area, **always check `.kiro/specs/` for related spec folders first**. Specs contain the original requirements, design decisions, architecture diagrams, data models, and task breakdowns that informed the implementation. They provide critical context about:
|
||||||
|
|
||||||
|
- Why a feature was built a certain way
|
||||||
|
- What data models and API contracts were agreed upon
|
||||||
|
- What correctness properties must hold
|
||||||
|
- What edge cases were considered
|
||||||
|
|
||||||
|
Even if the code has evolved since the spec was written, the spec is the starting point for understanding intent.
|
||||||
|
|
||||||
|
## Spec Folder Structure
|
||||||
|
|
||||||
|
Each spec folder typically contains:
|
||||||
|
|
||||||
|
- `requirements.md` — user stories and acceptance criteria
|
||||||
|
- `design.md` — architecture, data models, API contracts, error handling
|
||||||
|
- `tasks.md` — implementation task breakdown with completion status
|
||||||
|
|
||||||
|
## When to Check Specs
|
||||||
|
|
||||||
|
- Fixing bugs in a feature area — check the spec to understand intended behavior
|
||||||
|
- Adding to an existing feature — check the spec to understand design constraints
|
||||||
|
- Investigating unexpected behavior — the spec documents what "correct" looks like
|
||||||
|
- Refactoring — the spec documents which properties must be preserved
|
||||||
@@ -33,9 +33,25 @@ function createIvantiArchiveRouter() {
|
|||||||
// All routes require authentication
|
// All routes require authentication
|
||||||
router.use(requireAuth());
|
router.use(requireAuth());
|
||||||
|
|
||||||
// GET / — List archive records with optional state filtering
|
/**
|
||||||
|
* GET /
|
||||||
|
* List archive records with optional state and teams filtering.
|
||||||
|
*
|
||||||
|
* @query {string} [state] - Filter by lifecycle state. Valid values: ACTIVE, ARCHIVED, RETURNED, CLOSED.
|
||||||
|
* When state=ACTIVE, returns live open findings from ivanti_findings instead of archives.
|
||||||
|
* When state=CLOSED, includes both CLOSED and CLOSED_GONE records.
|
||||||
|
* @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG').
|
||||||
|
* Filters results to findings whose bu_ownership contains one of the specified teams.
|
||||||
|
*
|
||||||
|
* @response {object} 200
|
||||||
|
* { archives: Array<{ id, finding_id, finding_title, host_name, ip_address, current_state, last_severity, first_archived_at, last_transition_at, created_at, related_active: object|null }>, total: number }
|
||||||
|
* @response {object} 400 - Invalid state parameter
|
||||||
|
* { error: string }
|
||||||
|
* @response {object} 500 - Database error
|
||||||
|
* { error: string }
|
||||||
|
*/
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { state } = req.query;
|
const { state, teams } = req.query;
|
||||||
|
|
||||||
if (state && !VALID_STATES.includes(state)) {
|
if (state && !VALID_STATES.includes(state)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -43,17 +59,64 @@ function createIvantiArchiveRouter() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Parse teams filter into ILIKE patterns
|
||||||
let query = 'SELECT * FROM ivanti_finding_archives';
|
const teamPatterns = teams
|
||||||
const params = [];
|
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%')
|
||||||
let paramIndex = 1;
|
: [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ACTIVE state comes from ivanti_findings (live open findings), not archives
|
||||||
|
if (state === 'ACTIVE') {
|
||||||
|
let activeQuery = `SELECT id, id AS finding_id, title AS finding_title, host_name, ip_address,
|
||||||
|
'ACTIVE' AS current_state, severity AS last_severity,
|
||||||
|
synced_at AS first_archived_at, synced_at AS last_transition_at, synced_at AS created_at
|
||||||
|
FROM ivanti_findings WHERE state = 'open'`;
|
||||||
|
const activeParams = [];
|
||||||
|
let activeIdx = 1;
|
||||||
|
if (teamPatterns.length > 0) {
|
||||||
|
activeQuery += ` AND bu_ownership ILIKE ANY($${activeIdx++}::text[])`;
|
||||||
|
activeParams.push(teamPatterns);
|
||||||
|
}
|
||||||
|
activeQuery += ` ORDER BY severity DESC NULLS LAST LIMIT 200`;
|
||||||
|
const { rows: activeRows } = await pool.query(activeQuery, activeParams);
|
||||||
|
const archives = activeRows.map(r => ({ ...r, related_active: null }));
|
||||||
|
return res.json({ archives, total: archives.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-ACTIVE states, query archives with optional BU join
|
||||||
|
let query, params = [], paramIndex = 1;
|
||||||
|
|
||||||
|
if (teamPatterns.length > 0) {
|
||||||
|
// JOIN with ivanti_findings to filter by bu_ownership
|
||||||
|
query = `SELECT a.* FROM ivanti_finding_archives a
|
||||||
|
INNER JOIN ivanti_findings f ON a.finding_id = f.id
|
||||||
|
WHERE f.bu_ownership ILIKE ANY($${paramIndex++}::text[])`;
|
||||||
|
params.push(teamPatterns);
|
||||||
if (state) {
|
if (state) {
|
||||||
|
if (state === 'CLOSED') {
|
||||||
|
query += ` AND a.current_state IN ($${paramIndex++}, $${paramIndex++})`;
|
||||||
|
params.push('CLOSED', 'CLOSED_GONE');
|
||||||
|
} else {
|
||||||
|
query += ` AND a.current_state = $${paramIndex++}`;
|
||||||
|
params.push(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query = 'SELECT * FROM ivanti_finding_archives';
|
||||||
|
if (state) {
|
||||||
|
if (state === 'CLOSED') {
|
||||||
|
query += ` WHERE current_state IN ($${paramIndex++}, $${paramIndex++})`;
|
||||||
|
params.push('CLOSED', 'CLOSED_GONE');
|
||||||
|
} else {
|
||||||
query += ` WHERE current_state = $${paramIndex++}`;
|
query += ` WHERE current_state = $${paramIndex++}`;
|
||||||
params.push(state);
|
params.push(state);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
query += ' ORDER BY last_transition_at DESC';
|
query += teamPatterns.length > 0
|
||||||
|
? ' ORDER BY a.last_transition_at DESC'
|
||||||
|
: ' ORDER BY last_transition_at DESC';
|
||||||
|
|
||||||
const { rows: archives } = await pool.query(query, params);
|
const { rows: archives } = await pool.query(query, params);
|
||||||
|
|
||||||
@@ -82,27 +145,60 @@ function createIvantiArchiveRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /stats — Summary counts by lifecycle state
|
/**
|
||||||
|
* GET /stats
|
||||||
|
* Summary counts of archive records grouped by lifecycle state.
|
||||||
|
*
|
||||||
|
* @query {string} [teams] - Comma-separated BU team names (e.g. 'STEAM,ACCESS-ENG').
|
||||||
|
* Filters counts to findings whose bu_ownership contains one of the specified teams.
|
||||||
|
*
|
||||||
|
* @response {object} 200
|
||||||
|
* { ACTIVE: number, ARCHIVED: number, RETURNED: number, CLOSED: number, total: number }
|
||||||
|
* @response {object} 500 - Database error
|
||||||
|
* { error: string }
|
||||||
|
*/
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { teams } = req.query;
|
||||||
`SELECT current_state, COUNT(*) as count
|
const teamPatterns = teams
|
||||||
|
? teams.split(',').map(t => `%${t.trim()}%`).filter(p => p !== '%%')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let archiveQuery, archiveParams = [];
|
||||||
|
if (teamPatterns.length > 0) {
|
||||||
|
archiveQuery = `SELECT a.current_state, COUNT(*) as count
|
||||||
|
FROM ivanti_finding_archives a
|
||||||
|
INNER JOIN ivanti_findings f ON a.finding_id = f.id
|
||||||
|
WHERE f.bu_ownership ILIKE ANY($1::text[])
|
||||||
|
GROUP BY a.current_state`;
|
||||||
|
archiveParams = [teamPatterns];
|
||||||
|
} else {
|
||||||
|
archiveQuery = `SELECT current_state, COUNT(*) as count
|
||||||
FROM ivanti_finding_archives
|
FROM ivanti_finding_archives
|
||||||
GROUP BY current_state`
|
GROUP BY current_state`;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(archiveQuery, archiveParams);
|
||||||
|
|
||||||
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
|
const stats = { ACTIVE: 0, ARCHIVED: 0, RETURNED: 0, CLOSED: 0 };
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (stats.hasOwnProperty(row.current_state)) {
|
if (stats.hasOwnProperty(row.current_state)) {
|
||||||
stats[row.current_state] = parseInt(row.count);
|
stats[row.current_state] += parseInt(row.count);
|
||||||
|
} else if (row.current_state === 'CLOSED_GONE') {
|
||||||
|
stats.CLOSED += parseInt(row.count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACTIVE = total live findings count
|
// ACTIVE = total live findings count (scoped by teams if provided)
|
||||||
const countResult = await pool.query(
|
let activeQuery, activeParams = [];
|
||||||
`SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`
|
if (teamPatterns.length > 0) {
|
||||||
);
|
activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open' AND bu_ownership ILIKE ANY($1::text[])`;
|
||||||
|
activeParams = [teamPatterns];
|
||||||
|
} else {
|
||||||
|
activeQuery = `SELECT COUNT(*) as total FROM ivanti_findings WHERE state = 'open'`;
|
||||||
|
}
|
||||||
|
const countResult = await pool.query(activeQuery, activeParams);
|
||||||
stats.ACTIVE = parseInt(countResult.rows[0].total) || 0;
|
stats.ACTIVE = parseInt(countResult.rows[0].total) || 0;
|
||||||
|
|
||||||
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
|
const total = stats.ACTIVE + stats.ARCHIVED + stats.RETURNED + stats.CLOSED;
|
||||||
@@ -114,7 +210,17 @@ function createIvantiArchiveRouter() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:findingId/history — Transition history for a specific archived finding
|
/**
|
||||||
|
* GET /:findingId/history
|
||||||
|
* Transition history for a specific archived finding.
|
||||||
|
*
|
||||||
|
* @param {string} findingId - The finding ID to look up in the archives.
|
||||||
|
*
|
||||||
|
* @response {object} 200
|
||||||
|
* { finding_id: string, transitions: Array<{ id, archive_id, from_state, to_state, transitioned_at, reason }> }
|
||||||
|
* @response {object} 500 - Database error
|
||||||
|
* { error: string }
|
||||||
|
*/
|
||||||
router.get('/:findingId/history', async (req, res) => {
|
router.get('/:findingId/history', async (req, res) => {
|
||||||
const { findingId } = req.params;
|
const { findingId } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ async function readState() {
|
|||||||
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: row.total || 0,
|
total: workflows.length,
|
||||||
workflows,
|
workflows,
|
||||||
synced_at: row.synced_at,
|
synced_at: row.synced_at,
|
||||||
sync_status: row.sync_status,
|
sync_status: row.sync_status,
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
|||||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin } = useAuth();
|
const { isAuthenticated, loading: authLoading, canWrite, canDelete, canExport, isAdmin, getActiveTeamsParam, adminScope } = useAuth();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
|
||||||
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
|
||||||
@@ -402,7 +402,11 @@ export default function App() {
|
|||||||
setArchiveFilter(newFilter);
|
setArchiveFilter(newFilter);
|
||||||
if (newFilter) {
|
if (newFilter) {
|
||||||
setArchiveListLoading(true);
|
setArchiveListLoading(true);
|
||||||
fetch(`${API_BASE}/ivanti/archive?state=${newFilter}`, { credentials: 'include' })
|
const teamsParam = getActiveTeamsParam();
|
||||||
|
const url = teamsParam
|
||||||
|
? `${API_BASE}/ivanti/archive?state=${newFilter}&teams=${encodeURIComponent(teamsParam)}`
|
||||||
|
: `${API_BASE}/ivanti/archive?state=${newFilter}`;
|
||||||
|
fetch(url, { credentials: 'include' })
|
||||||
.then(res => res.ok ? res.json() : Promise.reject())
|
.then(res => res.ok ? res.json() : Promise.reject())
|
||||||
.then(data => setArchiveList(data.archives || []))
|
.then(data => setArchiveList(data.archives || []))
|
||||||
.catch(() => setArchiveList([]))
|
.catch(() => setArchiveList([]))
|
||||||
@@ -2357,12 +2361,12 @@ export default function App() {
|
|||||||
{/* Last synced line */}
|
{/* Last synced line */}
|
||||||
<div className="text-xs text-gray-500 font-mono mb-4">
|
<div className="text-xs text-gray-500 font-mono mb-4">
|
||||||
{ivantiSyncedAt
|
{ivantiSyncedAt
|
||||||
? `Synced ${new Date(ivantiSyncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
? `Synced ${new Date(ivantiSyncedAt).toLocaleString()}`
|
||||||
: 'Never synced'}
|
: 'Never synced'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Archive Summary Bar */}
|
{/* Archive Summary Bar */}
|
||||||
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} />
|
<ArchiveSummaryBar onStateClick={handleArchiveStateClick} activeFilter={archiveFilter} refreshKey={archiveRefreshKey} teamsParam={getActiveTeamsParam()} />
|
||||||
|
|
||||||
{/* Archive list — shown when a state card is clicked */}
|
{/* Archive list — shown when a state card is clicked */}
|
||||||
{archiveFilter && (
|
{archiveFilter && (
|
||||||
@@ -2408,7 +2412,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.55rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
<span style={{ fontFamily: 'monospace', fontSize: '0.55rem', padding: '0.15rem 0.35rem', borderRadius: '0.25rem', background: 'rgba(100, 116, 139, 0.2)', border: '1px solid rgba(100, 116, 139, 0.4)', color: '#94A3B8', whiteSpace: 'nowrap' }}>
|
||||||
Last seen: {(a.last_severity && a.last_severity !== 0) ? a.last_severity.toFixed(1) : '—'}
|
Last seen: {(a.last_severity && Number(a.last_severity) !== 0) ? Number(a.last_severity).toFixed(1) : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginLeft: '1.375rem' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B', marginLeft: '1.375rem' }}>
|
||||||
@@ -2416,7 +2420,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
{a.related_active && (
|
{a.related_active && (
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#0EA5E9', marginTop: '0.35rem', marginLeft: '1.375rem', padding: '0.2rem 0.4rem', background: 'rgba(14, 165, 233, 0.1)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.25rem', display: 'inline-block' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#0EA5E9', marginTop: '0.35rem', marginLeft: '1.375rem', padding: '0.2rem 0.4rem', background: 'rgba(14, 165, 233, 0.1)', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.25rem', display: 'inline-block' }}>
|
||||||
Similar finding active — ID: {a.related_active.id} ({a.related_active.severity?.toFixed(1) ?? '—'})
|
Similar finding active — ID: {a.related_active.id} ({a.related_active.severity ? Number(a.related_active.severity).toFixed(1) : '—'})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ function hexToRgb(hex) {
|
|||||||
return `${r}, ${g}, ${b}`;
|
return `${r}, ${g}, ${b}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshKey }) {
|
export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshKey, teamsParam }) {
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
@@ -132,7 +132,10 @@ export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshK
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/ivanti/archive/stats`, { credentials: 'include' });
|
const url = teamsParam
|
||||||
|
? `${API_BASE}/ivanti/archive/stats?teams=${encodeURIComponent(teamsParam)}`
|
||||||
|
: `${API_BASE}/ivanti/archive/stats`;
|
||||||
|
const res = await fetch(url, { credentials: 'include' });
|
||||||
if (res.ok && !cancelled) {
|
if (res.ok && !cancelled) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setStats(data);
|
setStats(data);
|
||||||
@@ -150,7 +153,7 @@ export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshK
|
|||||||
// Re-fetch every 60s so stats stay reasonably fresh after syncs
|
// Re-fetch every 60s so stats stay reasonably fresh after syncs
|
||||||
const interval = setInterval(load, 60000);
|
const interval = setInterval(load, 60000);
|
||||||
return () => { cancelled = true; clearInterval(interval); };
|
return () => { cancelled = true; clearInterval(interval); };
|
||||||
}, [refreshKey]);
|
}, [refreshKey, teamsParam]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4109,8 +4109,13 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
|||||||
)) : null;
|
)) : null;
|
||||||
})()}
|
})()}
|
||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', textAlign: 'center', padding: '2rem 0' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B', textAlign: 'center', padding: '2rem 0' }}>
|
||||||
No history entries.
|
{/* ⚠️ CONVENTION: Use lucide-react icons instead of raw HTML entities/emoji */}
|
||||||
|
<div style={{ fontSize: '1.5rem', marginBottom: '0.5rem', opacity: 0.4 }}>📋</div>
|
||||||
|
No history entries yet.
|
||||||
|
<div style={{ fontSize: '0.65rem', color: '#475569', marginTop: '0.35rem' }}>
|
||||||
|
Changes to this submission will appear here.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : history.map((entry, idx) => {
|
) : history.map((entry, idx) => {
|
||||||
const details = (() => {
|
const details = (() => {
|
||||||
@@ -5773,7 +5778,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
}, [buildExportRows]);
|
}, [buildExportRows]);
|
||||||
|
|
||||||
const syncedDisplay = syncedAt
|
const syncedDisplay = syncedAt
|
||||||
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
? `Synced ${new Date(syncedAt).toLocaleString()}`
|
||||||
: 'Never synced';
|
: 'Never synced';
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user