Compare commits
2 Commits
df31cc3c79
...
f9770872ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9770872ba
|
||
|
|
f9b96e9040
|
113
CHANGELOG.md
113
CHANGELOG.md
@@ -1,59 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## v1.0.0 — 2026-05-01
|
||||
All notable changes to the STEAM Security Dashboard are documented in this file.
|
||||
|
||||
First official release. Consolidates all features developed since initial commit into a stable, documented, deployment-ready package.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Core Platform
|
||||
- CVE tracking with multi-vendor support, document storage, and NVD API auto-fill
|
||||
- Session-based authentication with four user groups (Admin, Standard_User, Leadership, Read_Only)
|
||||
- Full audit logging of all state-changing actions
|
||||
- Dark tactical intelligence UI theme with monospace typography
|
||||
---
|
||||
|
||||
### Ivanti Integration
|
||||
- Live sync of open host findings from Ivanti/RiskSense API (auto-sync every 24h)
|
||||
- Reporting page with donut metric charts, advanced per-column filtering, inline editing
|
||||
- FP workflow submission directly to Ivanti API with file attachments
|
||||
- Ivanti Queue — personal staging list for batch FP, Archer, CARD, and Granite workflows
|
||||
- Queue item redirect between workflow types after completion
|
||||
- Row visibility controls with localStorage persistence
|
||||
## [2.0.0] — 2026-05-19
|
||||
|
||||
### Archive and Anomaly Tracking
|
||||
- Automatic detection of disappeared and returned findings across syncs
|
||||
- BU drift checker — classifies archived findings by reason (BU reassignment, severity drift, closed on platform, decommissioned)
|
||||
- Return classification — explains why findings came back (BU reassigned back, severity re-escalated, etc.)
|
||||
- Findings Trend chart with archive activity sparkline and shift reason tooltips
|
||||
- Anomaly banner for significant archive events
|
||||
### Breaking Changes
|
||||
|
||||
### Compliance (AEO Posture)
|
||||
- Weekly NTS_AEO xlsx upload with diff preview (new, resolved, recurring)
|
||||
- Schema drift detection with breaking/silent-miss/cosmetic classification
|
||||
- Admin config reconciliation for parser updates
|
||||
- Per-team metric health cards with grouped categories and variant pills
|
||||
- Device-level violation tracking with timestamped notes history
|
||||
- Multi-metric note grouping
|
||||
- Upload rollback support
|
||||
- **PostgreSQL migration** — database engine switched from SQLite to PostgreSQL. Requires running `deploy-postgres.sh`, data migration, and `DATABASE_URL` env var. SQLite is no longer supported.
|
||||
- **Multi-BU tenancy** — data is now scoped per business unit with per-user team assignments. Replaces the previous binary scope toggle.
|
||||
|
||||
### Integrations
|
||||
- Jira Data Center — create, sync, and track tickets linked to CVE/vendor pairs
|
||||
- Archer — risk acceptance exception tracking (EXC numbers)
|
||||
- Atlas InfoSec — action plan cache, bulk creation from row selection, metrics reporting
|
||||
- CARD API — Granite/CARD asset lookup for network device workflows
|
||||
- NVD API — auto-fill CVE metadata with bulk sync support
|
||||
### Features
|
||||
|
||||
### Knowledge Base
|
||||
- Internal document library with inline PDF and Markdown rendering
|
||||
- Category-based browsing and search
|
||||
- **In-app notification system** — replaces Webex bot integration with native notifications
|
||||
- **Screenshot uploads** in feedback modal, Webex bot DM on issue close
|
||||
- **CCP Metrics page** — multi-vertical VCL upload and cross-org compliance reporting
|
||||
- **VCL compliance reporting** — exec report page, device metadata fields, bulk upload
|
||||
- **Aggregated burndown forecast** on CCP Metrics overview page
|
||||
- **Sub-team drill-down** — metric sub-team intermediate view with per-team breakdowns
|
||||
- **Metric breakdown panel** — Non-Compliant stat clickable, reveals metric breakdown buttons, compact grid with top 8 and show-all toggle
|
||||
- **Remediation plan and resolution date history tracking**
|
||||
- **Data management panel** — delete vertical, rollback upload, and reset all
|
||||
- **VCL vertical metadata** — inline-editable team fields on compliance routes
|
||||
- **Re-queue findings** from rejected FP submissions
|
||||
- **FP submissions cleanup** — auto-clear approved, dismiss rejected, collapsible section
|
||||
- **DECOM workflow type** — auto-note/hide on decom, show CVEs on CARD queue items
|
||||
- **Interactive configuration wizard** for deployment setup
|
||||
- **Unified setup script** (`configure.js`) merging deploy + config wizard
|
||||
- **Per-BU trend lines** in Ivanti counts history chart
|
||||
- **Multi-select BU picker** replacing binary scope toggle
|
||||
- **Configurable IVANTI_MANAGED_BUS** env var for multi-tenant drift classification
|
||||
- **Pipeline-to-issue traceability** via `after_script` comments in CI/CD
|
||||
- **CI/CD pipeline** with feedback modal, Atlas `qualys_id` fallback, and health endpoint
|
||||
- **Docker Compose** and `deploy-postgres.sh` for production cutover
|
||||
- **Systemd service scripts** for start/stop management
|
||||
|
||||
### Admin
|
||||
- Full-page admin panel with user management, audit log, and system info tabs
|
||||
- Themed confirm modals replacing browser dialogs
|
||||
- User profile panel with self-service password change
|
||||
### Bug Fixes
|
||||
|
||||
- Fix duplicate failing metrics on same asset across compliance endpoints
|
||||
- Fix duplicate chart entries on compliance page when multiple verticals share a report_date
|
||||
- Fix requeue inserting Postgres array literal instead of JSON into `cves_json`
|
||||
- Fix todo queue crash on malformed `cves_json` data
|
||||
- Fix AEO compliance page not showing metric health cards on dev
|
||||
- Fix double-counting in VCL multi-vertical stats — use only `ALL:` rollup rows
|
||||
- Fix compliance stats to use Summary sheet data instead of item counts
|
||||
- Fix route mount order: `vcl-multi` must precede general compliance router
|
||||
- Fix requeue: fallback to `finding_ids_json` when queue items are deleted or absent
|
||||
- Sync FP submission `lifecycle_status` from Ivanti `currentState` on fetch
|
||||
- Fix History tab crash: coerce Ivanti note fields to strings before rendering
|
||||
- Fix archive bar chart: `fmtDate` now handles ISO datetime strings from PostgreSQL date columns
|
||||
- Fix Ivanti panel bugs: Invalid Date, wrong workflow count, crash on archive click, BU scope filtering
|
||||
- Fix BU drift checker: derive `EXPECTED_BUS` from `IVANTI_BU_FILTER` env var
|
||||
- Fix null `bu_teams` in postgres migration, add retry logic to deploy script
|
||||
- Fix missing `created_by` column in `archer_tickets` table
|
||||
- Fix FP workflow counts donut scoped by BU
|
||||
- Fix `dotenv` loading in `db.js` so `DATABASE_URL` is available on import
|
||||
- Fix property test CI failure: mock db module before importing route
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Track `package-lock.json` files for deterministic CI installs
|
||||
- Remove unused icon imports and unused imports to satisfy ESLint thresholds
|
||||
- CI pipeline fixes: dependency installation, lint thresholds, test isolation
|
||||
- Auto-run migrations in pipeline
|
||||
- Documentation updates for PostgreSQL migration, systemd scripts, and reference manual
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] — 2026-05-01
|
||||
|
||||
Initial release of the STEAM Security Dashboard.
|
||||
|
||||
### Infrastructure
|
||||
- Consolidated `setup.js` with complete database schema (27 tables, all indexes and triggers)
|
||||
- systemd service files for persistent deployment
|
||||
- GitLab CI/CD pipeline (install, lint, test, build, deploy)
|
||||
- GPG-signed commits for code provenance
|
||||
- Organized documentation structure (api, design, guides, security, testing, troubleshooting)
|
||||
- Migration scripts documented and retained for existing deployment upgrades
|
||||
|
||||
@@ -388,6 +388,135 @@ function computeAggregatedBurndown(devices) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes per-metric forecast burndown from device records and historical snapshots.
|
||||
*
|
||||
* Pure function — no side effects, no database access. Suitable for property-based testing.
|
||||
*
|
||||
* @param {Array<{hostname: string, resolution_date: string|null}>} currentDevices
|
||||
* Active non-compliant devices for the metric
|
||||
* @param {number} totalAssets
|
||||
* Total device count in scope for this metric (from snapshot or summary)
|
||||
* @param {Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>} historicalSnapshots
|
||||
* Pre-computed historical data points (up to 4 months)
|
||||
* @returns {{
|
||||
* historical: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||
* forecast: Array<{month: string, total_assets: number, non_compliant: number, compliance_pct: number}>,
|
||||
* current_snapshot: {total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number}
|
||||
* }}
|
||||
*/
|
||||
function computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots) {
|
||||
// Compute compliance_pct helper
|
||||
function calcCompliancePct(total, nc) {
|
||||
if (total === 0) return 0;
|
||||
return Math.round(((total - nc) / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
// Historical — pass through as-is
|
||||
const historical = (historicalSnapshots || []).map(snap => ({
|
||||
month: snap.month,
|
||||
total_assets: snap.total_assets,
|
||||
non_compliant: snap.non_compliant,
|
||||
compliance_pct: snap.compliance_pct,
|
||||
}));
|
||||
|
||||
// Requirement 3.7: empty currentDevices → empty forecast, zeroed snapshot except total_assets
|
||||
if (!currentDevices || currentDevices.length === 0) {
|
||||
return {
|
||||
historical,
|
||||
forecast: [],
|
||||
current_snapshot: {
|
||||
total_assets: totalAssets,
|
||||
non_compliant: 0,
|
||||
compliant: 0,
|
||||
compliance_pct: 0,
|
||||
blockers: 0,
|
||||
with_dates: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nonCompliant = currentDevices.length;
|
||||
|
||||
// Partition devices into blockers (no resolution_date) and with_dates
|
||||
const blockers = currentDevices.filter(d => d.resolution_date == null).length;
|
||||
const withDates = nonCompliant - blockers;
|
||||
|
||||
// Current snapshot
|
||||
const compliant = totalAssets - nonCompliant;
|
||||
const currentCompliancePct = calcCompliancePct(totalAssets, nonCompliant);
|
||||
|
||||
const current_snapshot = {
|
||||
total_assets: totalAssets,
|
||||
non_compliant: nonCompliant,
|
||||
compliant: compliant,
|
||||
compliance_pct: currentCompliancePct,
|
||||
blockers: blockers,
|
||||
with_dates: withDates,
|
||||
};
|
||||
|
||||
// If no devices have resolution dates, return empty forecast
|
||||
if (withDates === 0) {
|
||||
return { historical, forecast: [], current_snapshot };
|
||||
}
|
||||
|
||||
// Determine current month (YYYY-MM)
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth(); // 0-indexed
|
||||
|
||||
function formatMonth(year, month) {
|
||||
return `${year}-${String(month + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const currentMonthStr = formatMonth(currentYear, currentMonth);
|
||||
|
||||
// Bucket devices with resolution dates by their resolution month
|
||||
// Past-due dates (month before current month) are treated as remediated in current month
|
||||
const buckets = {};
|
||||
for (const device of currentDevices) {
|
||||
if (device.resolution_date == null) continue;
|
||||
const resMonth = device.resolution_date.slice(0, 7); // YYYY-MM
|
||||
if (resMonth < currentMonthStr) {
|
||||
// Past-due: treat as remediated in current month
|
||||
buckets[currentMonthStr] = (buckets[currentMonthStr] || 0) + 1;
|
||||
} else {
|
||||
buckets[resMonth] = (buckets[resMonth] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate forecast months starting from current month, up to 12 months max
|
||||
const forecast = [];
|
||||
let remainingNonCompliant = nonCompliant;
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const forecastYear = currentYear + Math.floor((currentMonth + i) / 12);
|
||||
const forecastMonth = (currentMonth + i) % 12;
|
||||
const monthStr = formatMonth(forecastYear, forecastMonth);
|
||||
|
||||
// Decrement by devices remediated in this month
|
||||
if (buckets[monthStr]) {
|
||||
remainingNonCompliant -= buckets[monthStr];
|
||||
}
|
||||
|
||||
const pct = calcCompliancePct(totalAssets, remainingNonCompliant);
|
||||
|
||||
forecast.push({
|
||||
month: monthStr,
|
||||
total_assets: totalAssets,
|
||||
non_compliant: remainingNonCompliant,
|
||||
compliance_pct: pct,
|
||||
});
|
||||
|
||||
// Terminate early if all dated devices are remediated (only blockers remain)
|
||||
if (remainingNonCompliant <= blockers) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { historical, forecast, current_snapshot };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateText,
|
||||
validateRemediationPlan,
|
||||
@@ -404,4 +533,5 @@ module.exports = {
|
||||
computeVerticalBurndown,
|
||||
deduplicateByHostname,
|
||||
computeAggregatedBurndown,
|
||||
computeMetricForecastBurndown,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant, deduplicateByHostname, computeAggregatedBurndown } = require('../helpers/vclHelpers');
|
||||
const { parseVerticalFilename, computeVerticalBurndown, isValidDateString, categorizeNonCompliant, deduplicateByHostname, computeAggregatedBurndown, computeMetricForecastBurndown } = require('../helpers/vclHelpers');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
const PARSER_SCRIPT = path.join(__dirname, '../scripts/parse_compliance_xlsx.py');
|
||||
@@ -1483,6 +1483,194 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metrics-list — Distinct metrics with active non-compliant device counts
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /metrics-list
|
||||
* Returns the list of distinct metrics that have at least one active non-compliant
|
||||
* device with a non-null vertical. Used by the MetricSelector component.
|
||||
*
|
||||
* @method GET
|
||||
* @route /metrics-list
|
||||
*
|
||||
* @response 200
|
||||
* Array<{ metric_id: string, device_count: number }>
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/metrics-list', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT metric_id, COUNT(DISTINCT hostname) AS device_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active' AND vertical IS NOT NULL
|
||||
GROUP BY metric_id
|
||||
ORDER BY metric_id ASC
|
||||
`);
|
||||
|
||||
res.json(rows.map(r => ({ metric_id: r.metric_id, device_count: parseInt(r.device_count, 10) })));
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /metrics-list error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch metrics list' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /metric/:metricId/forecast-burndown — Per-metric forecast burndown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /metric/:metricId/forecast-burndown
|
||||
* Returns combined historical + forecast burndown data for a specific metric.
|
||||
* Historical data is derived from compliance_snapshots using the ratio method.
|
||||
* Forecast data is computed by the computeMetricForecastBurndown helper.
|
||||
*
|
||||
* @method GET
|
||||
* @route /metric/:metricId/forecast-burndown
|
||||
* @param {string} metricId — metric identifier (e.g., "2.3.5")
|
||||
*
|
||||
* @response 200
|
||||
* {
|
||||
* metric_id: string,
|
||||
* historical: Array<{ month: string, total_assets: number, non_compliant: number, compliance_pct: number }>,
|
||||
* forecast: Array<{ month: string, total_assets: number, non_compliant: number, compliance_pct: number }>,
|
||||
* current_snapshot: { total_assets: number, non_compliant: number, compliant: number, compliance_pct: number, blockers: number, with_dates: number }
|
||||
* }
|
||||
* @response 500 { error: string }
|
||||
*/
|
||||
router.get('/metric/:metricId/forecast-burndown', async (req, res) => {
|
||||
const metricId = req.params.metricId;
|
||||
|
||||
try {
|
||||
// 1. Query active devices for this metric
|
||||
const { rows: activeDevices } = await pool.query(
|
||||
`SELECT hostname, resolution_date, vertical
|
||||
FROM compliance_items
|
||||
WHERE metric_id = $1 AND status = 'active' AND vertical IS NOT NULL`,
|
||||
[metricId]
|
||||
);
|
||||
|
||||
// If no active devices, return empty response
|
||||
if (activeDevices.length === 0) {
|
||||
return res.json({
|
||||
metric_id: metricId,
|
||||
historical: [],
|
||||
forecast: [],
|
||||
current_snapshot: {
|
||||
total_assets: 0,
|
||||
non_compliant: 0,
|
||||
compliant: 0,
|
||||
compliance_pct: 0,
|
||||
blockers: 0,
|
||||
with_dates: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Determine the vertical from active devices (use the first one found)
|
||||
const vertical = activeDevices[0].vertical;
|
||||
|
||||
// 3. Compute date range for 3 months of historical snapshots
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
// 3 months prior to current month
|
||||
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||
const startMonth = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
// 4. Query historical snapshots for the vertical (3 months prior, excluding current month)
|
||||
const { rows: snapshots } = await pool.query(
|
||||
`SELECT snapshot_month AS month, total_devices AS total_assets,
|
||||
non_compliant, compliance_pct::numeric AS compliance_pct
|
||||
FROM compliance_snapshots
|
||||
WHERE vertical = $1 AND snapshot_month >= $2 AND snapshot_month < $3
|
||||
ORDER BY snapshot_month ASC`,
|
||||
[vertical, startMonth, currentMonth]
|
||||
);
|
||||
|
||||
// 5. Get total non-compliant devices for the vertical (for ratio computation)
|
||||
const { rows: verticalNcRows } = await pool.query(
|
||||
`SELECT COUNT(DISTINCT hostname) AS total_nc
|
||||
FROM compliance_items
|
||||
WHERE vertical = $1 AND status = 'active'`,
|
||||
[vertical]
|
||||
);
|
||||
const verticalTotalNc = parseInt(verticalNcRows[0].total_nc, 10) || 0;
|
||||
|
||||
// Count metric's non-compliant devices (distinct hostnames)
|
||||
const metricNcCount = new Set(activeDevices.map(d => d.hostname)).size;
|
||||
|
||||
// 6. Compute per-metric historical non_compliant using the ratio method (Requirement 7.2)
|
||||
const historicalSnapshots = snapshots.map(snap => {
|
||||
const snapshotNc = parseInt(snap.non_compliant, 10) || 0;
|
||||
let metricNc;
|
||||
if (verticalTotalNc === 0) {
|
||||
// Requirement 7.3: if vertical's total non_compliant is 0, metric's is 0
|
||||
metricNc = 0;
|
||||
} else {
|
||||
// Ratio method: vertical_snapshot_nc * (metric_nc / vertical_total_nc)
|
||||
metricNc = Math.round(snapshotNc * (metricNcCount / verticalTotalNc));
|
||||
}
|
||||
|
||||
return {
|
||||
month: snap.month,
|
||||
total_assets: parseInt(snap.total_assets, 10) || 0,
|
||||
non_compliant: metricNc,
|
||||
compliance_pct: parseFloat(snap.compliance_pct) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 7. Include current month as the most recent historical data point (from live data)
|
||||
// Get totalAssets from the most recent snapshot's total_devices, or from live vertical count
|
||||
let totalAssets = 0;
|
||||
const { rows: latestSnapshotRows } = await pool.query(
|
||||
`SELECT total_devices
|
||||
FROM compliance_snapshots
|
||||
WHERE vertical = $1
|
||||
ORDER BY snapshot_month DESC
|
||||
LIMIT 1`,
|
||||
[vertical]
|
||||
);
|
||||
|
||||
if (latestSnapshotRows.length > 0) {
|
||||
totalAssets = parseInt(latestSnapshotRows[0].total_devices, 10) || 0;
|
||||
}
|
||||
|
||||
// Current month data point from live data
|
||||
const currentMonthNc = metricNcCount;
|
||||
const currentMonthCompliancePct = totalAssets > 0
|
||||
? Math.round(((totalAssets - currentMonthNc) / totalAssets) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
historicalSnapshots.push({
|
||||
month: currentMonth,
|
||||
total_assets: totalAssets,
|
||||
non_compliant: currentMonthNc,
|
||||
compliance_pct: currentMonthCompliancePct,
|
||||
});
|
||||
|
||||
// 8. Prepare currentDevices for the helper (only need hostname and resolution_date)
|
||||
const currentDevices = activeDevices.map(d => ({
|
||||
hostname: d.hostname,
|
||||
resolution_date: d.resolution_date || null,
|
||||
}));
|
||||
|
||||
// 9. Pass data to computeMetricForecastBurndown helper
|
||||
const result = computeMetricForecastBurndown(currentDevices, totalAssets, historicalSnapshots);
|
||||
|
||||
// 10. Return response
|
||||
res.json({
|
||||
metric_id: metricId,
|
||||
historical: result.historical,
|
||||
forecast: result.forecast,
|
||||
current_snapshot: result.current_snapshot,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[VCL Multi] GET /metric/:metricId/forecast-burndown error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to compute forecast burndown' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
412
backend/scripts/jira-uat-test.js
Normal file
412
backend/scripts/jira-uat-test.js
Normal file
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env node
|
||||
// ==========================================================================
|
||||
// Jira UAT Test Script
|
||||
// ==========================================================================
|
||||
// Exercises every Jira REST API use case the STEAM Dashboard will run in
|
||||
// production. Run this against the UAT instance before submitting the
|
||||
// ATLSUP Rest API Approval ticket.
|
||||
//
|
||||
// Usage:
|
||||
// cd backend
|
||||
// node scripts/jira-uat-test.js
|
||||
//
|
||||
// Note: The JQL search test uses a 72-hour window (updated >= -72h) to
|
||||
// match the production bulk-sync behavior and account for weekend gaps.
|
||||
//
|
||||
// Prerequisites:
|
||||
// - backend/.env has JIRA_BASE_URL pointing to UAT
|
||||
// - JIRA_API_USER / JIRA_API_TOKEN set to service account credentials
|
||||
// - JIRA_PROJECT_KEY set to a UAT project your service account can access
|
||||
// - Service account has been granted access to the target space by space owners
|
||||
//
|
||||
// The script logs every API call, response status, and timing to both
|
||||
// console and a log file at backend/scripts/jira-uat-test.log for the
|
||||
// ATLSUP reviewers.
|
||||
// ==========================================================================
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jiraApi = require('../helpers/jiraApi');
|
||||
|
||||
const LOG_FILE = path.join(__dirname, 'jira-uat-test.log');
|
||||
const results = [];
|
||||
let createdIssueKey = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logging
|
||||
// ---------------------------------------------------------------------------
|
||||
function log(level, message, data) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const entry = { timestamp, level, message };
|
||||
if (data !== undefined) entry.data = data;
|
||||
results.push(entry);
|
||||
|
||||
const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`;
|
||||
console.log(line);
|
||||
if (data) {
|
||||
const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
// Truncate long data to keep logs readable (HTML error pages can be 50KB+)
|
||||
const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr;
|
||||
console.log(' ' + truncated.split('\n').join('\n '));
|
||||
}
|
||||
}
|
||||
|
||||
function logPass(testName, data) { log('pass', `PASS: ${testName}`, data); }
|
||||
function logFail(testName, data) { log('fail', `FAIL: ${testName}`, data); }
|
||||
function logInfo(message, data) { log('info', message, data); }
|
||||
function logWarn(message, data) { log('warn', message, data); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test runner
|
||||
// ---------------------------------------------------------------------------
|
||||
async function runTest(name, fn) {
|
||||
logInfo(`--- Running: ${name} ---`);
|
||||
const start = Date.now();
|
||||
try {
|
||||
await fn();
|
||||
logPass(name, { durationMs: Date.now() - start });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logFail(name, { error: err.message, durationMs: Date.now() - start });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error('Assertion failed: ' + message);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 1: Connection Test (GET /rest/api/2/myself)
|
||||
// Production use: Admin clicks "Test Connection" button on Jira settings panel
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testConnection() {
|
||||
const result = await jiraApi.testConnection();
|
||||
assert(result.ok, 'Connection test should succeed. Got: ' + JSON.stringify(result));
|
||||
assert(result.user && result.user.name, 'Should return authenticated user name');
|
||||
logInfo('Authenticated as:', result.user);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 2: Create Issue (POST /rest/api/2/issue)
|
||||
// Production use: User clicks "Create in Jira" from CVE detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testCreateIssue() {
|
||||
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
||||
assert(projectKey, 'JIRA_PROJECT_KEY must be set in .env');
|
||||
|
||||
// Discover available issue types for this project
|
||||
const projRes = await jiraApi.jiraGet('/rest/api/2/project/' + encodeURIComponent(projectKey));
|
||||
assert(projRes.status === 200, 'Should be able to fetch project metadata. Got HTTP ' + projRes.status + ': ' + (projRes.body || '').substring(0, 300));
|
||||
|
||||
const projData = JSON.parse(projRes.body);
|
||||
const availableTypes = (projData.issueTypes || []).filter(t => !t.subtask);
|
||||
logInfo('Available issue types:', availableTypes.map(t => t.name));
|
||||
|
||||
// Determine which issue type to use: configured type first, then fallback order
|
||||
const configuredType = jiraApi.JIRA_ISSUE_TYPE || 'Task';
|
||||
const fallbackOrder = [configuredType, 'Story', 'Task', 'Bug'];
|
||||
let issueTypeName = null;
|
||||
|
||||
for (const candidate of fallbackOrder) {
|
||||
if (availableTypes.some(t => t.name === candidate)) {
|
||||
issueTypeName = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If none of the preferred types exist, use the first available non-subtask type
|
||||
if (!issueTypeName && availableTypes.length > 0) {
|
||||
issueTypeName = availableTypes[0].name;
|
||||
}
|
||||
|
||||
assert(issueTypeName, 'No usable issue type found in project ' + projectKey);
|
||||
|
||||
if (issueTypeName !== configuredType) {
|
||||
logWarn('Configured JIRA_ISSUE_TYPE "' + configuredType + '" not available — falling back to "' + issueTypeName + '"');
|
||||
}
|
||||
|
||||
const fields = {
|
||||
project: { key: projectKey },
|
||||
summary: '[UAT TEST] STEAM Dashboard - CVE-2025-0001 - TestVendor - ' + new Date().toISOString(),
|
||||
issuetype: { name: issueTypeName },
|
||||
description: 'UAT test issue created by STEAM Security Dashboard Jira integration test script. This issue can be safely deleted after review.'
|
||||
};
|
||||
|
||||
// Epic type requires an Epic Name field — add it if creating an Epic
|
||||
if (issueTypeName === 'Epic') {
|
||||
fields.customfield_10004 = fields.summary; // Epic Name (standard Jira field ID)
|
||||
}
|
||||
|
||||
logInfo('Creating issue with fields:', { project: fields.project, summary: fields.summary, issuetype: fields.issuetype });
|
||||
|
||||
let result = await jiraApi.createIssue(fields);
|
||||
|
||||
// If the first attempt fails with 400, try without description (some screens don't have it)
|
||||
if (!result.ok && result.status === 400) {
|
||||
const errBody = (result.body || '').substring(0, 500);
|
||||
logWarn('Create failed with 400, retrying without description. Error: ' + errBody);
|
||||
|
||||
const retryFields = { ...fields };
|
||||
delete retryFields.description;
|
||||
result = await jiraApi.createIssue(retryFields);
|
||||
}
|
||||
|
||||
// If still failing with 400 and we used Epic, try without the customfield_10004
|
||||
// (Epic Name field ID varies across Jira instances)
|
||||
if (!result.ok && result.status === 400 && issueTypeName === 'Epic') {
|
||||
const errBody = (result.body || '').substring(0, 500);
|
||||
logWarn('Epic create failed, retrying with alternate Epic Name field. Error: ' + errBody);
|
||||
|
||||
const retryFields = { ...fields };
|
||||
delete retryFields.customfield_10004;
|
||||
// Try common alternate Epic Name field IDs
|
||||
retryFields.customfield_10011 = fields.summary;
|
||||
result = await jiraApi.createIssue(retryFields);
|
||||
}
|
||||
|
||||
assert(result.ok, 'Create issue should succeed. Got HTTP ' + result.status + ': ' + (result.body || '').substring(0, 500));
|
||||
assert(result.data && result.data.key, 'Should return issue key');
|
||||
|
||||
createdIssueKey = result.data.key;
|
||||
logInfo('Created issue:', { key: createdIssueKey, id: result.data.id, self: result.data.self, issueType: issueTypeName });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 3: Get Single Issue (GET /rest/api/2/issue/{key}?fields=...)
|
||||
// Production use: User clicks "Sync" on a single Jira ticket row
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testGetIssue() {
|
||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||
|
||||
const result = await jiraApi.getIssue(createdIssueKey);
|
||||
assert(result.ok, 'Get issue should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
||||
|
||||
const issue = result.data;
|
||||
assert(issue.key === createdIssueKey, 'Returned key should match');
|
||||
assert(issue.fields && issue.fields.summary, 'Should have summary field');
|
||||
assert(issue.fields.status, 'Should have status field');
|
||||
|
||||
logInfo('Fetched issue:', {
|
||||
key: issue.key,
|
||||
summary: issue.fields.summary,
|
||||
status: issue.fields.status.name,
|
||||
issuetype: issue.fields.issuetype ? issue.fields.issuetype.name : null
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 4: Update Issue (PUT /rest/api/2/issue/{key})
|
||||
// Production use: Local ticket edits synced back to Jira (future feature)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testUpdateIssue() {
|
||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||
|
||||
const result = await jiraApi.updateIssue(createdIssueKey, {
|
||||
summary: `[UAT TEST] STEAM Dashboard - UPDATED - ${new Date().toISOString()}`
|
||||
});
|
||||
assert(result.ok, 'Update issue should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
||||
logInfo('Updated issue summary successfully');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 5: Add Comment (POST /rest/api/2/issue/{key}/comment)
|
||||
// Production use: Dashboard adds audit trail comments to linked Jira tickets
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testAddComment() {
|
||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||
|
||||
const commentBody = `STEAM Dashboard UAT test comment.\nTimestamp: ${new Date().toISOString()}\nThis comment was created by the automated test script.`;
|
||||
|
||||
const result = await jiraApi.addComment(createdIssueKey, commentBody);
|
||||
assert(result.ok, 'Add comment should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
||||
assert(result.data && result.data.id, 'Should return comment ID');
|
||||
|
||||
logInfo('Added comment:', { commentId: result.data.id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 6: Get Transitions (GET /rest/api/2/issue/{key}/transitions)
|
||||
// Production use: Dashboard checks available workflow transitions before
|
||||
// attempting to move a ticket to a new status
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testGetTransitions() {
|
||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||
|
||||
const result = await jiraApi.getTransitions(createdIssueKey);
|
||||
assert(result.ok, 'Get transitions should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
||||
|
||||
const transitions = result.data.transitions || [];
|
||||
logInfo('Available transitions:', transitions.map(t => ({ id: t.id, name: t.name, to: t.to ? t.to.name : null })));
|
||||
|
||||
// Store for the transition test
|
||||
return transitions;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 7: Transition Issue (POST /rest/api/2/issue/{key}/transitions)
|
||||
// Production use: Dashboard moves ticket status (e.g., Open → In Progress)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testTransitionIssue(transitions) {
|
||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||
|
||||
if (!transitions || transitions.length === 0) {
|
||||
logWarn('No transitions available — skipping transition test. This may be expected depending on the project workflow.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the first available transition
|
||||
const transition = transitions[0];
|
||||
logInfo(`Transitioning to: ${transition.name} (id: ${transition.id})`);
|
||||
|
||||
const result = await jiraApi.transitionIssue(createdIssueKey, transition.id);
|
||||
assert(result.ok, 'Transition should succeed (204). Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
||||
logInfo('Transition successful');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 8: JQL Search (POST /rest/api/2/search)
|
||||
// Production use: Bulk sync — fetches all tracked tickets in one request
|
||||
// instead of one GET per ticket (Charter-compliant)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testJqlSearch() {
|
||||
const projectKey = jiraApi.JIRA_PROJECT_KEY;
|
||||
assert(projectKey, 'JIRA_PROJECT_KEY must be set');
|
||||
|
||||
// Use a 72-hour window to account for weekend gaps between syncs
|
||||
const jql = `project = ${projectKey} AND updated >= -72h ORDER BY updated DESC`;
|
||||
logInfo('Searching with JQL:', jql);
|
||||
|
||||
const result = await jiraApi.searchIssues(jql, { maxResults: 10 });
|
||||
assert(result.ok, 'Search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
||||
|
||||
const data = result.data;
|
||||
logInfo('Search results:', {
|
||||
total: data.total,
|
||||
returned: (data.issues || []).length,
|
||||
issues: (data.issues || []).slice(0, 5).map(i => ({
|
||||
key: i.key,
|
||||
summary: i.fields.summary,
|
||||
status: i.fields.status ? i.fields.status.name : null
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 9: Bulk Key Search (searchIssuesByKeys)
|
||||
// Production use: sync-all endpoint — fetches multiple tickets by key
|
||||
// in a single JQL query
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testBulkKeySearch() {
|
||||
assert(createdIssueKey, 'Need a created issue key from previous test');
|
||||
|
||||
// Search for the issue we created plus a fake key to test partial results
|
||||
const keys = [createdIssueKey, 'FAKE-99999'];
|
||||
logInfo('Bulk searching keys:', keys);
|
||||
|
||||
const result = await jiraApi.searchIssuesByKeys(keys);
|
||||
assert(result.ok, 'Bulk key search should succeed. Got HTTP ' + (result.status || '') + ': ' + ((result.body || '').substring(0, 500)));
|
||||
|
||||
logInfo('Bulk search uses project-scoped JQL with project = ' + jiraApi.JIRA_PROJECT_KEY);
|
||||
|
||||
const found = (result.data.issues || []).map(i => i.key);
|
||||
logInfo('Found issues:', found);
|
||||
assert(found.includes(createdIssueKey), 'Should find the created issue');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use Case 10: Rate Limit Status Check
|
||||
// Production use: Admin views rate limit usage on the Jira settings panel
|
||||
// ---------------------------------------------------------------------------
|
||||
async function testRateLimitStatus() {
|
||||
const status = jiraApi.getRateLimitStatus();
|
||||
assert(status.daily && typeof status.daily.used === 'number', 'Should have daily usage');
|
||||
assert(status.burst && typeof status.burst.used === 'number', 'Should have burst usage');
|
||||
logInfo('Rate limit status after all tests:', status);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
logInfo('=== STEAM Dashboard — Jira UAT Test Run ===');
|
||||
logInfo('Timestamp: ' + new Date().toISOString());
|
||||
logInfo('JIRA_BASE_URL: ' + (process.env.JIRA_BASE_URL || '(not set)'));
|
||||
logInfo('JIRA_AUTH_METHOD: ' + (process.env.JIRA_AUTH_METHOD || 'basic'));
|
||||
logInfo('JIRA_API_USER: ' + (process.env.JIRA_API_USER || '(not set)'));
|
||||
logInfo('JIRA_PROJECT_KEY: ' + (jiraApi.JIRA_PROJECT_KEY || '(not set)'));
|
||||
logInfo('JIRA_ISSUE_TYPE: ' + (jiraApi.JIRA_ISSUE_TYPE || 'Task'));
|
||||
logInfo('JIRA_SKIP_TLS: ' + (process.env.JIRA_SKIP_TLS || 'false'));
|
||||
logInfo('isConfigured: ' + jiraApi.isConfigured);
|
||||
logInfo('');
|
||||
|
||||
if (!jiraApi.isConfigured) {
|
||||
logFail('Pre-flight check', 'Jira API is not configured. Set JIRA_BASE_URL, JIRA_API_USER, and JIRA_API_TOKEN in backend/.env');
|
||||
writeLog();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let transitions = [];
|
||||
|
||||
// Run tests in order — later tests depend on the created issue
|
||||
if (await runTest('1. Connection Test (GET /myself)', testConnection)) passed++; else failed++;
|
||||
if (await runTest('2. Create Issue (POST /issue)', testCreateIssue)) passed++; else failed++;
|
||||
if (await runTest('3. Get Single Issue (JQL search)', testGetIssue)) passed++; else failed++;
|
||||
if (await runTest('4. Update Issue (PUT /issue/{key})', testUpdateIssue)) passed++; else failed++;
|
||||
if (await runTest('5. Add Comment (POST /issue/{key}/comment)', testAddComment)) passed++; else failed++;
|
||||
|
||||
if (await runTest('6. Get Transitions (GET /issue/{key}/transitions)', async () => {
|
||||
transitions = await testGetTransitions();
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await runTest('7. Transition Issue (POST /issue/{key}/transitions)', async () => {
|
||||
await testTransitionIssue(transitions);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await runTest('8. JQL Search (GET /search)', testJqlSearch)) passed++; else failed++;
|
||||
if (await runTest('9. Bulk Key Search (searchIssuesByKeys)', testBulkKeySearch)) passed++; else failed++;
|
||||
if (await runTest('10. Rate Limit Status', testRateLimitStatus)) passed++; else failed++;
|
||||
|
||||
logInfo('');
|
||||
logInfo('=== Summary ===');
|
||||
logInfo(`Passed: ${passed} | Failed: ${failed} | Total: ${passed + failed}`);
|
||||
if (createdIssueKey) {
|
||||
logInfo(`Test issue created: ${createdIssueKey} — delete manually after ATLSUP review if desired.`);
|
||||
}
|
||||
logInfo('Rate limit usage:', jiraApi.getRateLimitStatus());
|
||||
|
||||
writeLog();
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\nSome tests failed. Review the log above and jira-uat-test.log for details.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\nAll tests passed. Log saved to backend/scripts/jira-uat-test.log');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Attach or reference backend/scripts/jira-uat-test.log in the ATLSUP ticket');
|
||||
console.log(' 2. Click "Script ran - Review Logs" on the ATLSUP ticket');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
function writeLog() {
|
||||
const lines = results.map(r => {
|
||||
let line = `[${r.timestamp}] ${r.level.toUpperCase().padEnd(5)} ${r.message}`;
|
||||
if (r.data) {
|
||||
const dataStr = (typeof r.data === 'string' ? r.data : JSON.stringify(r.data, null, 2));
|
||||
const truncated = dataStr.length > 2000 ? dataStr.substring(0, 2000) + '\n ... [truncated — ' + dataStr.length + ' chars total]' : dataStr;
|
||||
line += '\n ' + truncated.split('\n').join('\n ');
|
||||
}
|
||||
return line;
|
||||
});
|
||||
fs.writeFileSync(LOG_FILE, lines.join('\n') + '\n', 'utf8');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Unhandled error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PieChart, Pie, Cell, ComposedChart, Bar, BarChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
||||
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
|
||||
|
||||
// ⚠️ CONVENTION: Use relative API path (e.g. '/api') instead of absolute URL with localhost. The fallback 'http://localhost:3001/api' should be a relative path since Express serves both API and frontend on the same port in production.
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const PURPLE = '#A78BFA';
|
||||
@@ -1187,6 +1186,338 @@ function DataManagementPanel({ onClose, onDataChanged }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric Selector (Forecast Burndown)
|
||||
// ---------------------------------------------------------------------------
|
||||
function MetricSelector({ onMetricSelect, selectedMetric }) {
|
||||
const [metrics, setMetrics] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metrics-list`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to load metrics (${r.status})`);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
setMetrics(data || []);
|
||||
setLoading(false);
|
||||
// Auto-select first metric on initial load
|
||||
if (data && data.length > 0 && !selectedMetric) {
|
||||
onMetricSelect(data[0].metric_id);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<Loader style={{ animation: 'spin 1s linear infinite', width: '14px', height: '14px', color: PURPLE }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748B' }}>Loading metrics...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
border: '1px solid rgba(239, 68, 68, 0.5)',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (metrics.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '0.5rem 0', fontSize: '0.75rem', color: '#64748B' }}>
|
||||
No metrics with active non-compliant devices
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<label style={{ fontSize: '0.65rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>
|
||||
Metric
|
||||
</label>
|
||||
<select
|
||||
value={selectedMetric || ''}
|
||||
onChange={e => onMetricSelect(e.target.value)}
|
||||
style={{
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: `1px solid rgba(167, 139, 250, 0.4)`,
|
||||
borderRadius: '0.4rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
color: '#E2E8F0',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{metrics.map(m => (
|
||||
<option key={m.metric_id} value={m.metric_id}>
|
||||
{m.metric_id} — {m.device_count} device{m.device_count !== 1 ? 's' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forecast Burndown Chart
|
||||
// ---------------------------------------------------------------------------
|
||||
function ForecastBurndownChart({ metricId }) {
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const requestCounterRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!metricId) {
|
||||
setChartData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRequest = ++requestCounterRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(`${API_BASE}/compliance/vcl-multi/metric/${encodeURIComponent(metricId)}/forecast-burndown`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to load forecast data (${r.status})`);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Discard stale responses
|
||||
if (currentRequest !== requestCounterRef.current) return;
|
||||
setChartData(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (currentRequest !== requestCounterRef.current) return;
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [metricId]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ animation: 'spin 1s linear infinite', width: '20px', height: '20px', color: '#64748B' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading forecast data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{
|
||||
...CARD_STYLE,
|
||||
border: '1px solid rgba(239, 68, 68, 0.5)',
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '1.25rem',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No metric selected yet
|
||||
if (!metricId || !chartData) return null;
|
||||
|
||||
// Empty data state
|
||||
const historical = chartData.historical || [];
|
||||
const forecast = chartData.forecast || [];
|
||||
if (historical.length === 0 && forecast.length === 0) {
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, textAlign: 'center', padding: '2rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748B' }}>No data available for this metric</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Combine historical and forecast into a single array with isForecast flag
|
||||
const combinedData = [
|
||||
...historical.map(d => ({ ...d, isForecast: false })),
|
||||
...forecast.map(d => ({ ...d, isForecast: true })),
|
||||
];
|
||||
|
||||
// Determine the divider position (between last historical and first forecast)
|
||||
const hasForecast = forecast.length > 0;
|
||||
const dividerMonth = hasForecast && historical.length > 0
|
||||
? historical[historical.length - 1].month
|
||||
: null;
|
||||
|
||||
// Compute max total_assets for left Y-axis domain
|
||||
const maxTotal = Math.max(...combinedData.map(d => d.total_assets || 0), 1);
|
||||
|
||||
// Custom bar shape to apply opacity for forecast data points
|
||||
const renderTotalAssetsBar = (props) => {
|
||||
const { x, y, width, height, payload } = props;
|
||||
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<rect x={x} y={y} width={width} height={height} fill="#3B82F6" fillOpacity={opacity} rx={2} />
|
||||
);
|
||||
};
|
||||
|
||||
const renderNonCompliantBar = (props) => {
|
||||
const { x, y, width, height, payload } = props;
|
||||
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<rect x={x} y={y} width={width} height={height} fill="#F97316" fillOpacity={opacity} rx={2} />
|
||||
);
|
||||
};
|
||||
|
||||
// Custom label for bars (device counts inside bars)
|
||||
const renderTotalLabel = (props) => {
|
||||
const { x, y, width, height, value } = props;
|
||||
if (!value || height < 14) return null;
|
||||
return (
|
||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||
{value}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNonCompliantLabel = (props) => {
|
||||
const { x, y, width, height, value } = props;
|
||||
if (!value || height < 14) return null;
|
||||
return (
|
||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="middle" fill="#FFF" fontSize={9} fontWeight="600">
|
||||
{value}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom dot for the line to apply opacity
|
||||
const renderDot = (props) => {
|
||||
const { cx, cy, payload } = props;
|
||||
const opacity = payload.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<circle cx={cx} cy={cy} r={3} fill="#10B981" fillOpacity={opacity} stroke="#10B981" strokeOpacity={opacity} strokeWidth={1} />
|
||||
);
|
||||
};
|
||||
|
||||
// Custom label for compliance percentage on the line
|
||||
const renderLineLabel = (props) => {
|
||||
const { x, y, value, index } = props;
|
||||
if (value === undefined || value === null) return null;
|
||||
const point = combinedData[index];
|
||||
const opacity = point && point.isForecast ? 0.5 : 1.0;
|
||||
return (
|
||||
<text x={x} y={y - 10} textAnchor="middle" fill="#10B981" fillOpacity={opacity} fontSize={9} fontWeight="600">
|
||||
{value}%
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Forecast Burndown — {metricId}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={combinedData} margin={{ top: 20, right: 40, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
domain={[0, maxTotal]}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
label={{ value: 'Devices', angle: -90, position: 'insideLeft', style: { fontSize: 10, fill: '#64748B' } }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
label={{ value: '%', angle: 90, position: 'insideRight', style: { fontSize: 10, fill: '#64748B' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }}
|
||||
labelStyle={{ color: '#94A3B8' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '0.7rem', color: '#94A3B8' }}
|
||||
/>
|
||||
{dividerMonth && (
|
||||
<ReferenceLine
|
||||
x={dividerMonth}
|
||||
yAxisId="left"
|
||||
stroke={PURPLE}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: 'Forecast →', position: 'top', style: { fontSize: 9, fill: PURPLE } }}
|
||||
/>
|
||||
)}
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="total_assets"
|
||||
name="Total Assets"
|
||||
shape={renderTotalAssetsBar}
|
||||
label={renderTotalLabel}
|
||||
barSize={28}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="non_compliant"
|
||||
name="Non-Compliant"
|
||||
shape={renderNonCompliantBar}
|
||||
label={renderNonCompliantLabel}
|
||||
barSize={28}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="compliance_pct"
|
||||
name="Compliance %"
|
||||
stroke="#10B981"
|
||||
strokeWidth={2}
|
||||
dot={renderDot}
|
||||
label={renderLineLabel}
|
||||
activeDot={{ r: 5, fill: '#10B981' }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1203,6 +1534,7 @@ export default function CCPMetricsPage() {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showManage, setShowManage] = useState(false);
|
||||
const [showMetricBreakdown, setShowMetricBreakdown] = useState(false);
|
||||
const [forecastMetric, setForecastMetric] = useState(null);
|
||||
|
||||
// Drill-down state (metric-first hierarchy: metric → vertical → team)
|
||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||
@@ -1388,6 +1720,17 @@ export default function CCPMetricsPage() {
|
||||
error={burndownError}
|
||||
/>
|
||||
|
||||
{/* Per-Metric Forecast Burndown */}
|
||||
<div style={{ ...CARD_STYLE, marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', fontWeight: '700', color: '#E2E8F0', margin: '0 0 1rem 0' }}>
|
||||
Per-Metric Forecast Burndown
|
||||
</h3>
|
||||
<MetricSelector onMetricSelect={setForecastMetric} selectedMetric={forecastMetric} />
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<ForecastBurndownChart metricId={forecastMetric} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics overview table (metric-first model) */}
|
||||
<MetricTable
|
||||
metrics={metricsData?.metrics}
|
||||
|
||||
@@ -2197,7 +2197,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, right: 0,
|
||||
height: '100vh', width: '420px',
|
||||
height: '100vh', width: '600px',
|
||||
zIndex: 9999,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'linear-gradient(180deg, #0D1929 0%, #080F1C 100%)',
|
||||
|
||||
Reference in New Issue
Block a user