Compare commits
3 Commits
b111273e5a
...
5102a2c5b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 5102a2c5b4 | |||
| a0a8979c63 | |||
| 15ad207464 |
@@ -175,6 +175,15 @@ function initTables(db) {
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||
ON ivanti_finding_overrides(finding_id)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
open_count INTEGER NOT NULL,
|
||||
closed_count INTEGER NOT NULL,
|
||||
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
@@ -271,6 +280,14 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
||||
`UPDATE ivanti_counts_cache SET open_count=?, closed_count=?, synced_at=datetime('now') WHERE id=1`,
|
||||
[openCount, closedCount]
|
||||
);
|
||||
|
||||
// Append a snapshot to history — every sync is stored; the history
|
||||
// endpoint aggregates to last-per-day at query time (Option B).
|
||||
await dbRun(db,
|
||||
`INSERT INTO ivanti_counts_history (open_count, closed_count) VALUES (?, ?)`,
|
||||
[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);
|
||||
@@ -576,6 +593,33 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /counts/history — last snapshot per day, ascending, for the trend chart.
|
||||
// Uses a window function (ROW_NUMBER) to pick the final sync of each calendar day.
|
||||
router.get('/counts/history', async (req, res) => {
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT date, open_count, closed_count FROM (
|
||||
SELECT DATE(recorded_at) AS date,
|
||||
open_count, closed_count,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY DATE(recorded_at)
|
||||
ORDER BY recorded_at DESC
|
||||
) AS rn
|
||||
FROM ivanti_counts_history
|
||||
) WHERE rn = 1
|
||||
ORDER BY date ASC`,
|
||||
[],
|
||||
(err, rows) => { if (err) reject(err); else resolve(rows || []); }
|
||||
);
|
||||
});
|
||||
res.json({ history: rows });
|
||||
} catch (err) {
|
||||
console.error('[Ivanti Findings] GET /counts/history error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||
router.get('/fp-workflow-counts', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@ Renders natively in GitHub, GitLab, and most modern documentation tools.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([Open Reporting Page]) --> SYNC
|
||||
START([Open Vulnerability Triage Page]) --> SYNC
|
||||
|
||||
SYNC["① Sync & Sort<br/>Click Sync · Sort Due Date ascending"]
|
||||
SYNC --> DUE{Overdue<br/>findings?}
|
||||
@@ -94,11 +94,11 @@ flowchart TD
|
||||
|
||||
## Diagram 2 — FP Workflow Badge Status Decision Tree
|
||||
|
||||
What to do when a finding already has a workflow badge in the Reporting page.
|
||||
What to do when a finding already has a workflow badge in the Vulnerability Triage page.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A([Finding in<br/>Reporting Page]) --> B{"Check<br/>Workflow column"}
|
||||
A([Finding in<br/>Vulnerability Triage]) --> B{"Check<br/>Workflow column"}
|
||||
|
||||
B -->|No badge| C["UNTRIAGED<br/>No action on record"]
|
||||
C --> C1(["Follow the<br/>Step 1–5 triage workflow ↑"])
|
||||
|
||||
158
docs/team-training-agenda.md
Normal file
158
docs/team-training-agenda.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# STEAM Security Dashboard — Team Training Agenda
|
||||
|
||||
**Session length:** 30–40 minutes
|
||||
**Format:** Live walkthrough (share your screen on the dashboard)
|
||||
**Reference docs:** `security-posture-workflow.md` for full detail on anything covered here
|
||||
|
||||
---
|
||||
|
||||
## Pre-meeting prep
|
||||
|
||||
- Have the dashboard open and logged in before the meeting starts
|
||||
- Sync Vulnerability Triage page so data is fresh when you get there
|
||||
- Print or share `security-posture-workflow.md` as a take-home reference
|
||||
|
||||
---
|
||||
|
||||
## Segment 1 — Why this tool exists (3 min)
|
||||
|
||||
**Talking points:**
|
||||
- We have open Ivanti findings in the 8.5–9.9 VRR range — these are the ones we own and are accountable for
|
||||
- Every finding needs a documented action within **60 days of detection** (the SLA rule)
|
||||
- Findings that age past their Due Date make a device non-compliant in AEO posture reporting
|
||||
- This dashboard is how we track, triage, and prove we've actioned everything — replaces manual spreadsheet tracking
|
||||
|
||||
---
|
||||
|
||||
## Segment 2 — Dashboard orientation (4 min)
|
||||
|
||||
**Show on screen:** Navigate through each page in the nav drawer
|
||||
|
||||
- **Home (CVE Management)** — our CVE research library; this is where we store screenshots, advisories, and Archer EXC numbers against each CVE/vendor pair
|
||||
- **Vulnerability Triage (Host Findings)** — the daily operational page; this is where you spend most of your time
|
||||
- **Compliance** — AEO posture data uploaded from the NTS_AEO xlsx; shows metric health per team
|
||||
- **Knowledge Base** — internal docs, runbooks, advisories
|
||||
- **Exports** — bulk data extracts when needed
|
||||
|
||||
> Tell the team: *"The Vulnerability Triage page is what we'll focus on today — that's where the workflow lives."*
|
||||
|
||||
---
|
||||
|
||||
## Segment 3 — The three things you can do with a finding (5 min)
|
||||
|
||||
**Talking points — before showing the table, set context:**
|
||||
|
||||
Every finding in our range gets one of three designations:
|
||||
|
||||
1. **Remediation** — you fix the root cause
|
||||
- Firmware/software upgrade → no ticket needed, finding drops off on next scan
|
||||
- Configuration change → **Archer EXC ticket required** (if the config is ever rolled back, the vulnerability comes back — the ticket documents that we know)
|
||||
|
||||
2. **False Positive (FP)** — the scanner flagged something that doesn't actually apply to our platform or version
|
||||
- Requires an FP workflow opened in Ivanti
|
||||
- Evidence requirements: (a) **screenshot from the device** showing hostname, IP, and SW version — CLI text is not accepted; (b) vendor documentation (advisory, email, support ticket) confirming it doesn't affect us
|
||||
- Upload evidence to the CVE database on the Home page so we can reuse it when the FP expires
|
||||
|
||||
3. **Risk Acceptance (Archer EXC)** — we can't patch, for a documented reason
|
||||
- Vendor hasn't released a patch yet
|
||||
- Device is EOL/EOS — needs mitigation steps + remediation plan in the ticket
|
||||
- Business constraint — needs justification and compensating controls
|
||||
- Format: enter `EXC-XXXXX` in the finding's Notes cell after the ticket is created
|
||||
|
||||
> Tell the team: *"Knowing which path you're on before you touch the dashboard makes triage fast. The workflow is just deciding which of these three it is."*
|
||||
|
||||
---
|
||||
|
||||
## Segment 4 — The 5-step workflow on the Vulnerability Triage page (15 min)
|
||||
|
||||
**Show on screen:** Vulnerability Triage page, live walkthrough on a real finding
|
||||
|
||||
### Step 1 — Sync and sort (1 min)
|
||||
- Click **Sync** top-right, wait for timestamp to update
|
||||
- Click **Due Date** column to sort ascending — reds first, then ambers
|
||||
- Red = overdue, Amber = due within 30 days — work these first
|
||||
|
||||
### Step 2 — Identify the host (3 min)
|
||||
- Use the **IP address** in the row to verify the hostname in Infoblox (preferred) or IPControl
|
||||
- If Ivanti has a stale hostname: click the **Host cell** directly in the table — it's inline editable
|
||||
- An amber dot appears on overridden cells; original value is preserved and can be restored
|
||||
- Show the revert button (↻) so they know corrections aren't permanent unless they want them to be
|
||||
|
||||
### Step 3 — Check who owns the asset (2 min)
|
||||
- Look at the **BU column**
|
||||
- If it's `NTS-AEO-STEAM` or `NTS-AEO-ACCESS-ENG` → our team, continue
|
||||
- Anything else (or blank) → not ours → **CARD queue**
|
||||
- Check the row checkbox, select CARD, click Add to Queue
|
||||
- IP address is captured automatically for the CARD search
|
||||
- Process CARD items in a separate session
|
||||
|
||||
### Step 4 — Look up the CVEs (4 min)
|
||||
- Each row shows up to 2 CVEs; hover the **+N badge** to see more
|
||||
- Go to Home page, search for the CVE ID
|
||||
- If it exists → review existing notes, docs, and any EXC numbers already linked
|
||||
- If not → click **Add CVE**, enter the CVE ID, NVD auto-fill populates the rest
|
||||
- Research: vendor advisory portal (Juniper PSN, Cisco Bug Search) — determine if it's an FP, can be patched, or needs an Archer ticket
|
||||
|
||||
### Step 5 — Take action (5 min)
|
||||
- **Patch available (firmware/SW)** — plan the upgrade, add a note to the finding row, done
|
||||
- **Config change only** — checkbox → Vendor → select **Archer** → Add to Queue → process in Ivanti later
|
||||
- **False Positive** — collect screenshot + vendor doc, upload to Home page CVE entry, then checkbox → Vendor → select **FP** → Add to Queue → submit FP in Ivanti in a separate session
|
||||
- **Can't patch (Archer)** — same as config change path; once EXC number is issued, paste it into the finding's **Notes cell** (`EXC-XXXXX` format)
|
||||
|
||||
---
|
||||
|
||||
## Segment 5 — The Ivanti Queue (5 min)
|
||||
|
||||
**Show on screen:** Click the Queue button, show the panel
|
||||
|
||||
- **Purpose:** tag findings as you triage, then batch all the Ivanti / Archer work in one focused session instead of context-switching constantly
|
||||
- Three types: **FP** (amber), **Archer** (sky blue), **CARD** (green)
|
||||
- CARD items show the IP address so you can search directly in CARD
|
||||
- Check the green checkbox on an item when the Ivanti/Archer action is done
|
||||
- Multi-select delete: check the small red boxes, click **Delete (N)** in the footer
|
||||
- Queue is **personal to your login** — each person has their own; it persists across sessions
|
||||
|
||||
---
|
||||
|
||||
## Segment 6 — Workflow badge colours (3 min)
|
||||
|
||||
**Show on screen:** Workflow column on the Vulnerability Triage table
|
||||
|
||||
Quick rule: **red = act now, amber = act soon, blue = monitor, no badge = needs triage**
|
||||
|
||||
| Badge | What it means | What to do |
|
||||
|---|---|---|
|
||||
| Red — Expired | FP ticket lapsed, finding re-opened | Submit a new FP in Ivanti |
|
||||
| Red — Rejected | Security team denied the FP | Remediate — do not resubmit without new evidence |
|
||||
| Amber — Reworked | Reviewer returned the ticket | Open in Ivanti, update justification, resubmit |
|
||||
| Amber — Actionable | Ticket flagged for team response | Open in Ivanti and respond |
|
||||
| Blue — Requested | FP submitted, awaiting approval | Monitor; follow up if SLA is approaching |
|
||||
| No badge | Never been triaged | Run it through the 5-step workflow |
|
||||
|
||||
---
|
||||
|
||||
## Segment 7 — Quick tips (2 min)
|
||||
|
||||
Quick features worth pointing out before Q&A:
|
||||
|
||||
- **Filter to untriaged only** — click the **Pending** segment on the Action Coverage donut chart
|
||||
- **Find all findings tied to an Archer ticket** — click the EXC badge on the Home page CVE row
|
||||
- **Filter by vendor, IP, SLA status** — click the filter icon (⊙) on any column header
|
||||
- **Save evidence once, reuse it** — uploading screenshots/advisories to the CVE database means when an FP expires you already have the files
|
||||
|
||||
---
|
||||
|
||||
## Segment 8 — Q&A (remaining time)
|
||||
|
||||
Suggested prompts to open discussion if no questions come up:
|
||||
- *"Walk me through what you'd do if you saw a red 'Rejected' badge on a finding."*
|
||||
- *"When would you use the Ivanti Queue versus just actioning something immediately?"*
|
||||
- *"What's the difference between Path B (config change) and Path D (risk acceptance) — when does each apply?"*
|
||||
|
||||
---
|
||||
|
||||
## Takeaway for the team
|
||||
|
||||
Point them to:
|
||||
- `docs/security-posture-workflow.md` — the full process guide with all the steps, evidence requirements, and decision matrix
|
||||
- `docs/security-posture-workflow-diagrams.md` — the Mermaid flowcharts if they're visual learners
|
||||
@@ -10,7 +10,7 @@ import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
||||
import NavDrawer from './components/NavDrawer';
|
||||
import CalendarWidget from './components/CalendarWidget';
|
||||
import ReportingPage from './components/pages/ReportingPage';
|
||||
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
|
||||
import ExportsPage from './components/pages/ExportsPage';
|
||||
import CompliancePage from './components/pages/CompliancePage';
|
||||
@@ -966,14 +966,14 @@ export default function App() {
|
||||
currentPage={currentPage}
|
||||
onNavigate={(page) => {
|
||||
// Clear contextual filters when navigating directly via the nav drawer
|
||||
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||
if (page === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
{/* Scanning line effect */}
|
||||
<div className="scan-line"></div>
|
||||
|
||||
<div className={`${currentPage === 'reporting' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
@@ -1043,7 +1043,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||
{currentPage === 'triage' && <VulnerabilityTriagePage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||
{currentPage === 'compliance' && <CompliancePage onNavigate={setCurrentPage} />}
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
@@ -2231,7 +2231,7 @@ export default function App() {
|
||||
<CalendarWidget
|
||||
onDateClick={(dateStr) => {
|
||||
setCalendarFilter(dateStr);
|
||||
setCurrentPage('reporting');
|
||||
setCurrentPage('triage');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -2337,7 +2337,7 @@ export default function App() {
|
||||
</a>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('reporting'); }}
|
||||
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('triage'); }}
|
||||
title="View findings referencing this ticket"
|
||||
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-reac
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
{ id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' },
|
||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
|
||||
@@ -333,7 +333,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
</div>
|
||||
{onNavigate && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onNavigate('reporting'); }}
|
||||
onClick={e => { e.stopPropagation(); onNavigate('triage'); }}
|
||||
style={{
|
||||
flexShrink: 0, marginLeft: '0.5rem',
|
||||
background: 'rgba(14,165,233,0.1)',
|
||||
@@ -348,7 +348,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.18)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.7)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(14,165,233,0.1)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.4)'; }}
|
||||
>
|
||||
View in Reporting →
|
||||
View in Triage →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
207
frontend/src/components/pages/IvantiCountsChart.js
Normal file
@@ -0,0 +1,207 @@
|
||||
// IvantiCountsChart.js
|
||||
// Collapsible trend panel for the Vulnerability Triage page.
|
||||
// Shows open vs closed Ivanti finding counts over time (last sync per day).
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
LineChart, Line,
|
||||
XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend, ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { TrendingUp, ChevronDown, ChevronUp, Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const AMBER = '#F59E0B';
|
||||
const SKY = '#0EA5E9';
|
||||
const GREEN = '#10B981';
|
||||
const RED = '#EF4444';
|
||||
|
||||
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom dark tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
function DarkTooltip({ active, payload, label }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const openVal = payload.find(p => p.dataKey === 'open_count')?.value;
|
||||
const closedVal = payload.find(p => p.dataKey === 'closed_count')?.value;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(10,17,32,0.97)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '160px',
|
||||
}}>
|
||||
<div style={{ color: AMBER, marginBottom: '0.35rem', fontWeight: '700', fontSize: '0.65rem' }}>
|
||||
{label}
|
||||
</div>
|
||||
{payload.map(p => (
|
||||
<div key={p.dataKey} style={{ color: p.color, marginTop: '0.125rem', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<span style={{ opacity: 0.8 }}>{p.name}</span>
|
||||
<span style={{ fontWeight: '700' }}>{p.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{openVal != null && closedVal != null && (
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', marginTop: '0.35rem', paddingTop: '0.3rem', color: '#475569', display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
|
||||
<span>total</span>
|
||||
<span style={{ color: '#64748B', fontWeight: '600' }}>{openVal + closedVal}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shorten YYYY-MM-DD to MM/DD/YY
|
||||
// ---------------------------------------------------------------------------
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
const p = d.split('-');
|
||||
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function IvantiCountsChart() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [history, setHistory] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/counts/history`, { credentials: 'include' });
|
||||
if (res.ok && !cancelled) {
|
||||
const d = await res.json();
|
||||
setHistory(d.history || []);
|
||||
}
|
||||
} catch { /* silent — chart shows no-data state */ }
|
||||
finally { if (!cancelled) setLoading(false); }
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(
|
||||
() => history.map(r => ({ ...r, date: fmtDate(r.date) })),
|
||||
[history]
|
||||
);
|
||||
|
||||
// Compute a simple delta label for the latest vs previous point
|
||||
const deltaLabel = useMemo(() => {
|
||||
if (chartData.length < 2) return null;
|
||||
const latest = chartData[chartData.length - 1];
|
||||
const prev = chartData[chartData.length - 2];
|
||||
const delta = latest.open_count - prev.open_count;
|
||||
if (delta === 0) return { text: 'no change in open', color: '#475569' };
|
||||
if (delta < 0) return { text: `▼ ${Math.abs(delta)} open since ${prev.date}`, color: GREEN };
|
||||
return { text: `▲ ${delta} open since ${prev.date}`, color: RED };
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
|
||||
{/* ── Header ────────────────────────────────────────────────── */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '0 0 0.625rem 0',
|
||||
borderBottom: collapsed ? 'none' : '1px solid rgba(245,158,11,0.1)',
|
||||
marginBottom: collapsed ? 0 : '0.875rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<TrendingUp style={{ width: '14px', height: '14px', color: AMBER }} />
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
}}>
|
||||
Findings Trend
|
||||
</span>
|
||||
{loading && (
|
||||
<Loader style={{ width: '12px', height: '12px', color: '#334155', animation: 'spin 1s linear infinite' }} />
|
||||
)}
|
||||
{!loading && deltaLabel && (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: deltaLabel.color, marginLeft: '0.25rem' }}>
|
||||
{deltaLabel.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{collapsed
|
||||
? <ChevronDown style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||
: <ChevronUp style={{ width: '14px', height: '14px', color: '#334155' }} />
|
||||
}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(245,158,11,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem 1.25rem 0.875rem',
|
||||
}}>
|
||||
<div style={{ marginBottom: '0.625rem', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Open vs Closed — end-of-day snapshot per sync day
|
||||
</div>
|
||||
{chartData.length > 0 && (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||
{chartData.length} day{chartData.length !== 1 ? 's' : ''} of data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chartData.length < 2 ? (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '160px', color: '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
border: '1px dashed rgba(245,158,11,0.1)', borderRadius: '0.375rem',
|
||||
}}>
|
||||
{chartData.length === 0
|
||||
? 'Trend data begins accumulating after the first sync — check back tomorrow'
|
||||
: 'Need at least 2 days of syncs to display a trend'}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid {...GRID_STYLE} />
|
||||
<XAxis dataKey="date" tick={AXIS_STYLE} />
|
||||
<YAxis tick={AXIS_STYLE} allowDecimals={false} />
|
||||
<Tooltip content={<DarkTooltip />} />
|
||||
<Legend wrapperStyle={LEGEND_STYLE} iconSize={8} />
|
||||
<Line
|
||||
type="monotone" dataKey="open_count" name="Open"
|
||||
stroke={AMBER} strokeWidth={2}
|
||||
dot={{ r: 3, fill: AMBER, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone" dataKey="closed_count" name="Closed"
|
||||
stroke={SKY} strokeWidth={2}
|
||||
dot={{ r: 3, fill: SKY, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||
@@ -1536,7 +1537,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
const { canWrite } = useAuth();
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [total, setTotal] = useState(null);
|
||||
@@ -1965,6 +1966,11 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 1.5 — Open vs Closed trend over time
|
||||
---------------------------------------------------------------- */}
|
||||
<IvantiCountsChart />
|
||||
|
||||
{/* ----------------------------------------------------------------
|
||||
Panel 2 — Findings table
|
||||
---------------------------------------------------------------- */}
|
||||
|
||||
Reference in New Issue
Block a user