Compare commits
7 Commits
669396f635
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| f24cdb5063 | |||
| 3e2546323e | |||
| b1a21e8771 | |||
| bc9e223ab7 | |||
| 2d1acca990 | |||
| 9893460b64 | |||
| 51b1f99b3a |
@@ -9,6 +9,7 @@ const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
|||||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const FINDINGS_FILTERS = [
|
const FINDINGS_FILTERS = [
|
||||||
|
// NOTE: This filters for Open findings only — Closed count is fetched separately via syncClosedCount()
|
||||||
{
|
{
|
||||||
field: 'assetCustomAttributes.1550_host_1.value',
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
exclusive: false,
|
exclusive: false,
|
||||||
@@ -38,6 +39,37 @@ const FINDINGS_FILTERS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Same BU + severity filters but for Closed state — used only to fetch the total count
|
||||||
|
const CLOSED_COUNT_FILTERS = [
|
||||||
|
{
|
||||||
|
field: 'assetCustomAttributes.1550_host_1.value',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'severity',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'RANGE',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: '8.5,9.9',
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generic_state',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'EXACT',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: 'Closed',
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
// HTTP helper — mirrors the one in ivantiWorkflows.js
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -105,6 +137,20 @@ function initTables(db) {
|
|||||||
)
|
)
|
||||||
`, (err) => { if (err) return reject(err); });
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
open_count INTEGER DEFAULT 0,
|
||||||
|
closed_count INTEGER DEFAULT 0,
|
||||||
|
synced_at DATETIME
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
||||||
|
VALUES (1, 0, 0)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
ON ivanti_finding_notes(finding_id)
|
ON ivanti_finding_notes(finding_id)
|
||||||
@@ -130,6 +176,37 @@ function extractFinding(f) {
|
|||||||
// CVE list: vulnerabilities.vulnInfoList[].cve
|
// CVE list: vulnerabilities.vulnInfoList[].cve
|
||||||
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
|
||||||
|
|
||||||
|
// Workflow: only capture FP# (False Positive) tickets — SYS# are auto-generated
|
||||||
|
// system workflows and not actionable for our purposes.
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const fpBuckets = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
|
||||||
|
// Priority: actionable > requested > reworked > rejected > expired > approved
|
||||||
|
const fpEntry = fpBuckets[0] || null;
|
||||||
|
|
||||||
|
// Fallback: if no FP# in distribution, check workflowGeneratedNames directly
|
||||||
|
const generatedNames = f.workflowGeneratedNames || [];
|
||||||
|
const fpFromNames = !fpEntry
|
||||||
|
? generatedNames.find(n => n.startsWith('FP#')) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const workflow = fpEntry ? {
|
||||||
|
id: fpEntry.generatedId || '',
|
||||||
|
state: fpEntry.state || '',
|
||||||
|
type: 'FP',
|
||||||
|
} : fpFromNames ? {
|
||||||
|
id: fpFromNames,
|
||||||
|
state: '',
|
||||||
|
type: 'FP',
|
||||||
|
} : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(f.id),
|
id: String(f.id),
|
||||||
title: f.title || '',
|
title: f.title || '',
|
||||||
@@ -143,10 +220,47 @@ function extractFinding(f) {
|
|||||||
dueDate,
|
dueDate,
|
||||||
lastFoundOn: f.lastFoundOn || '',
|
lastFoundOn: f.lastFoundOn || '',
|
||||||
buOwnership,
|
buOwnership,
|
||||||
cves
|
cves,
|
||||||
|
workflow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fetch total count of Closed findings from Ivanti (page 0, size 1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
filters: CLOSED_COUNT_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page: 0,
|
||||||
|
size: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
if (result.status !== 200) throw new Error(`Closed count API returned status ${result.status}`);
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
// RiskSense returns total in page.totalElements or page.total
|
||||||
|
const closedCount = data.page?.totalElements ?? data.page?.total ?? 0;
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount, closedCount]
|
||||||
|
);
|
||||||
|
console.log(`[Ivanti Findings] Counts updated — open: ${openCount}, closed: ${closedCount}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] Failed to fetch closed count:', err.message);
|
||||||
|
// Still update open count so it stays in sync; leave closed_count as-is
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET open_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[openCount]
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -201,6 +315,7 @@ async function syncFindings(db) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||||
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || 'Unknown error';
|
const msg = err.message || 'Unknown error';
|
||||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||||
@@ -264,6 +379,22 @@ function readNotes(db) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCounts(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT open_count, closed_count, synced_at FROM ivanti_counts_cache WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve({
|
||||||
|
open: row?.open_count ?? 0,
|
||||||
|
closed: row?.closed_count ?? 0,
|
||||||
|
synced_at: row?.synced_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function readStateWithNotes(db) {
|
async function readStateWithNotes(db) {
|
||||||
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
||||||
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
||||||
@@ -301,6 +432,15 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /counts — open vs closed totals for pie chart
|
||||||
|
router.get('/counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readCounts(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||||
router.put('/:findingId/note', (req, res) => {
|
router.put('/:findingId/note', (req, res) => {
|
||||||
const { findingId } = req.params;
|
const { findingId } = req.params;
|
||||||
|
|||||||
120
docs/MOP-workflow-color-codes.md
Normal file
120
docs/MOP-workflow-color-codes.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# MOP: Ivanti Finding Workflow Status — STEAM Security Dashboard
|
||||||
|
|
||||||
|
**Document Type:** Method of Procedure
|
||||||
|
**Applies To:** STEAM Security Dashboard — Reporting Page
|
||||||
|
**Audience:** NTS-AEO-ACCESS-ENG / NTS-AEO-STEAM team members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document explains how to interpret the **Workflow** column on the Reporting page and what action to take for each status. The goal is to ensure every open finding is actively managed and no False Positive (FP) exception lapses unnoticed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Background
|
||||||
|
|
||||||
|
### What the Reporting Page Shows
|
||||||
|
The Reporting page displays **open findings only** (severity 8.5+, `generic_state = Open`). A finding disappears from this list when it is closed — which happens when a valid, approved FP exception is on file or when the vulnerability is remediated.
|
||||||
|
|
||||||
|
### What the Workflow Column Shows
|
||||||
|
The Workflow column tracks **FP# tickets only** — False Positive requests that a team member has manually submitted in Ivanti. These represent cases where the team has asserted a finding is not exploitable or applicable in our environment.
|
||||||
|
|
||||||
|
> **SYS# workflows are not shown.** SYS# are auto-generated system tracking records and do not require team action.
|
||||||
|
|
||||||
|
### Key Rule
|
||||||
|
If a finding appears in the Reporting page, it requires action — regardless of whether it has an FP# badge or not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workflow Column Color Codes
|
||||||
|
|
||||||
|
### 🔴 Red — Act Immediately
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Expired** | An FP# ticket existed but the exception window has lapsed. The finding re-opened. | Log into Ivanti and submit a **new FP request** for this finding. Reference the previous ticket if relevant. |
|
||||||
|
| **Rejected** | The security team reviewed the FP request and denied it. The finding is considered a real, exploitable vulnerability. | **Remediate the vulnerability.** Apply the relevant patch, configuration change, or compensating control. Do not resubmit an FP without new evidence. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 Amber — Action Required Soon
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Reworked** | The FP request was challenged by the reviewer and sent back for revision. | Review the reviewer's comments in Ivanti. Update the FP justification and **resubmit the ticket**. |
|
||||||
|
| **Actionable** | The FP ticket has been flagged as needing team action. | Open the ticket in Ivanti to review what is needed and respond accordingly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔵 Blue — In Flight, Monitor
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Requested** | An FP# ticket has been submitted and is awaiting security team approval. | No immediate action. Monitor for approval or rejection. If no response within your SLA window, follow up with the approver. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### — (No Badge) — Untriaged
|
||||||
|
|
||||||
|
| State | What It Means | Required Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **No workflow badge** | No FP ticket has ever been submitted for this finding. | Triage the finding. Determine whether to: (1) remediate it, or (2) submit a new FP request if you have justification that it is a false positive. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Finding appears in Reporting page
|
||||||
|
│
|
||||||
|
├── Does it have a Workflow badge?
|
||||||
|
│ │
|
||||||
|
│ ├── NO (—)
|
||||||
|
│ │ └── Triage → Remediate OR submit new FP request
|
||||||
|
│ │
|
||||||
|
│ └── YES → Check the color:
|
||||||
|
│ │
|
||||||
|
│ ├── 🔵 BLUE (Requested)
|
||||||
|
│ │ └── Wait for approval. Follow up if SLA window is approaching.
|
||||||
|
│ │
|
||||||
|
│ ├── 🟡 AMBER (Reworked / Actionable)
|
||||||
|
│ │ └── Open Ivanti ticket → Review feedback → Update → Resubmit
|
||||||
|
│ │
|
||||||
|
│ └── 🔴 RED
|
||||||
|
│ │
|
||||||
|
│ ├── Expired → Submit NEW FP request in Ivanti
|
||||||
|
│ │
|
||||||
|
│ └── Rejected → Remediate the vulnerability
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How to Submit or Renew an FP Request in Ivanti
|
||||||
|
|
||||||
|
1. Log into [Ivanti / RiskSense](https://platform4.risksense.com)
|
||||||
|
2. Navigate to **Host Findings**
|
||||||
|
3. Search for the Finding ID shown in the dashboard (Finding ID column)
|
||||||
|
4. Select the finding → **Actions** → **Request False Positive**
|
||||||
|
5. Complete the justification form:
|
||||||
|
- Describe why the finding is not exploitable in this environment
|
||||||
|
- Reference any compensating controls, network segmentation, or vendor guidance
|
||||||
|
- Attach supporting evidence if available
|
||||||
|
6. Submit — ticket will appear as **Requested** (blue) in the dashboard once processed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Quick Reference Card
|
||||||
|
|
||||||
|
| Badge Color | State | One-Line Action |
|
||||||
|
|---|---|---|
|
||||||
|
| 🔴 Red | Expired | Renew FP request in Ivanti |
|
||||||
|
| 🔴 Red | Rejected | Remediate the vulnerability |
|
||||||
|
| 🟡 Amber | Reworked | Update and resubmit FP ticket |
|
||||||
|
| 🟡 Amber | Actionable | Review ticket in Ivanti |
|
||||||
|
| 🔵 Blue | Requested | Monitor — no action yet |
|
||||||
|
| — | No badge | Triage: remediate or submit FP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: 2026-03-11*
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export default function App() {
|
|||||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||||
const [currentPage, setCurrentPage] = useState('home');
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
@@ -961,7 +962,11 @@ export default function App() {
|
|||||||
isOpen={navOpen}
|
isOpen={navOpen}
|
||||||
onClose={() => setNavOpen(false)}
|
onClose={() => setNavOpen(false)}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onNavigate={setCurrentPage}
|
onNavigate={(page) => {
|
||||||
|
// Clear calendar filter when navigating directly via the nav drawer
|
||||||
|
if (page === 'reporting') setCalendarFilter(null);
|
||||||
|
setCurrentPage(page);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Scanning line effect */}
|
{/* Scanning line effect */}
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
@@ -1036,7 +1041,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{currentPage === 'reporting' && <ReportingPage />}
|
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
|
||||||
@@ -2220,7 +2225,12 @@ export default function App() {
|
|||||||
Calendar
|
Calendar
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<CalendarWidget />
|
<CalendarWidget
|
||||||
|
onDateClick={(dateStr) => {
|
||||||
|
setCalendarFilter(dateStr);
|
||||||
|
setCurrentPage('reporting');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open Vendor Tickets */}
|
{/* Open Vendor Tickets */}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function toLocalDateStr(date) {
|
|||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarWidget() {
|
export default function CalendarWidget({ onDateClick }) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const todayStr = toLocalDateStr(today);
|
const todayStr = toLocalDateStr(today);
|
||||||
|
|
||||||
@@ -116,15 +116,19 @@ export default function CalendarWidget() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due` : undefined}
|
title={hasDue ? `${dueCount} finding${dueCount > 1 ? 's' : ''} due — click to view` : undefined}
|
||||||
|
onClick={hasDue && onDateClick ? () => onDateClick(dateStr) : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
gap: '2px', padding: '3px 1px',
|
gap: '2px', padding: '3px 1px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||||
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||||
cursor: hasDue ? 'default' : 'default',
|
cursor: hasDue ? 'pointer' : 'default',
|
||||||
|
transition: hasDue ? 'background 0.15s' : undefined,
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||||
|
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download } from 'lucide-react';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const STORAGE_KEY = 'steam_findings_columns_v1';
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Column definitions — source of truth for labels, sort behaviour, rendering
|
// Column definitions — source of truth for labels, sort behaviour, rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const COLUMN_DEFS = {
|
const COLUMN_DEFS = {
|
||||||
|
findingId: { label: 'Finding ID', sortable: true, filterable: false },
|
||||||
severity: { label: 'Severity', sortable: true, filterable: true },
|
severity: { label: 'Severity', sortable: true, filterable: true },
|
||||||
title: { label: 'Title', sortable: true, filterable: true },
|
title: { label: 'Title', sortable: true, filterable: true },
|
||||||
cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
|
cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
|
||||||
@@ -16,13 +18,15 @@ const COLUMN_DEFS = {
|
|||||||
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
||||||
dns: { label: 'DNS', sortable: true, filterable: true },
|
dns: { label: 'DNS', sortable: true, filterable: true },
|
||||||
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
||||||
slaStatus: { label: 'SLA', sortable: true, filterable: true },
|
slaStatus: { label: 'SLA', sortable: true, filterable: true },
|
||||||
buOwnership: { label: 'BU', sortable: true, filterable: true },
|
buOwnership: { label: 'BU', sortable: true, filterable: true },
|
||||||
|
workflow: { label: 'Workflow', sortable: true, filterable: true },
|
||||||
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
|
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
|
||||||
note: { label: 'Notes', sortable: false, filterable: false },
|
note: { label: 'Notes', sortable: false, filterable: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_COLUMN_ORDER = [
|
const DEFAULT_COLUMN_ORDER = [
|
||||||
|
{ key: 'findingId', visible: true },
|
||||||
{ key: 'severity', visible: true },
|
{ key: 'severity', visible: true },
|
||||||
{ key: 'title', visible: true },
|
{ key: 'title', visible: true },
|
||||||
{ key: 'cves', visible: true },
|
{ key: 'cves', visible: true },
|
||||||
@@ -32,6 +36,7 @@ const DEFAULT_COLUMN_ORDER = [
|
|||||||
{ key: 'dueDate', visible: true },
|
{ key: 'dueDate', visible: true },
|
||||||
{ key: 'slaStatus', visible: true },
|
{ key: 'slaStatus', visible: true },
|
||||||
{ key: 'buOwnership', visible: true },
|
{ key: 'buOwnership', visible: true },
|
||||||
|
{ key: 'workflow', visible: true },
|
||||||
{ key: 'lastFoundOn', visible: true },
|
{ key: 'lastFoundOn', visible: true },
|
||||||
{ key: 'note', visible: true },
|
{ key: 'note', visible: true },
|
||||||
];
|
];
|
||||||
@@ -63,6 +68,7 @@ function saveColumnOrder(order) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function getVal(finding, key) {
|
function getVal(finding, key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
case 'findingId': return finding.id ?? '';
|
||||||
case 'severity': return finding.severity ?? 0;
|
case 'severity': return finding.severity ?? 0;
|
||||||
case 'title': return finding.title ?? '';
|
case 'title': return finding.title ?? '';
|
||||||
case 'hostName': return finding.hostName ?? '';
|
case 'hostName': return finding.hostName ?? '';
|
||||||
@@ -72,6 +78,7 @@ function getVal(finding, key) {
|
|||||||
case 'slaStatus': return finding.slaStatus ?? '';
|
case 'slaStatus': return finding.slaStatus ?? '';
|
||||||
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
||||||
case 'buOwnership': return finding.buOwnership ?? '';
|
case 'buOwnership': return finding.buOwnership ?? '';
|
||||||
|
case 'workflow': return finding.workflow?.id ?? '';
|
||||||
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||||||
case 'note': return finding.note ?? '';
|
case 'note': return finding.note ?? '';
|
||||||
default: return '';
|
default: return '';
|
||||||
@@ -84,9 +91,32 @@ function getVal(finding, key) {
|
|||||||
function getFilterVal(finding, key) {
|
function getFilterVal(finding, key) {
|
||||||
if (key === 'severity') return finding.vrrGroup || '';
|
if (key === 'severity') return finding.vrrGroup || '';
|
||||||
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
|
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
|
||||||
|
if (key === 'workflow') return finding.workflow?.id || '';
|
||||||
return String(getVal(finding, key) ?? '');
|
return String(getVal(finding, key) ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export value accessor — plain text representation for CSV/XLSX
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function getExportVal(finding, key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'findingId': return finding.id ?? '';
|
||||||
|
case 'severity': return finding.vrrGroup ? `${finding.severity?.toFixed(2)} ${finding.vrrGroup}` : String(finding.severity ?? '');
|
||||||
|
case 'title': return finding.title ?? '';
|
||||||
|
case 'cves': return (finding.cves || []).join(', ');
|
||||||
|
case 'hostName': return finding.hostName ?? '';
|
||||||
|
case 'ipAddress': return finding.ipAddress ?? '';
|
||||||
|
case 'dns': return finding.dns ?? '';
|
||||||
|
case 'dueDate': return finding.dueDate ?? '';
|
||||||
|
case 'slaStatus': return finding.slaStatus ?? '';
|
||||||
|
case 'buOwnership': return finding.buOwnership ?? '';
|
||||||
|
case 'workflow': return finding.workflow ? `${finding.workflow.id} (${finding.workflow.state})` : '';
|
||||||
|
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||||||
|
case 'note': return finding.note ?? '';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Style helpers
|
// Style helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -118,6 +148,122 @@ function dueDateColor(dueDate) {
|
|||||||
return '#94A3B8';
|
return '#94A3B8';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowStyle(state) {
|
||||||
|
// Colors reflect action urgency — all findings here are Open, so Approved won't appear.
|
||||||
|
switch ((state || '').toLowerCase()) {
|
||||||
|
case 'expired': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // overdue — renew FP
|
||||||
|
case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // denied — must remediate
|
||||||
|
case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // challenged — resubmit FP
|
||||||
|
case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // needs action
|
||||||
|
case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' }; // in flight — awaiting approval
|
||||||
|
default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; // unknown state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG Donut Chart — Open vs Closed findings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function polarToCartesian(cx, cy, r, angleDeg) {
|
||||||
|
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||||
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function donutArcPath(cx, cy, outerR, innerR, startDeg, endDeg) {
|
||||||
|
// Full circle must be split into two arcs (SVG can't render a 360° arc)
|
||||||
|
if (Math.abs(endDeg - startDeg) >= 359.9) {
|
||||||
|
const mid = startDeg + 180;
|
||||||
|
return donutArcPath(cx, cy, outerR, innerR, startDeg, mid) + ' ' +
|
||||||
|
donutArcPath(cx, cy, outerR, innerR, mid, endDeg);
|
||||||
|
}
|
||||||
|
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||||
|
const s = polarToCartesian(cx, cy, outerR, startDeg);
|
||||||
|
const e = polarToCartesian(cx, cy, outerR, endDeg);
|
||||||
|
const si = polarToCartesian(cx, cy, innerR, endDeg);
|
||||||
|
const ei = polarToCartesian(cx, cy, innerR, startDeg);
|
||||||
|
return [
|
||||||
|
`M ${s.x.toFixed(2)} ${s.y.toFixed(2)}`,
|
||||||
|
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`,
|
||||||
|
`L ${si.x.toFixed(2)} ${si.y.toFixed(2)}`,
|
||||||
|
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${ei.x.toFixed(2)} ${ei.y.toFixed(2)}`,
|
||||||
|
'Z',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDonut({ open, closed, loading }) {
|
||||||
|
const SIZE = 180;
|
||||||
|
const CX = SIZE / 2;
|
||||||
|
const CY = SIZE / 2;
|
||||||
|
const OUTER = 72;
|
||||||
|
const INNER = 48;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||||
|
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = open + closed;
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||||
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeg = (open / total) * 360;
|
||||||
|
const segments = [
|
||||||
|
{ label: 'Open', count: open, color: '#0EA5E9', start: 0, end: openDeg },
|
||||||
|
{ label: 'Closed', count: closed, color: '#475569', start: openDeg, end: 360 },
|
||||||
|
].filter((s) => s.count > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||||
|
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||||
|
{/* Gap ring behind slices */}
|
||||||
|
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||||
|
{segments.map((seg) => (
|
||||||
|
<path
|
||||||
|
key={seg.label}
|
||||||
|
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||||
|
fill={seg.color}
|
||||||
|
opacity={0.88}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Center total */}
|
||||||
|
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||||
|
TOTAL
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{segments.map((seg) => (
|
||||||
|
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||||
|
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
{seg.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||||||
|
{seg.count.toLocaleString()}
|
||||||
|
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
|
||||||
|
({((seg.count / total) * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SortIcon({ colKey, sort }) {
|
function SortIcon({ colKey, sort }) {
|
||||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||||
return sort.dir === 'asc'
|
return sort.dir === 'asc'
|
||||||
@@ -449,6 +595,12 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function TableCell({ colKey, finding }) {
|
function TableCell({ colKey, finding }) {
|
||||||
switch (colKey) {
|
switch (colKey) {
|
||||||
|
case 'findingId':
|
||||||
|
return (
|
||||||
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#475569', fontFamily: 'monospace', fontSize: '0.68rem' }}>
|
||||||
|
{finding.id || '—'}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
case 'severity': {
|
case 'severity': {
|
||||||
const sc = severityColor(finding.vrrGroup);
|
const sc = severityColor(finding.vrrGroup);
|
||||||
return (
|
return (
|
||||||
@@ -544,6 +696,30 @@ function TableCell({ colKey, finding }) {
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'workflow': {
|
||||||
|
const wf = finding.workflow;
|
||||||
|
if (!wf || !wf.id) return <td style={{ padding: '0.45rem 0.75rem', color: '#334155' }}>—</td>;
|
||||||
|
const ws = workflowStyle(wf.state);
|
||||||
|
return (
|
||||||
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
<span
|
||||||
|
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||||
|
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
|
||||||
|
background: ws.bg, border: `1px solid ${ws.border}`,
|
||||||
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||||
|
color: ws.text, cursor: 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wf.id}
|
||||||
|
<span style={{ fontSize: '0.58rem', opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||||
|
{wf.state}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
case 'lastFoundOn':
|
case 'lastFoundOn':
|
||||||
return (
|
return (
|
||||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||||
@@ -564,7 +740,7 @@ function TableCell({ colKey, finding }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main ReportingPage
|
// Main ReportingPage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function ReportingPage() {
|
export default function ReportingPage({ filterDate }) {
|
||||||
const [findings, setFindings] = useState([]);
|
const [findings, setFindings] = useState([]);
|
||||||
const [total, setTotal] = useState(null);
|
const [total, setTotal] = useState(null);
|
||||||
const [syncedAt, setSyncedAt] = useState(null);
|
const [syncedAt, setSyncedAt] = useState(null);
|
||||||
@@ -572,9 +748,13 @@ export default function ReportingPage() {
|
|||||||
const [syncError, setSyncError] = useState(null);
|
const [syncError, setSyncError] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
|
||||||
|
const [countsLoading, setCountsLoading] = useState(true);
|
||||||
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||||||
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||||||
const [columnFilters, setColumnFilters] = useState({});
|
const [columnFilters, setColumnFilters] = useState(() =>
|
||||||
|
filterDate ? { dueDate: new Set([filterDate]) } : {}
|
||||||
|
);
|
||||||
const [openFilter, setOpenFilter] = useState(null);
|
const [openFilter, setOpenFilter] = useState(null);
|
||||||
const filterBtnRefs = useRef({});
|
const filterBtnRefs = useRef({});
|
||||||
|
|
||||||
@@ -591,6 +771,19 @@ export default function ReportingPage() {
|
|||||||
setSyncError(data.error_message || null);
|
setSyncError(data.error_message || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchCounts = async () => {
|
||||||
|
setCountsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading status counts:', e);
|
||||||
|
} finally {
|
||||||
|
setCountsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchFindings = async () => {
|
const fetchFindings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -609,7 +802,10 @@ export default function ReportingPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) applyState(data);
|
if (res.ok) {
|
||||||
|
applyState(data);
|
||||||
|
fetchCounts(); // refresh counts after sync
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error syncing findings:', e);
|
console.error('Error syncing findings:', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -617,7 +813,10 @@ export default function ReportingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
|
useEffect(() => {
|
||||||
|
fetchFindings();
|
||||||
|
fetchCounts();
|
||||||
|
}, []); // eslint-disable-line
|
||||||
|
|
||||||
// Set/clear a single column filter
|
// Set/clear a single column filter
|
||||||
const setColFilter = useCallback((colKey, vals) => {
|
const setColFilter = useCallback((colKey, vals) => {
|
||||||
@@ -674,6 +873,68 @@ export default function ReportingPage() {
|
|||||||
|
|
||||||
const activeFilterCount = Object.keys(columnFilters).length;
|
const activeFilterCount = Object.keys(columnFilters).length;
|
||||||
|
|
||||||
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||||
|
const exportBtnRef = useRef(null);
|
||||||
|
|
||||||
|
// Close export menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!exportMenuOpen) return;
|
||||||
|
const handler = (e) => {
|
||||||
|
if (exportBtnRef.current && !exportBtnRef.current.contains(e.target)) {
|
||||||
|
setExportMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [exportMenuOpen]);
|
||||||
|
|
||||||
|
const buildExportRows = useCallback(() => {
|
||||||
|
const cols = visibleCols.filter((c) => COLUMN_DEFS[c.key]);
|
||||||
|
const headers = cols.map((c) => COLUMN_DEFS[c.key].label);
|
||||||
|
const rows = sorted.map((finding) =>
|
||||||
|
cols.map((c) => getExportVal(finding, c.key))
|
||||||
|
);
|
||||||
|
return [headers, ...rows];
|
||||||
|
}, [sorted, visibleCols]);
|
||||||
|
|
||||||
|
const exportCSV = useCallback(() => {
|
||||||
|
setExportMenuOpen(false);
|
||||||
|
const rows = buildExportRows();
|
||||||
|
const csvContent = rows.map((row) =>
|
||||||
|
row.map((cell) => {
|
||||||
|
const s = String(cell ?? '');
|
||||||
|
// Quote if it contains comma, double-quote, or newline
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}).join(',')
|
||||||
|
).join('\r\n');
|
||||||
|
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `findings-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [buildExportRows]);
|
||||||
|
|
||||||
|
const exportXLSX = useCallback(() => {
|
||||||
|
setExportMenuOpen(false);
|
||||||
|
const rows = buildExportRows();
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||||||
|
// Auto-fit column widths
|
||||||
|
const colWidths = rows[0].map((_, ci) =>
|
||||||
|
Math.min(60, Math.max(10, ...rows.map((r) => String(r[ci] ?? '').length)))
|
||||||
|
);
|
||||||
|
ws['!cols'] = colWidths.map((w) => ({ wch: w }));
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Findings');
|
||||||
|
XLSX.writeFile(wb, `findings-export-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
|
}, [buildExportRows]);
|
||||||
|
|
||||||
const syncedDisplay = syncedAt
|
const syncedDisplay = syncedAt
|
||||||
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||||
: 'Never synced';
|
: 'Never synced';
|
||||||
@@ -701,10 +962,18 @@ export default function ReportingPage() {
|
|||||||
Metric Graphs
|
Metric Graphs
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '120px', border: '1px dashed rgba(245,158,11,0.2)', borderRadius: '0.375rem', background: 'rgba(245,158,11,0.03)' }}>
|
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
|
||||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
{/* Open vs Closed donut */}
|
||||||
Pie charts & metrics — coming soon
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
</p>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||||
|
Open vs Closed
|
||||||
|
</div>
|
||||||
|
<StatusDonut
|
||||||
|
open={statusCounts.open}
|
||||||
|
closed={statusCounts.closed}
|
||||||
|
loading={countsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -760,6 +1029,60 @@ export default function ReportingPage() {
|
|||||||
Clear Filters
|
Clear Filters
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Export dropdown */}
|
||||||
|
<div ref={exportBtnRef} style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExportMenuOpen((o) => !o)}
|
||||||
|
disabled={sorted.length === 0}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: 'rgba(16,185,129,0.08)',
|
||||||
|
border: '1px solid rgba(16,185,129,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: '#10B981', cursor: sorted.length === 0 ? 'not-allowed' : 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
opacity: sorted.length === 0 ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download style={{ width: '11px', height: '11px' }} />
|
||||||
|
Export
|
||||||
|
<ChevronDown style={{ width: '10px', height: '10px', marginLeft: '1px' }} />
|
||||||
|
</button>
|
||||||
|
{exportMenuOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 200,
|
||||||
|
background: 'rgb(12,22,40)', border: '1px solid rgba(16,185,129,0.3)',
|
||||||
|
borderRadius: '0.375rem', overflow: 'hidden',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||||
|
minWidth: '120px',
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ label: 'CSV (.csv)', action: exportCSV },
|
||||||
|
{ label: 'Excel (.xlsx)', action: exportXLSX },
|
||||||
|
].map(({ label, action }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={action}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left',
|
||||||
|
padding: '0.5rem 0.875rem',
|
||||||
|
background: 'none', border: 'none',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.73rem', fontWeight: '600',
|
||||||
|
color: '#10B981', cursor: 'pointer',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(16,185,129,0.1)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||||
<button
|
<button
|
||||||
onClick={syncFindings}
|
onClick={syncFindings}
|
||||||
|
|||||||
Reference in New Issue
Block a user