Compare commits
73 Commits
ddc3af9147
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8aa7038ad
|
||
|
|
e887fa8946
|
||
|
|
d9c47ec030
|
||
|
|
4e8f4cbb10
|
||
|
|
1cc8bd5a4c
|
||
|
|
50f14c14d2
|
||
|
|
4f40850fd2
|
||
|
|
e4abf8dc9b
|
||
|
|
3500787851
|
||
|
|
c5225c96a5
|
||
|
|
aae09020e6
|
||
|
|
0cf49e6ef1
|
||
|
|
7545457813
|
||
|
|
6cc06390b2
|
||
|
|
56a4c546d0
|
||
|
|
b23a49a78d
|
||
|
|
df62e13627
|
||
|
|
8224183679
|
||
|
|
a6e455311e
|
||
|
|
93811eda10
|
||
|
|
46dd2256f5
|
||
|
|
1256c7510f
|
||
|
|
3310f7fa22
|
||
|
|
5e95e35d26
|
||
|
|
8fc7c33cff
|
||
|
|
bd772087c4
|
||
|
|
18a377aea2
|
||
|
|
43e10b8c06
|
||
|
|
fe82362afa
|
||
|
|
1903e41088
|
||
|
|
9f7703c76f
|
||
|
|
04eb21a7d3
|
||
|
|
56e3f5f973
|
||
|
|
d65411b0d7
|
||
|
|
ea875e9193
|
||
|
|
fabf98790c
|
||
|
|
d081961341
|
||
|
|
44ecf98da6
|
||
|
|
594b170826
|
||
|
|
1a6f956fb8
|
||
|
|
2328ecca6a
|
||
|
|
2a3b25526f
|
||
|
|
8d82245c86
|
||
|
|
37c0970102
|
||
|
|
dd6fc394ea
|
||
|
|
bfd1c4986f
|
||
|
|
7f6f458949
|
||
|
|
caf6ca4008
|
||
|
|
33e449f520
|
||
|
|
e2fae896dc
|
||
|
|
fd144966b7
|
||
|
|
392e4917b6
|
||
|
|
c19d549ae8
|
||
|
|
2edf6228ff
|
||
|
|
8f42f9d9c3
|
||
|
|
8788b1e91a
|
||
|
|
60bb86f2ea
|
||
|
|
19b5009010
|
||
|
|
de4ff3f084
|
||
|
|
c9f93a2a9b
|
||
|
|
76667f65c6
|
||
|
|
6b805ee633
|
||
|
|
704432788c
|
||
|
|
e86dd8be15
|
||
|
|
6148f06a95
|
||
|
|
758a300f67
|
||
|
|
dff1fa3cc9
|
||
|
|
940cb3251c
|
||
|
|
ae2b7e0433
|
||
|
|
e45deccdb7
|
||
|
|
f9770872ba
|
||
|
|
f9b96e9040
|
||
|
|
df31cc3c79
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -71,3 +71,4 @@ docs/data-exports/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
docs/Team_Device Loader.xlsx
|
||||
|
||||
212
.gitlab-ci.yml
212
.gitlab-ci.yml
@@ -1,44 +1,21 @@
|
||||
# =============================================================================
|
||||
# GitLab CI/CD Pipeline — STEAM Security Dashboard
|
||||
# =============================================================================
|
||||
#
|
||||
# Pipeline stages:
|
||||
# 1. install — install dependencies for backend and frontend
|
||||
# 2. lint — run linters / static checks
|
||||
# 3. test — run backend (Jest) and frontend (react-scripts) tests
|
||||
# 4. build — produce the production frontend bundle
|
||||
# 5. deploy — deploy to staging (local) or production (SSH to 71.85.90.6)
|
||||
# 6. verify — post-deploy health checks
|
||||
#
|
||||
# Environments:
|
||||
# staging — dashboard-dev:3100 (auto-deploy on main/master)
|
||||
# production — 71.85.90.6:3001 (manual trigger, requires staging verification)
|
||||
#
|
||||
# Executor: shell (runs on dashboard-dev using system Node.js)
|
||||
# Executor: Docker (LXC 108 — 71.85.90.8)
|
||||
# Build/test jobs run in node:18 containers.
|
||||
# Release: v2.1.0
|
||||
# Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9)
|
||||
# and production (71.85.90.6) via SSH.
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
variables:
|
||||
PROD_HOST: "71.85.90.6"
|
||||
PROD_USER: "root"
|
||||
PROD_DIR: "/home/cve-dashboard"
|
||||
STAGING_HOST: "71.85.90.9"
|
||||
STAGING_USER: "root"
|
||||
STAGING_DIR: "/home/cve-dashboard-staging"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global cache — persists node_modules between pipeline runs
|
||||
# ---------------------------------------------------------------------------
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- node_modules/
|
||||
- frontend/node_modules/
|
||||
policy: pull
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stages
|
||||
# ---------------------------------------------------------------------------
|
||||
stages:
|
||||
- install
|
||||
- lint
|
||||
@@ -48,44 +25,45 @@ stages:
|
||||
- verify
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 1: Install dependencies
|
||||
# STAGE 1: Install
|
||||
# =============================================================================
|
||||
|
||||
install-backend:
|
||||
stage: install
|
||||
image: node:18
|
||||
script:
|
||||
- npm ci --prefer-offline
|
||||
- npm ci
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
key: backend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- node_modules/
|
||||
policy: pull-push
|
||||
policy: push
|
||||
|
||||
install-frontend:
|
||||
stage: install
|
||||
image: node:18
|
||||
script:
|
||||
- cd frontend && npm ci --prefer-offline
|
||||
- cd frontend && npm ci
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
key: frontend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
policy: pull-push
|
||||
policy: push
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 2: Lint / static analysis
|
||||
# STAGE 2: Lint
|
||||
# =============================================================================
|
||||
|
||||
lint-frontend:
|
||||
stage: lint
|
||||
script:
|
||||
- cd frontend && npm ci --prefer-offline && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 10
|
||||
needs:
|
||||
- install-frontend
|
||||
|
||||
lint-backend:
|
||||
stage: lint
|
||||
image: node:18
|
||||
cache:
|
||||
key: backend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- node_modules/
|
||||
policy: pull
|
||||
script:
|
||||
- npm ci --prefer-offline
|
||||
- test -d node_modules || npm ci
|
||||
- node -c backend/server.js
|
||||
- node -c backend/routes/*.js
|
||||
- node -c backend/helpers/*.js
|
||||
@@ -93,14 +71,35 @@ lint-backend:
|
||||
needs:
|
||||
- install-backend
|
||||
|
||||
lint-frontend:
|
||||
stage: lint
|
||||
image: node:18
|
||||
cache:
|
||||
key: frontend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
policy: pull
|
||||
script:
|
||||
- cd frontend && (test -d node_modules || npm ci) && npx eslint src/ --ignore-pattern '**/__tests__/**' --ignore-pattern '**/*.test.js' --max-warnings 25
|
||||
needs:
|
||||
- install-frontend
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 3: Tests
|
||||
# STAGE 3: Test
|
||||
# =============================================================================
|
||||
|
||||
test-backend:
|
||||
stage: test
|
||||
image: node:18
|
||||
variables:
|
||||
DATABASE_URL: $DATABASE_URL
|
||||
cache:
|
||||
key: backend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- node_modules/
|
||||
policy: pull
|
||||
script:
|
||||
- npm ci --prefer-offline
|
||||
- test -d node_modules || npm ci
|
||||
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
|
||||
timeout: 5 minutes
|
||||
needs:
|
||||
@@ -108,9 +107,18 @@ test-backend:
|
||||
|
||||
test-frontend:
|
||||
stage: test
|
||||
image: node:18
|
||||
cache:
|
||||
- key: frontend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
policy: pull
|
||||
- key: backend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- node_modules/
|
||||
policy: pull
|
||||
script:
|
||||
- npm ci --prefer-offline
|
||||
- cd frontend && npm ci --prefer-offline && CI=true npx react-scripts test --watchAll=false --ci
|
||||
- cd frontend && (test -d node_modules || npm ci) && cd .. && (test -d node_modules || npm ci) && cd frontend && CI=true npx react-scripts test --watchAll=false --ci
|
||||
timeout: 5 minutes
|
||||
needs:
|
||||
- install-frontend
|
||||
@@ -121,8 +129,14 @@ test-frontend:
|
||||
|
||||
build-frontend:
|
||||
stage: build
|
||||
image: node:18
|
||||
cache:
|
||||
key: frontend-${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
policy: pull
|
||||
script:
|
||||
- cd frontend && npm ci --prefer-offline && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build
|
||||
- cd frontend && (test -d node_modules || npm ci) && CI=false REACT_APP_API_BASE=/api REACT_APP_API_HOST="" npm run build
|
||||
artifacts:
|
||||
paths:
|
||||
- frontend/build/
|
||||
@@ -132,26 +146,30 @@ build-frontend:
|
||||
- lint-frontend
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 5: Deploy
|
||||
# STAGE 5: Deploy (SSH from container)
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Staging — auto-deploys on main/master to dashboard-dev:3100
|
||||
# ---------------------------------------------------------------------------
|
||||
.deploy-base: &deploy-base
|
||||
image: alpine:latest
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client rsync
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- echo -e "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null" > ~/.ssh/config
|
||||
|
||||
deploy-staging:
|
||||
<<: *deploy-base
|
||||
stage: deploy
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||
when: on_success
|
||||
environment:
|
||||
name: staging
|
||||
url: http://localhost:3100
|
||||
url: http://71.85.90.9:3100
|
||||
script:
|
||||
- echo "Deploying to staging (dashboard-dev:3100)..."
|
||||
# Ensure staging directory exists
|
||||
- mkdir -p ${STAGING_DIR}
|
||||
# Sync code (exclude .git, node_modules, uploads, logs)
|
||||
- rsync -a --delete
|
||||
- echo "Deploying to staging (${STAGING_HOST})..."
|
||||
- rsync -az --delete
|
||||
--exclude='.git'
|
||||
--exclude='node_modules'
|
||||
--exclude='frontend/node_modules'
|
||||
@@ -160,26 +178,16 @@ deploy-staging:
|
||||
--exclude='*.log'
|
||||
--exclude='*.db'
|
||||
--exclude='.env'
|
||||
${CI_PROJECT_DIR}/ ${STAGING_DIR}/
|
||||
# Copy built frontend
|
||||
- cp -r ${CI_PROJECT_DIR}/frontend/build ${STAGING_DIR}/frontend/build
|
||||
# Install deps in staging
|
||||
- cd ${STAGING_DIR} && npm ci --prefer-offline
|
||||
- cd ${STAGING_DIR}/frontend && npm ci --prefer-offline
|
||||
# Ensure staging .env exists
|
||||
- |
|
||||
if [ ! -f "${STAGING_DIR}/backend/.env" ]; then
|
||||
cp ${CI_PROJECT_DIR}/backend/.env ${STAGING_DIR}/backend/.env
|
||||
sed -i 's/^PORT=.*/PORT=3100/' ${STAGING_DIR}/backend/.env
|
||||
grep -q "^PORT=" ${STAGING_DIR}/backend/.env || echo "PORT=3100" >> ${STAGING_DIR}/backend/.env
|
||||
fi
|
||||
# Run migrations
|
||||
- cd ${STAGING_DIR}/backend && node migrations/run-all.js
|
||||
# Restart staging service
|
||||
- sudo systemctl restart cve-backend-staging || sudo systemctl start cve-backend-staging || true
|
||||
./ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/
|
||||
- rsync -az frontend/build/ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/frontend/build/
|
||||
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR} && npm ci --prefer-offline"
|
||||
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/frontend && npm ci --prefer-offline"
|
||||
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/backend && node migrations/run-all.js"
|
||||
- ssh ${STAGING_USER}@${STAGING_HOST} "systemctl restart cve-backend-staging || systemctl start cve-backend-staging || true"
|
||||
- echo "Staging deploy complete."
|
||||
after_script:
|
||||
- |
|
||||
apk add --no-cache curl > /dev/null 2>&1
|
||||
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
||||
for ISSUE in $ISSUES; do
|
||||
curl --silent --request POST \
|
||||
@@ -192,10 +200,8 @@ deploy-staging:
|
||||
- build-frontend
|
||||
- test-backend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production — manual trigger, SSH to 71.85.90.6
|
||||
# ---------------------------------------------------------------------------
|
||||
deploy-production:
|
||||
<<: *deploy-base
|
||||
stage: deploy
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||
@@ -205,10 +211,8 @@ deploy-production:
|
||||
url: http://71.85.90.6:3001
|
||||
script:
|
||||
- echo "Deploying to production (${PROD_HOST})..."
|
||||
# Record current commit on prod for rollback
|
||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git rev-parse HEAD 2>/dev/null || echo none" > /tmp/prod-prev-commit
|
||||
- echo "Previous production commit:$(cat /tmp/prod-prev-commit)"
|
||||
# Sync code to production (exclude local-only files)
|
||||
- rsync -az --delete
|
||||
--exclude='.git'
|
||||
--exclude='node_modules'
|
||||
@@ -219,20 +223,17 @@ deploy-production:
|
||||
--exclude='*.db'
|
||||
--exclude='.env'
|
||||
--exclude='.compliance-staging'
|
||||
${CI_PROJECT_DIR}/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
|
||||
# Copy built frontend
|
||||
- rsync -az ${CI_PROJECT_DIR}/frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
|
||||
# Install deps on production
|
||||
./ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
|
||||
- rsync -az frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
|
||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/frontend && npm ci --prefer-offline"
|
||||
# Run migrations
|
||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR}/backend && node migrations/run-all.js"
|
||||
# Restart services — install systemd unit if not present
|
||||
- ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service" || scp ${CI_PROJECT_DIR}/deploy/cve-backend-production.service ${PROD_USER}@${PROD_HOST}:/etc/systemd/system/cve-backend.service
|
||||
- ssh ${PROD_USER}@${PROD_HOST} "test -f /etc/systemd/system/cve-backend.service || true"
|
||||
- ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend"
|
||||
- echo "Production deploy complete."
|
||||
after_script:
|
||||
- |
|
||||
apk add --no-cache curl > /dev/null 2>&1
|
||||
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
||||
for ISSUE in $ISSUES; do
|
||||
curl --silent --request POST \
|
||||
@@ -246,23 +247,22 @@ deploy-production:
|
||||
- test-backend
|
||||
|
||||
# =============================================================================
|
||||
# STAGE 6: Post-deploy verification
|
||||
# STAGE 6: Verify
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Staging health check
|
||||
# ---------------------------------------------------------------------------
|
||||
verify-staging:
|
||||
stage: verify
|
||||
image: alpine:latest
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||
when: on_success
|
||||
script:
|
||||
- apk add --no-cache curl
|
||||
- echo "Verifying staging..."
|
||||
- sleep 3
|
||||
- |
|
||||
for i in 1 2 3 4 5; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3100/api/health 2>/dev/null || echo "000")
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://${STAGING_HOST}:3100/api/health 2>/dev/null || echo "000")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Staging health check passed (attempt $i)"
|
||||
break
|
||||
@@ -274,19 +274,28 @@ verify-staging:
|
||||
echo "FAILED: Staging health check failed after 5 attempts"
|
||||
exit 1
|
||||
fi
|
||||
- |
|
||||
COMP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${STAGING_HOST}:3100/api/compliance/items?page=1&limit=1" || echo "000")
|
||||
[ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true
|
||||
- |
|
||||
VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${STAGING_HOST}:3100/api/compliance/vcl/stats" || echo "000")
|
||||
[ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true
|
||||
- echo "Staging verification passed."
|
||||
needs:
|
||||
- deploy-staging
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production health check — rolls back on failure
|
||||
# ---------------------------------------------------------------------------
|
||||
verify-production:
|
||||
stage: verify
|
||||
image: alpine:latest
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||
when: on_success
|
||||
script:
|
||||
- apk add --no-cache curl openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- echo -e "Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null" > ~/.ssh/config
|
||||
- echo "Verifying production..."
|
||||
- sleep 3
|
||||
- |
|
||||
@@ -304,7 +313,6 @@ verify-production:
|
||||
PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then
|
||||
echo "Rolling back to $PREV_COMMIT..."
|
||||
# Re-sync the previous version
|
||||
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && git checkout ${PREV_COMMIT} --force 2>/dev/null" || true
|
||||
ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
||||
ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend"
|
||||
@@ -314,6 +322,12 @@ verify-production:
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
- |
|
||||
COMP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${PROD_HOST}:3001/api/compliance/items?page=1&limit=1" || echo "000")
|
||||
[ "$COMP_STATUS" != "200" ] && echo "WARN: Compliance items returned $COMP_STATUS" || true
|
||||
- |
|
||||
VCL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://${PROD_HOST}:3001/api/compliance/vcl/stats" || echo "000")
|
||||
[ "$VCL_STATUS" != "200" ] && echo "WARN: VCL stats returned $VCL_STATUS" || true
|
||||
- echo "Production verification passed."
|
||||
needs:
|
||||
- deploy-production
|
||||
|
||||
14
.kiro/hooks/migration-registration-check.kiro.hook
Normal file
14
.kiro/hooks/migration-registration-check.kiro.hook
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "Migration Registration Check",
|
||||
"description": "After any write to backend/migrations/, verify the file is registered in POSTGRES_MIGRATIONS array in run-all.js. Blocks until confirmed.",
|
||||
"version": "2",
|
||||
"when": {
|
||||
"type": "postToolUse",
|
||||
"toolTypes": ["write"]
|
||||
},
|
||||
"then": {
|
||||
"type": "askAgent",
|
||||
"prompt": "STOP. If the tool just wrote or created a file matching backend/migrations/*.js (but NOT run-all.js itself), you MUST immediately:\n1. Read backend/migrations/run-all.js\n2. Check if the migration filename exists in the POSTGRES_MIGRATIONS array\n3. If NOT present, add it to the end of the array RIGHT NOW before doing anything else\n4. Do NOT proceed with other work until this is done\n\nThis is a hard requirement — migrations not in run-all.js will not run in CI/CD and will break production deploys. If the written file is not a migration file, ignore this message."
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,62 @@ Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv
|
||||
- Both `.env` files are gitignored; see `.env.example` files for templates.
|
||||
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
|
||||
|
||||
## Code Style & Lint Rules
|
||||
|
||||
### Unused Variables
|
||||
|
||||
The frontend ESLint config enforces `no-unused-vars` as a warning. The CI pipeline fails if warnings exceed 25. To avoid lint failures:
|
||||
|
||||
- **Prefix intentionally-unused variables with `_`** — this suppresses the warning. The `varsIgnorePattern: "^_"` and `argsIgnorePattern: "^_"` rules are configured in `frontend/package.json`.
|
||||
- Common patterns:
|
||||
- `const [_unused, setFoo] = useState(...)` — destructured value you don't need
|
||||
- `const _legacyRef = useRef(...)` — kept for future use
|
||||
- `function handler(_event) { ... }` — required parameter signature but unused
|
||||
- **Do not leave variables unprefixed if unused.** Either use them, remove them, or prefix with `_`.
|
||||
- This applies to all frontend code written by the agent.
|
||||
|
||||
### Backend
|
||||
|
||||
No ESLint is configured for backend — the pipeline uses `node -c` syntax checking only. Keep code clean but there is no automated unused-var enforcement on the backend side.
|
||||
|
||||
## Ports
|
||||
|
||||
| Environment | URL | Notes |
|
||||
|---|---|---|
|
||||
| Production / Dev server | http://IP:3001 | Express serves API + static frontend build |
|
||||
| Production | http://71.85.90.6:3001 | Express serves API + static frontend build |
|
||||
| Staging | http://71.85.90.9:3100 | Auto-deploy on master push |
|
||||
| Local dev (frontend only) | http://localhost:3000 | React dev server with hot-reload, proxies API to :3001 |
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Role | Host | Notes |
|
||||
|---|---|---|
|
||||
| GitLab instance | steam-gitlab.charterlab.com | Self-hosted GitLab |
|
||||
| CI Runner (LXC 108) | 71.85.90.8 | Docker executor, Runner #6, project-locked |
|
||||
| Staging target | 71.85.90.9 | Auto-deploy on master, port 3100 |
|
||||
| Production target | 71.85.90.6 | Manual deploy trigger, port 3001 |
|
||||
|
||||
### Executor: Docker
|
||||
|
||||
The pipeline uses **Docker executor** on Runner #6. Jobs run in isolated containers:
|
||||
|
||||
- **Install / Lint / Test / Build stages**: `node:18` image
|
||||
- **Deploy stages**: `alpine:latest` image (installs `openssh-client` and `rsync` at runtime)
|
||||
|
||||
Deploy jobs SSH from inside the Alpine container to the target hosts using a base64-encoded `$SSH_PRIVATE_KEY` stored as a GitLab CI/CD variable.
|
||||
|
||||
### CI/CD Variables (project-level)
|
||||
|
||||
These are set in GitLab → Settings → CI/CD → Variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string for backend integration tests |
|
||||
| `SSH_PRIVATE_KEY` | Base64-encoded private key for deploy SSH access |
|
||||
| `GITLAB_PAT` | Project access token for issue comments and release creation |
|
||||
|
||||
### Pipeline file
|
||||
|
||||
The pipeline is defined in `.gitlab-ci.yml` at the project root. Stages: install → lint → test → build → deploy → verify.
|
||||
|
||||
179
CHANGELOG.md
179
CHANGELOG.md
@@ -1,59 +1,142 @@
|
||||
# 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.2.0] — 2026-06-04
|
||||
|
||||
### 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
|
||||
### Features
|
||||
|
||||
### 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
|
||||
- **Group by Host toggle** on the Ivanti findings table — collapses duplicate assets (same hostname + IP) with multiple finding IDs into expandable host rows. Hosts with only one finding remain as flat rows. Toggle between grouped and flat views from the toolbar.
|
||||
- **CARD ownership tooltip on IP hover** — hover over any IP address in the findings table to see CARD asset ownership data (confirmed/unconfirmed/candidate teams) in an interactive tooltip. Results cached per session for instant re-display.
|
||||
- **CARD direct action modal** — click "Actions" in the CARD tooltip to open a full confirm/decline/redirect modal that works directly against the CARD API without needing a queue item.
|
||||
- **Inline view panel** in the Archer Template Manager with per-section copy buttons
|
||||
- **Queue item redirect in place** — pending queue items can now be redirected without duplicating
|
||||
|
||||
### 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
|
||||
### Bug Fixes
|
||||
|
||||
### Knowledge Base
|
||||
- Internal document library with inline PDF and Markdown rendering
|
||||
- Category-based browsing and search
|
||||
- Improve CARD decline error diagnostics and prevent accidental modal dismiss
|
||||
- CARD teams fetch retries silently up to 3x on failure with increasing delay
|
||||
- Redirect dropdowns show owner-data teams as fallback when the full teams API fails
|
||||
- CARD tooltip uses quick mode (CTEC suffix only, 15s timeout) to avoid multi-minute waits
|
||||
- Timeouts (504) are not cached — re-hover will retry the lookup
|
||||
|
||||
### 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
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-06-06
|
||||
|
||||
### Features
|
||||
|
||||
- **Archer Template Library** — new template management system for Archer Risk Acceptance forms. Store static content (Environment Overview, Segmentation, Mitigating Controls) organized by Vendor > Platform > Model. Full CRUD with clone, search/filter, and per-section copy-to-clipboard. Accessible from the nav drawer (Template Mgr) and integrated into the Ivanti Queue for Archer workflow items.
|
||||
- **Estimated resolution date per metric** — the compliance asset sidebar now shows each noncompliant metric's estimated resolution date at the top of its section, in `YYYY-MM-DD` format, with placeholders for metrics that have no date set or an invalid date (closes #20)
|
||||
- **CARD Action Modal** with full owner context
|
||||
- **Granite Loader Sheet generator** with CARD enrichment, plus a Loader Sheet button on the Reporting page queue panel
|
||||
- **Vendor-specific issue type dropdown** for Jira ticket creation, with all vendor project keys
|
||||
- **LIVE and LAST REPORT badges** on the VCL compliance page
|
||||
- **Collapsible sections** on the Ivanti Queue page and side panel
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix remediation plan and resolution date missing from the compliance table; format `resolution_date` as `YYYY-MM-DD`
|
||||
- Improve CARD action error messages and default loader columns
|
||||
- Fix CARD production timeout by forcing IPv4 (`dns.setDefaultResultOrder('ipv4first')`)
|
||||
- Add IP address validation to CARD confirm/decline/redirect actions
|
||||
- Auto-resolve bare IP to CARD asset ID with suffix lookup
|
||||
- Increase CARD API timeout from 15s to 30s
|
||||
- Rewrite CARD enrich-batch to use the team assets endpoint for full data
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] — 2026-05-26
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **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.
|
||||
- **Raw Jira status display** — removed Open/In Progress/Closed status mapping; shows the actual Jira status field everywhere.
|
||||
|
||||
### Features
|
||||
|
||||
- **Jira integration overhaul**
|
||||
- Flexible Jira ticket creation — CVE/Vendor fields optional, source context tracking
|
||||
- Multi-item Jira ticket creation from Ivanti Queue (consolidation modal)
|
||||
- Issue type dropdown and Save to Dashboard from Jira lookup
|
||||
- Success toast after consolidated ticket creation
|
||||
- Improved Jira lookup error messages
|
||||
- **CCP Metrics page** — multi-vertical VCL upload and cross-org compliance reporting
|
||||
- Metric-first hierarchy restructure with Jira cross-project sync
|
||||
- Per-metric forecast burndown chart
|
||||
- Aggregated burndown forecast on overview page
|
||||
- Sub-team drill-down with intermediate view and per-team breakdowns
|
||||
- Non-Compliant stat clickable with metric breakdown buttons
|
||||
- Compliant/total counts on metric summary cards
|
||||
- Per-metric remediation plans
|
||||
- VCL metric calculations guide
|
||||
- **Exports page** — Jira Tickets, CCP Metrics, and Remediation Status export cards
|
||||
- **VCL compliance reporting** — exec report page, device metadata fields, bulk upload
|
||||
- **Data management panel** — delete vertical, rollback upload, and reset all
|
||||
- **In-app notification system** — replaces Webex bot integration with native notifications
|
||||
- **Remediation plan and resolution date history tracking**
|
||||
- **FP submissions cleanup** — auto-clear approved, dismiss rejected, collapsible section
|
||||
- **Re-queue findings** from rejected FP submissions
|
||||
- **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 health endpoint and automated deploy stages
|
||||
- **Docker Compose** and `deploy-postgres.sh` for production cutover
|
||||
- **Systemd service scripts** for start/stop management
|
||||
- **VCL vertical metadata** — inline-editable team fields on compliance routes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix Clear Completed button failing on queue items with Jira ticket links (FK violation)
|
||||
- Fix status badge background making text invisible
|
||||
- Fix calendar SLA dates not highlighting after Postgres migration
|
||||
- Fix document View link using localhost instead of relative URL
|
||||
- Validate library doc file types before sending to Ivanti API
|
||||
- Improve FP workflow error messages — include Ivanti API response body
|
||||
- Fix forecast chart bar order and snapshot month derivation
|
||||
- Fix forecast deduplication for multi-vertical metrics
|
||||
- Fix CCP Metrics page crash for non-Admin users
|
||||
- Fix CCP Metrics crash when donut chart has zero non-compliant devices
|
||||
- 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
|
||||
- Fix Ivanti panel bugs: Invalid Date, wrong workflow count, crash on archive click
|
||||
- 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
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Track `package-lock.json` files for deterministic CI installs
|
||||
- Remove unused imports to satisfy ESLint thresholds
|
||||
- CI pipeline fixes: dependency installation, lint thresholds, test isolation
|
||||
- Auto-run migrations in pipeline
|
||||
- Strengthen migration registration hook
|
||||
- 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
|
||||
|
||||
380
backend/__tests__/compliance-per-metric-metadata.test.js
Normal file
380
backend/__tests__/compliance-per-metric-metadata.test.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Unit Tests: PATCH /api/compliance/items/:hostname/metadata — Per-Metric Scoping
|
||||
*
|
||||
* Feature: remediation-plan-history (per-metric extension)
|
||||
*
|
||||
* Tests cover:
|
||||
* - Task 8.1: metric_id/metric_ids validation, precedence, non-empty/max 100 chars, active item check
|
||||
* - Task 8.2: Per-metric SELECT, INSERT history per metric, UPDATE only matching rows
|
||||
* - Task 8.3: Hostname-level behavior preserved with NULL metric_id in history
|
||||
*
|
||||
* Validates: Requirements 8, 11, 15
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => {
|
||||
req.user = { id: 1, username: 'testuser', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock audit log as a no-op
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock ivantiApi
|
||||
jest.mock('../helpers/ivantiApi', () => ({
|
||||
ivantiFormPost: jest.fn(),
|
||||
ivantiPost: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock driftChecker
|
||||
jest.mock('../helpers/driftChecker', () => ({
|
||||
loadConfig: jest.fn(() => ({})),
|
||||
compareSchemaToDrift: jest.fn(() => null),
|
||||
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
||||
}));
|
||||
|
||||
// Mock the db pool
|
||||
const mockPool = {
|
||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||
connect: jest.fn(),
|
||||
};
|
||||
jest.mock('../db', () => mockPool);
|
||||
|
||||
const { createComplianceRouter } = require('../routes/compliance');
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
||||
function request(server, method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const rawBody = Buffer.concat(chunks).toString();
|
||||
let json;
|
||||
try { json = JSON.parse(rawBody); } catch (e) { json = null; }
|
||||
resolve({ statusCode: res.statusCode, body: json });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
let app, server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
const mockUpload = { single: () => (req, res, next) => next() };
|
||||
app.use('/api/compliance', createComplianceRouter(mockUpload));
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- Task 8.1: Validation ---
|
||||
|
||||
describe('Task 8.1: metric_id/metric_ids validation', () => {
|
||||
it('returns 400 when metric_ids is not an array', async () => {
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_ids: 'not-an-array',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('metric_ids must be an array');
|
||||
});
|
||||
|
||||
it('returns 400 when metric_ids is empty array', async () => {
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_ids: [],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('metric_ids must contain at least one entry');
|
||||
});
|
||||
|
||||
it('returns 400 when metric_ids contains empty string', async () => {
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_ids: ['2.1.1', ''],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('metric_id cannot be empty');
|
||||
});
|
||||
|
||||
it('returns 400 when metric_id exceeds 100 characters', async () => {
|
||||
const longId = 'x'.repeat(101);
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_ids: [longId],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('metric_id exceeds 100 characters');
|
||||
});
|
||||
|
||||
it('returns 400 when single metric_id is empty string', async () => {
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_id: '',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('metric_id cannot be empty');
|
||||
});
|
||||
|
||||
it('returns 400 when single metric_id exceeds 100 characters', async () => {
|
||||
const longId = 'x'.repeat(101);
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_id: longId,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('metric_id exceeds 100 characters');
|
||||
});
|
||||
|
||||
it('returns 400 when metric_id does not correspond to active compliance_item', async () => {
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT active metrics — none found
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_ids: ['nonexistent-metric'],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toContain('Invalid metric_id: nonexistent-metric');
|
||||
});
|
||||
|
||||
it('uses metric_ids when both metric_id and metric_ids are provided', async () => {
|
||||
// metric_ids wins — should validate metric_ids, not metric_id
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ metric_id: '3.1.1', resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT active metrics
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_id: '2.1.1', // should be ignored
|
||||
metric_ids: ['3.1.1'], // should be used
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Verify the SELECT query used metric_ids value ['3.1.1'], not metric_id '2.1.1'
|
||||
const selectCall = mockClient.query.mock.calls[1];
|
||||
expect(selectCall[1]).toEqual(['srv-001', ['3.1.1']]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Task 8.2: Per-metric scoping behavior ---
|
||||
|
||||
describe('Task 8.2: Per-metric SELECT, INSERT history, UPDATE matching rows', () => {
|
||||
it('selects current values per targeted metric and inserts history per metric', async () => {
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [
|
||||
{ metric_id: '2.1.1', resolution_date: '2026-01-01', remediation_plan: 'Plan A' },
|
||||
{ metric_id: '2.3.2', resolution_date: '2026-02-01', remediation_plan: 'Plan B' },
|
||||
], rowCount: 2 }) // SELECT active metrics with current values
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.1.1 resolution_date
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.1.1 remediation_plan
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.3.2 resolution_date
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history for 2.3.2 remediation_plan
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // UPDATE matching rows
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
remediation_plan: 'New unified plan',
|
||||
metric_ids: ['2.1.1', '2.3.2'],
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.updated).toBe(2);
|
||||
|
||||
// Verify history inserts include metric_id
|
||||
const calls = mockClient.query.mock.calls;
|
||||
// Call [2] = INSERT history for 2.1.1 resolution_date
|
||||
expect(calls[2][0]).toContain('INSERT INTO compliance_item_history');
|
||||
expect(calls[2][1][1]).toBe('2.1.1'); // metric_id
|
||||
expect(calls[2][1][2]).toBe('2026-01-01'); // old_value
|
||||
expect(calls[2][1][3]).toBe('2026-06-15'); // new_value
|
||||
|
||||
// Call [3] = INSERT history for 2.1.1 remediation_plan
|
||||
expect(calls[3][1][1]).toBe('2.1.1');
|
||||
expect(calls[3][1][2]).toBe('Plan A'); // old_value
|
||||
expect(calls[3][1][3]).toBe('New unified plan'); // new_value
|
||||
|
||||
// Call [4] = INSERT history for 2.3.2 resolution_date
|
||||
expect(calls[4][1][1]).toBe('2.3.2');
|
||||
expect(calls[4][1][2]).toBe('2026-02-01'); // old_value
|
||||
|
||||
// Call [5] = INSERT history for 2.3.2 remediation_plan
|
||||
expect(calls[5][1][1]).toBe('2.3.2');
|
||||
expect(calls[5][1][2]).toBe('Plan B'); // old_value
|
||||
});
|
||||
|
||||
it('skips history insert when value is unchanged for a specific metric', async () => {
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [
|
||||
{ metric_id: '2.1.1', resolution_date: '2026-06-15', remediation_plan: null },
|
||||
], rowCount: 1 }) // SELECT — already has the target date
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE (no history inserts since value unchanged)
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_ids: ['2.1.1'],
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// No INSERT history calls — only BEGIN, SELECT, UPDATE, COMMIT
|
||||
const calls = mockClient.query.mock.calls;
|
||||
expect(calls.length).toBe(4);
|
||||
expect(calls[2][0]).toContain('UPDATE compliance_items');
|
||||
});
|
||||
|
||||
it('updates only matching rows with metric_id = ANY filter', async () => {
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [
|
||||
{ metric_id: '2.1.1', resolution_date: null, remediation_plan: null },
|
||||
], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // UPDATE
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
metric_id: '2.1.1',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// Verify UPDATE query includes metric_id = ANY filter
|
||||
const updateCall = mockClient.query.mock.calls[3];
|
||||
expect(updateCall[0]).toContain('metric_id = ANY');
|
||||
expect(updateCall[0]).toContain("status = 'active'");
|
||||
expect(updateCall[1]).toContain('srv-001');
|
||||
expect(updateCall[1]).toEqual(expect.arrayContaining([['2.1.1']]));
|
||||
});
|
||||
});
|
||||
|
||||
// --- Task 8.3: Hostname-level behavior preserved ---
|
||||
|
||||
describe('Task 8.3: Hostname-level behavior with NULL metric_id', () => {
|
||||
it('updates all active rows when no metric_id/metric_ids provided', async () => {
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ resolution_date: null, remediation_plan: null }], rowCount: 1 }) // SELECT current
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 5 }) // UPDATE all active rows
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.updated).toBe(5);
|
||||
|
||||
// Verify UPDATE does NOT include metric_id filter
|
||||
const updateCall = mockClient.query.mock.calls[3];
|
||||
expect(updateCall[0]).not.toContain('metric_id');
|
||||
expect(updateCall[0]).toContain("status = 'active'");
|
||||
});
|
||||
|
||||
it('inserts history with NULL metric_id when no metric scoping', async () => {
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ resolution_date: '2026-01-01', remediation_plan: 'Old plan' }], rowCount: 1 }) // SELECT current
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (resolution_date)
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // INSERT history (remediation_plan)
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // UPDATE
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // COMMIT
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
remediation_plan: 'New plan',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify history INSERT includes NULL for metric_id
|
||||
const historyCall1 = mockClient.query.mock.calls[2];
|
||||
expect(historyCall1[0]).toContain('INSERT INTO compliance_item_history');
|
||||
expect(historyCall1[0]).toContain('NULL');
|
||||
});
|
||||
|
||||
it('returns 404 when hostname has no active items (hostname-level path)', async () => {
|
||||
const mockClient = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT current — empty
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }), // ROLLBACK
|
||||
release: jest.fn(),
|
||||
};
|
||||
mockPool.connect.mockResolvedValueOnce(mockClient);
|
||||
|
||||
const res = await request(server, 'PATCH', '/api/compliance/items/srv-001/metadata', {
|
||||
resolution_date: '2026-06-15',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body.error).toBe('Device not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Bug Condition Exploration Property Test: Compliance Remediation Display Fix
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-remediation-display-fix/ (bugfix)
|
||||
*
|
||||
* BUG CONDITION:
|
||||
* isBugCondition(row) = row.resolution_date != null OR row.remediation_plan != null
|
||||
* Any row with metadata set will lose it through groupByHostname() because the
|
||||
* function does not propagate resolution_date or remediation_plan into device objects.
|
||||
*
|
||||
* THIS TEST IS EXPECTED TO FAIL ON UNFIXED CODE.
|
||||
* Failure confirms the bug exists — resolution_date and remediation_plan are undefined
|
||||
* in the grouped device objects returned by groupByHostname().
|
||||
*
|
||||
* **Validates: Requirements 1.1, 1.2, 2.2**
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
// --- Mocks (must be installed BEFORE requiring the route module) ---
|
||||
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => next(),
|
||||
requireGroup: () => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
jest.mock('../helpers/driftChecker', () => ({
|
||||
loadConfig: jest.fn(() => ({})),
|
||||
compareSchemaToDrift: jest.fn(() => null),
|
||||
reconcileConfig: jest.fn(() => ({ changes: [] })),
|
||||
}));
|
||||
|
||||
jest.mock('../helpers/vclHelpers', () => ({
|
||||
isValidDateString: jest.fn(() => true),
|
||||
validateRemediationPlan: jest.fn(() => ({ valid: true })),
|
||||
computeVCLStats: jest.fn(() => ({})),
|
||||
categorizeNonCompliant: jest.fn(() => []),
|
||||
rankHeavyHitters: jest.fn(() => []),
|
||||
computeForecastBurndown: jest.fn(() => ({})),
|
||||
matchByHostname: jest.fn(() => []),
|
||||
computeBulkDiff: jest.fn(() => ({})),
|
||||
mapColumnHeaders: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const mockPool = {
|
||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||
connect: jest.fn(() => Promise.resolve({
|
||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||
release: jest.fn(),
|
||||
})),
|
||||
};
|
||||
jest.mock('../db', () => mockPool);
|
||||
|
||||
const { groupByHostname } = require('../routes/compliance');
|
||||
|
||||
// --- Generators ---
|
||||
|
||||
/** Generate a date string in YYYY-MM-DD format (avoid toISOString on shrunk invalid dates) */
|
||||
const arbDateString = fc.tuple(
|
||||
fc.integer({ min: 2020, max: 2030 }),
|
||||
fc.integer({ min: 1, max: 12 }),
|
||||
fc.integer({ min: 1, max: 28 })
|
||||
).map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
||||
|
||||
/** Generate a non-empty remediation plan string */
|
||||
const arbRemediationPlan = fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0);
|
||||
|
||||
/** Generate a hostname (alphanumeric with dashes, realistic) */
|
||||
const arbHostname = fc.stringMatching(/^[A-Z][A-Z0-9\-]{2,20}$/);
|
||||
|
||||
/** Generate a metric_id like "7.1.1" or "3.2" */
|
||||
const arbMetricId = fc.tuple(
|
||||
fc.integer({ min: 1, max: 9 }),
|
||||
fc.integer({ min: 1, max: 9 }),
|
||||
fc.integer({ min: 1, max: 9 })
|
||||
).map(([a, b, c]) => `${a}.${b}.${c}`);
|
||||
|
||||
/**
|
||||
* Generate a compliance row with non-null resolution_date and/or remediation_plan.
|
||||
* This is the bug condition: rows that have metadata which should be propagated.
|
||||
*/
|
||||
const arbComplianceRowWithMetadata = fc.record({
|
||||
hostname: arbHostname,
|
||||
ip_address: fc.ipV4(),
|
||||
device_type: fc.constantFrom('Switch', 'Router', 'Firewall', 'Server'),
|
||||
team: fc.constantFrom('STEAM', 'ACCESS-ENG'),
|
||||
status: fc.constant('active'),
|
||||
metric_id: arbMetricId,
|
||||
metric_desc: fc.string({ minLength: 3, maxLength: 50 }),
|
||||
category: fc.constantFrom('Configuration', 'Patching', 'Access Control'),
|
||||
seen_count: fc.integer({ min: 1, max: 20 }),
|
||||
first_seen: arbDateString,
|
||||
last_seen: arbDateString,
|
||||
resolved_on: fc.constant(null),
|
||||
resolution_date: arbDateString,
|
||||
remediation_plan: arbRemediationPlan,
|
||||
});
|
||||
|
||||
// --- Property Test ---
|
||||
|
||||
describe('Bug Condition Exploration: resolution_date and remediation_plan in groupByHostname()', () => {
|
||||
it('Property 1: groupByHostname() should propagate resolution_date from rows to device objects', () => {
|
||||
fc.assert(
|
||||
fc.property(arbComplianceRowWithMetadata, (row) => {
|
||||
const rows = [row];
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
// There should be exactly one device for the single hostname
|
||||
expect(devices).toHaveLength(1);
|
||||
const device = devices[0];
|
||||
|
||||
// BUG CONDITION: resolution_date should be propagated but is undefined on unfixed code
|
||||
expect(device.resolution_date).toBe(row.resolution_date);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Property 2: groupByHostname() should propagate remediation_plan from rows to device objects', () => {
|
||||
fc.assert(
|
||||
fc.property(arbComplianceRowWithMetadata, (row) => {
|
||||
const rows = [row];
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
expect(devices).toHaveLength(1);
|
||||
const device = devices[0];
|
||||
|
||||
// BUG CONDITION: remediation_plan should be propagated but is undefined on unfixed code
|
||||
expect(device.remediation_plan).toBe(row.remediation_plan);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Property 3: groupByHostname() should pick first non-null resolution_date across multiple rows for same hostname', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbHostname,
|
||||
fc.array(arbMetricId, { minLength: 2, maxLength: 5 }),
|
||||
arbDateString,
|
||||
(hostname, metricIds, resolutionDate) => {
|
||||
// Create multiple rows for the same hostname, first row has resolution_date
|
||||
const rows = metricIds.map((mid, idx) => ({
|
||||
hostname,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
team: 'STEAM',
|
||||
status: 'active',
|
||||
metric_id: mid,
|
||||
metric_desc: `Metric ${mid}`,
|
||||
category: 'Configuration',
|
||||
seen_count: 1,
|
||||
first_seen: '2025-01-01',
|
||||
last_seen: '2025-06-01',
|
||||
resolved_on: null,
|
||||
resolution_date: idx === 0 ? resolutionDate : null,
|
||||
remediation_plan: null,
|
||||
}));
|
||||
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
expect(devices).toHaveLength(1);
|
||||
const device = devices[0];
|
||||
|
||||
// The first non-null resolution_date should be propagated
|
||||
expect(device.resolution_date).toBe(resolutionDate);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Property 4: groupByHostname() should pick first non-null remediation_plan across multiple rows for same hostname', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbHostname,
|
||||
fc.array(arbMetricId, { minLength: 2, maxLength: 5 }),
|
||||
arbRemediationPlan,
|
||||
(hostname, metricIds, plan) => {
|
||||
// Create multiple rows for the same hostname, first row has remediation_plan
|
||||
const rows = metricIds.map((mid, idx) => ({
|
||||
hostname,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
team: 'STEAM',
|
||||
status: 'active',
|
||||
metric_id: mid,
|
||||
metric_desc: `Metric ${mid}`,
|
||||
category: 'Configuration',
|
||||
seen_count: 1,
|
||||
first_seen: '2025-01-01',
|
||||
last_seen: '2025-06-01',
|
||||
resolved_on: null,
|
||||
resolution_date: null,
|
||||
remediation_plan: idx === 0 ? plan : null,
|
||||
}));
|
||||
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
expect(devices).toHaveLength(1);
|
||||
const device = devices[0];
|
||||
|
||||
// The first non-null remediation_plan should be propagated
|
||||
expect(device.remediation_plan).toBe(plan);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Preservation Property Tests: Compliance Remediation Display Fix
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-remediation-display-fix/ (bugfix)
|
||||
*
|
||||
* These tests verify that groupByHostname() correctly aggregates existing fields
|
||||
* on UNFIXED code. They should PASS on unfixed code — they capture baseline
|
||||
* behaviour that must be preserved through the fix.
|
||||
*
|
||||
* Properties tested:
|
||||
* P2.A — Each device.hostname appears exactly once in output
|
||||
* P2.B — device.failing_metrics contains no duplicate metric_ids
|
||||
* P2.C — device.seen_count >= every row's seen_count for that hostname
|
||||
* P2.D — device.first_seen <= every row's first_seen for that hostname
|
||||
* P2.E — device.last_seen >= every row's last_seen for that hostname
|
||||
* P2.F — device.has_notes matches noteHostnames membership
|
||||
*
|
||||
* **Validates: Requirements 3.3, 3.5**
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
// Mock dependencies required by the compliance module
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => next(),
|
||||
requireGroup: () => (req, res, next) => next(),
|
||||
}));
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
jest.mock('../db', () => ({
|
||||
query: jest.fn(() => Promise.resolve({ rows: [], rowCount: 0 })),
|
||||
connect: jest.fn(() => Promise.resolve({ query: jest.fn(), release: jest.fn() })),
|
||||
}));
|
||||
|
||||
const { groupByHostname } = require('../routes/compliance');
|
||||
|
||||
// --- Generators ---
|
||||
|
||||
/**
|
||||
* Generate a valid hostname string (alphanumeric + hyphens, 1-20 chars).
|
||||
*/
|
||||
const hostnameArb = fc.stringMatching(/^[A-Z][A-Z0-9\-]{0,14}$/);
|
||||
|
||||
/**
|
||||
* Generate a metric_id string like "7.1.1", "7.2.3", etc.
|
||||
*/
|
||||
const metricIdArb = fc.tuple(
|
||||
fc.integer({ min: 1, max: 9 }),
|
||||
fc.integer({ min: 1, max: 9 }),
|
||||
fc.integer({ min: 1, max: 9 })
|
||||
).map(([a, b, c]) => `${a}.${b}.${c}`);
|
||||
|
||||
/**
|
||||
* Generate a date string in YYYY-MM-DD format for first_seen/last_seen.
|
||||
*/
|
||||
const dateArb = fc.tuple(
|
||||
fc.integer({ min: 2023, max: 2025 }),
|
||||
fc.integer({ min: 1, max: 12 }),
|
||||
fc.integer({ min: 1, max: 28 })
|
||||
).map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
||||
|
||||
/**
|
||||
* Generate a compliance row with resolution_date = null and remediation_plan = null
|
||||
* (non-bug-condition inputs — these are the rows that should work correctly on unfixed code).
|
||||
*/
|
||||
function complianceRowArb(hostname, metricId) {
|
||||
return fc.record({
|
||||
hostname: fc.constant(hostname),
|
||||
ip_address: fc.constantFrom('10.0.0.1', '10.0.0.2', '192.168.1.1', ''),
|
||||
device_type: fc.constantFrom('Switch', 'Router', 'Firewall', ''),
|
||||
team: fc.constantFrom('STEAM', 'ACCESS-ENG'),
|
||||
status: fc.constantFrom('active', 'resolved'),
|
||||
metric_id: fc.constant(metricId),
|
||||
metric_desc: fc.constantFrom('Password Complexity', 'Firmware Version', 'Logging Enabled'),
|
||||
category: fc.constantFrom('Configuration', 'Patching', 'Monitoring'),
|
||||
seen_count: fc.integer({ min: 1, max: 20 }),
|
||||
first_seen: dateArb,
|
||||
last_seen: dateArb,
|
||||
resolved_on: fc.constant(null),
|
||||
resolution_date: fc.constant(null),
|
||||
remediation_plan: fc.constant(null),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an array of compliance rows with varying hostnames and metric_ids.
|
||||
* Ensures at least 1 row, with 1-4 hostnames and 1-5 metrics per hostname.
|
||||
*/
|
||||
const complianceRowsArb = fc.tuple(
|
||||
fc.array(hostnameArb, { minLength: 1, maxLength: 4 }),
|
||||
fc.array(metricIdArb, { minLength: 1, maxLength: 5 })
|
||||
).chain(([hostnames, metricIds]) => {
|
||||
// Ensure unique hostnames and metric_ids
|
||||
const uniqueHostnames = [...new Set(hostnames)];
|
||||
const uniqueMetricIds = [...new Set(metricIds)];
|
||||
|
||||
if (uniqueHostnames.length === 0 || uniqueMetricIds.length === 0) {
|
||||
// Fallback: generate at least one row
|
||||
return complianceRowArb('HOST-A', '1.1.1').map(row => [row]);
|
||||
}
|
||||
|
||||
// Generate rows: each hostname gets some subset of metric_ids
|
||||
// Some hostnames may share metric_ids (same metric failing on different devices)
|
||||
const rowArbs = [];
|
||||
for (const hostname of uniqueHostnames) {
|
||||
// Each hostname gets 1 to all metric_ids
|
||||
const metricsForHost = uniqueMetricIds.slice(0, Math.max(1, Math.ceil(Math.random() * uniqueMetricIds.length)));
|
||||
for (const metricId of metricsForHost) {
|
||||
rowArbs.push(complianceRowArb(hostname, metricId));
|
||||
}
|
||||
}
|
||||
|
||||
return fc.tuple(...rowArbs).map(rows => rows);
|
||||
});
|
||||
|
||||
/**
|
||||
* Better generator: explicitly controls the structure to ensure good coverage.
|
||||
* Generates 1-3 hostnames, each with 1-4 rows (possibly duplicate metric_ids to test dedup).
|
||||
*/
|
||||
const structuredRowsArb = fc.record({
|
||||
numHostnames: fc.integer({ min: 1, max: 3 }),
|
||||
numMetricsPerHost: fc.integer({ min: 1, max: 4 }),
|
||||
allowDuplicateMetrics: fc.boolean(),
|
||||
}).chain(({ numHostnames, numMetricsPerHost, allowDuplicateMetrics }) => {
|
||||
const hostnameArbs = fc.array(hostnameArb, { minLength: numHostnames, maxLength: numHostnames });
|
||||
const metricArbs = fc.array(metricIdArb, { minLength: numMetricsPerHost, maxLength: numMetricsPerHost });
|
||||
|
||||
return fc.tuple(hostnameArbs, metricArbs).chain(([hostnames, metrics]) => {
|
||||
const uniqueHostnames = [...new Set(hostnames)];
|
||||
if (uniqueHostnames.length === 0) return fc.constant([]);
|
||||
|
||||
const rowArbs = [];
|
||||
for (const hostname of uniqueHostnames) {
|
||||
const metricsToUse = allowDuplicateMetrics
|
||||
? metrics // May have duplicates
|
||||
: [...new Set(metrics)];
|
||||
|
||||
for (const metricId of metricsToUse) {
|
||||
rowArbs.push(complianceRowArb(hostname, metricId));
|
||||
}
|
||||
// Add an extra duplicate row for the first metric to test dedup
|
||||
if (allowDuplicateMetrics && metricsToUse.length > 0) {
|
||||
rowArbs.push(complianceRowArb(hostname, metricsToUse[0]));
|
||||
}
|
||||
}
|
||||
|
||||
if (rowArbs.length === 0) return fc.constant([]);
|
||||
return fc.tuple(...rowArbs).map(rows => rows);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Property P2.A — Each device.hostname appears exactly once in output
|
||||
// =============================================================================
|
||||
//
|
||||
// **Validates: Requirements 3.3, 3.5**
|
||||
//
|
||||
describe('Property P2.A — Each device.hostname appears exactly once in output', () => {
|
||||
it('P2.A — groupByHostname produces one device per unique hostname', () => {
|
||||
fc.assert(
|
||||
fc.property(structuredRowsArb, (rows) => {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
// Each hostname in the output should appear exactly once
|
||||
const outputHostnames = devices.map(d => d.hostname);
|
||||
const uniqueOutputHostnames = new Set(outputHostnames);
|
||||
expect(outputHostnames.length).toBe(uniqueOutputHostnames.size);
|
||||
|
||||
// Every hostname from input should appear in output
|
||||
const inputHostnames = new Set(rows.map(r => r.hostname));
|
||||
for (const hostname of inputHostnames) {
|
||||
expect(uniqueOutputHostnames.has(hostname)).toBe(true);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Property P2.B — device.failing_metrics contains no duplicate metric_ids
|
||||
// =============================================================================
|
||||
//
|
||||
// **Validates: Requirements 3.3, 3.5**
|
||||
//
|
||||
describe('Property P2.B — device.failing_metrics contains no duplicate metric_ids', () => {
|
||||
it('P2.B — groupByHostname deduplicates metrics by metric_id', () => {
|
||||
fc.assert(
|
||||
fc.property(structuredRowsArb, (rows) => {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
for (const device of devices) {
|
||||
const metricIds = device.failing_metrics.map(m => m.metric_id);
|
||||
const uniqueMetricIds = new Set(metricIds);
|
||||
expect(metricIds.length).toBe(uniqueMetricIds.size);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Property P2.C — device.seen_count >= every row's seen_count for that hostname
|
||||
// =============================================================================
|
||||
//
|
||||
// **Validates: Requirements 3.3, 3.5**
|
||||
//
|
||||
describe('Property P2.C — device.seen_count >= every row seen_count for that hostname', () => {
|
||||
it('P2.C — groupByHostname picks the maximum seen_count across rows', () => {
|
||||
fc.assert(
|
||||
fc.property(structuredRowsArb, (rows) => {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
for (const device of devices) {
|
||||
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
|
||||
for (const row of rowsForHost) {
|
||||
expect(device.seen_count).toBeGreaterThanOrEqual(row.seen_count);
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Property P2.D — device.first_seen <= every row's first_seen for that hostname
|
||||
// =============================================================================
|
||||
//
|
||||
// **Validates: Requirements 3.3, 3.5**
|
||||
//
|
||||
describe('Property P2.D — device.first_seen <= every row first_seen for that hostname', () => {
|
||||
it('P2.D — groupByHostname picks the earliest first_seen across rows', () => {
|
||||
fc.assert(
|
||||
fc.property(structuredRowsArb, (rows) => {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
for (const device of devices) {
|
||||
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
|
||||
for (const row of rowsForHost) {
|
||||
if (row.first_seen && device.first_seen) {
|
||||
expect(device.first_seen <= row.first_seen).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Property P2.E — device.last_seen >= every row's last_seen for that hostname
|
||||
// =============================================================================
|
||||
//
|
||||
// **Validates: Requirements 3.3, 3.5**
|
||||
//
|
||||
describe('Property P2.E — device.last_seen >= every row last_seen for that hostname', () => {
|
||||
it('P2.E — groupByHostname picks the latest last_seen across rows', () => {
|
||||
fc.assert(
|
||||
fc.property(structuredRowsArb, (rows) => {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const noteHostnames = new Set();
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
for (const device of devices) {
|
||||
const rowsForHost = rows.filter(r => r.hostname === device.hostname);
|
||||
for (const row of rowsForHost) {
|
||||
if (row.last_seen && device.last_seen) {
|
||||
expect(device.last_seen >= row.last_seen).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Property P2.F — device.has_notes matches noteHostnames membership
|
||||
// =============================================================================
|
||||
//
|
||||
// **Validates: Requirements 3.3, 3.5**
|
||||
//
|
||||
describe('Property P2.F — device.has_notes matches noteHostnames membership', () => {
|
||||
it('P2.F — groupByHostname sets has_notes based on noteHostnames Set', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
structuredRowsArb,
|
||||
fc.array(hostnameArb, { minLength: 0, maxLength: 3 }),
|
||||
(rows, noteHosts) => {
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Build noteHostnames set — include some from input, some random
|
||||
const inputHostnames = [...new Set(rows.map(r => r.hostname))];
|
||||
const noteHostnames = new Set([
|
||||
...noteHosts,
|
||||
// Include some actual hostnames from input to test true case
|
||||
...inputHostnames.slice(0, Math.ceil(inputHostnames.length / 2)),
|
||||
]);
|
||||
|
||||
const devices = groupByHostname(rows, noteHostnames);
|
||||
|
||||
for (const device of devices) {
|
||||
const expected = noteHostnames.has(device.hostname);
|
||||
expect(device.has_notes).toBe(expected);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Bug Condition Exploration Property Test: Ivanti Queue Clear Completed FK Violation
|
||||
*
|
||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
||||
*
|
||||
* BUG CONDITION (from design.md):
|
||||
* isBugCondition(input) returns true when linkedItems.length > 0
|
||||
* — completed queue items have associated rows in jira_ticket_queue_items
|
||||
*
|
||||
* The current DELETE /completed handler issues a bare:
|
||||
* DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'
|
||||
* which fails with a FK violation when child rows exist in jira_ticket_queue_items.
|
||||
*
|
||||
* THIS TEST ENCODES THE EXPECTED (FIXED) BEHAVIOR:
|
||||
* The handler should delete junction table references first, then delete
|
||||
* completed queue items, all within a transaction, and return success.
|
||||
*
|
||||
* ON UNFIXED CODE, THIS TEST WILL FAIL:
|
||||
* The current handler does not use transactions or clean up junction rows.
|
||||
* When pool.query receives the bare DELETE and junction rows exist, the mock
|
||||
* simulates the FK violation error that PostgreSQL would throw.
|
||||
* The handler catches the error and returns 500 instead of the expected 200.
|
||||
*
|
||||
* COUNTEREXAMPLE:
|
||||
* "DELETE FROM ivanti_todo_queue fails with FK violation when junction rows
|
||||
* reference completed items"
|
||||
*
|
||||
* **Validates: Requirements 1.1**
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const fc = require('fast-check');
|
||||
|
||||
// --- Mocks (must be installed BEFORE requiring the route module) ---
|
||||
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, _res, next) => {
|
||||
req.user = { id: 42, username: 'testuser', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Programmable pg pool mock
|
||||
let queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
||||
|
||||
const mockClient = {
|
||||
query: jest.fn((text, params) => queryHandler(text, params)),
|
||||
release: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPool = {
|
||||
query: jest.fn((text, params) => queryHandler(text, params)),
|
||||
connect: jest.fn(() => Promise.resolve(mockClient)),
|
||||
};
|
||||
|
||||
jest.mock('../db', () => mockPool);
|
||||
|
||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
||||
function request(server, method, urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path: urlPath,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
let body;
|
||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
||||
resolve({ statusCode: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generators ---
|
||||
|
||||
/**
|
||||
* Generate a non-empty array of completed queue item IDs (simulating items
|
||||
* that belong to the authenticated user and have status = 'complete').
|
||||
*/
|
||||
const completedItemIdsArb = fc.array(
|
||||
fc.integer({ min: 1, max: 10000 }),
|
||||
{ minLength: 1, maxLength: 10 }
|
||||
).map(ids => [...new Set(ids)]); // ensure unique IDs
|
||||
|
||||
/**
|
||||
* Generate junction table links for a subset of completed items.
|
||||
* At least one item MUST have a junction link (this is the bug condition).
|
||||
*/
|
||||
const junctionLinksArb = (itemIds) => {
|
||||
// Generate at least 1 junction link, up to 3 per item
|
||||
return fc.array(
|
||||
fc.record({
|
||||
jira_ticket_id: fc.integer({ min: 1, max: 5000 }),
|
||||
queue_item_id: fc.constantFrom(...itemIds),
|
||||
}),
|
||||
{ minLength: 1, maxLength: Math.min(itemIds.length * 3, 15) }
|
||||
);
|
||||
};
|
||||
|
||||
// --- Test Suite ---
|
||||
|
||||
describe('Bug Condition Exploration: FK Violation on Clear Completed With Junction Table Links', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.query.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 1: Bug Condition - FK Violation on Clear Completed With Junction Table Links
|
||||
*
|
||||
* For any set of completed queue items where at least one has junction table links,
|
||||
* the FIXED handler should:
|
||||
* 1. Use a transaction (BEGIN/COMMIT)
|
||||
* 2. Delete junction table references first
|
||||
* 3. Delete the completed queue items
|
||||
* 4. Return 200 with { message, deleted: N }
|
||||
*
|
||||
* On UNFIXED code: The handler uses a bare pool.query DELETE which will receive
|
||||
* a FK violation error from our mock (simulating PostgreSQL behavior), causing
|
||||
* the handler to return 500. This test FAILS, confirming the bug exists.
|
||||
*
|
||||
* **Validates: Requirements 1.1**
|
||||
*/
|
||||
it('Property 1: completed items with junction links are deleted successfully (encodes expected fixed behavior)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
completedItemIdsArb,
|
||||
fc.context(),
|
||||
async (itemIds, ctx) => {
|
||||
// Generate junction links for these items
|
||||
const junctionLinks = itemIds.map(id => ({
|
||||
jira_ticket_id: id * 10,
|
||||
queue_item_id: id,
|
||||
}));
|
||||
|
||||
ctx.log(`Testing with ${itemIds.length} completed items, ${junctionLinks.length} junction links`);
|
||||
ctx.log(`Item IDs: ${JSON.stringify(itemIds)}`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.query.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Configure the query handler to simulate the bug condition:
|
||||
// - If the code uses pool.query with a bare DELETE on ivanti_todo_queue,
|
||||
// simulate the FK violation error (this is what the UNFIXED code does)
|
||||
// - If the code uses a transaction (BEGIN, SELECT, DELETE junction, DELETE queue, COMMIT),
|
||||
// simulate successful execution (this is what the FIXED code should do)
|
||||
const fkViolationError = new Error(
|
||||
'update or delete on table "ivanti_todo_queue" violates foreign key constraint ' +
|
||||
'"jira_ticket_queue_items_queue_item_id_fkey" on table "jira_ticket_queue_items"'
|
||||
);
|
||||
fkViolationError.code = '23503'; // PostgreSQL FK violation error code
|
||||
|
||||
// Handler for pool.query (unfixed code path)
|
||||
mockPool.query.mockImplementation((text, params) => {
|
||||
// The unfixed code issues a bare DELETE — simulate FK violation
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.reject(fkViolationError);
|
||||
}
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
// Handler for client.query (fixed code path — transaction-based)
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({
|
||||
rows: itemIds.map(id => ({ id })),
|
||||
rowCount: itemIds.length,
|
||||
});
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({
|
||||
rows: [],
|
||||
rowCount: junctionLinks.length,
|
||||
});
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({
|
||||
rows: [],
|
||||
rowCount: itemIds.length,
|
||||
});
|
||||
}
|
||||
if (text === 'COMMIT') {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text === 'ROLLBACK') {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
// Make the request
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
// Assert EXPECTED (fixed) behavior:
|
||||
// The handler should return 200 with the correct deleted count
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(itemIds.length);
|
||||
}
|
||||
),
|
||||
{ numRuns: 20, verbose: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// --- Preservation Property Tests ---
|
||||
|
||||
/**
|
||||
* Property 2: Preservation - Clear Completed Without Junction Table Links
|
||||
*
|
||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
||||
*
|
||||
* PRESERVATION GOAL:
|
||||
* Verify that the UNFIXED code already handles non-bug-condition cases correctly.
|
||||
* These tests establish a baseline that must be preserved after the fix is applied.
|
||||
*
|
||||
* NON-BUG-CONDITION CASES:
|
||||
* - Completed items WITHOUT junction table links → DELETE succeeds, returns count
|
||||
* - No completed items exist → returns { deleted: 0 }
|
||||
* - Pending/in-progress items → never touched by the DELETE
|
||||
*
|
||||
* The current handler uses:
|
||||
* pool.query("DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'", [req.user.id])
|
||||
* This works correctly when no FK violations occur (no junction table references).
|
||||
*
|
||||
* EXPECTED OUTCOME: All tests PASS on unfixed code.
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.2, 3.3**
|
||||
*/
|
||||
|
||||
describe('Preservation: Clear Completed Without Junction Table Links', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.query.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
queryHandler = () => Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2a: Completed items without junction links are all deleted
|
||||
*
|
||||
* For any random count of completed items (1–20) belonging to the user,
|
||||
* when none have junction table links, the DELETE succeeds and returns
|
||||
* the correct count.
|
||||
*
|
||||
* The fixed code uses a transaction (client.query) so we mock client.query
|
||||
* to simulate successful execution without FK violations.
|
||||
*
|
||||
* **Validates: Requirements 3.1**
|
||||
*/
|
||||
it('Property 2a: completed items without junction links are deleted and correct count returned', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 20 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing with ${completedCount} completed items (no junction links)`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Generate item IDs
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
// Mock client.query for the transaction-based handler
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 }); // no junction links
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
// Assert preservation behavior
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(completedCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2b: When no completed items exist, returns deleted: 0
|
||||
*
|
||||
* When the user has no completed items, the SELECT returns empty rows
|
||||
* and the endpoint returns { message: 'Completed items cleared.', deleted: 0 }.
|
||||
*
|
||||
* **Validates: Requirements 3.2**
|
||||
*/
|
||||
it('Property 2b: no completed items returns deleted: 0', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.constant(null),
|
||||
fc.context(),
|
||||
async (_unused, ctx) => {
|
||||
ctx.log('Testing with 0 completed items');
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Mock client.query: SELECT returns empty rows (no completed items)
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 5, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2c: Pending/in-progress items are never touched
|
||||
*
|
||||
* The SELECT query only fetches items with status = 'complete' for the user.
|
||||
* We verify the query text and parameters to ensure non-complete items
|
||||
* are never affected.
|
||||
*
|
||||
* **Validates: Requirements 3.3**
|
||||
*/
|
||||
it('Property 2c: DELETE only targets complete status for the authenticated user', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 0, max: 15 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing query targeting with ${completedCount} completed items`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
// Track the queries issued via client
|
||||
const queriesIssued = [];
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
queriesIssued.push({ text, params });
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify the SELECT query targets only complete status for user 42
|
||||
const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('complete'));
|
||||
expect(selectQueries.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
for (const q of selectQueries) {
|
||||
expect(q.text).toMatch(/status\s*=\s*'\s*complete\s*'/i);
|
||||
expect(q.text).toMatch(/user_id\s*=/i);
|
||||
expect(q.params).toContain(42);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 20, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2d: Other users' items remain untouched
|
||||
*
|
||||
* The SELECT query is parameterized with req.user.id (mocked as 42).
|
||||
* For any random count of completed items, the query only affects user 42's items.
|
||||
* We verify the user_id parameter is always the authenticated user's ID.
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.3**
|
||||
*/
|
||||
it('Property 2d: DELETE is scoped to the authenticated user only', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 1, max: 10 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing user isolation with ${completedCount} completed items`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
const queriesIssued = [];
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
queriesIssued.push({ text, params });
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.deleted).toBe(completedCount);
|
||||
|
||||
// Verify the SELECT query is scoped to user 42
|
||||
const selectQueries = queriesIssued.filter(q => q.text.includes('SELECT') && q.text.includes('user_id'));
|
||||
for (const q of selectQueries) {
|
||||
expect(q.params).toContain(42); // req.user.id from mock
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 20, verbose: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 2e: Response shape is always { message: string, deleted: number }
|
||||
*
|
||||
* For any random count of completed items (including 0), the response
|
||||
* always has the correct shape with message as a string and deleted as a number.
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.2**
|
||||
*/
|
||||
it('Property 2e: response shape is { message: string, deleted: number }', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.integer({ min: 0, max: 50 }),
|
||||
fc.context(),
|
||||
async (completedCount, ctx) => {
|
||||
ctx.log(`Testing response shape with deleted count: ${completedCount}`);
|
||||
|
||||
// Reset mocks for this iteration
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
|
||||
const itemIds = Array.from({ length: completedCount }, (_, i) => i + 1);
|
||||
|
||||
mockClient.query.mockImplementation((text, params) => {
|
||||
if (text === 'BEGIN') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text.includes('SELECT') && text.includes('ivanti_todo_queue') && text.includes('complete')) {
|
||||
return Promise.resolve({ rows: itemIds.map(id => ({ id })), rowCount: completedCount });
|
||||
}
|
||||
if (text.includes('DELETE FROM jira_ticket_queue_items')) {
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
}
|
||||
if (text.includes('DELETE FROM ivanti_todo_queue')) {
|
||||
return Promise.resolve({ rows: [], rowCount: completedCount });
|
||||
}
|
||||
if (text === 'COMMIT') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
if (text === 'ROLLBACK') return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
return Promise.resolve({ rows: [], rowCount: 0 });
|
||||
});
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(typeof res.body.message).toBe('string');
|
||||
expect(typeof res.body.deleted).toBe('number');
|
||||
expect(res.body.message).toBe('Completed items cleared.');
|
||||
expect(res.body.deleted).toBe(completedCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 25, verbose: 2 }
|
||||
);
|
||||
});
|
||||
});
|
||||
371
backend/__tests__/ivanti-queue-clear-completed-fix.test.js
Normal file
371
backend/__tests__/ivanti-queue-clear-completed-fix.test.js
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Unit tests for DELETE /api/ivanti/todo-queue/completed transaction logic
|
||||
*
|
||||
* Spec: .kiro/specs/ivanti-queue-clear-completed-fix/ (bugfix)
|
||||
*
|
||||
* Validates: Requirements 2.1, 2.2, 3.1, 3.2
|
||||
*
|
||||
* Tests verify:
|
||||
* - Correct query sequence within a transaction (BEGIN → SELECT → DELETE junction → DELETE queue → COMMIT)
|
||||
* - ROLLBACK is called when any query in the transaction fails
|
||||
* - Client is always released in the finally block (even on error)
|
||||
* - Empty completed set triggers early COMMIT and returns { deleted: 0 }
|
||||
* - Response shape is { message: 'Completed items cleared.', deleted: N }
|
||||
*/
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, _res, next) => {
|
||||
req.user = { id: 7, username: 'testuser', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
const mockClient = {
|
||||
query: jest.fn(),
|
||||
release: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPool = {
|
||||
query: jest.fn(),
|
||||
connect: jest.fn(() => Promise.resolve(mockClient)),
|
||||
};
|
||||
|
||||
jest.mock('../db', () => mockPool);
|
||||
|
||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||
|
||||
// --- HTTP helper ---
|
||||
|
||||
function request(server, method, urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path: urlPath,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
let body;
|
||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
||||
resolve({ statusCode: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Test Suite ---
|
||||
|
||||
describe('DELETE /api/ivanti/todo-queue/completed — Transaction Logic', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.query.mockReset();
|
||||
mockClient.release.mockReset();
|
||||
mockPool.connect.mockReset();
|
||||
mockPool.connect.mockResolvedValue(mockClient);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 1: Correct query sequence
|
||||
* Validates: Requirements 2.1, 2.2
|
||||
*
|
||||
* Verifies the handler issues queries in the correct order:
|
||||
* BEGIN → SELECT IDs → DELETE junction → DELETE queue → COMMIT
|
||||
*/
|
||||
it('executes queries in correct transaction order: BEGIN → SELECT → DELETE junction → DELETE queue → COMMIT', async () => {
|
||||
const completedIds = [10, 20, 30];
|
||||
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: completedIds.map(id => ({ id })), rowCount: 3 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 2 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify query sequence
|
||||
const calls = mockClient.query.mock.calls;
|
||||
expect(calls.length).toBe(5);
|
||||
|
||||
// BEGIN
|
||||
expect(calls[0][0]).toBe('BEGIN');
|
||||
|
||||
// SELECT completed IDs
|
||||
expect(calls[1][0]).toContain('SELECT');
|
||||
expect(calls[1][0]).toContain('ivanti_todo_queue');
|
||||
expect(calls[1][0]).toContain('complete');
|
||||
expect(calls[1][1]).toEqual([7]); // user id
|
||||
|
||||
// DELETE junction table references
|
||||
expect(calls[2][0]).toContain('DELETE FROM jira_ticket_queue_items');
|
||||
expect(calls[2][0]).toContain('ANY');
|
||||
expect(calls[2][1]).toEqual([completedIds]);
|
||||
|
||||
// DELETE queue items
|
||||
expect(calls[3][0]).toContain('DELETE FROM ivanti_todo_queue');
|
||||
expect(calls[3][0]).toContain('ANY');
|
||||
expect(calls[3][1]).toEqual([completedIds]);
|
||||
|
||||
// COMMIT
|
||||
expect(calls[4][0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 2: ROLLBACK on error
|
||||
* Validates: Requirements 2.1
|
||||
*
|
||||
* Verifies ROLLBACK is called when any query in the transaction fails.
|
||||
*/
|
||||
describe('ROLLBACK on error', () => {
|
||||
it('calls ROLLBACK when SELECT query fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockRejectedValueOnce(new Error('SELECT failed')); // SELECT throws
|
||||
|
||||
// After the catch, ROLLBACK is called
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls ROLLBACK when DELETE junction query fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 }) // SELECT
|
||||
.mockRejectedValueOnce(new Error('DELETE junction failed')); // DELETE junction throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls ROLLBACK when DELETE queue query fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 5 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
||||
.mockRejectedValueOnce(new Error('DELETE queue failed')); // DELETE queue throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls ROLLBACK when COMMIT fails', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
||||
.mockRejectedValueOnce(new Error('COMMIT failed')); // COMMIT throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
|
||||
const calls = mockClient.query.mock.calls;
|
||||
const rollbackCall = calls.find(c => c[0] === 'ROLLBACK');
|
||||
expect(rollbackCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 3: Client always released
|
||||
* Validates: Requirements 2.1
|
||||
*
|
||||
* Verifies client.release() is always called in the finally block,
|
||||
* even when an error occurs.
|
||||
*/
|
||||
describe('client always released', () => {
|
||||
it('releases client after successful transaction', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases client after failed transaction (error in SELECT)', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockRejectedValueOnce(new Error('DB error')); // SELECT throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases client after failed transaction (error in DELETE)', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 }) // SELECT
|
||||
.mockRejectedValueOnce(new Error('FK violation')); // DELETE junction throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases client when empty completed set triggers early return', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT (empty)
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(mockClient.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 4: Empty completed set
|
||||
* Validates: Requirements 3.1, 3.2
|
||||
*
|
||||
* When SELECT returns no completed items, the handler should:
|
||||
* - Issue COMMIT (not ROLLBACK)
|
||||
* - Return { message: 'Completed items cleared.', deleted: 0 }
|
||||
* - NOT issue any DELETE queries
|
||||
*/
|
||||
it('empty completed set triggers early COMMIT and returns { deleted: 0 }', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // SELECT (empty)
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ message: 'Completed items cleared.', deleted: 0 });
|
||||
|
||||
// Verify query sequence: BEGIN → SELECT → COMMIT (no DELETEs)
|
||||
const calls = mockClient.query.mock.calls;
|
||||
expect(calls.length).toBe(3);
|
||||
expect(calls[0][0]).toBe('BEGIN');
|
||||
expect(calls[1][0]).toContain('SELECT');
|
||||
expect(calls[2][0]).toBe('COMMIT');
|
||||
|
||||
// No DELETE queries issued
|
||||
const deleteQueries = calls.filter(c => c[0].includes('DELETE'));
|
||||
expect(deleteQueries.length).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Test 5: Response shape preserved
|
||||
* Validates: Requirements 2.2, 3.1
|
||||
*
|
||||
* Verifies the response is always { message: 'Completed items cleared.', deleted: N }
|
||||
*/
|
||||
describe('response shape preserved', () => {
|
||||
it('returns correct shape with deleted count matching rowCount', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }], rowCount: 5 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 3 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 5 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
message: 'Completed items cleared.',
|
||||
deleted: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns correct shape with single deleted item', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 99 }], rowCount: 1 }) // SELECT
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // DELETE junction
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // DELETE queue
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // COMMIT
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
message: 'Completed items cleared.',
|
||||
deleted: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error shape on failure', async () => {
|
||||
mockClient.query
|
||||
.mockResolvedValueOnce({ rows: [], rowCount: 0 }) // BEGIN
|
||||
.mockRejectedValueOnce(new Error('Something broke')); // SELECT throws
|
||||
|
||||
mockClient.query.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ROLLBACK
|
||||
|
||||
const res = await request(server, 'DELETE', '/api/ivanti/todo-queue/completed');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
});
|
||||
});
|
||||
});
|
||||
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
137
backend/__tests__/ivanti-todo-queue-ticket-links.test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Unit tests for GET /api/ivanti/todo-queue/ticket-links endpoint
|
||||
* Validates: Requirements 6.3, 6.4
|
||||
*/
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, _res, next) => {
|
||||
req.user = { id: 7, username: 'testuser' };
|
||||
next();
|
||||
},
|
||||
requireGroup: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock audit log
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock the db pool
|
||||
jest.mock('../db', () => ({
|
||||
query: jest.fn(() => Promise.resolve({ rows: [] })),
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require('../db');
|
||||
const createIvantiTodoQueueRouter = require('../routes/ivantiTodoQueue');
|
||||
|
||||
/**
|
||||
* Helper: send an HTTP request and return { statusCode, body }.
|
||||
*/
|
||||
function request(server, method, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
let body;
|
||||
try { body = JSON.parse(raw); } catch { body = raw; }
|
||||
resolve({ statusCode: res.statusCode, body });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/ivanti/todo-queue/ticket-links', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns an empty links object when no associations exist', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ links: {} });
|
||||
});
|
||||
|
||||
it('returns a map of queue_item_id to ticket info', async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ queue_item_id: 12, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
{ queue_item_id: 15, ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
{ queue_item_id: 22, ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
links: {
|
||||
'12': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
'15': { ticket_key: 'VULN-789', jira_url: 'https://jira.example.com/browse/VULN-789' },
|
||||
'22': { ticket_key: 'VULN-801', jira_url: 'https://jira.example.com/browse/VULN-801' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by the authenticated user ID', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
const [sql, params] = pool.query.mock.calls[0];
|
||||
expect(sql).toContain('q.user_id = $1');
|
||||
expect(params).toEqual([7]);
|
||||
});
|
||||
|
||||
it('joins jira_ticket_queue_items with jira_tickets and ivanti_todo_queue', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
const [sql] = pool.query.mock.calls[0];
|
||||
expect(sql).toContain('jira_ticket_queue_items');
|
||||
expect(sql).toContain('JOIN jira_tickets');
|
||||
expect(sql).toContain('JOIN ivanti_todo_queue');
|
||||
});
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
pool.query.mockRejectedValueOnce(new Error('DB connection failed'));
|
||||
|
||||
const res = await request(server, 'GET', '/api/ivanti/todo-queue/ticket-links');
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Internal server error.' });
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,8 @@ describe('Feature: jira-api-compliance-cleanup, Property 1: JQL window is always
|
||||
|
||||
expect(capturedJql).not.toBeNull();
|
||||
expect(capturedJql).toContain('updated >= -72h');
|
||||
expect(capturedJql).toContain('project =');
|
||||
// project filter intentionally removed — issue keys are globally unique
|
||||
// and the filter broke cross-project ticket sync
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
|
||||
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
214
backend/__tests__/jira-ticket-queue-items.test.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Unit Tests: POST /api/jira-tickets/:id/queue-items
|
||||
*
|
||||
* Feature: multi-item-jira-ticket
|
||||
*
|
||||
* Tests the junction endpoint that links queue items to a Jira ticket.
|
||||
* Validates: Requirements 5.3, 6.1, 6.2
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
// Mock the auth middleware so routes don't require real sessions/cookies.
|
||||
jest.mock('../middleware/auth', () => ({
|
||||
requireAuth: () => (req, res, next) => {
|
||||
req.user = { id: 1, username: 'test', group: 'Admin' };
|
||||
next();
|
||||
},
|
||||
requireGroup: (...groups) => (req, res, next) => next(),
|
||||
}));
|
||||
|
||||
// Mock the audit log helper to be a no-op.
|
||||
jest.mock('../helpers/auditLog', () => jest.fn());
|
||||
|
||||
// Mock the jiraApi helper
|
||||
jest.mock('../helpers/jiraApi', () => ({
|
||||
isConfigured: false,
|
||||
getRateLimitStatus: jest.fn(() => ({
|
||||
burst: { remaining: 60, limit: 60 },
|
||||
daily: { remaining: 1440, limit: 1440 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const pool = require('../db');
|
||||
jest.mock('../db', () => ({
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const createJiraTicketsRouter = require('../routes/jiraTickets');
|
||||
|
||||
/**
|
||||
* Helper: send an HTTP request to the test server and return { statusCode, body }.
|
||||
*/
|
||||
function request(server, method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address();
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
||||
resolve({ statusCode: res.statusCode, body: parsed });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('POST /api/jira-tickets/:id/queue-items', () => {
|
||||
let app;
|
||||
let server;
|
||||
|
||||
beforeAll((done) => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/jira-tickets', createJiraTicketsRouter());
|
||||
server = app.listen(0, '127.0.0.1', done);
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 400 when queue_item_ids is missing', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids is an empty array', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: [],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids is not an array', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: 'not-an-array',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids contains non-integers', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: [1, 2.5, 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
it('returns 400 when queue_item_ids contains strings', async () => {
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/1/queue-items', {
|
||||
queue_item_ids: [1, 'abc', 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('queue_item_ids must be a non-empty array of integers');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Ticket existence check
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 404 when jira ticket does not exist', async () => {
|
||||
pool.query.mockResolvedValueOnce({ rows: [] }); // ticket lookup
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/999/queue-items', {
|
||||
queue_item_ids: [1, 2, 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.body.error).toBe('Jira ticket not found');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Queue item existence check
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 400 when some queue items do not exist', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }] }); // only 2 of 3 exist
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [1, 2, 3],
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toBe('One or more queue items not found');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Successful linking
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 201 with linked_count on success', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }, { id: 18 }] }) // all queue items exist
|
||||
.mockResolvedValueOnce({ rowCount: 3 }); // insert result
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [12, 15, 18],
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.message).toBe('Queue items linked to ticket');
|
||||
expect(res.body.ticket_id).toBe(42);
|
||||
expect(res.body.linked_count).toBe(3);
|
||||
});
|
||||
|
||||
it('returns linked_count reflecting ON CONFLICT DO NOTHING (duplicates ignored)', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 12 }, { id: 15 }] }) // all queue items exist
|
||||
.mockResolvedValueOnce({ rowCount: 1 }); // only 1 new row (1 was duplicate)
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [12, 15],
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.linked_count).toBe(1);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
pool.query
|
||||
.mockResolvedValueOnce({ rows: [{ id: 42 }] }) // ticket exists
|
||||
.mockResolvedValueOnce({ rows: [{ id: 12 }] }) // queue items exist
|
||||
.mockRejectedValueOnce(new Error('Connection lost')); // insert fails
|
||||
|
||||
const res = await request(server, 'POST', '/api/jira-tickets/42/queue-items', {
|
||||
queue_item_ids: [12],
|
||||
});
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error).toContain('Connection lost');
|
||||
});
|
||||
});
|
||||
115
backend/__tests__/migrations-idempotency.integration.test.js
Normal file
115
backend/__tests__/migrations-idempotency.integration.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// Migration Idempotency Integration Test
|
||||
// This test requires a running PostgreSQL instance with DATABASE_URL configured in backend/.env.
|
||||
// It runs ALL Postgres migrations twice (via run-all.js) to verify they are idempotent (safe to re-run),
|
||||
// then checks that key tables and columns exist.
|
||||
//
|
||||
// SKIPS AUTOMATICALLY when DATABASE_URL is not set (e.g., in CI environments without DB access).
|
||||
//
|
||||
// Run separately: npx jest backend/__tests__/migrations-idempotency.integration.test.js --forceExit
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const BACKEND_DIR = path.join(__dirname, '..');
|
||||
|
||||
// Load .env manually to check for DATABASE_URL without triggering db.js process.exit
|
||||
function loadEnvFile() {
|
||||
const envPath = path.join(BACKEND_DIR, '.env');
|
||||
if (!fs.existsSync(envPath)) return {};
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
const vars = {};
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
vars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
const envVars = loadEnvFile();
|
||||
const hasDatabase = !!(process.env.DATABASE_URL || envVars.DATABASE_URL);
|
||||
|
||||
// Skip entire suite if no database is available
|
||||
const describeIfDb = hasDatabase ? describe : describe.skip;
|
||||
|
||||
let pool;
|
||||
|
||||
if (hasDatabase) {
|
||||
// Set DATABASE_URL in process.env so db.js picks it up
|
||||
if (!process.env.DATABASE_URL && envVars.DATABASE_URL) {
|
||||
process.env.DATABASE_URL = envVars.DATABASE_URL;
|
||||
}
|
||||
pool = require('../db');
|
||||
}
|
||||
|
||||
function runAllMigrations() {
|
||||
execSync('node migrations/run-all.js', {
|
||||
cwd: BACKEND_DIR,
|
||||
stdio: 'pipe',
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (pool) await pool.end();
|
||||
});
|
||||
|
||||
describeIfDb('Migration Idempotency', () => {
|
||||
it('runs all migrations twice without errors (idempotent)', () => {
|
||||
// First run
|
||||
runAllMigrations();
|
||||
// Second run — should not throw if migrations are truly idempotent
|
||||
runAllMigrations();
|
||||
}, 30000);
|
||||
|
||||
it('key tables exist after migrations', async () => {
|
||||
const expectedTables = [
|
||||
'compliance_items',
|
||||
'compliance_item_history',
|
||||
'compliance_notes',
|
||||
'jira_tickets',
|
||||
'ivanti_fp_submissions',
|
||||
];
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ANY($1)
|
||||
`, [expectedTables]);
|
||||
|
||||
const foundTables = rows.map(r => r.table_name);
|
||||
for (const table of expectedTables) {
|
||||
expect(foundTables).toContain(table);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('compliance_item_history has expected columns', async () => {
|
||||
const expectedColumns = [
|
||||
'id',
|
||||
'hostname',
|
||||
'field_name',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'change_reason',
|
||||
'changed_by',
|
||||
'changed_at',
|
||||
'metric_id',
|
||||
];
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'compliance_item_history'
|
||||
`);
|
||||
|
||||
const foundColumns = rows.map(r => r.column_name);
|
||||
for (const col of expectedColumns) {
|
||||
expect(foundColumns).toContain(col);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
190
backend/__tests__/vendor-issue-type-dropdown.property.test.js
Normal file
190
backend/__tests__/vendor-issue-type-dropdown.property.test.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Property-Based Tests: Vendor Issue Type Dropdown
|
||||
*
|
||||
* Feature: vendor-issue-type-dropdown
|
||||
*
|
||||
* Tests the pure determination logic that decides which issue type list
|
||||
* to display based on the project key input.
|
||||
*
|
||||
* Validates: Requirements 1.2, 1.3, 1.4, 1.5, 2.1, 2.3, 3.4, 3.5, 4.1, 4.2, 6.3
|
||||
*/
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Replicate the pure functions from JiraPage.js for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VENDOR_PROJECT_KEYS = [
|
||||
'AA_ADTRAN',
|
||||
'AA_ADVA',
|
||||
'AA_CASA',
|
||||
'AA_CISCO',
|
||||
'AACOMMSCOP',
|
||||
'AA_COMMSCOP',
|
||||
'AA_HARMONI',
|
||||
'AA_JUNIPER',
|
||||
'AA_VECIMA',
|
||||
'AA_VIAVI',
|
||||
];
|
||||
|
||||
const VENDOR_ISSUE_TYPES = [
|
||||
'Epic',
|
||||
'Story',
|
||||
'Task',
|
||||
'Defect',
|
||||
'Production Defect/Incident Fix',
|
||||
'New Feature',
|
||||
'Spike',
|
||||
'Release Candidate',
|
||||
'Documentation',
|
||||
];
|
||||
|
||||
const STEAM_ISSUE_TYPES = [
|
||||
'Story',
|
||||
'Epic',
|
||||
'Program',
|
||||
'Project',
|
||||
'Reservation',
|
||||
'Automation Maintenance',
|
||||
];
|
||||
|
||||
function isVendorProject(projectKey, vendorKeys) {
|
||||
if (!projectKey || typeof projectKey !== 'string') return false;
|
||||
const normalized = projectKey.trim().toUpperCase();
|
||||
if (normalized.length === 0) return false;
|
||||
return vendorKeys.includes(normalized);
|
||||
}
|
||||
|
||||
function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) {
|
||||
return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the project_key onChange logic: resets issue_type only on context switch.
|
||||
*/
|
||||
function simulateProjectKeyChange(oldKey, newKey, currentIssueType, vendorKeys) {
|
||||
const wasVendor = isVendorProject(oldKey, vendorKeys);
|
||||
const isNowVendor = isVendorProject(newKey, vendorKeys);
|
||||
return (wasVendor !== isNowVendor) ? '' : currentIssueType;
|
||||
}
|
||||
|
||||
// Helper: generate a vendor key with random casing and optional whitespace
|
||||
const arbVendorKey = fc.constantFrom(...VENDOR_PROJECT_KEYS).chain(key =>
|
||||
fc.oneof(
|
||||
fc.constant(key),
|
||||
fc.constant(key.toLowerCase()),
|
||||
fc.constant(` ${key} `),
|
||||
)
|
||||
);
|
||||
|
||||
// Helper: generate a string that does NOT match any vendor key after normalization
|
||||
const arbNonVendorKey = fc.string({ minLength: 0, maxLength: 50 }).filter(s => {
|
||||
const normalized = s.trim().toUpperCase();
|
||||
return !VENDOR_PROJECT_KEYS.includes(normalized);
|
||||
});
|
||||
|
||||
const arbNonVendorKeyNonEmpty = fc.string({ minLength: 1, maxLength: 30 }).filter(s => {
|
||||
const normalized = s.trim().toUpperCase();
|
||||
return !VENDOR_PROJECT_KEYS.includes(normalized) && normalized.length > 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 1: Issue type list determination
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: vendor-issue-type-dropdown, Property 1: Issue type list determination', () => {
|
||||
it('returns VENDOR_ISSUE_TYPES when project key matches a vendor key (case-insensitive, trimmed)', () => {
|
||||
fc.assert(
|
||||
fc.property(arbVendorKey, (key) => {
|
||||
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
||||
expect(result).toBe(VENDOR_ISSUE_TYPES);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns STEAM_ISSUE_TYPES for any string that does not match a vendor key after normalization', () => {
|
||||
fc.assert(
|
||||
fc.property(arbNonVendorKey, (key) => {
|
||||
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
||||
expect(result).toBe(STEAM_ISSUE_TYPES);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns STEAM_ISSUE_TYPES for null, undefined, and empty string', () => {
|
||||
const emptyInputs = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.constant(undefined),
|
||||
fc.constant(''),
|
||||
fc.constant(' '),
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(emptyInputs, (key) => {
|
||||
const result = getIssueTypesForProject(key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES);
|
||||
expect(result).toBe(STEAM_ISSUE_TYPES);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 2: Context switch resets issue type selection
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: vendor-issue-type-dropdown, Property 2: Context switch resets issue type', () => {
|
||||
it('resets issue_type to empty when switching from vendor to non-vendor context', () => {
|
||||
const anyIssueType = fc.string({ minLength: 1, maxLength: 50 });
|
||||
|
||||
fc.assert(
|
||||
fc.property(arbVendorKey, arbNonVendorKeyNonEmpty, anyIssueType, (oldKey, newKey, issueType) => {
|
||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||
expect(result).toBe('');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('resets issue_type to empty when switching from non-vendor to vendor context', () => {
|
||||
const anyIssueType = fc.string({ minLength: 1, maxLength: 50 });
|
||||
|
||||
fc.assert(
|
||||
fc.property(arbNonVendorKeyNonEmpty, arbVendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||
expect(result).toBe('');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property 3: Same context preserves issue type selection
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Feature: vendor-issue-type-dropdown, Property 3: Same context preserves issue type', () => {
|
||||
it('preserves issue_type when both old and new keys resolve to STEAM context', () => {
|
||||
const anyIssueType = fc.string({ minLength: 0, maxLength: 50 });
|
||||
|
||||
fc.assert(
|
||||
fc.property(arbNonVendorKey, arbNonVendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||
expect(result).toBe(issueType);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves issue_type when both old and new keys resolve to vendor context', () => {
|
||||
const anyIssueType = fc.string({ minLength: 0, maxLength: 50 });
|
||||
|
||||
fc.assert(
|
||||
fc.property(arbVendorKey, arbVendorKey, anyIssueType, (oldKey, newKey, issueType) => {
|
||||
const result = simulateProjectKeyChange(oldKey, newKey, issueType, VENDOR_PROJECT_KEYS);
|
||||
expect(result).toBe(issueType);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS jira_tickets (
|
||||
ticket_key TEXT NOT NULL,
|
||||
url TEXT,
|
||||
summary TEXT,
|
||||
status TEXT DEFAULT 'Open' CHECK (status IN ('Open', 'In Progress', 'Closed')),
|
||||
status TEXT DEFAULT 'Open',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const dns = require('dns');
|
||||
|
||||
// Force IPv4-first DNS resolution — card.charter.com has both IPv4 and IPv6
|
||||
// records but IPv6 is unreachable from this network, causing timeouts.
|
||||
dns.setDefaultResultOrder('ipv4first');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — read from process.env at module load
|
||||
@@ -57,12 +62,13 @@ function acquireToken(timeout) {
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method: 'POST',
|
||||
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'authorization': 'Basic ' + authString,
|
||||
'content-length': '0',
|
||||
},
|
||||
timeout: timeout || 15000,
|
||||
timeout: timeout || 30000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
@@ -123,7 +129,7 @@ async function ensureToken(timeout) {
|
||||
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
||||
// ---------------------------------------------------------------------------
|
||||
async function cardRequest(method, urlPath, body, options) {
|
||||
const timeout = (options && options.timeout) || 15000;
|
||||
const timeout = (options && options.timeout) || 30000;
|
||||
const skipAuth = (options && options.skipAuth) || false;
|
||||
|
||||
async function doRequest(bearerToken) {
|
||||
@@ -150,6 +156,7 @@ async function cardRequest(method, urlPath, body, options) {
|
||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||
path: fullUrl.pathname + fullUrl.search,
|
||||
method,
|
||||
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
||||
headers,
|
||||
timeout,
|
||||
};
|
||||
@@ -245,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
||||
/**
|
||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||
*/
|
||||
async function getOwner(assetId) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
||||
async function getOwner(assetId, options) {
|
||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
@@ -288,6 +295,62 @@ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) {
|
||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare IP address to a full CARD asset ID by trying known suffixes.
|
||||
* Returns the first asset ID that returns a valid owner record, or null if none found.
|
||||
*
|
||||
* @param {string} ip - IP address or existing asset ID
|
||||
* @param {object} [options] - { quick: true } to only try CTEC suffix (for tooltip/hover use)
|
||||
*/
|
||||
async function resolveAssetId(ip, options) {
|
||||
const quick = options && options.quick;
|
||||
const SUFFIXES = quick ? ['CTEC'] : ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
||||
const timeout = quick ? 15000 : undefined; // 15s timeout for quick mode
|
||||
const trimmedIp = (ip || '').trim();
|
||||
if (!trimmedIp) return null;
|
||||
|
||||
// If it already has a suffix (contains a dash followed by letters), use as-is
|
||||
if (/\d+-[A-Z]+$/i.test(trimmedIp)) {
|
||||
try {
|
||||
const result = await getOwner(trimmedIp, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try each suffix
|
||||
for (const suffix of SUFFIXES) {
|
||||
const candidate = `${trimmedIp}-${suffix}`;
|
||||
try {
|
||||
const result = await getOwner(candidate, timeout ? { timeout } : undefined);
|
||||
if (result.ok) return candidate;
|
||||
} catch (err) {
|
||||
// Timeout — throw so caller can distinguish from "not found"
|
||||
if (quick && err.message && err.message.includes('timed out')) {
|
||||
throw new Error('CARD_TIMEOUT');
|
||||
}
|
||||
// Continue to next suffix
|
||||
}
|
||||
}
|
||||
|
||||
// Try bare IP as last resort (skip in quick mode to avoid extra delay)
|
||||
if (!quick) {
|
||||
try {
|
||||
const result = await getOwner(trimmedIp);
|
||||
if (result.ok) return trimmedIp;
|
||||
} catch (_) {
|
||||
// Not found
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isConfigured,
|
||||
missingVars,
|
||||
@@ -302,4 +365,5 @@ module.exports = {
|
||||
declineAsset,
|
||||
redirectAsset,
|
||||
invalidateToken,
|
||||
resolveAssetId,
|
||||
};
|
||||
|
||||
@@ -276,8 +276,9 @@ function jiraDelete(urlPath, options) {
|
||||
* @param {string[]} [fields] - Jira field names to return
|
||||
*/
|
||||
async function getIssue(issueKey, fields) {
|
||||
// Don't filter by project — issue keys are globally unique in Jira and
|
||||
// tickets may belong to projects other than JIRA_PROJECT_KEY (e.g. AA_ADTRAN).
|
||||
// Use JQL search to look up a single issue by key.
|
||||
// Issue keys are globally unique in Jira — no project filter needed.
|
||||
// Charter compliance: uses GET /rest/api/2/search with explicit field list.
|
||||
const jql = `key = "${issueKey}"`;
|
||||
const result = await searchIssues(jql, { fields: fields || DEFAULT_FIELDS, maxResults: 1, startAt: 0 });
|
||||
if (result.ok && result.data.issues && result.data.issues.length > 0) {
|
||||
|
||||
@@ -388,6 +388,144 @@ 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;
|
||||
// Handle both Date objects (from PostgreSQL) and YYYY-MM-DD strings
|
||||
const dateStr = device.resolution_date instanceof Date
|
||||
? device.resolution_date.toISOString().slice(0, 7)
|
||||
: String(device.resolution_date).slice(0, 7);
|
||||
const resMonth = dateStr; // 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 NEXT month, up to 12 months max
|
||||
const forecast = [];
|
||||
let remainingNonCompliant = nonCompliant;
|
||||
|
||||
// Account for devices remediated in the current month (past-due dates bucketed here)
|
||||
if (buckets[currentMonthStr]) {
|
||||
remainingNonCompliant -= buckets[currentMonthStr];
|
||||
}
|
||||
|
||||
for (let i = 1; 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 +542,5 @@ module.exports = {
|
||||
computeVerticalBurndown,
|
||||
deduplicateByHostname,
|
||||
computeAggregatedBurndown,
|
||||
computeMetricForecastBurndown,
|
||||
};
|
||||
|
||||
60
backend/migrations/add_archer_templates_table.js
Normal file
60
backend/migrations/add_archer_templates_table.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Migration: Add archer_templates table for the Archer Template Library feature
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting archer_templates table migration...');
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS archer_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vendor VARCHAR(100) NOT NULL,
|
||||
platform VARCHAR(100) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
environment_overview TEXT NOT NULL DEFAULT '',
|
||||
segmentation TEXT NOT NULL DEFAULT '',
|
||||
mitigating_controls TEXT NOT NULL DEFAULT '',
|
||||
additional_info TEXT NOT NULL DEFAULT '',
|
||||
charter_network_banner TEXT NOT NULL DEFAULT '',
|
||||
data_classification TEXT NOT NULL DEFAULT '',
|
||||
charter_network TEXT NOT NULL DEFAULT '',
|
||||
additional_access_list TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
console.log('✓ archer_templates table created (or already exists)');
|
||||
|
||||
// Case-insensitive uniqueness on trimmed vendor/platform/model
|
||||
await pool.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_archer_templates_unique_combo
|
||||
ON archer_templates (LOWER(TRIM(vendor)), LOWER(TRIM(platform)), LOWER(TRIM(model)))
|
||||
`);
|
||||
console.log('✓ idx_archer_templates_unique_combo index created (or already exists)');
|
||||
|
||||
// Indexes for list query performance
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_vendor
|
||||
ON archer_templates(vendor)
|
||||
`);
|
||||
console.log('✓ idx_archer_templates_vendor index created (or already exists)');
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_archer_templates_platform
|
||||
ON archer_templates(platform)
|
||||
`);
|
||||
console.log('✓ idx_archer_templates_platform index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Self-execute when run directly
|
||||
if (require.main === module) {
|
||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||
}
|
||||
42
backend/migrations/add_compliance_history_metric_id.js
Normal file
42
backend/migrations/add_compliance_history_metric_id.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting compliance_item_history metric_id column migration...');
|
||||
try {
|
||||
// Idempotent: only add column if it doesn't already exist
|
||||
const { rows } = await pool.query(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'compliance_item_history'
|
||||
AND column_name = 'metric_id'
|
||||
`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
await pool.query(`
|
||||
ALTER TABLE compliance_item_history
|
||||
ADD COLUMN metric_id TEXT
|
||||
`);
|
||||
console.log('✓ metric_id column added to compliance_item_history');
|
||||
} else {
|
||||
console.log('✓ metric_id column already exists (skipped)');
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_metric
|
||||
ON compliance_item_history(hostname, metric_id)
|
||||
`);
|
||||
console.log('✓ hostname/metric_id index created');
|
||||
|
||||
console.log('Migration complete.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
||||
// Self-execute when run directly
|
||||
if (require.main === module) {
|
||||
run().then(() => process.exit(0)).catch(() => process.exit(1));
|
||||
}
|
||||
90
backend/migrations/add_flexible_jira_ticket_creation.js
Normal file
90
backend/migrations/add_flexible_jira_ticket_creation.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Migration: Add flexible Jira ticket creation support
|
||||
// - Drops NOT NULL on cve_id and vendor columns
|
||||
// - Adds source_context column with CHECK constraint
|
||||
// - Backfills existing rows with source_context = 'manual'
|
||||
// - Adds index on source_context
|
||||
// Idempotent — safe to run multiple times.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting flexible Jira ticket creation migration...');
|
||||
|
||||
// Verify jira_tickets table exists before proceeding
|
||||
const { rows } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ jira_tickets table exists');
|
||||
|
||||
// Drop NOT NULL constraint on cve_id (idempotent — no-op if already nullable)
|
||||
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN cve_id DROP NOT NULL`);
|
||||
console.log('✓ cve_id NOT NULL constraint dropped (or was already nullable)');
|
||||
|
||||
// Drop NOT NULL constraint on vendor (idempotent — no-op if already nullable)
|
||||
await pool.query(`ALTER TABLE jira_tickets ALTER COLUMN vendor DROP NOT NULL`);
|
||||
console.log('✓ vendor NOT NULL constraint dropped (or was already nullable)');
|
||||
|
||||
// Add jira_id, jira_status, last_synced_at, created_by columns
|
||||
// (originally from SQLite migration add_jira_sync_columns.js — never ported to Postgres run-all)
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_id TEXT`);
|
||||
console.log('✓ jira_id column added (or already exists)');
|
||||
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_status TEXT`);
|
||||
console.log('✓ jira_status column added (or already exists)');
|
||||
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ`);
|
||||
console.log('✓ last_synced_at column added (or already exists)');
|
||||
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS created_by INTEGER`);
|
||||
console.log('✓ created_by column added (or already exists)');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)`);
|
||||
console.log('✓ jira_id index created (or already exists)');
|
||||
|
||||
// Add source_context column with default value (IF NOT EXISTS makes it idempotent)
|
||||
await pool.query(`
|
||||
ALTER TABLE jira_tickets
|
||||
ADD COLUMN IF NOT EXISTS source_context TEXT DEFAULT 'manual'
|
||||
`);
|
||||
console.log('✓ source_context column added (or already exists)');
|
||||
|
||||
// Add CHECK constraint for allowed source_context values (idempotent guard)
|
||||
await pool.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'jira_tickets_source_context_check'
|
||||
) THEN
|
||||
ALTER TABLE jira_tickets
|
||||
ADD CONSTRAINT jira_tickets_source_context_check
|
||||
CHECK (source_context IN ('cve', 'archer', 'ivanti_queue', 'email', 'manual'));
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
console.log('✓ source_context CHECK constraint added (or already exists)');
|
||||
|
||||
// Backfill existing rows where source_context is NULL
|
||||
const result = await pool.query(`
|
||||
UPDATE jira_tickets SET source_context = 'manual' WHERE source_context IS NULL
|
||||
`);
|
||||
console.log(`✓ Backfilled ${result.rowCount} rows with source_context = 'manual'`);
|
||||
|
||||
// Add index on source_context for filtering performance
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_tickets_source_context
|
||||
ON jira_tickets(source_context)
|
||||
`);
|
||||
console.log('✓ source_context index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
42
backend/migrations/add_jira_sync_columns_pg.js
Normal file
42
backend/migrations/add_jira_sync_columns_pg.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Migration: Add Jira sync columns to jira_tickets (Postgres version)
|
||||
// Adds jira_id, jira_status, last_synced_at, and created_by columns.
|
||||
// These were originally added via a SQLite migration that was never ported to Postgres.
|
||||
// Idempotent — safe to run multiple times.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Adding Jira sync columns to jira_tickets (Postgres)...');
|
||||
|
||||
// Verify table exists
|
||||
const { rows } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||
`);
|
||||
if (rows.length === 0) {
|
||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_id TEXT`);
|
||||
console.log('✓ jira_id column added (or already exists)');
|
||||
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS jira_status TEXT`);
|
||||
console.log('✓ jira_status column added (or already exists)');
|
||||
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ`);
|
||||
console.log('✓ last_synced_at column added (or already exists)');
|
||||
|
||||
await pool.query(`ALTER TABLE jira_tickets ADD COLUMN IF NOT EXISTS created_by INTEGER`);
|
||||
console.log('✓ created_by column added (or already exists)');
|
||||
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_jira_tickets_jira_id ON jira_tickets(jira_id)`);
|
||||
console.log('✓ jira_id index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
65
backend/migrations/add_multi_item_jira_ticket.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Migration: Add multi-item Jira ticket junction table
|
||||
// - Creates jira_ticket_queue_items table linking jira_tickets to ivanti_todo_queue items
|
||||
// - Adds UNIQUE constraint on (jira_ticket_id, queue_item_id)
|
||||
// - Adds indexes on queue_item_id and jira_ticket_id
|
||||
// Idempotent — safe to run multiple times.
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('Starting multi-item Jira ticket migration...');
|
||||
|
||||
// Verify prerequisite tables exist
|
||||
const { rows: jiraTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'jira_tickets'
|
||||
`);
|
||||
if (jiraTable.length === 0) {
|
||||
console.error('✗ jira_tickets table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ jira_tickets table exists');
|
||||
|
||||
const { rows: queueTable } = await pool.query(`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'ivanti_todo_queue'
|
||||
`);
|
||||
if (queueTable.length === 0) {
|
||||
console.error('✗ ivanti_todo_queue table does not exist. Cannot proceed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ ivanti_todo_queue table exists');
|
||||
|
||||
// Create junction table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS jira_ticket_queue_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
jira_ticket_id INTEGER NOT NULL REFERENCES jira_tickets(id),
|
||||
queue_item_id INTEGER NOT NULL REFERENCES ivanti_todo_queue(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (jira_ticket_id, queue_item_id)
|
||||
)
|
||||
`);
|
||||
console.log('✓ jira_ticket_queue_items table created (or already exists)');
|
||||
|
||||
// Add index on queue_item_id for efficient lookup of tickets by queue item
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_queue_item
|
||||
ON jira_ticket_queue_items(queue_item_id)
|
||||
`);
|
||||
console.log('✓ queue_item_id index created (or already exists)');
|
||||
|
||||
// Add index on jira_ticket_id for efficient lookup of queue items by ticket
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_jira_ticket_queue_items_ticket
|
||||
ON jira_ticket_queue_items(jira_ticket_id)
|
||||
`);
|
||||
console.log('✓ jira_ticket_id index created (or already exists)');
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
18
backend/migrations/drop_jira_status_check_constraint.js
Normal file
18
backend/migrations/drop_jira_status_check_constraint.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Migration: Drop CHECK constraint on jira_tickets.status
|
||||
// Allows storing raw Jira status strings (e.g. "Approval/Handoff", "Prioritizing")
|
||||
// instead of mapping to the limited set of Open/In Progress/Closed.
|
||||
// Idempotent — safe to run multiple times.
|
||||
|
||||
const pool = require('../db');
|
||||
|
||||
async function run() {
|
||||
console.log('[Migration] Dropping jira_tickets_status_check constraint...');
|
||||
await pool.query(`ALTER TABLE jira_tickets DROP CONSTRAINT IF EXISTS jira_tickets_status_check`);
|
||||
console.log('✓ jira_tickets status CHECK constraint dropped (or did not exist)');
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Migration failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,6 +21,12 @@ const POSTGRES_MIGRATIONS = [
|
||||
'add_vcl_vertical_metadata.js',
|
||||
'add_vcl_multi_vertical.js',
|
||||
'add_compliance_item_history.js',
|
||||
'add_jira_sync_columns_pg.js',
|
||||
'add_flexible_jira_ticket_creation.js',
|
||||
'add_multi_item_jira_ticket.js',
|
||||
'drop_jira_status_check_constraint.js',
|
||||
'add_compliance_history_metric_id.js',
|
||||
'add_archer_templates_table.js',
|
||||
];
|
||||
|
||||
async function runAll() {
|
||||
|
||||
543
backend/routes/archerTemplates.js
Normal file
543
backend/routes/archerTemplates.js
Normal file
@@ -0,0 +1,543 @@
|
||||
// routes/archerTemplates.js
|
||||
const express = require('express');
|
||||
const pool = require('../db');
|
||||
const { requireAuth, requireGroup } = require('../middleware/auth');
|
||||
const logAudit = require('../helpers/auditLog');
|
||||
|
||||
// Section fields and their max length
|
||||
const SECTION_FIELDS = [
|
||||
'environment_overview',
|
||||
'segmentation',
|
||||
'mitigating_controls',
|
||||
'additional_info',
|
||||
'charter_network_banner',
|
||||
'data_classification',
|
||||
'charter_network',
|
||||
'additional_access_list'
|
||||
];
|
||||
const SECTION_MAX_LENGTH = 10000;
|
||||
|
||||
function createArcherTemplatesRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// --- Hierarchy endpoints (MUST be defined before /:id to avoid route conflicts) ---
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/hierarchy/vendors
|
||||
*
|
||||
* Returns a sorted array of distinct vendor names across all templates.
|
||||
*
|
||||
* @returns {string[]} 200 - Array of vendor names sorted alphabetically
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/hierarchy/vendors', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT vendor FROM archer_templates ORDER BY vendor ASC'
|
||||
);
|
||||
res.json(rows.map(r => r.vendor));
|
||||
} catch (err) {
|
||||
console.error('Error fetching template vendors:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/hierarchy/platforms
|
||||
*
|
||||
* Returns a sorted array of distinct platform names for a given vendor.
|
||||
*
|
||||
* @query {string} vendor - (required) The vendor to filter platforms by
|
||||
* @returns {string[]} 200 - Array of platform names sorted alphabetically
|
||||
* @returns {object} 400 - { error: 'vendor query parameter is required' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/hierarchy/platforms', requireAuth(), async (req, res) => {
|
||||
const { vendor } = req.query;
|
||||
if (!vendor) {
|
||||
return res.status(400).json({ error: 'vendor query parameter is required' });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT platform FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) ORDER BY platform ASC',
|
||||
[vendor]
|
||||
);
|
||||
res.json(rows.map(r => r.platform));
|
||||
} catch (err) {
|
||||
console.error('Error fetching template platforms:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/hierarchy/models
|
||||
*
|
||||
* Returns a sorted array of distinct model names for a given vendor and platform.
|
||||
*
|
||||
* @query {string} vendor - (required) The vendor to filter by
|
||||
* @query {string} platform - (required) The platform to filter by
|
||||
* @returns {string[]} 200 - Array of model names sorted alphabetically
|
||||
* @returns {object} 400 - { error: 'Missing required query parameters: ...' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/hierarchy/models', requireAuth(), async (req, res) => {
|
||||
const { vendor, platform } = req.query;
|
||||
const missing = [];
|
||||
if (!vendor) missing.push('vendor');
|
||||
if (!platform) missing.push('platform');
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({ error: `Missing required query parameters: ${missing.join(', ')}` });
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT model FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) ORDER BY model ASC',
|
||||
[vendor, platform]
|
||||
);
|
||||
res.json(rows.map(r => r.model));
|
||||
} catch (err) {
|
||||
console.error('Error fetching template models:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Core CRUD endpoints ---
|
||||
|
||||
/**
|
||||
* POST /api/archer-templates
|
||||
*
|
||||
* Creates a new Archer template with vendor/platform/model hierarchy and section content.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @body {string} vendor - (required) Vendor name, 1-100 chars after trim
|
||||
* @body {string} platform - (required) Platform name, 1-100 chars after trim
|
||||
* @body {string} model - (required) Model name, 1-100 chars after trim
|
||||
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
||||
* @body {string} [segmentation] - Section content, max 10,000 chars
|
||||
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_info] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
||||
* @body {string} [data_classification] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
||||
* @returns {object} 201 - The created template record (all columns)
|
||||
* @returns {object} 400 - { error: 'validation message' }
|
||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { vendor, platform, model } = req.body;
|
||||
|
||||
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
||||
const errors = [];
|
||||
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
||||
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(`${field} is required`);
|
||||
} else if (value.trim().length > 100) {
|
||||
errors.push(`${field} must be 100 characters or fewer`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
// Validate section fields — max 10,000 chars each, default to empty string
|
||||
const sectionValues = {};
|
||||
for (const field of SECTION_FIELDS) {
|
||||
const val = req.body[field];
|
||||
if (val !== undefined && val !== null && typeof val === 'string') {
|
||||
if (val.length > SECTION_MAX_LENGTH) {
|
||||
return res.status(400).json({ error: `${field} must be 10,000 characters or fewer` });
|
||||
}
|
||||
sectionValues[field] = val;
|
||||
} else {
|
||||
sectionValues[field] = '';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
vendor.trim(),
|
||||
platform.trim(),
|
||||
model.trim(),
|
||||
sectionValues.environment_overview,
|
||||
sectionValues.segmentation,
|
||||
sectionValues.mitigating_controls,
|
||||
sectionValues.additional_info,
|
||||
sectionValues.charter_network_banner,
|
||||
sectionValues.data_classification,
|
||||
sectionValues.charter_network,
|
||||
sectionValues.additional_access_list,
|
||||
req.user.id
|
||||
]
|
||||
);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_created',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(rows[0].id),
|
||||
details: { vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||
}
|
||||
console.error('Error creating template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates
|
||||
*
|
||||
* Lists all templates with optional search and exact-match filters.
|
||||
* Results are sorted by vendor, platform, model (ascending).
|
||||
*
|
||||
* @query {string} [search] - Substring search across vendor, platform, and model (ILIKE)
|
||||
* @query {string} [vendor] - Exact-match filter on vendor (case-insensitive)
|
||||
* @query {string} [platform] - Exact-match filter on platform (case-insensitive)
|
||||
* @query {string} [model] - Exact-match filter on model (case-insensitive)
|
||||
* @returns {object[]} 200 - Array of template records sorted by vendor/platform/model
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { search, vendor, platform, model } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM archer_templates WHERE 1=1';
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Search — ILIKE substring match across vendor, platform, model
|
||||
const trimmedSearch = search ? search.trim() : '';
|
||||
if (trimmedSearch.length > 0) {
|
||||
query += ` AND (vendor ILIKE $${paramIndex} OR platform ILIKE $${paramIndex} OR model ILIKE $${paramIndex})`;
|
||||
params.push(`%${trimmedSearch}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Exact-match filters (case-insensitive via LOWER/TRIM)
|
||||
if (vendor) {
|
||||
query += ` AND LOWER(TRIM(vendor)) = LOWER(TRIM($${paramIndex}))`;
|
||||
params.push(vendor);
|
||||
paramIndex++;
|
||||
}
|
||||
if (platform) {
|
||||
query += ` AND LOWER(TRIM(platform)) = LOWER(TRIM($${paramIndex}))`;
|
||||
params.push(platform);
|
||||
paramIndex++;
|
||||
}
|
||||
if (model) {
|
||||
query += ` AND LOWER(TRIM(model)) = LOWER(TRIM($${paramIndex}))`;
|
||||
params.push(model);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += ' ORDER BY vendor ASC, platform ASC, model ASC';
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(query, params);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching templates:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/archer-templates/:id/clone
|
||||
*
|
||||
* Clones an existing template's section content into a new template with different
|
||||
* vendor/platform/model hierarchy values. Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - The ID of the source template to clone from
|
||||
* @body {string} vendor - (required) New vendor name, 1-100 chars after trim
|
||||
* @body {string} platform - (required) New platform name, 1-100 chars after trim
|
||||
* @body {string} model - (required) New model name, 1-100 chars after trim
|
||||
* @returns {object} 201 - The newly created cloned template record
|
||||
* @returns {object} 400 - { error: 'validation message' }
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.post('/:id/clone', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { vendor, platform, model } = req.body;
|
||||
|
||||
// Validate vendor, platform, model — required, 1-100 chars after trim, non-empty after trim
|
||||
const errors = [];
|
||||
for (const [field, value] of [['vendor', vendor], ['platform', platform], ['model', model]]) {
|
||||
if (value === undefined || value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(`${field} is required`);
|
||||
} else if (value.trim().length > 100) {
|
||||
errors.push(`${field} must be 100 characters or fewer`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify source template exists
|
||||
const { rows: sourceRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (sourceRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
const source = sourceRows[0];
|
||||
|
||||
// INSERT copying all 8 section fields from source with new hierarchy values
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO archer_templates (vendor, platform, model, environment_overview, segmentation, mitigating_controls, additional_info, charter_network_banner, data_classification, charter_network, additional_access_list, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
vendor.trim(),
|
||||
platform.trim(),
|
||||
model.trim(),
|
||||
source.environment_overview,
|
||||
source.segmentation,
|
||||
source.mitigating_controls,
|
||||
source.additional_info,
|
||||
source.charter_network_banner,
|
||||
source.data_classification,
|
||||
source.charter_network,
|
||||
source.additional_access_list,
|
||||
req.user.id
|
||||
]
|
||||
);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_cloned',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(rows[0].id),
|
||||
details: { sourceId: Number(id), newId: rows[0].id, vendor: vendor.trim(), platform: platform.trim(), model: model.trim() },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||
}
|
||||
console.error('Error cloning template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/archer-templates/:id
|
||||
*
|
||||
* Fetches a single template by its ID.
|
||||
*
|
||||
* @param {number} id - The template ID
|
||||
* @returns {object} 200 - The template record
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.get('/:id', requireAuth(), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error fetching template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/archer-templates/:id
|
||||
*
|
||||
* Updates an existing template. Supports partial updates — only provided fields are changed.
|
||||
* Always updates `updated_at` to NOW(). Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - The template ID to update
|
||||
* @body {string} [vendor] - New vendor name, 1-100 chars after trim
|
||||
* @body {string} [platform] - New platform name, 1-100 chars after trim
|
||||
* @body {string} [model] - New model name, 1-100 chars after trim
|
||||
* @body {string} [environment_overview] - Section content, max 10,000 chars
|
||||
* @body {string} [segmentation] - Section content, max 10,000 chars
|
||||
* @body {string} [mitigating_controls] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_info] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network_banner] - Section content, max 10,000 chars
|
||||
* @body {string} [data_classification] - Section content, max 10,000 chars
|
||||
* @body {string} [charter_network] - Section content, max 10,000 chars
|
||||
* @body {string} [additional_access_list] - Section content, max 10,000 chars
|
||||
* @returns {object} 200 - The updated template record
|
||||
* @returns {object} 400 - { error: 'validation message' }
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 409 - { error: 'A template with this vendor/platform/model combination already exists' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
// Verify template exists
|
||||
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (existingRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
const existing = existingRows[0];
|
||||
|
||||
// Validate provided hierarchy fields
|
||||
const errors = [];
|
||||
const updatedFields = {};
|
||||
const changedFieldNames = [];
|
||||
|
||||
for (const field of ['vendor', 'platform', 'model']) {
|
||||
const value = req.body[field];
|
||||
if (value !== undefined) {
|
||||
if (value === null || typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(`${field} is required`);
|
||||
} else if (value.trim().length > 100) {
|
||||
errors.push(`${field} must be 100 characters or fewer`);
|
||||
} else {
|
||||
updatedFields[field] = value.trim();
|
||||
if (value.trim() !== existing[field]) {
|
||||
changedFieldNames.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate provided section fields
|
||||
for (const field of SECTION_FIELDS) {
|
||||
const val = req.body[field];
|
||||
if (val !== undefined) {
|
||||
if (val !== null && typeof val === 'string') {
|
||||
if (val.length > SECTION_MAX_LENGTH) {
|
||||
errors.push(`${field} must be 10,000 characters or fewer`);
|
||||
} else {
|
||||
updatedFields[field] = val;
|
||||
if (val !== existing[field]) {
|
||||
changedFieldNames.push(field);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updatedFields[field] = '';
|
||||
if ('' !== existing[field]) {
|
||||
changedFieldNames.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join('; ') });
|
||||
}
|
||||
|
||||
// Check uniqueness if vendor/platform/model changed (excluding self)
|
||||
const newVendor = updatedFields.vendor || existing.vendor;
|
||||
const newPlatform = updatedFields.platform || existing.platform;
|
||||
const newModel = updatedFields.model || existing.model;
|
||||
|
||||
if (updatedFields.vendor !== undefined || updatedFields.platform !== undefined || updatedFields.model !== undefined) {
|
||||
const { rows: conflictRows } = await pool.query(
|
||||
`SELECT id FROM archer_templates WHERE LOWER(TRIM(vendor)) = LOWER(TRIM($1)) AND LOWER(TRIM(platform)) = LOWER(TRIM($2)) AND LOWER(TRIM(model)) = LOWER(TRIM($3)) AND id != $4`,
|
||||
[newVendor, newPlatform, newModel, id]
|
||||
);
|
||||
if (conflictRows.length > 0) {
|
||||
return res.status(409).json({ error: 'A template with this vendor/platform/model combination already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
// Build dynamic UPDATE SET clause for only provided fields
|
||||
const setClauses = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [field, value] of Object.entries(updatedFields)) {
|
||||
setClauses.push(`${field} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Always set updated_at = NOW()
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
|
||||
// Execute update
|
||||
params.push(id);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE archer_templates SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_updated',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(id),
|
||||
details: { changedFields: changedFieldNames },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('Error updating template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/archer-templates/:id
|
||||
*
|
||||
* Permanently deletes a template. Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {number} id - The template ID to delete
|
||||
* @returns {object} 200 - { message: 'Template deleted successfully' }
|
||||
* @returns {object} 404 - { error: 'Template not found' }
|
||||
* @returns {object} 500 - { error: 'Internal server error' }
|
||||
*/
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
// Verify template exists
|
||||
const { rows: existingRows } = await pool.query('SELECT * FROM archer_templates WHERE id = $1', [id]);
|
||||
if (existingRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
const existing = existingRows[0];
|
||||
|
||||
// Delete the template
|
||||
await pool.query('DELETE FROM archer_templates WHERE id = $1', [id]);
|
||||
|
||||
// Fire-and-forget audit log
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'template_deleted',
|
||||
entityType: 'archer_template',
|
||||
entityId: String(id),
|
||||
details: { vendor: existing.vendor, platform: existing.platform, model: existing.model },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.json({ message: 'Template deleted successfully' });
|
||||
} catch (err) {
|
||||
console.error('Error deleting template:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createArcherTemplatesRouter;
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
confirmAsset,
|
||||
declineAsset,
|
||||
redirectAsset,
|
||||
resolveAssetId,
|
||||
} = require('../helpers/cardApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,7 +53,14 @@ function handleCardError(err, res) {
|
||||
function createCardApiRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /status
|
||||
/**
|
||||
* GET /status
|
||||
*
|
||||
* Returns whether the CARD API integration is configured.
|
||||
*
|
||||
* @response 200 - { configured: true }
|
||||
* @response 503 - { configured: false, error: string, missingVars: string[] }
|
||||
*/
|
||||
router.get('/status', requireAuth(), (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
||||
@@ -60,7 +68,14 @@ function createCardApiRouter() {
|
||||
res.json({ configured: true });
|
||||
});
|
||||
|
||||
// GET /teams
|
||||
/**
|
||||
* GET /teams
|
||||
*
|
||||
* Returns the list of teams from the CARD API.
|
||||
*
|
||||
* @response 200 - Array of team objects from CARD
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -82,7 +97,19 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /teams/:teamName/assets
|
||||
/**
|
||||
* GET /teams/:teamName/assets
|
||||
*
|
||||
* Returns paginated assets for a team filtered by disposition.
|
||||
*
|
||||
* @param {string} teamName - Team name (path parameter)
|
||||
* @query {string} disposition - Required. Asset disposition filter.
|
||||
* @query {number} [page] - Page number for pagination.
|
||||
* @query {number} [page_size=50] - Number of results per page.
|
||||
* @response 200 - { assets: object[], total: number, ... } from CARD
|
||||
* @response 400 - { error: string } — missing disposition
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -133,7 +160,15 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /owner/:assetId
|
||||
/**
|
||||
* GET /owner/:assetId
|
||||
*
|
||||
* Returns the CARD owner record for a given asset ID.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier (path parameter)
|
||||
* @response 200 - CARD owner/asset object
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
@@ -156,22 +191,48 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /queue/:queueItemId/confirm
|
||||
/**
|
||||
* POST /queue/:queueItemId/confirm
|
||||
*
|
||||
* Confirms ownership of a CARD asset for a queue item. Fetches the owner
|
||||
* record to obtain the update_token, then calls the CARD confirm endpoint.
|
||||
* Marks the queue item as complete on success.
|
||||
*
|
||||
* @param {string} queueItemId - Queue item ID (path parameter)
|
||||
* @body {string} teamName - Team name to confirm ownership for (required)
|
||||
* @body {string} assetId - CARD asset identifier (required)
|
||||
* @body {string} [comment] - Optional comment for the confirmation
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields or item not pending
|
||||
* @response 404 - { error: string } — queue item not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
const { teamName, assetId: rawAssetId, comment } = req.body;
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
let assetId = rawAssetId.trim();
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
||||
@@ -232,22 +293,48 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /queue/:queueItemId/decline
|
||||
/**
|
||||
* POST /queue/:queueItemId/decline
|
||||
*
|
||||
* Declines ownership of a CARD asset for a queue item. Fetches the owner
|
||||
* record to obtain the update_token, then calls the CARD decline endpoint.
|
||||
* Marks the queue item as complete on success.
|
||||
*
|
||||
* @param {string} queueItemId - Queue item ID (path parameter)
|
||||
* @body {string} teamName - Team name declining ownership (required)
|
||||
* @body {string} assetId - CARD asset identifier (required)
|
||||
* @body {string} [comment] - Optional comment for the decline
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields or item not pending
|
||||
* @response 404 - { error: string } — queue item not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { teamName, assetId, comment } = req.body;
|
||||
const { teamName, assetId: rawAssetId, comment } = req.body;
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
let assetId = rawAssetId.trim();
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
||||
@@ -276,8 +363,8 @@ function createCardApiRouter() {
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
const errMsg = 'update_token not found in owner record.';
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
||||
const errMsg = 'update_token not found in owner record. The asset may have already been actioned or the owner record is in an unexpected state.';
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null, ownerResponse: ownerData }, ipAddress: req.ip });
|
||||
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
||||
}
|
||||
|
||||
@@ -308,14 +395,30 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /queue/:queueItemId/redirect
|
||||
/**
|
||||
* POST /queue/:queueItemId/redirect
|
||||
*
|
||||
* Redirects a CARD asset from one team to another for a queue item. Fetches
|
||||
* the owner record to obtain the update_token, then calls the CARD redirect
|
||||
* endpoint. Marks the queue item as complete on success.
|
||||
*
|
||||
* @param {string} queueItemId - Queue item ID (path parameter)
|
||||
* @body {string} fromTeam - Current owning team (required)
|
||||
* @body {string} toTeam - Target team to redirect to (required)
|
||||
* @body {string} assetId - CARD asset identifier (required)
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields or item not pending
|
||||
* @response 404 - { error: string } — queue item not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { queueItemId } = req.params;
|
||||
const { fromTeam, toTeam, assetId } = req.body;
|
||||
const { fromTeam, toTeam, assetId: rawAssetId } = req.body;
|
||||
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
@@ -323,10 +426,20 @@ function createCardApiRouter() {
|
||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||
return res.status(400).json({ error: 'toTeam is required.' });
|
||||
}
|
||||
if (!assetId || typeof assetId !== 'string' || !assetId.trim()) {
|
||||
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
|
||||
return res.status(400).json({ error: 'assetId is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID (e.g., 10.240.78.110 → 10.240.78.110-CTEC)
|
||||
let assetId = rawAssetId.trim();
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
||||
@@ -387,7 +500,484 @@ function createCardApiRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /owner-lookup/:ip
|
||||
*
|
||||
* Resolve an IP to a CARD asset ID and return the full owner record.
|
||||
* Used by the CARD Action Modal to display ownership state before
|
||||
* confirm/decline/redirect operations.
|
||||
*
|
||||
* @param {string} ip - IP address (path parameter)
|
||||
* @query {string} [quick] - Set to "1" for quick mode (CTEC only, 15s timeout). Used for tooltip lookups.
|
||||
* @response 200 - { asset_id, ip, confirmed, unconfirmed, declined, candidate, update_token }
|
||||
* @response 400 - { error: string } — missing IP
|
||||
* @response 404 - { error: string } — IP not found in CARD
|
||||
* @response 504 - { error: string, timeout: true } — CARD lookup timed out
|
||||
* @response 503 - { error: string } — CARD not configured
|
||||
*/
|
||||
router.get('/owner-lookup/:ip', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const ip = req.params.ip;
|
||||
if (!ip || !ip.trim()) {
|
||||
return res.status(400).json({ error: 'IP address is required.' });
|
||||
}
|
||||
|
||||
// Use quick mode (CTEC only, 15s timeout) for tooltip lookups
|
||||
const quick = req.query.quick === '1';
|
||||
|
||||
// Resolve to full asset ID
|
||||
let assetId;
|
||||
try {
|
||||
assetId = await resolveAssetId(ip.trim(), quick ? { quick: true } : undefined);
|
||||
} catch (err) {
|
||||
if (err.message === 'CARD_TIMEOUT') {
|
||||
return res.status(504).json({ error: 'CARD lookup timed out', timeout: true });
|
||||
}
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
if (!assetId) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for IP: ${ip}` });
|
||||
}
|
||||
|
||||
// Fetch full owner record
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
const data = JSON.parse(ownerResult.body);
|
||||
const owner = data.owner || {};
|
||||
|
||||
res.json({
|
||||
asset_id: assetId,
|
||||
ip: ip.trim(),
|
||||
confirmed: owner.confirmed || null,
|
||||
unconfirmed: owner.unconfirmed || null,
|
||||
declined: owner.declined || [],
|
||||
candidate: owner.candidate || [],
|
||||
update_token: owner.update_token || null,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/confirm
|
||||
*
|
||||
* Directly confirm ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD confirm.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to confirm ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
// Resolve bare IP to full CARD asset ID
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (confirmResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
||||
return res.status(confirmResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/decline
|
||||
*
|
||||
* Directly decline ownership of a CARD asset (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD decline.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} teamName - Team to decline ownership for (required)
|
||||
* @body {string} [comment] - Optional comment
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { teamName, comment } = req.body || {};
|
||||
|
||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||
return res.status(400).json({ error: 'teamName is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
||||
|
||||
if (declineResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline_direct', entityType: 'card_asset', entityId: assetId, details: { teamName: teamName.trim(), comment: comment || '' }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
||||
return res.status(declineResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /owner/:assetId/redirect
|
||||
*
|
||||
* Directly redirect a CARD asset between teams (no queue item required).
|
||||
* Fetches the owner record for the update_token, then calls CARD redirect.
|
||||
*
|
||||
* @param {string} assetId - CARD asset identifier or IP address (path parameter)
|
||||
* @body {string} fromTeam - Current owning team (required)
|
||||
* @body {string} toTeam - Target team (required)
|
||||
* @response 200 - { success: true, cardResponse: object }
|
||||
* @response 400 - { error: string } — missing fields
|
||||
* @response 404 - { error: string } — asset not found
|
||||
* @response 502 - { error: string } — CARD API failure
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/owner/:assetId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
let assetId = req.params.assetId;
|
||||
const { fromTeam, toTeam } = req.body || {};
|
||||
|
||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||
}
|
||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||
return res.status(400).json({ error: 'toTeam is required.' });
|
||||
}
|
||||
|
||||
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
||||
const resolved = await resolveAssetId(assetId);
|
||||
if (!resolved) {
|
||||
return res.status(404).json({ error: `Asset not found in CARD for: ${assetId}` });
|
||||
}
|
||||
assetId = resolved;
|
||||
}
|
||||
|
||||
try {
|
||||
const ownerResult = await getOwner(assetId);
|
||||
if (!ownerResult.ok) {
|
||||
return res.status(ownerResult.status).json({ error: `Failed to fetch owner record: HTTP ${ownerResult.status}` });
|
||||
}
|
||||
|
||||
let ownerData;
|
||||
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
||||
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||
|
||||
if (!updateToken) {
|
||||
return res.status(502).json({ error: 'update_token not found in owner record.' });
|
||||
}
|
||||
|
||||
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
||||
|
||||
if (redirectResult.ok) {
|
||||
let cardResponse;
|
||||
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect_direct', entityType: 'card_asset', entityId: assetId, details: { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() }, ipAddress: req.ip });
|
||||
return res.json({ success: true, cardResponse });
|
||||
}
|
||||
|
||||
let errorBody;
|
||||
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
||||
return res.status(redirectResult.status).json(errorBody);
|
||||
} catch (err) {
|
||||
return handleCardError(err, res);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /enrich-batch
|
||||
*
|
||||
* Batch lookup IPs in CARD to extract Granite loader fields. Fetches team
|
||||
* assets (paginated, across confirmed, unconfirmed, and candidate
|
||||
* dispositions) and matches against the provided IPs. When no team is
|
||||
* specified, searches both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG.
|
||||
* Returns enrichment results for each IP.
|
||||
*
|
||||
* @body {string[]} ips - Non-empty array of IP address strings (max 200)
|
||||
* @body {string} [team] - Team name to search assets under. Defaults to both NTS-AEO-STEAM and NTS-AEO-ACCESS-ENG if omitted.
|
||||
* @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number }
|
||||
* Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: string|null, equip_status?: string|null, serial_number?: string|null, error?: string }
|
||||
* @response 400 - { error: string } — invalid or empty ips array, or exceeds 200
|
||||
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
||||
*/
|
||||
router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!isConfigured) {
|
||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||
}
|
||||
|
||||
const { ips, team } = req.body || {};
|
||||
if (!Array.isArray(ips) || ips.length === 0) {
|
||||
return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' });
|
||||
}
|
||||
if (ips.length > 200) {
|
||||
return res.status(400).json({ error: 'Maximum 200 IPs per request.' });
|
||||
}
|
||||
|
||||
// Build a set of IPs we're looking for
|
||||
const targetIps = new Set(ips.map(ip => (ip || '').trim()).filter(Boolean));
|
||||
const resultMap = {};
|
||||
|
||||
// Strategy: fetch team assets (paginated) and match against our target IPs.
|
||||
// The team assets endpoint returns the full enriched record with ncim_discovery,
|
||||
// card_flags, netops_granite_allips, etc.
|
||||
const teams = team ? [team] : ['NTS-AEO-STEAM', 'NTS-AEO-ACCESS-ENG'];
|
||||
const dispositions = ['confirmed', 'unconfirmed', 'candidate'];
|
||||
let foundCount = 0;
|
||||
|
||||
for (const teamName of teams) {
|
||||
if (foundCount >= targetIps.size) break;
|
||||
|
||||
for (const disposition of dispositions) {
|
||||
if (foundCount >= targetIps.size) break;
|
||||
|
||||
let page = 1;
|
||||
const pageSize = 200;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore && foundCount < targetIps.size) {
|
||||
try {
|
||||
const result = await getTeamAssets(teamName, { disposition, page, pageSize });
|
||||
if (!result.ok) break;
|
||||
|
||||
const data = JSON.parse(result.body);
|
||||
const assets = data.assets || data.results || (Array.isArray(data) ? data : []);
|
||||
|
||||
if (assets.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Match assets against target IPs
|
||||
for (const asset of assets) {
|
||||
const assetId = asset._id || '';
|
||||
// Extract IP from asset ID (e.g., "10.240.78.110-CTEC" → "10.240.78.110")
|
||||
const assetIp = assetId.replace(/-[A-Z]+$/i, '');
|
||||
|
||||
if (targetIps.has(assetIp) && !resultMap[assetIp]) {
|
||||
resultMap[assetIp] = extractGraniteFields(asset, assetIp);
|
||||
foundCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
const total = data.total || data.count || 0;
|
||||
if (page * pageSize >= total || assets.length < pageSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[card-api] enrich-batch: Error fetching ${teamName}/${disposition} page ${page}:`, err.message);
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build results array in the same order as input IPs
|
||||
const results = [];
|
||||
let enrichedCount = 0;
|
||||
let notFoundCount = 0;
|
||||
|
||||
for (const ip of ips) {
|
||||
const trimmedIp = (ip || '').trim();
|
||||
if (!trimmedIp) {
|
||||
results.push({ ip: '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' });
|
||||
notFoundCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resultMap[trimmedIp]) {
|
||||
results.push({ ip: trimmedIp, found: true, ...resultMap[trimmedIp] });
|
||||
enrichedCount++;
|
||||
} else {
|
||||
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD team assets' });
|
||||
notFoundCount++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Granite-relevant fields from a CARD asset/owner record.
|
||||
* The owner endpoint returns: owner (confirmed/unconfirmed/etc), card_flags, tmp.
|
||||
* ncim_discovery is only available from the team assets endpoint.
|
||||
*/
|
||||
function extractGraniteFields(asset, ip) {
|
||||
// card_flags can be an array or nested in the response differently
|
||||
let flags = {};
|
||||
if (asset.card_flags) {
|
||||
flags = Array.isArray(asset.card_flags) ? (asset.card_flags[0] || {}) : asset.card_flags;
|
||||
}
|
||||
// Also check if flags are at the top level (owner endpoint format)
|
||||
if (!flags.CARD_HOSTNAME && asset.CARD_HOSTNAME) {
|
||||
flags = asset;
|
||||
}
|
||||
|
||||
const ncim = asset.ncim_discovery || [];
|
||||
const granite = asset.netops_granite_allips || [];
|
||||
const iseGranite = asset.ise_granite_equipment || [];
|
||||
const qualys = asset.qualys_hosts || [];
|
||||
const ivanti = asset.ivanti_assets || [];
|
||||
|
||||
// EQUIP_INST_ID — from ncim_discovery or granite (only available from team assets endpoint)
|
||||
let equip_inst_id = null;
|
||||
let site_name = null;
|
||||
let responsible_team = null;
|
||||
let hostname = null;
|
||||
|
||||
if (ncim.length > 0) {
|
||||
equip_inst_id = ncim[0].EQUIP_INST_ID || null;
|
||||
responsible_team = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
|
||||
site_name = ncim[0].SITE_NAME || ncim[0].SITENAME || null;
|
||||
hostname = ncim[0].HOSTNAME || null;
|
||||
}
|
||||
|
||||
if (!equip_inst_id && Array.isArray(granite) && granite.length > 0) {
|
||||
equip_inst_id = granite[0].EQUIP_INST_ID || null;
|
||||
if (!site_name) site_name = granite[0].SITE_NAME || null;
|
||||
if (!responsible_team) responsible_team = granite[0].RESPONSIBLE_TEAM || null;
|
||||
}
|
||||
|
||||
if (!equip_inst_id && Array.isArray(iseGranite) && iseGranite.length > 0) {
|
||||
equip_inst_id = iseGranite[0].EQUIP_INST_ID || null;
|
||||
}
|
||||
|
||||
// Hostname from card_flags (primary source from owner endpoint)
|
||||
if (!hostname && flags.CARD_HOSTNAME) {
|
||||
const hostnames = Array.isArray(flags.CARD_HOSTNAME) ? flags.CARD_HOSTNAME : [flags.CARD_HOSTNAME];
|
||||
hostname = hostnames[0] || null;
|
||||
}
|
||||
if (!hostname && qualys.length > 0) hostname = qualys[0].HOSTNAME || null;
|
||||
if (!hostname && ivanti.length > 0) hostname = ivanti[0].hostName || null;
|
||||
|
||||
// ASN from card_flags
|
||||
const mgmt_ip_asn = flags.CARD_ASN || null;
|
||||
|
||||
// CLLI → can be used as site hint
|
||||
if (!site_name && flags.CARD_CLLI) {
|
||||
site_name = flags.CARD_CLLI; // CLLI code, not full site name — user may need to map
|
||||
}
|
||||
|
||||
// Equipment class — always S (Shelf) from CARD context
|
||||
const equipment_class = 'S';
|
||||
|
||||
// Device ID → maps to SERIALNUMBER in Granite
|
||||
const serial_number = flags.CARD_DEVICE_ID || null;
|
||||
|
||||
// Equip status from flags
|
||||
const equip_status = flags.status || null;
|
||||
|
||||
// Vendor/model from card_flags
|
||||
let equip_template = null;
|
||||
if (flags.CARD_VENDOR_MODEL && Array.isArray(flags.CARD_VENDOR_MODEL) && flags.CARD_VENDOR_MODEL.length > 0) {
|
||||
const vm = flags.CARD_VENDOR_MODEL[0];
|
||||
equip_template = typeof vm === 'string' ? vm : (vm.vendor_model || null);
|
||||
}
|
||||
|
||||
// Confirmed team from owner record
|
||||
const confirmed_team = asset.owner && asset.owner.confirmed
|
||||
? asset.owner.confirmed.name : null;
|
||||
|
||||
return {
|
||||
equip_inst_id: equip_inst_id ? String(equip_inst_id) : null,
|
||||
hostname,
|
||||
site_name,
|
||||
mgmt_ip_asn: mgmt_ip_asn ? String(mgmt_ip_asn) : null,
|
||||
responsible_team: responsible_team || confirmed_team || null,
|
||||
equipment_class,
|
||||
equip_template,
|
||||
equip_status,
|
||||
serial_number,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createCardApiRouter;
|
||||
|
||||
@@ -227,6 +227,7 @@ function groupByHostname(rows, noteHostnames) {
|
||||
seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
|
||||
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
|
||||
has_notes: noteHostnames.has(row.hostname),
|
||||
resolution_date: null, remediation_plan: null,
|
||||
};
|
||||
}
|
||||
const dev = deviceMap[row.hostname];
|
||||
@@ -237,6 +238,11 @@ function groupByHostname(rows, noteHostnames) {
|
||||
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
||||
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen;
|
||||
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen;
|
||||
if (row.resolution_date && !dev.resolution_date) {
|
||||
const rd = row.resolution_date;
|
||||
dev.resolution_date = typeof rd === 'string' ? rd.slice(0, 10) : (rd instanceof Date ? rd.toISOString().slice(0, 10) : rd);
|
||||
}
|
||||
if (row.remediation_plan && !dev.remediation_plan) dev.remediation_plan = row.remediation_plan;
|
||||
}
|
||||
return Object.values(deviceMap).map(({ _seenMetricIds, ...dev }) => dev);
|
||||
}
|
||||
@@ -598,6 +604,7 @@ function createComplianceRouter(upload) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT ON (ci.hostname, ci.metric_id)
|
||||
ci.hostname, ci.ip_address, ci.device_type, ci.team, ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count,
|
||||
ci.resolution_date, ci.remediation_plan,
|
||||
fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on
|
||||
FROM compliance_items ci
|
||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
@@ -665,7 +672,7 @@ function createComplianceRouter(upload) {
|
||||
let history = [];
|
||||
try {
|
||||
const { rows: historyRows } = await pool.query(
|
||||
`SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
`SELECT id, metric_id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||
FROM compliance_item_history WHERE hostname = $1 ORDER BY changed_at DESC LIMIT 10`,
|
||||
[hostname]
|
||||
);
|
||||
@@ -943,13 +950,14 @@ function createComplianceRouter(upload) {
|
||||
|
||||
/**
|
||||
* PATCH /items/:hostname/metadata
|
||||
* Updates resolution_date and/or remediation_plan for all active compliance items matching a hostname.
|
||||
* Updates resolution_date and/or remediation_plan for active compliance items matching a hostname.
|
||||
* Supports optional per-metric scoping via metric_id (single) or metric_ids (array).
|
||||
* Records field-level change history in compliance_item_history for each modified field.
|
||||
*
|
||||
* @param hostname — the device hostname
|
||||
* @body { resolution_date?: string|null, remediation_plan?: string|null, change_reason?: string|null }
|
||||
* @body { resolution_date?: string|null, remediation_plan?: string|null, change_reason?: string|null, metric_id?: string, metric_ids?: string[] }
|
||||
* @response 200 { updated: number }
|
||||
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, change_reason exceeds 500 chars, or no fields provided
|
||||
* @response 400 { error } — invalid hostname, invalid date format, plan exceeds 2000 chars, change_reason exceeds 500 chars, no fields provided, or invalid metric_id
|
||||
* @response 404 { error } — device not found
|
||||
* @response 500 { error } — update failure
|
||||
*/
|
||||
@@ -957,7 +965,7 @@ function createComplianceRouter(upload) {
|
||||
const hostname = req.params.hostname;
|
||||
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid hostname' });
|
||||
|
||||
const { resolution_date, remediation_plan, change_reason } = req.body;
|
||||
const { resolution_date, remediation_plan, change_reason, metric_id, metric_ids } = req.body;
|
||||
|
||||
// Validate resolution_date: must be a valid ISO date string or null
|
||||
if (resolution_date !== undefined && resolution_date !== null) {
|
||||
@@ -979,6 +987,31 @@ function createComplianceRouter(upload) {
|
||||
return res.status(400).json({ error: 'Change reason exceeds 500 characters' });
|
||||
}
|
||||
|
||||
// Resolve metric scoping: metric_ids takes precedence over metric_id
|
||||
let resolvedMetricIds = null; // null means hostname-level (no metric scoping)
|
||||
if (metric_ids !== undefined) {
|
||||
if (!Array.isArray(metric_ids)) return res.status(400).json({ error: 'metric_ids must be an array' });
|
||||
if (metric_ids.length === 0) return res.status(400).json({ error: 'metric_ids must contain at least one entry' });
|
||||
for (let i = 0; i < metric_ids.length; i++) {
|
||||
const mid = metric_ids[i];
|
||||
if (!mid || typeof mid !== 'string' || mid.length === 0) {
|
||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
||||
}
|
||||
if (mid.length > 100) {
|
||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
||||
}
|
||||
}
|
||||
resolvedMetricIds = metric_ids;
|
||||
} else if (metric_id !== undefined && metric_id !== null) {
|
||||
if (typeof metric_id !== 'string' || metric_id.length === 0) {
|
||||
return res.status(400).json({ error: 'metric_id cannot be empty' });
|
||||
}
|
||||
if (metric_id.length > 100) {
|
||||
return res.status(400).json({ error: 'metric_id exceeds 100 characters' });
|
||||
}
|
||||
resolvedMetricIds = [metric_id];
|
||||
}
|
||||
|
||||
const setClauses = [];
|
||||
const values = [];
|
||||
let paramIdx = 1;
|
||||
@@ -1000,69 +1033,148 @@ function createComplianceRouter(upload) {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get current values before updating
|
||||
const { rows: currentRows } = await client.query(
|
||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||
ORDER BY hostname, id DESC LIMIT 1`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
if (currentRows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
const current = currentRows[0];
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null;
|
||||
|
||||
// Insert history for each changed field
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, 'resolution_date', $2, $3, $4, $5)`,
|
||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
if (resolvedMetricIds !== null) {
|
||||
// --- Per-metric scoping path ---
|
||||
// Validate that each metric_id corresponds to an active compliance_item for this hostname
|
||||
const { rows: activeMetricRows } = await client.query(
|
||||
`SELECT metric_id, resolution_date, remediation_plan
|
||||
FROM compliance_items
|
||||
WHERE hostname = $1 AND metric_id = ANY($2) AND status = 'active'`,
|
||||
[hostname, resolvedMetricIds]
|
||||
);
|
||||
|
||||
const activeMetricMap = new Map();
|
||||
for (const row of activeMetricRows) {
|
||||
activeMetricMap.set(row.metric_id, row);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, 'remediation_plan', $2, $3, $4, $5)`,
|
||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
|
||||
// Check for invalid metric_ids
|
||||
for (const mid of resolvedMetricIds) {
|
||||
if (!activeMetricMap.has(mid)) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(400).json({ error: `Invalid metric_id: ${mid} — no active compliance item found` });
|
||||
}
|
||||
}
|
||||
|
||||
// Insert history per metric per changed field
|
||||
for (const mid of resolvedMetricIds) {
|
||||
const current = activeMetricMap.get(mid);
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, 'resolution_date', $3, $4, $5, $6)`,
|
||||
[hostname, mid, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, $2, 'remediation_plan', $3, $4, $5, $6)`,
|
||||
[hostname, mid, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update only matching rows
|
||||
values.push(hostname);
|
||||
values.push(resolvedMetricIds);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND metric_id = ANY($${paramIdx + 1}) AND status = 'active'`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText, metric_ids: resolvedMetricIds },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
} else {
|
||||
// --- Hostname-level path (backward compatible, NULL metric_id in history) ---
|
||||
// Get current values before updating (pick one representative row)
|
||||
const { rows: currentRows } = await client.query(
|
||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||
ORDER BY hostname, id DESC LIMIT 1`,
|
||||
[hostname]
|
||||
);
|
||||
|
||||
if (currentRows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
client.release();
|
||||
return res.status(404).json({ error: 'Device not found' });
|
||||
}
|
||||
|
||||
const current = currentRows[0];
|
||||
const currentResDate = current.resolution_date
|
||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||
: null;
|
||||
const currentPlan = current.remediation_plan || null;
|
||||
|
||||
// Insert history for each changed field with NULL metric_id
|
||||
if (resolution_date !== undefined) {
|
||||
const newVal = resolution_date || null;
|
||||
if (currentResDate !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, NULL, 'resolution_date', $2, $3, $4, $5)`,
|
||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (remediation_plan !== undefined) {
|
||||
const newVal = remediation_plan || null;
|
||||
if (currentPlan !== newVal) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||
VALUES ($1, NULL, 'remediation_plan', $2, $3, $4, $5)`,
|
||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all active items for hostname
|
||||
values.push(hostname);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
}
|
||||
|
||||
// Update the items
|
||||
values.push(hostname);
|
||||
const result = await client.query(
|
||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
|
||||
values
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'compliance_metadata_update',
|
||||
entityType: 'compliance_item',
|
||||
entityId: hostname,
|
||||
details: { resolution_date, remediation_plan, change_reason: reasonText },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
res.json({ updated: result.rowCount });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
||||
@@ -1631,4 +1743,4 @@ function createComplianceRouter(upload) {
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload };
|
||||
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload, groupByHostname };
|
||||
|
||||
@@ -10,6 +10,19 @@ const pool = require('../db');
|
||||
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// PostgreSQL DATE columns return JS Date objects — normalize to 'YYYY-MM-DD' strings
|
||||
function formatDate(val) {
|
||||
if (!val) return null;
|
||||
if (val instanceof Date) {
|
||||
const y = val.getFullYear();
|
||||
const m = String(val.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(val.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
// Already a string — strip any time portion (e.g. "2025-05-22T00:00:00.000Z")
|
||||
return String(val).slice(0, 10);
|
||||
}
|
||||
|
||||
// Configurable BU filter — broadened via env var to support multi-tenancy.
|
||||
// Users see only their assigned teams' findings (filtered at query time).
|
||||
const BU_FILTER_VALUE = process.env.IVANTI_BU_FILTER || 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM';
|
||||
@@ -1033,8 +1046,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
dns: row.dns,
|
||||
status: row.status,
|
||||
slaStatus: row.sla_status,
|
||||
dueDate: row.due_date,
|
||||
lastFoundOn: row.last_found_on,
|
||||
dueDate: formatDate(row.due_date),
|
||||
lastFoundOn: formatDate(row.last_found_on),
|
||||
buOwnership: row.bu_ownership,
|
||||
cves: row.cves || [],
|
||||
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
||||
@@ -1089,8 +1102,8 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
dns: row.dns,
|
||||
status: row.status,
|
||||
slaStatus: row.sla_status,
|
||||
dueDate: row.due_date,
|
||||
lastFoundOn: row.last_found_on,
|
||||
dueDate: formatDate(row.due_date),
|
||||
lastFoundOn: formatDate(row.last_found_on),
|
||||
buOwnership: row.bu_ownership,
|
||||
cves: row.cves || [],
|
||||
workflow: row.workflow_id ? { id: row.workflow_id, state: row.workflow_state, type: row.workflow_type } : null,
|
||||
|
||||
@@ -260,6 +260,12 @@ function createIvantiFpWorkflowRouter() {
|
||||
const foundIds = new Set(libraryDocs.map(d => d.id));
|
||||
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
||||
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
||||
// Validate file types for library docs (same rules as local uploads)
|
||||
const unsupportedDocs = libraryDocs.filter(d => !isAllowedFileExtension(d.name));
|
||||
if (unsupportedDocs.length > 0) {
|
||||
const names = unsupportedDocs.map(d => d.name).join(', ');
|
||||
return res.status(400).json({ error: `Library document file type not supported by Ivanti: ${names}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` });
|
||||
}
|
||||
}
|
||||
|
||||
const libraryFormFiles = [];
|
||||
@@ -281,8 +287,19 @@ function createIvantiFpWorkflowRouter() {
|
||||
|
||||
if (createResult.status !== 200 && createResult.status !== 201 && createResult.status !== 202) {
|
||||
const errorMap = { 401: 'Ivanti API key is invalid or missing.', 419: 'API key lacks workflow creation permissions.', 429: 'Ivanti API rate limit reached.' };
|
||||
const errorMsg = errorMap[createResult.status] || `Workflow creation failed: ${createResult.status}`;
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: errorMsg, status: createResult.status, findingIds }, ipAddress: req.ip });
|
||||
let errorMsg = errorMap[createResult.status];
|
||||
if (!errorMsg) {
|
||||
// Try to extract detail from the Ivanti response body
|
||||
let bodyDetail = '';
|
||||
try {
|
||||
const parsed = JSON.parse(createResult.body);
|
||||
bodyDetail = parsed.message || parsed.error || parsed.detail || JSON.stringify(parsed);
|
||||
} catch (_) {
|
||||
bodyDetail = (createResult.body || '').slice(0, 500);
|
||||
}
|
||||
errorMsg = `Workflow creation failed (${createResult.status}): ${bodyDetail || 'No details returned by Ivanti API.'}`;
|
||||
}
|
||||
logAudit({ userId: req.user.id, username: req.user.username, action: 'ivanti_fp_workflow_failed', entityType: 'ivanti_workflow', details: { error: errorMsg, status: createResult.status, responseBody: (createResult.body || '').slice(0, 1000), findingIds }, ipAddress: req.ip });
|
||||
return res.status(createResult.status === 429 ? 429 : 502).json({ success: false, error: errorMsg, step: 'create_workflow' });
|
||||
}
|
||||
|
||||
@@ -810,6 +827,12 @@ function createIvantiFpWorkflowRouter() {
|
||||
const foundIds = new Set(libraryDocs.map(d => d.id));
|
||||
const missingIds = libraryDocIds.filter(id => !foundIds.has(id));
|
||||
if (missingIds.length > 0) return res.status(400).json({ error: `Library document IDs not found: ${missingIds.join(', ')}` });
|
||||
// Validate file types for library docs
|
||||
const unsupportedDocs = libraryDocs.filter(d => !isAllowedFileExtension(d.name));
|
||||
if (unsupportedDocs.length > 0) {
|
||||
const names = unsupportedDocs.map(d => d.name).join(', ');
|
||||
return res.status(400).json({ error: `Library document file type not supported by Ivanti: ${names}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}` });
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = process.env.IVANTI_API_KEY;
|
||||
|
||||
@@ -318,16 +318,17 @@ function createIvantiTodoQueueRouter() {
|
||||
/**
|
||||
* POST /api/ivanti/todo-queue/:id/redirect
|
||||
*
|
||||
* Redirects a completed queue item to a different workflow by creating a new
|
||||
* pending queue item with the same finding data but a new workflow type/vendor.
|
||||
* Redirects a queue item to a different workflow type. If the item is pending,
|
||||
* updates workflow_type in place. If the item is complete, creates a new pending
|
||||
* queue item with the same finding data but a new workflow type/vendor.
|
||||
* Requires Admin or Standard_User group.
|
||||
*
|
||||
* @param {string} id — Queue item ID of the completed item (URL parameter)
|
||||
* @param {string} id — Queue item ID (URL parameter)
|
||||
* @body {Object}
|
||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||
* @returns {Object} The newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input or item not in complete status
|
||||
* @returns {Object} The updated or newly created queue item with parsed `cves` array
|
||||
* @error 400 Invalid input
|
||||
* @error 404 Queue item not found
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
@@ -358,10 +359,38 @@ function createIvantiTodoQueueRouter() {
|
||||
if (!original) {
|
||||
return res.status(404).json({ error: 'Queue item not found.' });
|
||||
}
|
||||
if (original.status !== 'complete') {
|
||||
return res.status(400).json({ error: 'Only completed queue items can be redirected.' });
|
||||
|
||||
// If the item is still pending, update workflow_type in place (no duplication)
|
||||
if (original.status === 'pending') {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE ivanti_todo_queue SET workflow_type = $1, vendor = $2, updated_at = NOW()
|
||||
WHERE id = $3 AND user_id = $4 RETURNING *`,
|
||||
[workflow_type, vendorVal, id, req.user.id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'queue_item_redirected',
|
||||
entityType: 'ivanti_todo_queue',
|
||||
entityId: String(original.id),
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
method: 'in_place_update',
|
||||
vendor: vendorVal,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...rows[0],
|
||||
cves: rows[0].cves_json ? JSON.parse(rows[0].cves_json) : [],
|
||||
};
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
// If the item is complete, create a new pending item (legacy behavior)
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO ivanti_todo_queue
|
||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||
@@ -379,6 +408,7 @@ function createIvantiTodoQueueRouter() {
|
||||
details: {
|
||||
original_workflow_type: original.workflow_type,
|
||||
target_workflow_type: workflow_type,
|
||||
method: 'new_item_from_complete',
|
||||
new_item_id: rows[0].id,
|
||||
vendor: vendorVal,
|
||||
},
|
||||
@@ -396,6 +426,41 @@ function createIvantiTodoQueueRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ivanti/todo-queue/ticket-links
|
||||
*
|
||||
* Returns Jira ticket associations for the current user's queue items.
|
||||
* Joins jira_ticket_queue_items with jira_tickets to get ticket_key and url.
|
||||
*
|
||||
* @returns {Object} { links: { [queue_item_id]: { ticket_key, jira_url } } }
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
router.get('/ticket-links', requireAuth(), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT jtqi.queue_item_id, jt.ticket_key, jt.url AS jira_url
|
||||
FROM jira_ticket_queue_items jtqi
|
||||
JOIN jira_tickets jt ON jt.id = jtqi.jira_ticket_id
|
||||
JOIN ivanti_todo_queue q ON q.id = jtqi.queue_item_id
|
||||
WHERE q.user_id = $1`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
const links = {};
|
||||
for (const row of rows) {
|
||||
links[row.queue_item_id] = {
|
||||
ticket_key: row.ticket_key,
|
||||
jira_url: row.jira_url
|
||||
};
|
||||
}
|
||||
|
||||
res.json({ links });
|
||||
} catch (err) {
|
||||
console.error('Error fetching ticket links:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ivanti/todo-queue/completed
|
||||
*
|
||||
@@ -406,15 +471,43 @@ function createIvantiTodoQueueRouter() {
|
||||
* @error 500 Internal server error
|
||||
*/
|
||||
router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const result = await pool.query(
|
||||
"DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Select completed item IDs for this user
|
||||
const { rows: completedRows } = await client.query(
|
||||
"SELECT id FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ message: 'Completed items cleared.', deleted: result.rowCount });
|
||||
|
||||
if (completedRows.length === 0) {
|
||||
await client.query('COMMIT');
|
||||
return res.json({ message: 'Completed items cleared.', deleted: 0 });
|
||||
}
|
||||
|
||||
const ids = completedRows.map(r => r.id);
|
||||
|
||||
// Delete junction table references first
|
||||
await client.query(
|
||||
'DELETE FROM jira_ticket_queue_items WHERE queue_item_id = ANY($1::int[])',
|
||||
[ids]
|
||||
);
|
||||
|
||||
// Delete the completed queue items
|
||||
const deleteResult = await client.query(
|
||||
'DELETE FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
|
||||
[ids]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ message: 'Completed items cleared.', deleted: deleteResult.rowCount });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error clearing completed queue items:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const jiraApi = require('../helpers/jiraApi');
|
||||
|
||||
// Validation helpers
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
||||
|
||||
function isValidCveId(cveId) {
|
||||
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||
@@ -35,6 +34,16 @@ function createJiraTicketsRouter() {
|
||||
// Jira API integration endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets/connection-test
|
||||
*
|
||||
* Tests connectivity to the configured Jira instance.
|
||||
*
|
||||
* @requires Admin group
|
||||
* @returns {object} 200 - { connected: true, user: { name, displayName, ... } }
|
||||
* @returns {object} 502 - { connected: false, error: string } on connection failure
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.get('/connection-test', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured. Set JIRA_BASE_URL and credentials in backend/.env.' });
|
||||
@@ -60,10 +69,33 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets/rate-limit
|
||||
*
|
||||
* Returns the current Jira API rate limit status (burst and daily counters).
|
||||
*
|
||||
* @requires Admin group
|
||||
* @returns {object} 200 - { burst: { remaining, limit, ... }, daily: { remaining, limit, ... } }
|
||||
*/
|
||||
router.get('/rate-limit', requireAuth(), requireGroup('Admin'), (req, res) => {
|
||||
res.json(jiraApi.getRateLimitStatus());
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets/lookup/:issueKey
|
||||
*
|
||||
* Looks up a single Jira issue by its key (e.g., PROJECT-123) and returns
|
||||
* a summary of its fields.
|
||||
*
|
||||
* @param {string} issueKey - Jira issue key (path parameter, format: PROJECT-123)
|
||||
* @requires Authenticated user
|
||||
* @returns {object} 200 - { key, summary, status, assignee, priority, issuetype, created, updated, self }
|
||||
* @returns {object} 400 - { error: string } for invalid issue key format
|
||||
* @returns {object} 404 - { error: string } when issue not found in Jira
|
||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error: string } on Jira API error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.get('/lookup/:issueKey', requireAuth(), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
@@ -93,8 +125,24 @@ function createJiraTicketsRouter() {
|
||||
if (result.rateLimited) {
|
||||
return res.status(429).json({ error: 'Jira rate limit exceeded. Try again later.' });
|
||||
}
|
||||
// Build a meaningful error message from Jira's response
|
||||
let errorMsg = result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.';
|
||||
if (result.body) {
|
||||
try {
|
||||
const parsed = typeof result.body === 'string' ? JSON.parse(result.body) : result.body;
|
||||
if (parsed.errorMessages && parsed.errorMessages.length > 0) {
|
||||
errorMsg = parsed.errorMessages.join('; ');
|
||||
} else if (parsed.errors && Object.keys(parsed.errors).length > 0) {
|
||||
errorMsg = Object.values(parsed.errors).join('; ');
|
||||
}
|
||||
} catch (_) {
|
||||
if (typeof result.body === 'string' && result.body.length < 300) {
|
||||
errorMsg = result.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
return res.status(result.status === 404 ? 404 : 502).json({
|
||||
error: result.status === 404 ? 'Issue not found in Jira.' : 'Jira API error.',
|
||||
error: errorMsg,
|
||||
details: result.body
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -102,19 +150,63 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/create-in-jira
|
||||
*
|
||||
* Creates a new issue in Jira and saves a local tracking record.
|
||||
*
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} [cve_id] - Optional CVE ID (format: CVE-YYYY-NNNN+); stored as NULL if absent/empty
|
||||
* @body {string} [vendor] - Optional vendor name (max 200 chars after trim); stored as NULL if absent/empty/whitespace
|
||||
* @body {string} summary - Required issue summary (max 255 chars)
|
||||
* @body {string} [description] - Optional issue description
|
||||
* @body {string} [project_key] - Jira project key (defaults to JIRA_PROJECT_KEY env var)
|
||||
* @body {string} [issue_type] - Jira issue type name (defaults to JIRA_ISSUE_TYPE env var)
|
||||
* @body {string} [source_context] - One of: cve, archer, ivanti_queue, email, manual (defaults to 'manual')
|
||||
* @returns {object} 201 - { id, ticket_key, jira_url, source_context, message }
|
||||
* @returns {object} 207 - { warning, jira_key, jira_url, error } when Jira issue created but local DB save failed
|
||||
* @returns {object} 400 - { error: string } for validation failures
|
||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
||||
* @returns {object} 502 - { error: string } on Jira API error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.post('/create-in-jira', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
}
|
||||
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type } = req.body;
|
||||
const { cve_id, vendor, summary, description, project_key, issue_type, source_context } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
// --- CVE ID validation: optional, but must match format if non-empty ---
|
||||
let normalizedCveId = null;
|
||||
if (cve_id !== undefined && cve_id !== null && cve_id !== '') {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
normalizedCveId = cve_id;
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
|
||||
// --- Vendor validation: optional, but must be <= 200 chars after trim if non-empty ---
|
||||
let normalizedVendor = null;
|
||||
if (vendor !== undefined && vendor !== null && typeof vendor === 'string' && vendor.trim().length > 0) {
|
||||
const trimmedVendor = vendor.trim();
|
||||
if (trimmedVendor.length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
normalizedVendor = trimmedVendor;
|
||||
}
|
||||
|
||||
// --- source_context validation: must be in allowed set if provided, default to 'manual' ---
|
||||
const ALLOWED_SOURCE_CONTEXTS = ['cve', 'archer', 'ivanti_queue', 'email', 'manual'];
|
||||
let normalizedSourceContext = 'manual';
|
||||
if (source_context !== undefined && source_context !== null) {
|
||||
if (!ALLOWED_SOURCE_CONTEXTS.includes(source_context)) {
|
||||
return res.status(400).json({ error: 'source_context must be one of: cve, archer, ivanti_queue, email, manual.' });
|
||||
}
|
||||
normalizedSourceContext = source_context;
|
||||
}
|
||||
|
||||
// --- Summary validation: required, non-empty, max 255 chars ---
|
||||
if (!summary || typeof summary !== 'string' || summary.trim().length === 0 || summary.length > 255) {
|
||||
return res.status(400).json({ error: 'Summary is required (max 255 chars).' });
|
||||
}
|
||||
@@ -153,10 +245,10 @@ function createJiraTicketsRouter() {
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, jira_id, jira_status, last_synced_at, created_by, source_context)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, $10)
|
||||
RETURNING id`,
|
||||
[cve_id, vendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id]
|
||||
[normalizedCveId, normalizedVendor, ticketKey, jiraUrl, summary.trim(), 'Open', jiraIssue.id, 'Open', req.user.id, normalizedSourceContext]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
@@ -165,7 +257,7 @@ function createJiraTicketsRouter() {
|
||||
action: 'jira_ticket_create_via_api',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { cve_id, vendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey },
|
||||
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key: ticketKey, jira_id: jiraIssue.id, project_key: projectKey, source_context: normalizedSourceContext },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -173,6 +265,7 @@ function createJiraTicketsRouter() {
|
||||
id: rows[0].id,
|
||||
ticket_key: ticketKey,
|
||||
jira_url: jiraUrl,
|
||||
source_context: normalizedSourceContext,
|
||||
message: 'Jira issue created and linked successfully'
|
||||
});
|
||||
} catch (dbErr) {
|
||||
@@ -189,6 +282,18 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/sync-all
|
||||
*
|
||||
* Syncs all local Jira ticket records with their current Jira status using
|
||||
* bulk JQL search. Updates summary, status, and last_synced_at for each ticket.
|
||||
* Stops early if rate limits are approaching.
|
||||
*
|
||||
* @requires Admin group
|
||||
* @returns {object} 200 - { synced, failed, skipped, unchanged, errors: string[] }
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.post('/sync-all', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
@@ -248,12 +353,11 @@ function createJiraTicketsRouter() {
|
||||
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, ticket.id]
|
||||
[jiraSummary, jiraStatus || 'Open', jiraStatus, ticket.id]
|
||||
);
|
||||
results.synced++;
|
||||
} catch (dbErr) {
|
||||
@@ -280,10 +384,26 @@ function createJiraTicketsRouter() {
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/:id/sync
|
||||
*
|
||||
* Syncs a single local Jira ticket record with its current Jira status.
|
||||
* Fetches the issue by ticket_key and updates summary, status, and last_synced_at.
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @returns {object} 200 - { message, ticket_key, jira_status, local_status, summary }
|
||||
* @returns {object} 400 - { error: string } when ticket has no Jira key
|
||||
* @returns {object} 404 - { error: string } when local ticket not found
|
||||
* @returns {object} 429 - { error: string } when Jira rate limit exceeded
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
* @returns {object} 502 - { error: string } on Jira API error
|
||||
* @returns {object} 503 - { error: string } when Jira API is not configured
|
||||
*/
|
||||
router.post('/:id/sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
if (!jiraApi.isConfigured) {
|
||||
return res.status(503).json({ error: 'Jira API is not configured.' });
|
||||
@@ -313,11 +433,10 @@ function createJiraTicketsRouter() {
|
||||
const issue = result.data;
|
||||
const jiraStatus = issue.fields.status ? issue.fields.status.name : null;
|
||||
const jiraSummary = issue.fields.summary || ticket.summary;
|
||||
const localStatus = mapJiraStatusToLocal(jiraStatus);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE jira_tickets SET summary = $1, status = $2, jira_status = $3, last_synced_at = NOW(), updated_at = NOW() WHERE id = $4`,
|
||||
[jiraSummary, localStatus, jiraStatus, id]
|
||||
[jiraSummary, jiraStatus || 'Open', jiraStatus, id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
@@ -339,7 +458,7 @@ function createJiraTicketsRouter() {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Internal server error.' });
|
||||
return res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -347,8 +466,22 @@ function createJiraTicketsRouter() {
|
||||
// Local CRUD endpoints
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET /api/jira-tickets
|
||||
*
|
||||
* Lists all Jira tickets with optional filtering by query parameters.
|
||||
* Results are ordered by created_at descending.
|
||||
*
|
||||
* @query {string} [cve_id] - Filter by exact CVE ID
|
||||
* @query {string} [vendor] - Filter by exact vendor name
|
||||
* @query {string} [status] - Filter by ticket status (Open, In Progress, Closed)
|
||||
* @query {string} [source_context] - Filter by source context (cve, archer, ivanti_queue, email, manual)
|
||||
* @requires Authenticated user
|
||||
* @returns {array} 200 - Array of jira_tickets rows
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.get('/', requireAuth(), async (req, res) => {
|
||||
const { cve_id, vendor, status } = req.query;
|
||||
const { cve_id, vendor, status, source_context } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
||||
const params = [];
|
||||
@@ -366,6 +499,10 @@ function createJiraTicketsRouter() {
|
||||
query += ` AND status = $${paramIndex++}`;
|
||||
params.push(status);
|
||||
}
|
||||
if (source_context) {
|
||||
query += ` AND source_context = $${paramIndex++}`;
|
||||
params.push(source_context);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
@@ -374,18 +511,45 @@ function createJiraTicketsRouter() {
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Error fetching JIRA tickets:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets
|
||||
*
|
||||
* Creates a local Jira ticket record (without creating an issue in Jira).
|
||||
* Used for manually tracking tickets that already exist in Jira.
|
||||
*
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} cve_id - Required CVE ID (format: CVE-YYYY-NNNN+)
|
||||
* @body {string} vendor - Required vendor name (max 200 chars)
|
||||
* @body {string} ticket_key - Required Jira ticket key (max 50 chars)
|
||||
* @body {string} [url] - Optional Jira ticket URL (max 500 chars)
|
||||
* @body {string} [summary] - Optional summary (max 500 chars)
|
||||
* @body {string} [status] - Optional status: Open, In Progress, or Closed (defaults to Open)
|
||||
* @returns {object} 201 - { id, message }
|
||||
* @returns {object} 400 - { error: string } for validation failures
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.post('/', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
||||
|
||||
if (!cve_id || !isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
||||
// CVE ID is optional — validate format only if provided and non-empty
|
||||
let normalizedCveId = null;
|
||||
if (cve_id && typeof cve_id === 'string' && cve_id.trim().length > 0) {
|
||||
if (!isValidCveId(cve_id)) {
|
||||
return res.status(400).json({ error: 'CVE ID format is invalid. Expected CVE-YYYY-NNNN+.' });
|
||||
}
|
||||
normalizedCveId = cve_id;
|
||||
}
|
||||
if (!vendor || !isValidVendor(vendor)) {
|
||||
return res.status(400).json({ error: 'Valid vendor is required.' });
|
||||
// Vendor is optional — validate length only if provided and non-empty
|
||||
let normalizedVendor = null;
|
||||
if (vendor && typeof vendor === 'string' && vendor.trim().length > 0) {
|
||||
if (vendor.trim().length > 200) {
|
||||
return res.status(400).json({ error: 'Vendor exceeds maximum length of 200 characters.' });
|
||||
}
|
||||
normalizedVendor = vendor.trim();
|
||||
}
|
||||
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
||||
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
||||
@@ -396,8 +560,8 @@ function createJiraTicketsRouter() {
|
||||
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
if (status && typeof status !== 'string') {
|
||||
return res.status(400).json({ error: 'Status must be a string.' });
|
||||
}
|
||||
|
||||
const ticketStatus = status || 'Open';
|
||||
@@ -407,7 +571,7 @@ function createJiraTicketsRouter() {
|
||||
`INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
[cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
||||
[normalizedCveId, normalizedVendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id]
|
||||
);
|
||||
|
||||
logAudit({
|
||||
@@ -416,7 +580,7 @@ function createJiraTicketsRouter() {
|
||||
action: 'jira_ticket_create',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: rows[0].id.toString(),
|
||||
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
||||
details: { cve_id: normalizedCveId, vendor: normalizedVendor, ticket_key, status: ticketStatus },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
@@ -426,14 +590,36 @@ function createJiraTicketsRouter() {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error creating JIRA ticket:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
res.status(500).json({ error: `Failed to save ticket: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/jira-tickets/:id
|
||||
*
|
||||
* Updates an existing local Jira ticket record. Only provided fields are updated.
|
||||
* The source_context field is immutable after creation — including it returns 400.
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {string} [ticket_key] - Jira ticket key (max 50 chars)
|
||||
* @body {string} [url] - Jira ticket URL (max 500 chars, null to clear)
|
||||
* @body {string} [summary] - Summary (max 500 chars, null to clear)
|
||||
* @body {string} [status] - Status: Open, In Progress, or Closed
|
||||
* @returns {object} 200 - { message, changes }
|
||||
* @returns {object} 400 - { error: string } for validation failures or source_context mutation attempt
|
||||
* @returns {object} 404 - { error: string } when ticket not found
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.put('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ticket_key, url, summary, status } = req.body;
|
||||
|
||||
// source_context is immutable after creation (Requirement 3.6)
|
||||
if ('source_context' in req.body) {
|
||||
return res.status(400).json({ error: 'source_context is immutable after creation' });
|
||||
}
|
||||
|
||||
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
||||
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
||||
}
|
||||
@@ -443,8 +629,8 @@ function createJiraTicketsRouter() {
|
||||
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
||||
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
||||
}
|
||||
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
||||
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
||||
if (status !== undefined && typeof status !== 'string') {
|
||||
return res.status(400).json({ error: 'Status must be a string.' });
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
@@ -488,10 +674,24 @@ function createJiraTicketsRouter() {
|
||||
res.json({ message: 'JIRA ticket updated successfully', changes: result.rowCount });
|
||||
} catch (err) {
|
||||
console.error('Error updating JIRA ticket:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/jira-tickets/:id
|
||||
*
|
||||
* Deletes a local Jira ticket record. Admin can delete any ticket.
|
||||
* Standard_User can only delete tickets they created, and only if the ticket
|
||||
* is not linked to an active compliance item.
|
||||
*
|
||||
* @param {string} id - Local ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @returns {object} 200 - { message }
|
||||
* @returns {object} 403 - { error: string } when user lacks permission or ticket is linked to compliance
|
||||
* @returns {object} 404 - { error: string } when ticket not found
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -555,7 +755,91 @@ function createJiraTicketsRouter() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting JIRA ticket:', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Junction table endpoint — link queue items to a Jira ticket
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/jira-tickets/:id/queue-items
|
||||
*
|
||||
* Records associations between a Jira ticket and Ivanti queue items that
|
||||
* contributed to it. Uses ON CONFLICT DO NOTHING to handle duplicates.
|
||||
*
|
||||
* @param {string} id - Local Jira ticket ID (path parameter)
|
||||
* @requires Admin or Standard_User group
|
||||
* @body {number[]} queue_item_ids - Non-empty array of ivanti_todo_queue IDs
|
||||
* @returns {object} 201 - { message, ticket_id, linked_count }
|
||||
* @returns {object} 400 - { error: string } for validation failures
|
||||
* @returns {object} 404 - { error: string } when ticket not found
|
||||
* @returns {object} 500 - { error: string } on internal error
|
||||
*/
|
||||
router.post('/:id/queue-items', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { queue_item_ids } = req.body;
|
||||
|
||||
// Validate queue_item_ids is a non-empty array of integers
|
||||
if (!Array.isArray(queue_item_ids) || queue_item_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
||||
}
|
||||
|
||||
for (const qid of queue_item_ids) {
|
||||
if (!Number.isInteger(qid)) {
|
||||
return res.status(400).json({ error: 'queue_item_ids must be a non-empty array of integers' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the jira_ticket exists
|
||||
const { rows: ticketRows } = await pool.query(
|
||||
'SELECT id FROM jira_tickets WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
if (ticketRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Jira ticket not found' });
|
||||
}
|
||||
|
||||
// Verify all referenced queue items exist
|
||||
const { rows: existingItems } = await pool.query(
|
||||
'SELECT id FROM ivanti_todo_queue WHERE id = ANY($1::int[])',
|
||||
[queue_item_ids]
|
||||
);
|
||||
if (existingItems.length !== queue_item_ids.length) {
|
||||
return res.status(400).json({ error: 'One or more queue items not found' });
|
||||
}
|
||||
|
||||
// Insert rows with ON CONFLICT DO NOTHING
|
||||
const values = queue_item_ids.map((qid, idx) => `($1, $${idx + 2})`).join(', ');
|
||||
const params = [id, ...queue_item_ids];
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
`INSERT INTO jira_ticket_queue_items (jira_ticket_id, queue_item_id)
|
||||
VALUES ${values}
|
||||
ON CONFLICT (jira_ticket_id, queue_item_id) DO NOTHING`,
|
||||
params
|
||||
);
|
||||
|
||||
logAudit({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'jira_ticket_link_queue_items',
|
||||
entityType: 'jira_ticket',
|
||||
entityId: id,
|
||||
details: { queue_item_ids, linked_count: rowCount },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Queue items linked to ticket',
|
||||
ticket_id: parseInt(id, 10),
|
||||
linked_count: rowCount
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error linking queue items to Jira ticket:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -144,7 +144,8 @@ async function persistMultiVerticalUpload({ items, summary, reportDate, filename
|
||||
// Use summary data for accurate totals (compliance_items only has non-compliant devices).
|
||||
// IMPORTANT: Only use "ALL:" rollup rows to avoid double-counting. Each Summary sheet
|
||||
// has sub-team rows AND a rollup row per metric — the rollup already includes sub-teams.
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
// Use the file's report_date month for the snapshot so historical uploads land in the correct bucket.
|
||||
const snapshotMonth = reportDate ? reportDate.slice(0, 7) : new Date().toISOString().slice(0, 7);
|
||||
let totalDevices = 0, snapshotCompliant = 0, snapshotNonCompliant = 0;
|
||||
|
||||
if (summary && summary.entries && summary.entries.length > 0) {
|
||||
@@ -165,7 +166,7 @@ async function persistMultiVerticalUpload({ items, summary, reportDate, filename
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (snapshot_month, vertical)
|
||||
DO UPDATE SET total_devices = $3, compliant = $4, non_compliant = $5, compliance_pct = $6`,
|
||||
[currentMonth, vertical, totalDevices, snapshotCompliant, snapshotNonCompliant, compPct]
|
||||
[snapshotMonth, vertical, totalDevices, snapshotCompliant, snapshotNonCompliant, compPct]
|
||||
);
|
||||
|
||||
return { uploadId, newCount, recurringCount, resolvedCount };
|
||||
@@ -1195,10 +1196,10 @@ function createVCLMultiVerticalRouter(upload) {
|
||||
await client.query(`DELETE FROM vcl_multi_vertical_summary WHERE upload_id = $1`, [uploadId]);
|
||||
await client.query(`DELETE FROM compliance_uploads WHERE id = $1`, [uploadId]);
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const rollbackMonth = upload.report_date ? upload.report_date.slice(0, 7) : new Date().toISOString().slice(0, 7);
|
||||
await client.query(
|
||||
`DELETE FROM compliance_snapshots WHERE vertical = $1 AND snapshot_month = $2`,
|
||||
[upload.vertical, currentMonth]
|
||||
[upload.vertical, rollbackMonth]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
@@ -1483,6 +1484,226 @@ 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(s) from active devices
|
||||
// Group by vertical to handle metrics that span multiple verticals
|
||||
const verticalSet = new Set(activeDevices.map(d => d.vertical));
|
||||
const vertical = activeDevices[0].vertical; // primary vertical for snapshot lookup
|
||||
|
||||
// 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 ALL verticals this metric spans
|
||||
const verticals = [...verticalSet];
|
||||
const { rows: snapshots } = await pool.query(
|
||||
`SELECT snapshot_month AS month,
|
||||
SUM(total_devices)::int AS total_assets,
|
||||
SUM(non_compliant)::int AS non_compliant,
|
||||
ROUND((SUM(compliant)::numeric / NULLIF(SUM(total_devices), 0)) * 100, 1) AS compliance_pct
|
||||
FROM compliance_snapshots
|
||||
WHERE vertical = ANY($1) AND snapshot_month >= $2 AND snapshot_month < $3
|
||||
GROUP BY snapshot_month
|
||||
ORDER BY snapshot_month ASC`,
|
||||
[verticals, startMonth, currentMonth]
|
||||
);
|
||||
|
||||
// 5. Get total non-compliant devices across all verticals this metric spans
|
||||
const { rows: verticalNcRows } = await pool.query(
|
||||
`SELECT COUNT(DISTINCT hostname) AS total_nc
|
||||
FROM compliance_items
|
||||
WHERE vertical = ANY($1) AND status = 'active'`,
|
||||
[verticals]
|
||||
);
|
||||
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)
|
||||
// Use the metric's own total (from summary) rather than the vertical's total_devices
|
||||
const historicalSnapshots = snapshots.map(snap => {
|
||||
const snapshotNc = parseInt(snap.non_compliant, 10) || 0;
|
||||
let metricNc;
|
||||
if (verticalTotalNc === 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: 0, // Will be filled in after we get the metric's totalAssets
|
||||
non_compliant: metricNc,
|
||||
compliance_pct: 0, // Will be recomputed
|
||||
};
|
||||
});
|
||||
|
||||
// 7. Include current month as the most recent historical data point (from live data)
|
||||
// Get totalAssets from the per-metric summary (vcl_multi_vertical_summary)
|
||||
// This gives us the actual total devices for THIS metric, not the entire vertical
|
||||
let totalAssets = 0;
|
||||
const { rows: metricSummaryRows } = await pool.query(
|
||||
`SELECT SUM(total)::int AS total
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
||||
AND upload_id IN (
|
||||
SELECT id FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
)`,
|
||||
[metricId]
|
||||
);
|
||||
|
||||
if (metricSummaryRows.length > 0 && metricSummaryRows[0].total) {
|
||||
totalAssets = parseInt(metricSummaryRows[0].total, 10) || 0;
|
||||
}
|
||||
|
||||
// Fallback: if no summary data, use non_compliant count as minimum
|
||||
if (totalAssets === 0) {
|
||||
totalAssets = metricNcCount;
|
||||
}
|
||||
|
||||
// Backfill historical snapshots with the correct per-metric totalAssets and compliance_pct
|
||||
for (const snap of historicalSnapshots) {
|
||||
snap.total_assets = totalAssets;
|
||||
snap.compliance_pct = totalAssets > 0
|
||||
? Math.round(((totalAssets - snap.non_compliant) / totalAssets) * 1000) / 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 — deduplicate by hostname
|
||||
// A device may appear in multiple verticals; count it once, keeping the
|
||||
// earliest resolution_date (or null if any row has no date)
|
||||
const deviceMap = {};
|
||||
for (const d of activeDevices) {
|
||||
if (!deviceMap[d.hostname]) {
|
||||
deviceMap[d.hostname] = { hostname: d.hostname, resolution_date: d.resolution_date || null };
|
||||
} else if (d.resolution_date && !deviceMap[d.hostname].resolution_date) {
|
||||
// If any row has a resolution_date and current doesn't, use it
|
||||
deviceMap[d.hostname].resolution_date = d.resolution_date;
|
||||
}
|
||||
}
|
||||
const currentDevices = Object.values(deviceMap);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
69
backend/scripts/card-connectivity-test.js
Normal file
69
backend/scripts/card-connectivity-test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CARD API Connectivity Test
|
||||
* Tests: token acquisition → teams list → sample asset lookup
|
||||
*/
|
||||
require('dns').setDefaultResultOrder('ipv4first');
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const { isConfigured, missingVars, testConnection, getTeams } = require('../helpers/cardApi');
|
||||
|
||||
async function main() {
|
||||
console.log('=== CARD API Connectivity Test ===');
|
||||
console.log(`Timestamp: ${new Date().toISOString()}`);
|
||||
console.log(`Target: ${process.env.CARD_API_URL}`);
|
||||
console.log(`User: ${process.env.CARD_API_USER}`);
|
||||
console.log(`TLS Skip: ${process.env.CARD_SKIP_TLS}`);
|
||||
console.log('');
|
||||
|
||||
if (!isConfigured) {
|
||||
console.error('FAIL: CARD API not configured. Missing:', missingVars.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 1: Token acquisition
|
||||
console.log('1. Acquiring Bearer token...');
|
||||
const connResult = await testConnection();
|
||||
if (!connResult.ok) {
|
||||
console.error(' FAIL:', connResult.error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' OK — token acquired:', connResult.token);
|
||||
|
||||
// Step 2: List teams
|
||||
console.log('2. Fetching teams (GET /api/v1/teams)...');
|
||||
const teamsResult = await getTeams();
|
||||
console.log(' Status:', teamsResult.status);
|
||||
if (!teamsResult.ok) {
|
||||
console.error(' FAIL:', teamsResult.body.substring(0, 300));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let teams;
|
||||
try {
|
||||
teams = JSON.parse(teamsResult.body);
|
||||
} catch (e) {
|
||||
console.error(' FAIL: Could not parse response:', teamsResult.body.substring(0, 200));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Array.isArray(teams)) {
|
||||
console.log(` OK — ${teams.length} teams found`);
|
||||
const sample = teams.slice(0, 8);
|
||||
sample.forEach(t => {
|
||||
const name = t.name || t.team_name || t.teamName || JSON.stringify(t).substring(0, 60);
|
||||
console.log(` • ${name}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' Response structure:', Object.keys(teams).join(', '));
|
||||
console.log(' Preview:', JSON.stringify(teams).substring(0, 200));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('=== RESULT: PASS — CARD API is reachable and authenticated ===');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('ERROR:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
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,6 +1,10 @@
|
||||
// CVE Management Backend API
|
||||
// Install: npm install express pg multer cors dotenv bcryptjs cookie-parser
|
||||
|
||||
// Force IPv4-first DNS resolution globally — must be set before any network modules load.
|
||||
// card.charter.com has IPv6 AAAA records that are unreachable from this network.
|
||||
require('dns').setDefaultResultOrder('ipv4first');
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
@@ -22,6 +26,7 @@ const logAudit = require('./helpers/auditLog');
|
||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||
const createArcherTemplatesRouter = require('./routes/archerTemplates');
|
||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
||||
@@ -197,6 +202,9 @@ app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
|
||||
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||
app.use('/api/archer-tickets', createArcherTicketsRouter());
|
||||
|
||||
// Archer template library routes (editor/admin for create/update/delete/clone, all authenticated for view)
|
||||
app.use('/api/archer-templates', createArcherTemplatesRouter());
|
||||
|
||||
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
||||
|
||||
|
||||
@@ -533,6 +533,8 @@ A dedicated page for managing Jira Data Center tickets linked to CVE/vendor pair
|
||||
- **Sync** — refresh a single ticket's status and summary from Jira, or bulk-sync all tracked tickets via JQL search
|
||||
- **Create / Edit / Delete** — manage local ticket records linking Jira keys to CVE/vendor pairs
|
||||
|
||||
**Vendor-specific issue types:** The Issue Type dropdown in the creation modal is context-aware. When the Project Key field matches a recognized vendor project key (e.g., `AA_VECIMA`, `AA_CISCO`, `AA_ADTRAN`), the dropdown switches to vendor-specific issue types (Epic, Story, Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release Candidate, Documentation). For all other project keys — including the default from `JIRA_PROJECT_KEY` — the dropdown shows STEAM issue types (Story, Epic, Program, Project, Reservation, Automation Maintenance). Matching is case-insensitive and trims whitespace. Changing the project key such that the context switches (STEAM to vendor or vice versa) resets the selected issue type. The same behavior applies when creating a Jira ticket from the Ivanti Queue. The list of recognized vendor project keys is defined in `VENDOR_PROJECT_KEYS` in `frontend/src/components/pages/JiraPage.js`.
|
||||
|
||||
**Connection test (Admin)** — verify Jira API credentials and connectivity from the page header.
|
||||
|
||||
**Rate limit monitoring (Admin)** — view current burst and daily rate limit usage against Charter's posted limits (60/minute burst, 1 440/day).
|
||||
|
||||
988
docs/guides/vcl-metric-calculations.md
Normal file
988
docs/guides/vcl-metric-calculations.md
Normal file
@@ -0,0 +1,988 @@
|
||||
# VCL Metric Calculations — Database Reference
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how every percentage, total, and forecast number on the VCL Report and CCP Metrics pages is computed from the underlying database. It is the single reference for verifying that what you see on the page matches what is in the data.
|
||||
|
||||
Each section answers four questions:
|
||||
|
||||
- **What it shows** — the field name on screen and the data path
|
||||
- **What feeds it** — the table(s) and columns the value is computed from
|
||||
- **How it is calculated** — the exact SQL or formula, plus any rounding rules
|
||||
- **Why it can drift** — known sources of inaccuracy and how the dashboard guards against them
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Data Sources](#data-sources)
|
||||
- [compliance_items](#compliance_items)
|
||||
- [compliance_uploads](#compliance_uploads)
|
||||
- [compliance_snapshots](#compliance_snapshots)
|
||||
- [vcl_multi_vertical_summary](#vcl_multi_vertical_summary)
|
||||
- [VCL Report Page (Single-Vertical / Legacy AEO)](#vcl-report-page-single-vertical--legacy-aeo)
|
||||
- [Stats Bar](#stats-bar)
|
||||
- [Donut Chart — Status of Non-Compliant Assets](#donut-chart--status-of-non-compliant-assets)
|
||||
- [Heavy Hitters Table](#heavy-hitters-table)
|
||||
- [Vertical Breakdown Table](#vertical-breakdown-table)
|
||||
- [Compliance Trend Chart](#compliance-trend-chart)
|
||||
- [CCP Metrics Page (Multi-Vertical)](#ccp-metrics-page-multi-vertical)
|
||||
- [Aggregated Stats Bar](#aggregated-stats-bar)
|
||||
- [Donut — Blocked vs In-Progress](#donut--blocked-vs-in-progress)
|
||||
- [Trend Chart](#trend-chart)
|
||||
- [Aggregated Burndown Forecast](#aggregated-burndown-forecast)
|
||||
- [Metric Table (Cross-Vertical)](#metric-table-cross-vertical)
|
||||
- [Metric Detail View — Per-Vertical Breakdown](#metric-detail-view--per-vertical-breakdown)
|
||||
- [Per-Metric Forecast Burndown Chart](#per-metric-forecast-burndown-chart)
|
||||
- [Per-Vertical Detail and Burndown](#per-vertical-detail-and-burndown)
|
||||
- [Forecast Algorithms](#forecast-algorithms)
|
||||
- [Linear Regression Forecast (Trend)](#linear-regression-forecast-trend)
|
||||
- [Resolution-Date Burndown Forecast](#resolution-date-burndown-forecast)
|
||||
- [Per-Metric Forecast (Historical + Projected)](#per-metric-forecast-historical--projected)
|
||||
- [Cross-Cutting Correctness Rules](#cross-cutting-correctness-rules)
|
||||
- [Verifying Values by Hand](#verifying-values-by-hand)
|
||||
- [Worked Example — Vulns_Aging vs 7.1.1 Forecast Charts](#worked-example--vulns_aging-vs-711-forecast-charts)
|
||||
|
||||
---
|
||||
|
||||
## Data Sources
|
||||
|
||||
The VCL pages read from four tables. Knowing what each one stores is the prerequisite for understanding the calculations.
|
||||
|
||||
### compliance_items
|
||||
|
||||
One row per `(hostname, metric_id, vertical)` combination per upload. **Only non-compliant findings are stored here.** Compliant devices never appear in this table — they are inferred from the Summary sheet's totals minus what is in `compliance_items`.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `hostname` | TEXT | Device hostname |
|
||||
| `metric_id` | TEXT | Compliance metric identifier (e.g., `2.3.5`, `7.1.1`) |
|
||||
| `team` | TEXT | Sub-team responsible (`STEAM`, `ACCESS-ENG`, etc.) |
|
||||
| `vertical` | TEXT | Vertical code (`NTS_AEO`, `SDIT_CISO`, `TSI`); `NULL` for legacy AEO uploads |
|
||||
| `status` | TEXT | `'active'` if currently failing, `'resolved'` once the device drops off the next upload |
|
||||
| `resolution_date` | DATE | Target remediation date (manual entry) |
|
||||
| `seen_count` | INTEGER | Number of consecutive uploads this finding has appeared on |
|
||||
| `first_seen_upload_id` / `upload_id` / `resolved_upload_id` | INTEGER | Upload references for first appearance, latest, and resolution |
|
||||
|
||||
> A device is "compliant" when **no** active row exists for it in this table.
|
||||
|
||||
### compliance_uploads
|
||||
|
||||
One row per uploaded xlsx. A multi-vertical upload day produces multiple rows that share the same `report_date`.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `report_date` | TEXT | The reporting period the file covers (`YYYY-MM-DD`) |
|
||||
| `vertical` | TEXT | Same vertical code as `compliance_items.vertical`; `NULL` for legacy AEO |
|
||||
| `new_count` / `recurring_count` / `resolved_count` | INTEGER | Per-upload deltas (vertical-scoped) |
|
||||
| `summary_json` | TEXT | The raw parsed Summary sheet — used as a fallback by `/summary` |
|
||||
|
||||
### compliance_snapshots
|
||||
|
||||
Monthly aggregated snapshot keyed by `(snapshot_month, vertical)`. The trend chart reads exclusively from here. Snapshots are written automatically inside the upload commit transaction.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `snapshot_month` | TEXT | `YYYY-MM` |
|
||||
| `vertical` | TEXT | Vertical code or team name (legacy) |
|
||||
| `total_devices` / `compliant` / `non_compliant` | INTEGER | Counts at month end |
|
||||
| `compliance_pct` | NUMERIC(5,2) | Pre-computed for that month |
|
||||
|
||||
> The `(snapshot_month, vertical)` pair is `UNIQUE`. Re-uploading inside the same calendar month overwrites the row via `ON CONFLICT DO UPDATE`.
|
||||
|
||||
### vcl_multi_vertical_summary
|
||||
|
||||
One row per `(metric_id, team)` pair per upload, populated from the Summary sheet of a multi-vertical xlsx. This is the **source of truth for compliant counts** — `compliance_items` only has non-compliant rows.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `upload_id` | INTEGER | FK → `compliance_uploads` |
|
||||
| `vertical` | TEXT | Vertical code |
|
||||
| `metric_id` | TEXT | Metric identifier |
|
||||
| `team` | TEXT | Either an `ALL: <vertical>` rollup row or a sub-team row (`STEAM`, `ACCESS-ENG`) |
|
||||
| `non_compliant` / `compliant` / `total` | INTEGER | From the Summary sheet |
|
||||
| `compliance_pct` | NUMERIC(5,2) | From the Summary sheet (decimal — `0.95` = 95%) |
|
||||
| `target` | NUMERIC(5,2) | Per-metric target from the spreadsheet |
|
||||
|
||||
> **Critical aggregation rule:** rows where `team LIKE 'ALL:%'` are vertical-level rollups that already include their sub-teams. **Aggregating both rollup and sub-team rows would double-count.** Every cross-vertical query in this codebase filters with `WHERE team LIKE 'ALL:%'`.
|
||||
|
||||
---
|
||||
|
||||
## VCL Report Page (Single-Vertical / Legacy AEO)
|
||||
|
||||
This is the original VCL Report at `/api/compliance/vcl/...`. It aggregates across whatever data exists in `compliance_items` regardless of vertical, and is primarily used for the AEO single-team view.
|
||||
|
||||
Source: `backend/routes/compliance.js` (`router.get('/vcl/stats', ...)` and `router.get('/vcl/trend', ...)`)
|
||||
|
||||
### Stats Bar
|
||||
|
||||
**What it shows:** Total Devices, In-Scope, Compliant, Non-Compliant, Remediations Required, Current %, Target %.
|
||||
|
||||
**What feeds it:** `compliance_items` — every distinct hostname.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(DISTINCT hostname) AS total_devices,
|
||||
COUNT(DISTINCT hostname) AS in_scope,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN hostname NOT IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active')
|
||||
THEN hostname END) AS compliant,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN hostname IN (SELECT DISTINCT hostname FROM compliance_items WHERE status = 'active')
|
||||
THEN hostname END) AS non_compliant
|
||||
FROM compliance_items;
|
||||
```
|
||||
|
||||
Then in JavaScript:
|
||||
|
||||
```javascript
|
||||
compliance_pct = in_scope > 0 ? Math.round((compliant / in_scope) * 100) : 0
|
||||
remediations_required = non_compliant
|
||||
target_pct = process.env.VCL_TARGET_PCT || 95
|
||||
```
|
||||
|
||||
**Field-by-field:**
|
||||
|
||||
| Field | Definition |
|
||||
|---|---|
|
||||
| Total Devices | Count of unique hostnames that have ever appeared in any upload |
|
||||
| In-Scope | Same as Total Devices — every tracked device is in-scope by definition |
|
||||
| Compliant | Hostnames with **zero** rows where `status = 'active'` |
|
||||
| Non-Compliant | Hostnames with **at least one** row where `status = 'active'` |
|
||||
| Remediations Required | Equals Non-Compliant — every non-compliant device needs at least one fix |
|
||||
| Current % | `ROUND((Compliant / In-Scope) * 100)` — whole-number percent |
|
||||
| Target % | `VCL_TARGET_PCT` env var on the backend, default 95 |
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- If a hostname has both an `active` row in one vertical and a `resolved` row in another, the `IN`/`NOT IN` subqueries above already classify correctly — `active` wins because the `IN` subquery includes any active row.
|
||||
- Compliant devices are inferred. If `compliance_items` is missing rows that the Summary sheet reported (e.g., truncated upload), the count silently undercounts.
|
||||
|
||||
### Donut Chart — Status of Non-Compliant Assets
|
||||
|
||||
**What it shows:** Two slices — Blocked (red) and In-Progress (amber) — with counts and percentages.
|
||||
|
||||
**What feeds it:** `compliance_items` rows where `status = 'active'`, deduplicated to one row per hostname using `MAX(resolution_date)`.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
SELECT hostname, MAX(resolution_date) AS resolution_date
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY hostname;
|
||||
```
|
||||
|
||||
Then `categorizeNonCompliant()` partitions:
|
||||
|
||||
```javascript
|
||||
blocked = items.filter(i => i.resolution_date == null)
|
||||
in_progress = items.filter(i => i.resolution_date != null)
|
||||
blocked.pct = Math.round((blocked.count / total) * 100)
|
||||
in_progress.pct = Math.round((in_progress.count / total) * 100)
|
||||
```
|
||||
|
||||
> A device with **any** resolution date set on **any** of its active findings is considered In-Progress. Only when every active finding lacks a date is the device counted as Blocked.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- The `MAX(resolution_date)` clause means a device with one dated finding and one undated finding is classified as In-Progress, not Blocked. This is intentional — once one team commits to a date, the device is no longer fully blocked.
|
||||
- Rounding to whole numbers means `blocked.pct + in_progress.pct` may total 99 or 101 in edge cases. The chart still displays the correct underlying counts.
|
||||
|
||||
### Heavy Hitters Table
|
||||
|
||||
**What it shows:** One row per team, sorted by non-compliant device count descending. Columns: Vertical/Team, Non-Compliant, Compliance Date, Notes.
|
||||
|
||||
**What feeds it:** `compliance_items` deduplicated to one team per hostname, plus `vcl_vertical_metadata` for manual fields (Notes, Compliance Date, RAs).
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
WITH device_team AS (
|
||||
SELECT DISTINCT ON (hostname)
|
||||
hostname,
|
||||
COALESCE(team, 'Unknown') AS team,
|
||||
resolution_date
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
||||
)
|
||||
SELECT team,
|
||||
COUNT(DISTINCT hostname)::int AS non_compliant,
|
||||
MAX(resolution_date) AS compliance_date
|
||||
FROM device_team
|
||||
GROUP BY team
|
||||
ORDER BY COUNT(DISTINCT hostname) DESC;
|
||||
```
|
||||
|
||||
The CTE picks one representative row per hostname using the `(seen_count DESC, upload_id DESC)` rule — the longest-running, most recently seen team assignment wins. This guarantees `SUM(heavy_hitters[*].non_compliant) == stats.non_compliant`.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- Before the fix tracked under spec `compliance-duplicate-failing-metrics`, a hostname that appeared with different `team` values across verticals was double-counted. The CTE above is the fix — confirmed by Property 3 of that spec.
|
||||
- `compliance_date` here is the latest resolution date across the team's devices, used as a default. The team's manually entered Compliance Date in `vcl_vertical_metadata` overrides it when present.
|
||||
|
||||
### Vertical Breakdown Table
|
||||
|
||||
**What it shows:** Same teams as Heavy Hitters, plus per-team Compliance %, Forecast Burndown columns, Blockers count, RAs, Notes.
|
||||
|
||||
**What feeds it:**
|
||||
- Per-team total devices: same `device_team` CTE as Heavy Hitters but without the `status = 'active'` filter.
|
||||
- Forecast: `compliance_items` with non-null `resolution_date`.
|
||||
- Manual fields: `vcl_vertical_metadata`.
|
||||
|
||||
**How it is calculated (per team):**
|
||||
|
||||
```sql
|
||||
-- Total devices for the team
|
||||
WITH device_team AS (
|
||||
SELECT DISTINCT ON (hostname)
|
||||
hostname,
|
||||
COALESCE(team, 'Unknown') AS team
|
||||
FROM compliance_items
|
||||
ORDER BY hostname, seen_count DESC, upload_id DESC
|
||||
)
|
||||
SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1;
|
||||
|
||||
-- Forecast resolution dates for the team
|
||||
SELECT DISTINCT ON (hostname, metric_id) resolution_date
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
AND COALESCE(team, 'Unknown') = $1
|
||||
AND resolution_date IS NOT NULL
|
||||
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC;
|
||||
```
|
||||
|
||||
In JavaScript:
|
||||
|
||||
```javascript
|
||||
team_compliant = team_total - team_non_compliant
|
||||
compliance_pct = team_total > 0 ? Math.round((team_compliant / team_total) * 100) : 0
|
||||
forecast_burndown = computeForecastBurndown(forecastItems) // YYYY-MM → count
|
||||
blockers = Math.max(team_non_compliant - forecastItems.length, 0)
|
||||
```
|
||||
|
||||
`computeForecastBurndown` buckets each device's resolution date into a `YYYY-MM` key. The result is `{ "2026-06": 12, "2026-07": 8, ... }` — the count of devices expected to resolve each month.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- The `DISTINCT ON (hostname, metric_id)` in the forecast query was added by the duplicate-failing-metrics fix. Without it, a device failing the same metric in two verticals would have its resolution date counted twice and `blockers` would go negative (the `Math.max` clamp protects the UI but masks the inconsistency).
|
||||
- The team total uses **all rows** in `compliance_items` (active and resolved), so a team's `total` here is "every device that has ever been part of this team," not just current devices.
|
||||
|
||||
### Compliance Trend Chart
|
||||
|
||||
**What it shows:** Bar chart of compliant device count per month, plus a solid line (actual %) and a dashed line (forecasted %) on a secondary axis. A horizontal reference line marks the target.
|
||||
|
||||
**What feeds it:** `compliance_snapshots`, aggregated across all verticals.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
SELECT snapshot_month,
|
||||
SUM(compliant)::int AS compliant_count,
|
||||
CASE WHEN SUM(total_devices) > 0
|
||||
THEN ROUND((SUM(compliant)::numeric / SUM(total_devices)::numeric) * 100, 1)
|
||||
ELSE 0 END AS compliance_pct
|
||||
FROM compliance_snapshots
|
||||
GROUP BY snapshot_month
|
||||
ORDER BY snapshot_month ASC;
|
||||
```
|
||||
|
||||
The forecast logic is described in [Linear Regression Forecast](#linear-regression-forecast-trend). Snapshots are persisted in `persistUpload()` using the upload's `report_date` month so historical uploads land in the correct bucket. See [Cross-Cutting Correctness Rules](#cross-cutting-correctness-rules) for the snapshot upsert behavior.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- Snapshots are keyed `(snapshot_month, vertical)`, so re-uploading the same month overwrites — only the latest upload's totals are preserved per month.
|
||||
- Pre-fix snapshots from before the duplicate-failing-metrics correction may have `compliant + non_compliant > total_devices` if a hostname had both active and resolved rows across verticals. The fix uses `MIN(status)` inside a CTE so each hostname is classified once. Older snapshots written before the fix should be regenerated by re-running the affected uploads.
|
||||
|
||||
---
|
||||
|
||||
## CCP Metrics Page (Multi-Vertical)
|
||||
|
||||
The CCP Metrics page is the executive cross-vertical view. It uses the multi-vertical Summary sheet data as the source of truth for totals (since `compliance_items` only contains non-compliant devices).
|
||||
|
||||
Source: `backend/routes/vclMultiVertical.js`. Mounted at `/api/compliance/vcl-multi/...`.
|
||||
|
||||
### Aggregated Stats Bar
|
||||
|
||||
**What it shows:** Total Devices, Compliant, Non-Compliant, Current %, Target % across **every** vertical's latest upload.
|
||||
|
||||
**What feeds it:** `vcl_multi_vertical_summary` filtered to ALL: rollup rows of the latest upload per vertical.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
-- 1. Find the latest upload ID per vertical
|
||||
SELECT DISTINCT ON (vertical) id, vertical
|
||||
FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY vertical, id DESC;
|
||||
|
||||
-- 2. Sum totals from rollup rows only (avoids double-counting sub-teams)
|
||||
SELECT vertical,
|
||||
SUM(total)::int AS total_devices,
|
||||
SUM(compliant)::int AS compliant,
|
||||
SUM(non_compliant)::int AS non_compliant
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = ANY($1) AND team LIKE 'ALL:%'
|
||||
GROUP BY vertical;
|
||||
```
|
||||
|
||||
In JavaScript:
|
||||
|
||||
```javascript
|
||||
agg_total = SUM(vertical.total_devices for each vertical)
|
||||
agg_compliant = SUM(vertical.compliant for each vertical)
|
||||
agg_non_compliant = SUM(vertical.non_compliant for each vertical)
|
||||
compliance_pct = agg_total > 0 ? Math.round((agg_compliant / agg_total) * 100) : 0
|
||||
```
|
||||
|
||||
> **The `team LIKE 'ALL:%'` filter is the most important rule on this page.** Each Summary sheet contains one rollup row per metric (`ALL: NTS-AEO`) plus one row per sub-team (`STEAM`, `ACCESS-ENG`). Summing both rollup and sub-team rows would double the totals. Every cross-vertical query enforces this filter.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- If a Summary sheet ever omits the `ALL:` rollup row for a metric, that metric's totals will be missing from the aggregate. The Python parser does not fabricate rollup rows, so this is a function of what the upstream xlsx contains.
|
||||
- Verticals with no rows in `vcl_multi_vertical_summary` (e.g., the legacy AEO data) do not contribute. Their data is visible only on the original VCL Report, not the CCP Metrics page.
|
||||
|
||||
### Donut — Blocked vs In-Progress
|
||||
|
||||
**What it shows:** Same as the legacy donut, scoped to multi-vertical data.
|
||||
|
||||
**What feeds it:** `compliance_items` where `vertical IS NOT NULL`, deduplicated by hostname.
|
||||
|
||||
**How it is calculated:** Identical formula to the [legacy donut](#donut-chart--status-of-non-compliant-assets), but the filter is `WHERE vertical IS NOT NULL AND status = 'active'`.
|
||||
|
||||
```sql
|
||||
SELECT hostname, MAX(resolution_date) AS resolution_date
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL AND status = 'active'
|
||||
GROUP BY hostname;
|
||||
```
|
||||
|
||||
### Trend Chart
|
||||
|
||||
**What it shows:** Cross-vertical monthly trend of compliant device count and compliance percentage with a 3-month forecast.
|
||||
|
||||
**What feeds it:** `compliance_snapshots` where `vertical IS NOT NULL AND vertical != ''`.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
SELECT snapshot_month,
|
||||
SUM(total_devices)::int AS total_devices,
|
||||
SUM(compliant)::int AS compliant,
|
||||
SUM(non_compliant)::int AS non_compliant
|
||||
FROM compliance_snapshots
|
||||
WHERE vertical IS NOT NULL AND vertical != ''
|
||||
GROUP BY snapshot_month
|
||||
ORDER BY snapshot_month ASC;
|
||||
```
|
||||
|
||||
Each row's `compliance_pct` is `ROUND((compliant / total_devices) * 100, 1)` — one decimal place. The forecast then uses the [Linear Regression Forecast](#linear-regression-forecast-trend) logic.
|
||||
|
||||
### Aggregated Burndown Forecast
|
||||
|
||||
**What it shows:** A bar chart of expected device remediations per month across all verticals, plus stat cards for In-Progress, Blockers, and Projected Clear date.
|
||||
|
||||
**What feeds it:** `compliance_items` where `vertical IS NOT NULL AND status = 'active'`.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
SELECT hostname, resolution_date, vertical
|
||||
FROM compliance_items
|
||||
WHERE vertical IS NOT NULL AND status = 'active';
|
||||
```
|
||||
|
||||
Then the rows pass through two pure helpers:
|
||||
|
||||
1. `deduplicateByHostname(rows)` — collapses each hostname to one entry, keeping the **earliest non-null** `resolution_date`. A device that fails three metrics with different planned dates is bucketed by its earliest commitment.
|
||||
|
||||
2. `computeAggregatedBurndown(devices)` — computes:
|
||||
|
||||
```javascript
|
||||
total = devices.length
|
||||
blockers = devices.filter(d => d.resolution_date == null).length
|
||||
with_dates = total - blockers
|
||||
monthly[m] = count of devices whose resolution_date falls in month m // YYYY-MM
|
||||
projection[m] = { remediated: monthly[m], remaining: running_remainder }
|
||||
projected_clear_date = (blockers === 0 && monthly is non-empty)
|
||||
? last_month_in_monthly_keys
|
||||
: null
|
||||
```
|
||||
|
||||
> Projected Clear is **only computed when `blockers === 0`**. Any device without a resolution date prevents the projection from showing — the dashboard is honest about the fact that an open-ended commitment cannot be projected.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- Devices with multiple `compliance_items` rows (one per failing metric) are deduplicated by hostname before bucketing. Without deduplication, a device with three failing metrics and one resolution date would count three times.
|
||||
- A resolution date in the **past** still buckets into its actual month — `computeAggregatedBurndown` does not roll past-due dates forward. The per-metric chart does roll them forward; see [Per-Metric Forecast](#per-metric-forecast-historical--projected) for that distinction.
|
||||
|
||||
### Metric Table (Cross-Vertical)
|
||||
|
||||
**What it shows:** One row per metric, with non-compliant, compliant, total, compliance %, and target % aggregated across **every** vertical's latest upload. Sorted by non-compliant descending.
|
||||
|
||||
**What feeds it:** `vcl_multi_vertical_summary` rollup rows from the latest upload per vertical.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
SELECT metric_id,
|
||||
MAX(metric_desc) AS metric_desc,
|
||||
MAX(category) AS category,
|
||||
SUM(non_compliant)::int AS non_compliant,
|
||||
SUM(compliant)::int AS compliant,
|
||||
SUM(total)::int AS total,
|
||||
ROUND(AVG(target::numeric), 4) AS target
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = ANY($1) AND team LIKE 'ALL:%'
|
||||
GROUP BY metric_id
|
||||
ORDER BY non_compliant DESC;
|
||||
```
|
||||
|
||||
Then for each row:
|
||||
|
||||
```javascript
|
||||
compliance_pct = total > 0 ? compliant / total : 0 // stored as decimal
|
||||
```
|
||||
|
||||
The frontend renders the percentage with one decimal: `(compliance_pct * 100).toFixed(1) + '%'`.
|
||||
|
||||
> **`target` is the arithmetic mean across verticals**, not the worst or best. If two verticals report a target of 0.90 and 0.95 for the same metric, the cross-vertical target is 0.925. This is a deliberate choice — the page shows a fleet-wide composite target, not the strictest individual one.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- `MAX(metric_desc)` and `MAX(category)` rely on every Summary sheet using the same description for the same `metric_id`. If two verticals describe the same metric differently, the alphabetically-last description wins.
|
||||
|
||||
### Metric Detail View — Per-Vertical Breakdown
|
||||
|
||||
**What it shows:** For a selected metric, one row per vertical with that metric's numbers, plus a `sub_teams` array per vertical.
|
||||
|
||||
**What feeds it:** `vcl_multi_vertical_summary` for the selected metric, latest upload per vertical, both rollup and sub-team rows.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
SELECT vertical, metric_desc, category, team,
|
||||
non_compliant, compliant, total, compliance_pct, target
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = ANY($1) AND metric_id = $2
|
||||
ORDER BY vertical, team;
|
||||
```
|
||||
|
||||
The handler then separates rollup rows (`team LIKE 'ALL:%'`) from sub-team rows in JavaScript:
|
||||
|
||||
- The rollup row for each vertical becomes the primary entry.
|
||||
- Each sub-team row is attached to its vertical's `sub_teams` array.
|
||||
- Rows where `team = '(Other)'` are skipped — they are catch-all rows already counted in the rollup.
|
||||
|
||||
`compliance_pct` is read directly from the table (already a decimal — `0.95` = 95%).
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- A sub-team named `(Other)` is used by the spreadsheet for unassignable devices — it is intentionally excluded from the sub-team breakdown to avoid duplication.
|
||||
- The vertical-level `compliance_pct` is what was in the Summary sheet at upload time. It is not recomputed from `compliant / total`. If those numbers ever disagree (Summary rounded differently), the table shows the spreadsheet's number.
|
||||
|
||||
### Per-Metric Forecast Burndown Chart
|
||||
|
||||
**What it shows:** A combined chart with up to 4 historical monthly snapshots (left of the divider) and up to 12 forecast months (right of the divider). Each data point shows total assets, non-compliant count, and compliance %.
|
||||
|
||||
**What feeds it:** Three sources combined:
|
||||
1. `compliance_snapshots` for historical totals (3 months back).
|
||||
2. `vcl_multi_vertical_summary` for the metric's `total` (used as `total_assets`).
|
||||
3. `compliance_items` for current devices and their resolution dates.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
```sql
|
||||
-- Active devices for this metric across every vertical it spans
|
||||
SELECT hostname, resolution_date, vertical
|
||||
FROM compliance_items
|
||||
WHERE metric_id = $1 AND status = 'active' AND vertical IS NOT NULL;
|
||||
|
||||
-- Historical snapshots for those verticals (3 months back)
|
||||
SELECT snapshot_month AS month,
|
||||
SUM(total_devices)::int AS total_assets,
|
||||
SUM(non_compliant)::int AS non_compliant,
|
||||
ROUND((SUM(compliant)::numeric / NULLIF(SUM(total_devices), 0)) * 100, 1) AS compliance_pct
|
||||
FROM compliance_snapshots
|
||||
WHERE vertical = ANY($1) AND snapshot_month >= $2 AND snapshot_month < $3
|
||||
GROUP BY snapshot_month
|
||||
ORDER BY snapshot_month ASC;
|
||||
|
||||
-- The metric's per-metric total assets (from latest summary)
|
||||
SELECT SUM(total)::int AS total
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
||||
AND upload_id IN (
|
||||
SELECT id FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 20
|
||||
);
|
||||
```
|
||||
|
||||
#### Historical Computation — Ratio Method
|
||||
|
||||
The `compliance_snapshots` table stores **vertical-level** totals, not per-metric. To estimate this metric's historical non-compliant count for a past month, the handler uses a ratio:
|
||||
|
||||
```javascript
|
||||
metric_nc_for_month = ROUND(
|
||||
snapshot.non_compliant_for_vertical * (current_metric_nc / current_vertical_total_nc)
|
||||
)
|
||||
```
|
||||
|
||||
In words: "the metric's share of the vertical's current non-compliant load is assumed constant — apply that ratio to historical snapshot non-compliant counts."
|
||||
|
||||
> The ratio method is an approximation. It assumes the metric's contribution to the vertical's non-compliance is steady over the last three months. If the metric load shifts dramatically month-to-month, the historical bars will not match the actual past.
|
||||
|
||||
The current month is **always** computed from live data (not the ratio):
|
||||
|
||||
```javascript
|
||||
current_month_nc = number of distinct active hostnames for this metric
|
||||
current_compliance_pct = ROUND((total_assets - current_month_nc) / total_assets * 1000) / 10 // 1 decimal
|
||||
```
|
||||
|
||||
#### Forecast Computation — Resolution Date Bucketing
|
||||
|
||||
Forward projections are handled by `computeMetricForecastBurndown`. The algorithm:
|
||||
|
||||
1. Partition active devices into `blockers` (no resolution date) and `with_dates`.
|
||||
2. Bucket each dated device by its `resolution_date` month (`YYYY-MM`).
|
||||
3. **Past-due dates roll into the current month.** A device whose date is March when today is May counts as remediating in May, not March.
|
||||
4. Walk forward up to 12 months, decrementing `remaining_non_compliant` by each month's bucket.
|
||||
5. Stop early if `remaining_non_compliant <= blockers` — meaning every device with a date is projected to be done and only blockers remain.
|
||||
|
||||
The compliance percentage at each forecast point is:
|
||||
|
||||
```javascript
|
||||
compliance_pct = ROUND((total_assets - remaining_non_compliant) / total_assets * 1000) / 10
|
||||
```
|
||||
|
||||
#### Correctness Properties of the Forecast
|
||||
|
||||
These properties hold for any input (verified by property-based tests in `backend/__tests__/compliance-duplicate-chart-entries.property.test.js` and the forecast spec):
|
||||
|
||||
1. **Partition invariant:** `blockers + with_dates == non_compliant`.
|
||||
2. **Compliance formula:** `compliance_pct == ROUND((total - nc) / total * 1000) / 10` (or 0 when total is 0).
|
||||
3. **Monotonic non-increasing:** each month's `non_compliant` is less than or equal to the previous month's.
|
||||
4. **Horizon bound:** at most 12 forecast points; terminates early when only blockers remain.
|
||||
5. **Past-due treated as current month:** dates in already-passed months are bucketed into the current month for projection purposes.
|
||||
|
||||
**Why it can drift:**
|
||||
|
||||
- The ratio method for historical data is an estimate. Verify by hand using the actual upload's Summary sheet for that month if precise historical numbers matter.
|
||||
- The fallback `totalAssets = metricNcCount` triggers when no Summary data exists for the metric. This produces a `compliance_pct` of 0 because every "asset" is non-compliant. This is correct for the data we have — the chart cannot show compliance percentages for metrics that have only been observed as failures.
|
||||
|
||||
### Per-Vertical Detail and Burndown
|
||||
|
||||
**What it shows:** Stats and burndown for a single vertical (e.g., NTS_AEO).
|
||||
|
||||
**What feeds it:**
|
||||
- Stats: `vcl_multi_vertical_summary` for that vertical, latest upload, with sub-team breakouts.
|
||||
- Burndown: `compliance_items` for that vertical, deduplicated by hostname.
|
||||
|
||||
**How it is calculated:**
|
||||
|
||||
The vertical-level burndown deduplicates per hostname using the **first non-null** resolution date (any one is enough to mark the device In-Progress):
|
||||
|
||||
```javascript
|
||||
// In the route handler, after fetching compliance_items for the vertical:
|
||||
const deviceMap = {};
|
||||
for (const row of rows) {
|
||||
if (!deviceMap[row.hostname]) {
|
||||
deviceMap[row.hostname] = { hostname: row.hostname, resolution_date: row.resolution_date };
|
||||
} else if (row.resolution_date && !deviceMap[row.hostname].resolution_date) {
|
||||
// Promote a null entry to In-Progress when any other row has a date
|
||||
deviceMap[row.hostname].resolution_date = row.resolution_date;
|
||||
}
|
||||
}
|
||||
const devices = Object.values(deviceMap);
|
||||
const burndown = computeVerticalBurndown(devices);
|
||||
```
|
||||
|
||||
`computeVerticalBurndown` returns the same shape as `computeAggregatedBurndown` but scoped to one vertical.
|
||||
|
||||
---
|
||||
|
||||
## Forecast Algorithms
|
||||
|
||||
The dashboard uses three different forecasting approaches depending on the data being projected.
|
||||
|
||||
### Linear Regression Forecast (Trend)
|
||||
|
||||
**Used by:** Trend chart on the VCL Report and CCP Metrics pages.
|
||||
|
||||
**Inputs:** Monthly compliance percentages from `compliance_snapshots` (one decimal place).
|
||||
|
||||
**Algorithm:** Least-squares linear regression on the time series.
|
||||
|
||||
```javascript
|
||||
// X = month index (0, 1, 2, ...), Y = compliance_pct
|
||||
slope = (n * SUM(X*Y) - SUM(X) * SUM(Y)) / (n * SUM(X^2) - SUM(X)^2)
|
||||
intercept = (SUM(Y) - slope * SUM(X)) / n
|
||||
```
|
||||
|
||||
For each future month `i` (1, 2, 3 — three months out):
|
||||
|
||||
```javascript
|
||||
forecast_pct = ROUND((slope * (n + i - 1) + intercept) * 10) / 10
|
||||
forecast_pct = Math.min(100, Math.max(0, forecast_pct)) // clamp to [0, 100]
|
||||
```
|
||||
|
||||
**Activation:** Forecast appears only when **3 or more** historical months exist. With fewer points, the regression is unreliable, so the dashed line is omitted.
|
||||
|
||||
**Why it works for compliance trends:** Compliance is bounded [0, 100] and changes slowly across months. A linear fit captures the directional trajectory ("we're trending up two points per month") accurately enough to inform planning, though it can over-predict near the boundaries (the clamp prevents impossible values).
|
||||
|
||||
### Resolution-Date Burndown Forecast
|
||||
|
||||
**Used by:** Aggregated burndown, per-vertical burndown, the deprecated team-level forecast in the legacy VCL Report.
|
||||
|
||||
**Inputs:** Active non-compliant devices and their `resolution_date` values.
|
||||
|
||||
**Algorithm:** Simple monthly bucketing — no math beyond grouping and counting.
|
||||
|
||||
```javascript
|
||||
buckets = {} // YYYY-MM → count
|
||||
for each device with a non-null resolution_date:
|
||||
month = first 7 chars of resolution_date // 'YYYY-MM-DD' → 'YYYY-MM'
|
||||
buckets[month] += 1
|
||||
|
||||
remaining = total_with_dates
|
||||
projection = {}
|
||||
for each month (sorted ascending):
|
||||
remaining -= buckets[month]
|
||||
projection[month] = { remediated: buckets[month], remaining }
|
||||
```
|
||||
|
||||
**Projected Clear date:** the last month in `projection` **only if** `blockers === 0`. If any device lacks a date, no projection is shown — there is no honest way to forecast something with no commitment.
|
||||
|
||||
**Why this is preferred over regression for burndown:** Resolution dates are explicit human commitments. Linear regression on past remediation rates would project an average pace that ignores what teams have actually committed to. The bucketing approach reports exactly what has been promised and nothing more.
|
||||
|
||||
### Per-Metric Forecast (Historical + Projected)
|
||||
|
||||
**Used by:** The CCP Metrics per-metric forecast burndown chart.
|
||||
|
||||
**Inputs:** Historical snapshots (vertical-level) + current metric devices (per-metric) + the metric's `total_assets` from the Summary sheet.
|
||||
|
||||
**Algorithm:** Two separate parts joined at the current month.
|
||||
|
||||
**Historical part — Ratio Method:**
|
||||
|
||||
```javascript
|
||||
// For each historical month from compliance_snapshots:
|
||||
metric_share = current_metric_nc / current_vertical_total_nc
|
||||
month.non_compliant = ROUND(snapshot.non_compliant * metric_share)
|
||||
month.compliance_pct = ROUND((total_assets - month.non_compliant) / total_assets * 1000) / 10
|
||||
```
|
||||
|
||||
> The ratio method assumes the metric's share of vertical non-compliance is stable. If a metric was recently introduced or recently fixed at scale, the historical bars will be off. Validate against the source Summary sheet for that month if you need precision.
|
||||
|
||||
**Forecast part — Resolution-Date Bucketing:**
|
||||
|
||||
Identical to [Resolution-Date Burndown Forecast](#resolution-date-burndown-forecast), with one extra rule: **past-due dates roll into the current month**. A device with `resolution_date = '2026-02-15'` when today is May is bucketed into May, not February. Empirically, past-due dates are commitments that slipped — projecting them as remediating "now" reflects reality (the team is overdue and has to act this month) better than leaving them stuck in the past.
|
||||
|
||||
**Termination:** the loop exits as soon as `remaining_non_compliant <= blockers`. Once every dated device is projected to be done, continuing would just show flat blocker count.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Correctness Rules
|
||||
|
||||
These rules apply across every metric on both pages. Violations indicate a real bug — the dashboard's tests verify these properties hold.
|
||||
|
||||
**Rule 1: Rollup-only aggregation across verticals.**
|
||||
Every cross-vertical query that touches `vcl_multi_vertical_summary` filters with `WHERE team LIKE 'ALL:%'`. Aggregating both rollup rows and sub-team rows would double-count. (Validated by the `ccp-metrics-view-restructure` spec, Property 1.)
|
||||
|
||||
**Rule 2: Latest upload per vertical.**
|
||||
Cross-vertical queries select `DISTINCT ON (vertical)` from `compliance_uploads ORDER BY vertical, id DESC` to take only the most recent upload per vertical. Older uploads contribute to `compliance_snapshots` but not to current totals.
|
||||
|
||||
**Rule 3: Snapshot deduplication.**
|
||||
`compliance_snapshots` is keyed `UNIQUE(snapshot_month, vertical)` and updated via `ON CONFLICT DO UPDATE`. Re-uploading the same month for a vertical overwrites the earlier snapshot. Snapshots are upserted using the upload's `report_date` month, not the current calendar month, so a backfilled upload for March lands in `2026-03` even if it is uploaded in May.
|
||||
|
||||
**Rule 4: Status classification on duplicate hostnames.**
|
||||
When a hostname has rows in multiple verticals (one active, one resolved), the snapshot logic uses `MIN(status)` inside a CTE — `'active'` lexicographically wins over `'resolved'`, so the device is classified as non-compliant. This guarantees `compliant + non_compliant <= total_devices` for every snapshot row.
|
||||
|
||||
**Rule 5: Per-(hostname, metric_id) deduplication.**
|
||||
Queries that bucket or count active findings use `DISTINCT ON (hostname, metric_id)` with the canonical `ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC`. A device failing the same metric in two verticals contributes one entry, not two. (Validated by the `compliance-duplicate-failing-metrics` spec, Properties 1–5.)
|
||||
|
||||
**Rule 6: Aggregation by `report_date`, not upload ID.**
|
||||
Trends, top-recurring, and category-trend queries `GROUP BY report_date` rather than `GROUP BY id`. A multi-vertical day produces multiple `compliance_uploads` rows sharing one `report_date`. (Validated by the `compliance-duplicate-chart-entries` spec, Properties 1–3.)
|
||||
|
||||
**Rule 7: Decimal vs whole-number percentages.**
|
||||
Two conventions coexist:
|
||||
|
||||
| Source | Format | Example |
|
||||
|---|---|---|
|
||||
| `vcl_multi_vertical_summary.compliance_pct` | Decimal | `0.95` |
|
||||
| `compliance_snapshots.compliance_pct` | Whole-number | `95.00` |
|
||||
| `/vcl/stats` response `stats.compliance_pct` | Whole-number integer | `95` |
|
||||
| `/vcl-multi/stats` response `stats.compliance_pct` | Whole-number integer | `95` |
|
||||
| `/vcl-multi/metrics` response `compliance_pct` | Decimal | `0.95` |
|
||||
|
||||
The frontend handles both — multiply decimals by 100 and call `toFixed(1)` for the metric-table view, or display the whole number directly for stats bars. Mismatching the formats is a common source of "values look 100x off" bugs.
|
||||
|
||||
---
|
||||
|
||||
## Verifying Values by Hand
|
||||
|
||||
When you suspect a number is wrong, work through this checklist before opening a bug.
|
||||
|
||||
**1. Check whether the value comes from `compliance_items` or `vcl_multi_vertical_summary`.**
|
||||
|
||||
- `compliance_items` only has non-compliant rows. If a "compliant count" is wrong, the bug is in the Summary sheet path, not item counting.
|
||||
- `vcl_multi_vertical_summary` is the source of truth for both compliant and non-compliant totals on the CCP Metrics page.
|
||||
|
||||
**2. Check the upload date.**
|
||||
|
||||
- Cross-vertical numbers use the **latest upload per vertical**. If you uploaded NTS_AEO yesterday but TSI two weeks ago, the aggregate uses today's NTS_AEO and the two-week-old TSI.
|
||||
- The "Last Upload" column in the vertical breakdown shows the `report_date` of each vertical's most recent upload.
|
||||
|
||||
**3. Check the snapshot.**
|
||||
|
||||
- The trend chart reads from `compliance_snapshots`, not from current `compliance_items`. If you fixed a hostname today, it will not appear in the trend until the next upload writes a new snapshot.
|
||||
- Historical months are frozen — only the current month's snapshot updates on re-upload.
|
||||
|
||||
**4. Check for ALL: rollup vs sub-team aggregation.**
|
||||
|
||||
- If a vertical or cross-vertical total looks roughly 2x too high, you are probably summing rollup AND sub-team rows. Confirm with:
|
||||
|
||||
```sql
|
||||
SELECT team, COUNT(*) FROM vcl_multi_vertical_summary
|
||||
WHERE upload_id = <latest> AND metric_id = '<metric>'
|
||||
GROUP BY team;
|
||||
```
|
||||
|
||||
You should see one `ALL: <vertical>` row plus one row per sub-team. Use only the `ALL:` row for cross-vertical totals.
|
||||
|
||||
**5. Check for cross-vertical hostname collisions.**
|
||||
|
||||
- A hostname appearing in two verticals (e.g., it was migrated between teams) needs the deduplication rules in [Cross-Cutting Correctness Rules](#cross-cutting-correctness-rules) to count once. Confirm with:
|
||||
|
||||
```sql
|
||||
SELECT hostname, vertical, team, status, seen_count
|
||||
FROM compliance_items
|
||||
WHERE hostname = '<hostname>'
|
||||
ORDER BY hostname, vertical;
|
||||
```
|
||||
|
||||
If you see two rows with different `team` values, the device is counted under the team from its representative row (highest `seen_count`, then most recent `upload_id`).
|
||||
|
||||
**6. Reconcile against the Summary sheet.**
|
||||
|
||||
- Open the source xlsx for the upload, navigate to the Summary tab, and find the `ALL: <vertical>` row for the metric in question. The `Total`, `Compliant`, and `Non-Compliant` columns should match `vcl_multi_vertical_summary` exactly (the parser does not transform these numbers — it copies them verbatim).
|
||||
|
||||
If after these steps the displayed value still does not match the source data, file an issue with the SQL output from steps 4–5 and the relevant Summary sheet rows attached.
|
||||
|
||||
---
|
||||
|
||||
## Worked Example — Vulns_Aging vs 7.1.1 Forecast Charts
|
||||
|
||||
This example walks the playbook through two side-by-side per-metric forecast burndown charts that look very different despite using the same code path. It is the canonical case study for "the chart looks weird but the data is internally consistent."
|
||||
|
||||
### What the charts show
|
||||
|
||||
**Vulns_Aging — 17,628 devices.** Every bar is full-height, predominantly orange. The compliance line reads `0.1%` for March, jumps to `27.3%` in April, then back to `0.0%` for May. The total bar height is `17628` on every month.
|
||||
|
||||
**7.1.1 — 6,149 non-compliant out of ~66,674 total.** Every bar is mostly blue (compliant) with a thin orange slice at the top. The compliance line stays between `90.8%` and `93.3%`. The total bar height is `66674` on every month.
|
||||
|
||||
Both charts are produced by the same endpoint (`GET /api/compliance/vcl-multi/metric/:metricId/forecast-burndown`) and the same helper (`computeMetricForecastBurndown`). The difference is entirely in the input data.
|
||||
|
||||
### Walking the playbook
|
||||
|
||||
**Step 1 — Where does the value come from?**
|
||||
|
||||
Both charts blend two sources: `compliance_items` for active non-compliant devices, and `vcl_multi_vertical_summary` for the metric's `total_assets`. The historical bars also use `compliance_snapshots` (vertical-level totals) reshaped via the ratio method.
|
||||
|
||||
The differing input here is `total_assets`, sourced from this query:
|
||||
|
||||
```sql
|
||||
SELECT SUM(total)::int AS total
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
||||
AND upload_id IN (
|
||||
SELECT id FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL ORDER BY id DESC LIMIT 20
|
||||
);
|
||||
```
|
||||
|
||||
For `7.1.1` this returns ~66,674. For `Vulns_Aging` it returns 0 or null, and the route handler falls back to `totalAssets = metricNcCount` — the device count from `compliance_items` instead.
|
||||
|
||||
**Step 2 — Check the upload date.**
|
||||
|
||||
Both metrics are read from the latest 20 uploads, so this is not the cause of the difference. Skip.
|
||||
|
||||
**Step 3 — Check the snapshot.**
|
||||
|
||||
`compliance_snapshots` is vertical-level only — it does not store per-metric totals. Both metrics use the same snapshot rows scaled by the ratio method:
|
||||
|
||||
```javascript
|
||||
metricNc = ROUND(snapshot.non_compliant * (currentMetricNc / currentVerticalTotalNc))
|
||||
```
|
||||
|
||||
For `Vulns_Aging` the ratio is large (most of the vertical's non-compliant load is aging vulnerabilities), so historical `metricNc` values are sizeable. April happened to have a snapshot where the vertical's total was smaller — the ratio method produced a metricNc of roughly `17628 * 0.727 ≈ 12810`, leaving `(17628 - 12810) / 17628 * 100 ≈ 27.3%` compliance for that bar.
|
||||
|
||||
That April spike is not a real compliance gain. It is an artifact of the historical snapshot's vertical-level non-compliant count being lower that month, multiplied by the metric's current share.
|
||||
|
||||
**Step 4 — ALL: rollup vs sub-team aggregation.**
|
||||
|
||||
Run the diagnostic query for each metric:
|
||||
|
||||
```sql
|
||||
SELECT team, total, compliant, non_compliant
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = '7.1.1' AND team LIKE 'ALL:%'
|
||||
ORDER BY upload_id DESC LIMIT 5;
|
||||
```
|
||||
|
||||
For `7.1.1` this returns rows like `('ALL: NTS-AEO', 66674, 60525, 6149)` — a clean, complete rollup.
|
||||
|
||||
```sql
|
||||
SELECT team, total, compliant, non_compliant
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = 'Vulns_Aging' AND team LIKE 'ALL:%'
|
||||
ORDER BY upload_id DESC LIMIT 5;
|
||||
```
|
||||
|
||||
For `Vulns_Aging` this returns **zero rows**. The Summary sheet of the source xlsx does not contain `Vulns_Aging` as a tracked metric. The metric exists only as a detail sheet inside the workbook, never as a Summary row.
|
||||
|
||||
**Step 5 — Cross-vertical hostname collisions.**
|
||||
|
||||
Confirm the device counts come from `compliance_items`:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(DISTINCT hostname) FROM compliance_items
|
||||
WHERE metric_id = 'Vulns_Aging' AND status = 'active' AND vertical IS NOT NULL;
|
||||
-- Returns 17628
|
||||
```
|
||||
|
||||
This matches the chart's `total_assets` exactly, which is the smoking gun. The chart is showing 17,628 in scope and 17,628 non-compliant — `compliance_pct = 0%` is mathematically correct for the data the chart received.
|
||||
|
||||
**Step 6 — Reconcile against the Summary sheet.**
|
||||
|
||||
Open any recent xlsx upload for any vertical and inspect the Summary tab. Search for `Vulns_Aging` in the `Metric` column — it is not there. The Summary sheet enumerates the standard metric IDs (`2.3.5`, `5.2.4`, `7.1.1`, etc.) and has no row for the aging vulnerability dashboard.
|
||||
|
||||
Now search for `7.1.1` — it appears with rows for both the rollup (`ALL: NTS-AEO`) and each sub-team. The Summary's `Total`, `Compliant`, and `Non-Compliant` columns match `vcl_multi_vertical_summary` exactly.
|
||||
|
||||
### Why the charts look the way they do
|
||||
|
||||
**Vulns_Aging is a tracked-but-uncategorized detail metric.** The Python parser walks every detail sheet of the xlsx and writes one `compliance_items` row per non-compliant device, using the sheet name as the `metric_id`. `Vulns_Aging` is one such sheet. But because `Vulns_Aging` does not appear in the workbook's Summary sheet, no row is written to `vcl_multi_vertical_summary` — there is no rollup, no compliant count, no total population.
|
||||
|
||||
When the per-metric forecast endpoint asks for the metric's `total`, the query returns null. The handler falls back to `totalAssets = metricNcCount`, which is the count of non-compliant devices. The chart's denominator is therefore identical to its numerator, which forces every "current" compliance percentage to 0%. The April 27.3% spike is the historical ratio method projecting a smaller non-compliant count for that month against the same fallback denominator. May returns to 0% because May is the current month and uses the live device count, which always equals the fallback total by construction.
|
||||
|
||||
The chart is **internally consistent with the data the database has**. It is not internally consistent with what an executive expects "compliance" to mean for an aging dashboard, because the source xlsx never reported a population total for that detail. The fix is upstream — either the xlsx Summary sheet needs to include a `Vulns_Aging` row with a population total, or the handler needs a special case to mark metrics with no Summary data as "not measurable for compliance percentage" and render the chart differently (e.g., counts only, no percentage line).
|
||||
|
||||
**7.1.1 is a fully populated standard metric.** It has a sheet for the failing devices, a `metric_categories` mapping (`Logging & Monitoring`), and a Summary row with `Total`, `Compliant`, and `Non-Compliant` numbers. The route gets a real `totalAssets` value (~66,674), the compliance percentage is `(66674 - 6149) / 66674 ≈ 90.8%`, and the bar visualizes the actual ratio of compliant to non-compliant devices. Historical and forecast bars track real population data, not a fallback.
|
||||
|
||||
### What this tells you about the dashboard
|
||||
|
||||
The forecast burndown chart is honest about what it knows. When the source data lacks a population total, the chart degrades gracefully to "every device in scope is non-compliant" — which is a literal reading of the rows that exist. It does not fabricate a denominator. The cost is that metrics without Summary entries look catastrophically non-compliant on the chart even when the underlying business reality may be different.
|
||||
|
||||
The diagnostic flow above is the canonical procedure: when a chart looks wrong, walk down to the Summary sheet rows and ask whether the metric is even represented as a tracked compliance metric in the source file. If the answer is no, the chart is reflecting an upstream data shape, not a calculation bug.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Worked Example — Vulns_Aging vs 7.1.1 Forecast Charts
|
||||
|
||||
This example walks the playbook through two side-by-side per-metric forecast burndown charts that look very different despite using the same code path. It is the canonical case study for "the chart looks weird but the data is internally consistent."
|
||||
|
||||
### What the charts show
|
||||
|
||||
**Vulns_Aging — 17,628 devices.** Every bar is full-height, predominantly orange. The compliance line reads `0.1%` for March, jumps to `27.3%` in April, then back to `0.0%` for May. The total bar height is `17628` on every month.
|
||||
|
||||
**7.1.1 — 6,149 non-compliant out of ~66,674 total.** Every bar is mostly blue (compliant) with a thin orange slice at the top. The compliance line stays between `90.8%` and `93.3%`. The total bar height is `66674` on every month.
|
||||
|
||||
Both charts are produced by the same endpoint (`GET /api/compliance/vcl-multi/metric/:metricId/forecast-burndown`) and the same helper (`computeMetricForecastBurndown`). The difference is entirely in the input data.
|
||||
|
||||
### Walking the playbook
|
||||
|
||||
**Step 1 — Where does the value come from?**
|
||||
|
||||
Both charts blend two sources: `compliance_items` for active non-compliant devices, and `vcl_multi_vertical_summary` for the metric's `total_assets`. The historical bars also use `compliance_snapshots` (vertical-level totals) reshaped via the ratio method.
|
||||
|
||||
The differing input here is `total_assets`, sourced from this query:
|
||||
|
||||
```sql
|
||||
SELECT SUM(total)::int AS total
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = $1 AND team LIKE 'ALL:%'
|
||||
AND upload_id IN (
|
||||
SELECT id FROM compliance_uploads
|
||||
WHERE vertical IS NOT NULL ORDER BY id DESC LIMIT 20
|
||||
);
|
||||
```
|
||||
|
||||
For `7.1.1` this returns ~66,674. For `Vulns_Aging` it returns 0 or null, and the route handler falls back to `totalAssets = metricNcCount` — the device count from `compliance_items` instead.
|
||||
|
||||
**Step 2 — Check the upload date.**
|
||||
|
||||
Both metrics are read from the latest 20 uploads, so this is not the cause of the difference. Skip.
|
||||
|
||||
**Step 3 — Check the snapshot.**
|
||||
|
||||
`compliance_snapshots` is vertical-level only — it does not store per-metric totals. Both metrics use the same snapshot rows scaled by the ratio method:
|
||||
|
||||
```javascript
|
||||
metricNc = ROUND(snapshot.non_compliant * (currentMetricNc / currentVerticalTotalNc))
|
||||
```
|
||||
|
||||
For `Vulns_Aging` the ratio is large (most of the vertical's non-compliant load is aging vulnerabilities), so historical `metricNc` values are sizeable. April happened to have a snapshot where the vertical's total non-compliant count was lower — the ratio method produced a `metricNc` of roughly `17628 * 0.727 ≈ 12810`, leaving `(17628 - 12810) / 17628 * 100 ≈ 27.3%` compliance for that bar.
|
||||
|
||||
That April spike is not a real compliance gain. It is an artifact of the historical snapshot's vertical-level non-compliant count being lower that month, multiplied by the metric's current share.
|
||||
|
||||
**Step 4 — ALL: rollup vs sub-team aggregation.**
|
||||
|
||||
Run the diagnostic query for each metric:
|
||||
|
||||
```sql
|
||||
SELECT team, total, compliant, non_compliant
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = '7.1.1' AND team LIKE 'ALL:%'
|
||||
ORDER BY upload_id DESC LIMIT 5;
|
||||
```
|
||||
|
||||
For `7.1.1` this returns rows like `('ALL: NTS-AEO', 66674, 60525, 6149)` — a clean, complete rollup.
|
||||
|
||||
```sql
|
||||
SELECT team, total, compliant, non_compliant
|
||||
FROM vcl_multi_vertical_summary
|
||||
WHERE metric_id = 'Vulns_Aging' AND team LIKE 'ALL:%'
|
||||
ORDER BY upload_id DESC LIMIT 5;
|
||||
```
|
||||
|
||||
For `Vulns_Aging` this returns **zero rows**. The Summary sheet of the source xlsx does not contain `Vulns_Aging` as a tracked metric. The metric exists only as a detail sheet inside the workbook, never as a Summary row.
|
||||
|
||||
**Step 5 — Cross-vertical hostname collisions.**
|
||||
|
||||
Confirm the device counts come from `compliance_items`:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(DISTINCT hostname) FROM compliance_items
|
||||
WHERE metric_id = 'Vulns_Aging' AND status = 'active' AND vertical IS NOT NULL;
|
||||
-- Returns 17628
|
||||
```
|
||||
|
||||
This matches the chart's `total_assets` exactly, which is the smoking gun. The chart is showing 17,628 in scope and 17,628 non-compliant — `compliance_pct = 0%` is mathematically correct for the data the chart received.
|
||||
|
||||
**Step 6 — Reconcile against the Summary sheet.**
|
||||
|
||||
Open any recent xlsx upload for any vertical and inspect the Summary tab. Search for `Vulns_Aging` in the `Metric` column — it is not there. The Summary sheet enumerates the standard metric IDs (`2.3.4i`, `5.2.4`, `7.1.1`, etc.) and has no row for the aging vulnerability dashboard.
|
||||
|
||||
Now search for `7.1.1` — it appears with rows for both the rollup (`ALL: NTS-AEO`) and each sub-team. The Summary's `Total`, `Compliant`, and `Non-Compliant` columns match `vcl_multi_vertical_summary` exactly.
|
||||
|
||||
### Why the charts look the way they do
|
||||
|
||||
**Vulns_Aging is a tracked-but-uncategorized detail metric.** The Python parser walks every detail sheet of the xlsx and writes one `compliance_items` row per non-compliant device, using the sheet name as the `metric_id`. `Vulns_Aging` is one such sheet. But because `Vulns_Aging` does not appear in the workbook's Summary sheet, no row is written to `vcl_multi_vertical_summary` — there is no rollup, no compliant count, no total population.
|
||||
|
||||
When the per-metric forecast endpoint asks for the metric's `total`, the query returns null. The handler falls back to `totalAssets = metricNcCount`, which is the count of non-compliant devices. The chart's denominator is therefore identical to its numerator, which forces every "current" compliance percentage to 0%. The April 27.3% spike is the historical ratio method projecting a smaller non-compliant count for that month against the same fallback denominator. May returns to 0% because May is the current month and uses the live device count, which always equals the fallback total by construction.
|
||||
|
||||
The chart is **internally consistent with the data the database has**. It is not internally consistent with what an executive expects "compliance" to mean for an aging dashboard, because the source xlsx never reported a population total for that detail. The fix is upstream — either the xlsx Summary sheet needs to include a `Vulns_Aging` row with a population total, or the handler needs a special case to mark metrics with no Summary data as "not measurable for compliance percentage" and render the chart differently (e.g., counts only, no percentage line).
|
||||
|
||||
**7.1.1 is a fully populated standard metric.** It has a sheet for the failing devices, a `metric_categories` mapping (`Logging & Monitoring`), and a Summary row with `Total`, `Compliant`, and `Non-Compliant` numbers. The route gets a real `totalAssets` value (~66,674), the compliance percentage is `(66674 - 6149) / 66674 ≈ 90.8%`, and the bar visualizes the actual ratio of compliant to non-compliant devices. Historical and forecast bars track real population data, not a fallback.
|
||||
|
||||
### What this tells you about the dashboard
|
||||
|
||||
The forecast burndown chart is honest about what it knows. When the source data lacks a population total, the chart degrades gracefully to "every device in scope is non-compliant" — which is a literal reading of the rows that exist. It does not fabricate a denominator. The cost is that metrics without Summary entries look catastrophically non-compliant on the chart even when the underlying business reality may be different.
|
||||
|
||||
The diagnostic flow above is the canonical procedure: when a chart looks wrong, walk down to the Summary sheet rows and ask whether the metric is even represented as a tracked compliance metric in the source file. If the answer is no, the chart is reflecting an upstream data shape, not a calculation bug.
|
||||
@@ -28,7 +28,10 @@
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": ["warn", { "vars": "all", "args": "after-used", "ignoreRestSiblings": true, "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }]
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -18,12 +18,30 @@ import CCPMetricsPage from './components/pages/CCPMetricsPage';
|
||||
import JiraPage from './components/pages/JiraPage';
|
||||
import AdminPage from './components/pages/AdminPage';
|
||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||
import ArcherPage from './components/pages/ArcherPage';
|
||||
import ArcherTemplatePage from './components/pages/ArcherTemplatePage';
|
||||
import FeedbackModal from './components/FeedbackModal';
|
||||
import NotificationBell from './components/NotificationBell';
|
||||
import './App.css';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// Determine if a Jira status represents a "closed/done" state
|
||||
function isClosedStatus(status) {
|
||||
if (!status) return false;
|
||||
const lower = status.toLowerCase();
|
||||
return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
|
||||
}
|
||||
|
||||
function getTicketStatusColor(status) {
|
||||
if (!status) return '#F59E0B';
|
||||
if (isClosedStatus(status)) return '#10B981';
|
||||
const lower = status.toLowerCase();
|
||||
if (['open', 'to do', 'backlog', 'new'].some(s => lower === s)) return '#F59E0B';
|
||||
// Everything else (in progress, approval, prioritizing, etc.) gets blue/purple
|
||||
return '#0EA5E9';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INLINE STYLES - NUCLEAR OPTION FOR VISIBILITY
|
||||
// ============================================
|
||||
@@ -164,7 +182,6 @@ const getSeverityDotColor = (severity) => {
|
||||
default: return '#0EA5E9';
|
||||
}
|
||||
};
|
||||
const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
||||
|
||||
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
|
||||
|
||||
@@ -183,7 +200,7 @@ export default function App() {
|
||||
const [cveDocuments, setCveDocuments] = useState({});
|
||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||
const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin']);
|
||||
const VALID_PAGES = new Set(['home', 'triage', 'compliance', 'knowledge-base', 'exports', 'jira', 'admin', 'archer-templates']);
|
||||
const [currentPage, setCurrentPageRaw] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('cve-dashboard-page');
|
||||
@@ -1072,7 +1089,7 @@ export default function App() {
|
||||
<div style={{...STYLES.statCard, border: '2px solid #F59E0B', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(245, 158, 11, 0.15), inset 0 1px 0 rgba(245, 158, 11, 0.15)'}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #F59E0B, transparent)', boxShadow: '0 0 8px rgba(245, 158, 11, 0.5)' }}></div>
|
||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Open Tickets</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#F59E0B', textShadow: '0 0 16px rgba(245, 158, 11, 0.4)' }}>{jiraTickets.filter(t => t.status !== 'Closed').length}</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#F59E0B', textShadow: '0 0 16px rgba(245, 158, 11, 0.4)' }}>{jiraTickets.filter(t => !isClosedStatus(t.status)).length}</div>
|
||||
</div>
|
||||
<div style={{...STYLES.statCard, border: '2px solid #EF4444', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(239, 68, 68, 0.15), inset 0 1px 0 rgba(239, 68, 68, 0.15)'}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #EF4444, transparent)', boxShadow: '0 0 8px rgba(239, 68, 68, 0.5)' }}></div>
|
||||
@@ -1089,6 +1106,7 @@ export default function App() {
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
{currentPage === 'jira' && <JiraPage />}
|
||||
{currentPage === 'archer-templates' && <ArcherTemplatePage />}
|
||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
||||
|
||||
@@ -1475,6 +1493,9 @@ export default function App() {
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
{ticketForm.status && !['Open', 'In Progress', 'Closed'].includes(ticketForm.status) && (
|
||||
<option value={ticketForm.status}>{ticketForm.status}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
@@ -1544,6 +1565,9 @@ export default function App() {
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
{ticketForm.status && !['Open', 'In Progress', 'Closed'].includes(ticketForm.status) && (
|
||||
<option value={ticketForm.status}>{ticketForm.status}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
@@ -2025,7 +2049,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`${API_HOST}/${doc.file_path}`}
|
||||
href={`/${doc.file_path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 text-sm text-intel-accent hover:bg-intel-medium rounded transition-all border border-intel-accent/50 font-mono uppercase tracking-wider"
|
||||
@@ -2096,15 +2120,11 @@ export default function App() {
|
||||
</a>
|
||||
{ticket.summary && <span style={{ fontSize: '0.875rem', color: '#E4E8F1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '20rem' }}>{ticket.summary}</span>}
|
||||
<span style={
|
||||
ticket.status === 'Open' ? STYLES.badgeCritical :
|
||||
ticket.status === 'In Progress' ? STYLES.badgeHigh :
|
||||
STYLES.badgeLow
|
||||
isClosedStatus(ticket.status) ? STYLES.badgeLow :
|
||||
getTicketStatusColor(ticket.status) === '#0EA5E9' ? STYLES.badgeHigh :
|
||||
STYLES.badgeCritical
|
||||
}>
|
||||
<span style={STYLES.glowDot(
|
||||
ticket.status === 'Open' ? '#FF3366' :
|
||||
ticket.status === 'In Progress' ? '#FFB800' :
|
||||
'#00FF88'
|
||||
)}></span>
|
||||
<span style={STYLES.glowDot(getTicketStatusColor(ticket.status))}></span>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -2220,12 +2240,12 @@ export default function App() {
|
||||
</div>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#F59E0B', textShadow: '0 0 16px rgba(245, 158, 11, 0.4)' }}>
|
||||
{jiraTickets.filter(t => t.status !== 'Closed').length}
|
||||
{jiraTickets.filter(t => !isClosedStatus(t.status)).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{jiraTickets.filter(t => t.status !== 'Closed').slice(0, 10).map(ticket => (
|
||||
{jiraTickets.filter(t => !isClosedStatus(t.status)).slice(0, 10).map(ticket => (
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(245, 158, 11, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<a
|
||||
@@ -2254,13 +2274,13 @@ export default function App() {
|
||||
{ticket.summary && <div className="text-xs text-gray-300 mt-1 truncate">{ticket.summary}</div>}
|
||||
<div className="mt-2">
|
||||
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem' }}>
|
||||
<span style={{...STYLES.glowDot('#F59E0B'), width: '6px', height: '6px'}}></span>
|
||||
<span style={{...STYLES.glowDot(getTicketStatusColor(ticket.status)), width: '6px', height: '6px'}}></span>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{jiraTickets.filter(t => t.status !== 'Closed').length === 0 && (
|
||||
{jiraTickets.filter(t => !isClosedStatus(t.status)).length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400 italic font-mono">No open tickets</p>
|
||||
@@ -2270,77 +2290,14 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Archer Risk Acceptance Tickets */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
|
||||
<Shield className="w-5 h-5" />
|
||||
Archer Risk Tickets
|
||||
</h2>
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
|
||||
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<a
|
||||
href={ticket.archer_url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{ticket.exc_number}
|
||||
</a>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('triage'); }}
|
||||
title="View findings referencing this ticket"
|
||||
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete(ticket) && (
|
||||
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||
<div className="mt-2">
|
||||
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
|
||||
<span style={{...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px'}}></span>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArcherPage
|
||||
archerTickets={archerTickets}
|
||||
onEditTicket={handleEditArcherTicket}
|
||||
onDeleteTicket={handleDeleteArcherTicket}
|
||||
onFilterByExc={(exc) => { setReportingExcFilter(exc); setCurrentPage('triage'); }}
|
||||
onAddTicket={() => { setAddArcherTicketContext(null); setArcherTicketForm({ exc_number: '', archer_url: '', status: 'Draft', cve_id: '', vendor: '' }); setShowAddArcherTicket(true); }}
|
||||
canDeleteTicket={canDelete}
|
||||
/>
|
||||
|
||||
{/* Ivanti Workflows */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Property-Based Test: Section Header Count Accuracy
|
||||
*
|
||||
* Feature: queue-collapsible-sections, Property 4: Section Header Count Accuracy
|
||||
* **Validates: Requirements 3.1, 3.2**
|
||||
*
|
||||
* For every section in the grouped output, the items array length equals the count
|
||||
* that would be displayed in the header. The sum of all section item counts equals
|
||||
* the total input array length.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Replicate the grouping logic inline (utility file not yet extracted)
|
||||
function groupQueueItems(visibleItems) {
|
||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
||||
const inventoryItems = [];
|
||||
const vendorMap = new Map();
|
||||
for (const item of visibleItems) {
|
||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
||||
inventoryItems.push(item);
|
||||
} else {
|
||||
const vendor = item.vendor?.trim() || 'Unknown';
|
||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
||||
vendorMap.get(vendor).push(item);
|
||||
}
|
||||
}
|
||||
const sections = [];
|
||||
if (inventoryItems.length > 0) {
|
||||
sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems });
|
||||
}
|
||||
const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
for (const [vendor, items] of sortedVendors) {
|
||||
sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Generator for queue items with realistic workflow types and vendor names
|
||||
const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer');
|
||||
const vendorArb = fc.oneof(
|
||||
fc.constantFrom('Microsoft', 'Adobe', 'Cisco', 'Oracle', 'VMware', 'Apple'),
|
||||
fc.constant(''),
|
||||
fc.constant(null),
|
||||
fc.constant(undefined)
|
||||
);
|
||||
|
||||
const queueItemArb = fc.record({
|
||||
id: fc.uuid(),
|
||||
workflow_type: workflowTypeArb,
|
||||
vendor: vendorArb,
|
||||
hostname: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
status: fc.constant('pending'),
|
||||
});
|
||||
|
||||
const queueItemsArb = fc.array(queueItemArb, { minLength: 1, maxLength: 50 });
|
||||
|
||||
describe('Queue Grouping — Property 4: Section Header Count Accuracy', () => {
|
||||
it('each section items.length is a positive integer (the displayed count)', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArb, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
for (const section of sections) {
|
||||
// items.length is the count that would be displayed in the header
|
||||
expect(Number.isInteger(section.items.length)).toBe(true);
|
||||
expect(section.items.length).toBeGreaterThan(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('sum of all section item counts equals total input array length', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArb, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
const totalItemsInSections = sections.reduce(
|
||||
(sum, section) => sum + section.items.length,
|
||||
0
|
||||
);
|
||||
|
||||
expect(totalItemsInSections).toBe(items.length);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Property-Based Test: Empty Section Omission
|
||||
*
|
||||
* Feature: queue-collapsible-sections, Property 3: Empty Section Omission
|
||||
* **Validates: Requirements 1.6, 1.7**
|
||||
*
|
||||
* For any array of queue items (including empty arrays), the grouped output
|
||||
* contains no sections with zero items. If no CARD/GRANITE/DECOM items exist
|
||||
* in the input, no section with type 'inventory' appears in the output.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Inline the grouping logic since the utility file may not exist yet
|
||||
function groupQueueItems(visibleItems) {
|
||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
||||
const inventoryItems = [];
|
||||
const vendorMap = new Map();
|
||||
for (const item of visibleItems) {
|
||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
||||
inventoryItems.push(item);
|
||||
} else {
|
||||
const vendor = item.vendor?.trim() || 'Unknown';
|
||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
||||
vendorMap.get(vendor).push(item);
|
||||
}
|
||||
}
|
||||
const sections = [];
|
||||
if (inventoryItems.length > 0) {
|
||||
sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems });
|
||||
}
|
||||
const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
for (const [vendor, items] of sortedVendors) {
|
||||
sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Generator for queue items with various workflow types and vendors
|
||||
const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer');
|
||||
const vendorArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.constant(undefined),
|
||||
fc.constant(''),
|
||||
fc.constant(' '),
|
||||
fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/)
|
||||
);
|
||||
|
||||
const queueItemArb = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
workflow_type: workflowTypeArb,
|
||||
vendor: vendorArb,
|
||||
hostname: fc.stringMatching(/^[a-z]{3,10}$/),
|
||||
status: fc.constant('pending'),
|
||||
});
|
||||
|
||||
const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 });
|
||||
|
||||
describe('Queue Grouping — Property 3: Empty Section Omission', () => {
|
||||
it('no section in the output has zero items', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArb, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
for (const section of sections) {
|
||||
expect(section.items.length).toBeGreaterThan(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('if no inventory-type items exist, no inventory section appears', () => {
|
||||
// Generate arrays that explicitly exclude inventory types
|
||||
const nonInventoryTypeArb = fc.constantFrom('FP', 'Archer');
|
||||
const nonInventoryItemArb = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
workflow_type: nonInventoryTypeArb,
|
||||
vendor: vendorArb,
|
||||
hostname: fc.stringMatching(/^[a-z]{3,10}$/),
|
||||
status: fc.constant('pending'),
|
||||
});
|
||||
const nonInventoryItemsArb = fc.array(nonInventoryItemArb, { minLength: 0, maxLength: 50 });
|
||||
|
||||
fc.assert(
|
||||
fc.property(nonInventoryItemsArb, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
||||
expect(inventorySection).toBeUndefined();
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('if no CARD/GRANITE/DECOM items exist in a mixed array, no inventory section appears', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArb, (items) => {
|
||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
||||
const hasInventoryItems = items.some(item => INVENTORY_TYPES.has(item.workflow_type));
|
||||
const sections = groupQueueItems(items);
|
||||
const inventorySection = sections.find(s => s.type === 'inventory');
|
||||
|
||||
if (!hasInventoryItems) {
|
||||
expect(inventorySection).toBeUndefined();
|
||||
} else {
|
||||
expect(inventorySection).toBeDefined();
|
||||
expect(inventorySection.items.length).toBeGreaterThan(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Property-Based Test: Section Ordering
|
||||
*
|
||||
* Feature: queue-collapsible-sections, Property 2: Section Ordering
|
||||
* **Validates: Requirements 1.3, 1.4**
|
||||
*
|
||||
* For any array of queue items, the Inventory section (if present) is always
|
||||
* the first element, and all vendor sections are sorted alphabetically by label.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// Inline grouping logic (mirrors the implementation in IvantiTodoQueuePage)
|
||||
function groupQueueItems(visibleItems) {
|
||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
||||
const inventoryItems = [];
|
||||
const vendorMap = new Map();
|
||||
for (const item of visibleItems) {
|
||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
||||
inventoryItems.push(item);
|
||||
} else {
|
||||
const vendor = item.vendor?.trim() || 'Unknown';
|
||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
||||
vendorMap.get(vendor).push(item);
|
||||
}
|
||||
}
|
||||
const sections = [];
|
||||
if (inventoryItems.length > 0) {
|
||||
sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems });
|
||||
}
|
||||
const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
for (const [vendor, items] of sortedVendors) {
|
||||
sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Generator for queue items with random workflow types and vendors
|
||||
const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer');
|
||||
|
||||
const vendorArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.constant(undefined),
|
||||
fc.constant(''),
|
||||
fc.constant(' '),
|
||||
fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/)
|
||||
);
|
||||
|
||||
const queueItemArb = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
workflow_type: workflowTypeArb,
|
||||
vendor: vendorArb,
|
||||
hostname: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
status: fc.constant('pending'),
|
||||
});
|
||||
|
||||
const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 });
|
||||
|
||||
describe('Queue Grouping — Property 2: Section Ordering', () => {
|
||||
it('Inventory section (if present) is always the first element', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArb, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
const inventoryIndex = sections.findIndex(s => s.type === 'inventory');
|
||||
|
||||
// If inventory section exists, it must be at index 0
|
||||
if (inventoryIndex !== -1) {
|
||||
expect(inventoryIndex).toBe(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Vendor sections are sorted alphabetically by label', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArb, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
const vendorSections = sections.filter(s => s.type === 'vendor');
|
||||
|
||||
// Each consecutive pair of vendor sections must be in alphabetical order
|
||||
for (let i = 1; i < vendorSections.length; i++) {
|
||||
const prev = vendorSections[i - 1].label;
|
||||
const curr = vendorSections[i].label;
|
||||
expect(prev.localeCompare(curr)).toBeLessThanOrEqual(0);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 500 }
|
||||
);
|
||||
});
|
||||
});
|
||||
135
frontend/src/__tests__/queue-grouping.property.test.js
Normal file
135
frontend/src/__tests__/queue-grouping.property.test.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Property-Based Test: Grouping Correctness
|
||||
*
|
||||
* Feature: queue-collapsible-sections, Property 1: Grouping Correctness
|
||||
* **Validates: Requirements 1.1, 1.2, 1.5**
|
||||
*
|
||||
* For any array of visible queue items, every item with workflow_type CARD,
|
||||
* GRANITE, or DECOM appears in the Inventory section; every FP/Archer item
|
||||
* appears in the vendor section matching its vendor field (or "Unknown" if
|
||||
* null/empty); no item appears in more than one section; and total items
|
||||
* across all sections equals input length.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { groupQueueItems } from '../utils/queueGrouping';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arbitraries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM'];
|
||||
const VENDOR_TYPES = ['FP', 'Archer'];
|
||||
const ALL_WORKFLOW_TYPES = [...INVENTORY_TYPES, ...VENDOR_TYPES];
|
||||
|
||||
// Generate vendor strings including edge cases: null, undefined, empty, whitespace-only, normal strings
|
||||
const vendorArbitrary = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.constant(undefined),
|
||||
fc.constant(''),
|
||||
fc.constant(' '),
|
||||
fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,19}$/)
|
||||
);
|
||||
|
||||
// Generate a single queue item with a random workflow_type and vendor
|
||||
const queueItemArbitrary = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
workflow_type: fc.constantFrom(...ALL_WORKFLOW_TYPES),
|
||||
vendor: vendorArbitrary,
|
||||
status: fc.constant('pending'),
|
||||
hostname: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
});
|
||||
|
||||
// Generate arrays of queue items (0 to 50 items)
|
||||
const queueItemsArbitrary = fc.array(queueItemArbitrary, { minLength: 0, maxLength: 50 });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Queue Grouping — Property 1: Grouping Correctness', () => {
|
||||
it('every CARD/GRANITE/DECOM item appears in Inventory section', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArbitrary, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
const inventorySection = sections.find(s => s.key === 'inventory');
|
||||
const inventoryItems = inventorySection ? inventorySection.items : [];
|
||||
|
||||
const expectedInventoryItems = items.filter(item =>
|
||||
INVENTORY_TYPES.includes(item.workflow_type)
|
||||
);
|
||||
|
||||
// Every inventory-type item must be in the inventory section
|
||||
for (const item of expectedInventoryItems) {
|
||||
expect(inventoryItems).toContain(item);
|
||||
}
|
||||
|
||||
// Inventory section should contain exactly the inventory-type items
|
||||
expect(inventoryItems.length).toBe(expectedInventoryItems.length);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('every FP/Archer item appears in a vendor section matching its vendor field (or "Unknown")', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArbitrary, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
const vendorSections = sections.filter(s => s.type === 'vendor');
|
||||
const allVendorItems = vendorSections.flatMap(s => s.items);
|
||||
|
||||
const expectedVendorItems = items.filter(item =>
|
||||
VENDOR_TYPES.includes(item.workflow_type)
|
||||
);
|
||||
|
||||
// Every vendor-type item must appear in a vendor section
|
||||
for (const item of expectedVendorItems) {
|
||||
expect(allVendorItems).toContain(item);
|
||||
|
||||
// Determine expected vendor key
|
||||
const expectedVendor = item.vendor?.trim() || 'Unknown';
|
||||
const expectedKey = `vendor:${expectedVendor}`;
|
||||
const matchingSection = sections.find(s => s.key === expectedKey);
|
||||
|
||||
expect(matchingSection).toBeDefined();
|
||||
expect(matchingSection.items).toContain(item);
|
||||
}
|
||||
|
||||
// Vendor sections should contain exactly the vendor-type items
|
||||
expect(allVendorItems.length).toBe(expectedVendorItems.length);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('no item appears in more than one section', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArbitrary, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
|
||||
// Collect all items across all sections
|
||||
const allSectionItems = sections.flatMap(s => s.items);
|
||||
|
||||
// Use a Set to check for duplicates (by reference)
|
||||
const seen = new Set();
|
||||
for (const item of allSectionItems) {
|
||||
expect(seen.has(item)).toBe(false);
|
||||
seen.add(item);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('total items across all sections equals input length', () => {
|
||||
fc.assert(
|
||||
fc.property(queueItemsArbitrary, (items) => {
|
||||
const sections = groupQueueItems(items);
|
||||
const totalInSections = sections.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
expect(totalInSections).toBe(items.length);
|
||||
}),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Property-Based Test: Selection Independence from Collapse State
|
||||
*
|
||||
* Feature: queue-collapsible-sections, Property 5: Selection Independence from Collapse State
|
||||
* **Validates: Requirements 4.2, 4.4, 4.5**
|
||||
*
|
||||
* For any combination of selected items and collapse state, the set of selected
|
||||
* item IDs remains unchanged when sections are collapsed or expanded. Select All
|
||||
* always covers all visible items regardless of which sections are collapsed.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
|
||||
// --- Pure logic under test (mirrors IvantiTodoQueuePage behavior) ---
|
||||
|
||||
/**
|
||||
* Toggle a section's collapse state. Only modifies collapsedSections,
|
||||
* never touches selectedIds.
|
||||
*/
|
||||
function toggleSection(collapsedSections, sectionKey) {
|
||||
return { ...collapsedSections, [sectionKey]: !collapsedSections[sectionKey] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Select All — always selects all visible items regardless of collapse state.
|
||||
* The visibleItems array contains ALL pending items, not just those in expanded sections.
|
||||
*/
|
||||
function selectAll(visibleItems) {
|
||||
return new Set(visibleItems.map(item => item.id));
|
||||
}
|
||||
|
||||
// --- Generators ---
|
||||
|
||||
const WORKFLOW_TYPES = ['CARD', 'GRANITE', 'DECOM', 'FP', 'Archer'];
|
||||
const VENDORS = ['Microsoft', 'Adobe', 'Cisco', 'Unknown', 'Oracle', 'VMware'];
|
||||
|
||||
const queueItemArbitrary = fc.record({
|
||||
id: fc.integer({ min: 1, max: 100000 }),
|
||||
workflow_type: fc.constantFrom(...WORKFLOW_TYPES),
|
||||
vendor: fc.constantFrom(...VENDORS),
|
||||
status: fc.constant('pending'),
|
||||
});
|
||||
|
||||
// Generate arrays of queue items with unique IDs
|
||||
const queueItemsArbitrary = fc
|
||||
.array(queueItemArbitrary, { minLength: 1, maxLength: 50 })
|
||||
.map(items => {
|
||||
// Ensure unique IDs
|
||||
const seen = new Set();
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
.filter(items => items.length > 0);
|
||||
|
||||
// Generate a subset of item IDs as the selected set
|
||||
const selectedIdsArbitrary = (items) =>
|
||||
fc.subarray(items.map(i => i.id), { minLength: 0 }).map(ids => new Set(ids));
|
||||
|
||||
// Generate section keys that would exist for a given set of items
|
||||
const sectionKeysForItems = (items) => {
|
||||
const keys = new Set();
|
||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
||||
let hasInventory = false;
|
||||
for (const item of items) {
|
||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
||||
hasInventory = true;
|
||||
} else {
|
||||
keys.add(`vendor:${item.vendor || 'Unknown'}`);
|
||||
}
|
||||
}
|
||||
if (hasInventory) keys.add('inventory');
|
||||
return [...keys];
|
||||
};
|
||||
|
||||
// Generate arbitrary collapse state for given section keys
|
||||
const collapsedSectionsArbitrary = (sectionKeys) =>
|
||||
fc.record(
|
||||
Object.fromEntries(sectionKeys.map(key => [key, fc.boolean()])),
|
||||
{ withDeletedKeys: true }
|
||||
);
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('Property 5: Selection Independence from Collapse State', () => {
|
||||
it('toggling collapse state does not alter the set of selected item IDs', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
queueItemsArbitrary.chain(items =>
|
||||
fc.tuple(
|
||||
fc.constant(items),
|
||||
selectedIdsArbitrary(items),
|
||||
fc.constant(sectionKeysForItems(items))
|
||||
)
|
||||
).chain(([items, selectedIds, sectionKeys]) =>
|
||||
fc.tuple(
|
||||
fc.constant(items),
|
||||
fc.constant(selectedIds),
|
||||
sectionKeys.length > 0
|
||||
? collapsedSectionsArbitrary(sectionKeys)
|
||||
: fc.constant({}),
|
||||
sectionKeys.length > 0
|
||||
? fc.array(fc.constantFrom(...sectionKeys), { minLength: 1, maxLength: 10 })
|
||||
: fc.constant([])
|
||||
)
|
||||
),
|
||||
([items, selectedIds, initialCollapsed, toggleSequence]) => {
|
||||
// Record the original selected IDs
|
||||
const originalSelectedIds = new Set(selectedIds);
|
||||
|
||||
// Apply a sequence of toggleSection calls
|
||||
let currentCollapsed = { ...initialCollapsed };
|
||||
for (const sectionKey of toggleSequence) {
|
||||
currentCollapsed = toggleSection(currentCollapsed, sectionKey);
|
||||
}
|
||||
|
||||
// The selectedIds set must remain completely unchanged
|
||||
// (toggleSection only modifies collapsedSections, never selectedIds)
|
||||
expect(selectedIds).toEqual(originalSelectedIds);
|
||||
expect(selectedIds.size).toBe(originalSelectedIds.size);
|
||||
for (const id of originalSelectedIds) {
|
||||
expect(selectedIds.has(id)).toBe(true);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('Select All always covers all visible items regardless of collapse state', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
queueItemsArbitrary.chain(items =>
|
||||
fc.tuple(
|
||||
fc.constant(items),
|
||||
fc.constant(sectionKeysForItems(items))
|
||||
)
|
||||
).chain(([items, sectionKeys]) =>
|
||||
fc.tuple(
|
||||
fc.constant(items),
|
||||
sectionKeys.length > 0
|
||||
? collapsedSectionsArbitrary(sectionKeys)
|
||||
: fc.constant({})
|
||||
)
|
||||
),
|
||||
([visibleItems, collapsedSections]) => {
|
||||
// Regardless of which sections are collapsed, selectAll operates
|
||||
// on the full visibleItems array (all pending items)
|
||||
const allSelectedIds = selectAll(visibleItems);
|
||||
|
||||
// Every visible item must be in the selected set
|
||||
for (const item of visibleItems) {
|
||||
expect(allSelectedIds.has(item.id)).toBe(true);
|
||||
}
|
||||
|
||||
// The selected set size must equal the number of visible items
|
||||
expect(allSelectedIds.size).toBe(visibleItems.length);
|
||||
|
||||
// Verify this holds even after toggling all sections to collapsed
|
||||
let fullyCollapsed = { ...collapsedSections };
|
||||
const sectionKeys = Object.keys(collapsedSections);
|
||||
for (const key of sectionKeys) {
|
||||
fullyCollapsed = toggleSection(fullyCollapsed, key);
|
||||
}
|
||||
|
||||
// selectAll still returns all visible items — collapse state is irrelevant
|
||||
const afterCollapseSelectAll = selectAll(visibleItems);
|
||||
expect(afterCollapseSelectAll).toEqual(allSelectedIds);
|
||||
expect(afterCollapseSelectAll.size).toBe(visibleItems.length);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it('selection count always equals total selected items across all sections regardless of collapse', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
queueItemsArbitrary.chain(items =>
|
||||
fc.tuple(
|
||||
fc.constant(items),
|
||||
selectedIdsArbitrary(items),
|
||||
fc.constant(sectionKeysForItems(items))
|
||||
)
|
||||
).chain(([items, selectedIds, sectionKeys]) =>
|
||||
fc.tuple(
|
||||
fc.constant(items),
|
||||
fc.constant(selectedIds),
|
||||
sectionKeys.length > 0
|
||||
? collapsedSectionsArbitrary(sectionKeys)
|
||||
: fc.constant({})
|
||||
)
|
||||
),
|
||||
([_items, selectedIds, collapsedSections]) => {
|
||||
// The selection count (selectedIds.size) is independent of collapse state.
|
||||
// Changing collapse state should never change the count.
|
||||
const countBefore = selectedIds.size;
|
||||
|
||||
// Toggle every section
|
||||
let currentCollapsed = { ...collapsedSections };
|
||||
const sectionKeys = Object.keys(collapsedSections);
|
||||
for (const key of sectionKeys) {
|
||||
currentCollapsed = toggleSection(currentCollapsed, key);
|
||||
// After each toggle, selection count must remain the same
|
||||
expect(selectedIds.size).toBe(countBefore);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
346
frontend/src/components/CardActionModal.js
Normal file
346
frontend/src/components/CardActionModal.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* CardActionModal — CARD Asset Action Modal
|
||||
*
|
||||
* Shows the full CARD owner record for an asset and allows
|
||||
* confirm/decline/redirect operations with full context.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, ArrowRightLeft, XCircle } from 'lucide-react'; // ⚠️ CONVENTION: Removed unused `Shield` import to satisfy no-unused-vars lint rule
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const OVERLAY = {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
const MODAL = {
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)',
|
||||
width: '90vw', maxWidth: '600px', maxHeight: '85vh', overflow: 'auto',
|
||||
padding: '1.5rem', position: 'relative',
|
||||
};
|
||||
const SECTION = {
|
||||
background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)',
|
||||
borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem',
|
||||
};
|
||||
const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' };
|
||||
const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" };
|
||||
const TEAM_BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
background: `${color}15`, border: `1px solid ${color}40`, color,
|
||||
});
|
||||
const INPUT = {
|
||||
width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
};
|
||||
const BTN = {
|
||||
padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none',
|
||||
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s',
|
||||
};
|
||||
|
||||
export default function CardActionModal({ isOpen, onClose, item, initialAction, cardTeams, onSuccess }) {
|
||||
const [ownerData, setOwnerData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [action, setAction] = useState(initialAction || 'confirm');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [fromTeam, setFromTeam] = useState('');
|
||||
const [toTeam, setToTeam] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [execError, setExecError] = useState(null);
|
||||
|
||||
// Fetch owner data when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen || !item?.ip_address) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setOwnerData(null);
|
||||
setExecError(null);
|
||||
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(item.ip_address)}`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
setOwnerData(data);
|
||||
// Pre-fill team fields based on owner data
|
||||
if (data.confirmed) {
|
||||
setTeamName(data.confirmed.name || '');
|
||||
setFromTeam(data.confirmed.name || '');
|
||||
} else if (data.unconfirmed) {
|
||||
setTeamName(data.unconfirmed.name || '');
|
||||
setFromTeam(data.unconfirmed.name || '');
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen, item]);
|
||||
|
||||
// Reset action when initialAction changes
|
||||
useEffect(() => {
|
||||
if (initialAction) setAction(initialAction);
|
||||
}, [initialAction]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
setExecuting(true);
|
||||
setExecError(null);
|
||||
|
||||
try {
|
||||
let url, body;
|
||||
|
||||
if (action === 'confirm') {
|
||||
url = `${API_BASE}/card/queue/${item.id}/confirm`;
|
||||
body = { teamName: teamName.trim(), assetId: item.ip_address, comment: comment.trim() };
|
||||
} else if (action === 'decline') {
|
||||
url = `${API_BASE}/card/queue/${item.id}/decline`;
|
||||
body = { teamName: teamName.trim(), assetId: item.ip_address, comment: comment.trim() };
|
||||
} else if (action === 'redirect') {
|
||||
url = `${API_BASE}/card/queue/${item.id}/redirect`;
|
||||
body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), assetId: item.ip_address };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setExecError(data.error || data.message || `${action} failed.`);
|
||||
setExecuting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setExecuting(false);
|
||||
if (onSuccess) onSuccess(item.id, action);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setExecError(err.message || 'Network error.');
|
||||
setExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canExecute = () => {
|
||||
if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0;
|
||||
if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY} onClick={onClose}>
|
||||
<div style={MODAL} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Action</h3>
|
||||
{ownerData && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.2rem' }}>
|
||||
{ownerData.asset_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ width: '20px', height: '20px', color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading CARD data...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error loading */}
|
||||
{error && (
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(239, 68, 68, 0.4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data display */}
|
||||
{ownerData && (
|
||||
<>
|
||||
{/* Ownership section */}
|
||||
<div style={SECTION}>
|
||||
<div style={LABEL}>Ownership</div>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Confirmed:</span>
|
||||
{ownerData.confirmed ? (
|
||||
<span style={TEAM_BADGE('#10B981')}>{ownerData.confirmed.name}</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.7rem', color: '#475569' }}>—</span>
|
||||
)}
|
||||
{ownerData.confirmed && (
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Unconfirmed:</span>
|
||||
{ownerData.unconfirmed ? (
|
||||
<span style={TEAM_BADGE('#F59E0B')}>{ownerData.unconfirmed.name}</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.7rem', color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
{ownerData.candidate && ownerData.candidate.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Candidates:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.candidate.map((c, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ownerData.declined && ownerData.declined.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Declined:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.declined.map((d, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue item info */}
|
||||
<div style={SECTION}>
|
||||
<div style={LABEL}>Queue Item</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.3rem', marginTop: '0.3rem' }}>
|
||||
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>Hostname: </span><span style={VALUE}>{item.hostname || '—'}</span></div>
|
||||
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>IP: </span><span style={VALUE}>{item.ip_address || '—'}</span></div>
|
||||
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>Finding: </span><span style={VALUE}>{item.finding_id || '—'}</span></div>
|
||||
<div><span style={{ fontSize: '0.6rem', color: '#64748B' }}>CVE: </span><span style={{ ...VALUE, fontSize: '0.65rem' }}>{item.cves_json ? JSON.parse(item.cves_json).slice(0, 2).join(', ') : '—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action section */}
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(124, 58, 237, 0.3)' }}>
|
||||
<div style={LABEL}>Action</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', marginBottom: '0.75rem' }}>
|
||||
{['confirm', 'decline', 'redirect'].map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAction(a)}
|
||||
style={{
|
||||
...BTN,
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: action === a ? (a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)') : 'transparent',
|
||||
border: `1px solid ${action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#334155'}`,
|
||||
color: action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#64748B',
|
||||
}}
|
||||
>
|
||||
{a === 'confirm' && <CheckCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'decline' && <XCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'redirect' && <ArrowRightLeft style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action-specific fields */}
|
||||
{(action === 'confirm' || action === 'decline') && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Team</label>
|
||||
<select style={INPUT} value={teamName} onChange={e => setTeamName(e.target.value)}>
|
||||
<option value="">Select team...</option>
|
||||
{/* Pre-populate with owner teams first */}
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate, score: {c.score})</option>
|
||||
))}
|
||||
<option disabled>───────────</option>
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Comment (optional)</label>
|
||||
<input style={INPUT} value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional comment..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'redirect' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>From Team</label>
|
||||
<select style={INPUT} value={fromTeam} onChange={e => setFromTeam(e.target.value)}>
|
||||
<option value="">Select from team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{cardTeams && cardTeams.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>To Team</label>
|
||||
<select style={INPUT} value={toTeam} onChange={e => setToTeam(e.target.value)}>
|
||||
<option value="">Select to team...</option>
|
||||
{cardTeams && cardTeams.map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution error */}
|
||||
{execError && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>{execError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={onClose} style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}>Cancel</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!canExecute() || executing}
|
||||
style={{
|
||||
...BTN,
|
||||
background: canExecute() && !executing ? '#7C3AED' : '#1E293B',
|
||||
color: canExecute() && !executing ? '#fff' : '#475569',
|
||||
cursor: canExecute() && !executing ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{executing ? 'Executing...' : `Execute ${action.charAt(0).toUpperCase() + action.slice(1)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
376
frontend/src/components/CardDetailModal.js
Normal file
376
frontend/src/components/CardDetailModal.js
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* CardDetailModal — Full CARD ownership detail view
|
||||
*
|
||||
* Opens from the CARD tooltip "Actions" button on the reporting page.
|
||||
* Shows the full ownership record and allows confirm/decline/redirect
|
||||
* directly against the CARD API (no queue item required).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Loader, AlertCircle, CheckCircle, XCircle, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Prefer using REACT_APP_API_BASE without an absolute URL fallback — other components use relative paths via the env var (e.g. '' default) rather than hardcoding http://localhost:3001/api
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const OVERLAY = {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 10200, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
const MODAL = {
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
borderRadius: '1rem', border: '1px solid rgba(124, 58, 237, 0.25)',
|
||||
width: '90vw', maxWidth: '580px', maxHeight: '85vh', overflow: 'auto',
|
||||
padding: '1.5rem', position: 'relative',
|
||||
};
|
||||
const SECTION = {
|
||||
background: 'rgba(15, 23, 42, 0.6)', border: '1px solid rgba(51, 65, 85, 0.5)',
|
||||
borderRadius: '0.5rem', padding: '0.75rem', marginBottom: '0.75rem',
|
||||
};
|
||||
const LABEL = { fontSize: '0.6rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.2rem' };
|
||||
const VALUE = { fontSize: '0.75rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace" };
|
||||
const TEAM_BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
background: `${color}15`, border: `1px solid ${color}40`, color,
|
||||
});
|
||||
const INPUT = {
|
||||
width: '100%', boxSizing: 'border-box', background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(51, 65, 85, 0.6)', borderRadius: '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.5rem 0.75rem', fontSize: '0.75rem',
|
||||
fontFamily: "'JetBrains Mono', monospace", outline: 'none',
|
||||
};
|
||||
const BTN = {
|
||||
padding: '0.5rem 1.25rem', borderRadius: '0.375rem', border: 'none',
|
||||
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', transition: 'all 0.12s',
|
||||
};
|
||||
|
||||
export default function CardDetailModal({ isOpen, onClose, ip, ownerData: initialOwnerData, cardTeams }) {
|
||||
const [ownerData, setOwnerData] = useState(initialOwnerData || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [action, setAction] = useState('confirm');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const [fromTeam, setFromTeam] = useState('');
|
||||
const [toTeam, setToTeam] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [execError, setExecError] = useState(null);
|
||||
const [execSuccess, setExecSuccess] = useState(null);
|
||||
|
||||
// Fetch owner data if not provided or refresh on open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !ip) return;
|
||||
|
||||
// If we already have data from the tooltip cache, use it
|
||||
if (initialOwnerData && !initialOwnerData.notFound && !initialOwnerData.error) {
|
||||
setOwnerData(initialOwnerData);
|
||||
// Pre-fill team fields
|
||||
if (initialOwnerData.confirmed) {
|
||||
setTeamName(initialOwnerData.confirmed.name || '');
|
||||
setFromTeam(initialOwnerData.confirmed.name || '');
|
||||
} else if (initialOwnerData.unconfirmed) {
|
||||
setTeamName(initialOwnerData.unconfirmed.name || '');
|
||||
setFromTeam(initialOwnerData.unconfirmed.name || '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fresh
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}`, { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) return r.json().then(d => { throw new Error(d.error || `HTTP ${r.status}`); });
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
setOwnerData(data);
|
||||
if (data.confirmed) {
|
||||
setTeamName(data.confirmed.name || '');
|
||||
setFromTeam(data.confirmed.name || '');
|
||||
} else if (data.unconfirmed) {
|
||||
setTeamName(data.unconfirmed.name || '');
|
||||
setFromTeam(data.unconfirmed.name || '');
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [isOpen, ip, initialOwnerData]);
|
||||
|
||||
// Reset state on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
setComment('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!ownerData?.asset_id) return;
|
||||
setExecuting(true);
|
||||
setExecError(null);
|
||||
setExecSuccess(null);
|
||||
|
||||
try {
|
||||
let url, body;
|
||||
const assetId = ownerData.asset_id;
|
||||
|
||||
if (action === 'confirm') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/confirm`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'decline') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/decline`;
|
||||
body = { teamName: teamName.trim(), comment: comment.trim() };
|
||||
} else if (action === 'redirect') {
|
||||
url = `${API_BASE}/card/owner/${encodeURIComponent(assetId)}/redirect`;
|
||||
body = { fromTeam: fromTeam.trim(), toTeam: toTeam.trim() };
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setExecError(data.error || data.message || `${action} failed.`);
|
||||
} else {
|
||||
setExecSuccess(`${action.charAt(0).toUpperCase() + action.slice(1)} successful.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setExecError(err.message || 'Network error.');
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [ownerData, action, teamName, fromTeam, toTeam, comment]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canExecute = () => {
|
||||
if (action === 'confirm' || action === 'decline') return teamName.trim().length > 0;
|
||||
if (action === 'redirect') return fromTeam.trim().length > 0 && toTeam.trim().length > 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={OVERLAY} onClick={onClose}>
|
||||
<div style={MODAL} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '0.95rem' }}>CARD Asset Details</h3>
|
||||
<div style={{ fontSize: '0.72rem', color: '#0EA5E9', fontFamily: "'JetBrains Mono', monospace", marginTop: '0.2rem' }}>
|
||||
{ip}
|
||||
</div>
|
||||
{ownerData?.asset_id && (
|
||||
<div style={{ fontSize: '0.65rem', color: '#7C3AED', fontFamily: 'monospace', marginTop: '0.1rem' }}>
|
||||
{ownerData.asset_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Loader style={{ width: '20px', height: '20px', color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B', marginTop: '0.5rem' }}>Loading CARD data...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(239, 68, 68, 0.4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{ownerData && !loading && (
|
||||
<>
|
||||
{/* Ownership section */}
|
||||
<div style={SECTION}>
|
||||
<div style={LABEL}>Ownership</div>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', marginTop: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Confirmed:</span>
|
||||
{ownerData.confirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#10B981')}>{ownerData.confirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.confirmed.score}, {ownerData.confirmed.datasource || 'n/a'})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px' }}>Unconfirmed:</span>
|
||||
{ownerData.unconfirmed ? (
|
||||
<>
|
||||
<span style={TEAM_BADGE('#F59E0B')}>{ownerData.unconfirmed.name}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>
|
||||
(score: {ownerData.unconfirmed.score})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ ...VALUE, color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
{ownerData.candidate && ownerData.candidate.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Candidates:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.candidate.map((c, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ownerData.declined && ownerData.declined.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B', width: '80px', flexShrink: 0 }}>Declined:</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
{ownerData.declined.map((d, i) => (
|
||||
<span key={i} style={TEAM_BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action section */}
|
||||
<div style={{ ...SECTION, borderColor: 'rgba(124, 58, 237, 0.3)' }}>
|
||||
<div style={LABEL}>Action</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', marginBottom: '0.75rem' }}>
|
||||
{['confirm', 'decline', 'redirect'].map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => setAction(a)}
|
||||
style={{
|
||||
...BTN,
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: action === a ? (a === 'confirm' ? 'rgba(16,185,129,0.15)' : a === 'decline' ? 'rgba(239,68,68,0.15)' : 'rgba(14,165,233,0.15)') : 'transparent',
|
||||
border: `1px solid ${action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#334155'}`,
|
||||
color: action === a ? (a === 'confirm' ? '#10B981' : a === 'decline' ? '#EF4444' : '#0EA5E9') : '#64748B',
|
||||
}}
|
||||
>
|
||||
{a === 'confirm' && <CheckCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'decline' && <XCircle style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a === 'redirect' && <ArrowRightLeft style={{ width: '12px', height: '12px', marginRight: '0.3rem', display: 'inline' }} />}
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action-specific fields */}
|
||||
{(action === 'confirm' || action === 'decline') && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Team</label>
|
||||
<select style={INPUT} value={teamName} onChange={e => setTeamName(e.target.value)}>
|
||||
<option value="">Select team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate, score: {c.score})</option>
|
||||
))}
|
||||
<option disabled>───────────</option>
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>Comment (optional)</label>
|
||||
<input style={INPUT} value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional comment..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'redirect' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>From Team</label>
|
||||
<select style={INPUT} value={fromTeam} onChange={e => setFromTeam(e.target.value)}>
|
||||
<option value="">Select from team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ ...LABEL, display: 'block' }}>To Team</label>
|
||||
<select style={INPUT} value={toTeam} onChange={e => setToTeam(e.target.value)}>
|
||||
<option value="">Select to team...</option>
|
||||
{ownerData.confirmed && <option value={ownerData.confirmed.name}>{ownerData.confirmed.name} (confirmed)</option>}
|
||||
{ownerData.unconfirmed && <option value={ownerData.unconfirmed.name}>{ownerData.unconfirmed.name} (unconfirmed)</option>}
|
||||
{ownerData.candidate && ownerData.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map(c => (
|
||||
<option key={c.name} value={c.name}>{c.name} (candidate)</option>
|
||||
))}
|
||||
{cardTeams && cardTeams.filter(t => t !== ownerData.confirmed?.name && t !== ownerData.unconfirmed?.name).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution error */}
|
||||
{execError && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<AlertCircle style={{ width: '13px', height: '13px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#FCA5A5' }}>{execError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{execSuccess && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0.75rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '0.375rem', marginBottom: '0.75rem' }}>
|
||||
<CheckCircle style={{ width: '13px', height: '13px', color: '#10B981', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.7rem', color: '#6EE7B7' }}>{execSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button onClick={onClose} style={{ ...BTN, background: '#334155', color: '#E2E8F0' }}>Close</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!canExecute() || executing || !!execSuccess}
|
||||
style={{
|
||||
...BTN,
|
||||
background: canExecute() && !executing && !execSuccess ? '#7C3AED' : '#1E293B',
|
||||
color: canExecute() && !executing && !execSuccess ? '#fff' : '#475569',
|
||||
cursor: canExecute() && !executing && !execSuccess ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{executing ? 'Executing...' : `Execute ${action.charAt(0).toUpperCase() + action.slice(1)}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
frontend/src/components/CardOwnerTooltip.js
Normal file
333
frontend/src/components/CardOwnerTooltip.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* CardOwnerTooltip — CARD ownership hover tooltip
|
||||
*
|
||||
* Shows CARD asset ownership data (confirmed/unconfirmed/candidate teams)
|
||||
* when hovering over an IP address in the findings table.
|
||||
* Interactive — stays open when you hover into it, includes an Actions button.
|
||||
* Follows the same portal + positioning pattern as CveTooltip.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Loader, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
|
||||
// ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only (no absolute URL fallback).
|
||||
// Other components use: const API_BASE = process.env.REACT_APP_API_BASE || '/api';
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const TOOLTIP_GAP = 8;
|
||||
const ARROW_SIZE = 6;
|
||||
const BORDER_COLOR = '#7C3AED'; // purple to match CARD branding
|
||||
|
||||
function calcPosition(anchorRect, tooltipHeight, viewportHeight) {
|
||||
const spaceAbove = anchorRect.top;
|
||||
const spaceBelow = viewportHeight - anchorRect.bottom;
|
||||
const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE;
|
||||
|
||||
const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow;
|
||||
|
||||
let top;
|
||||
if (placeAbove) {
|
||||
top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE;
|
||||
if (top < 0) top = 0;
|
||||
} else {
|
||||
top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE;
|
||||
if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight;
|
||||
}
|
||||
|
||||
const left = anchorRect.left + anchorRect.width / 2;
|
||||
|
||||
return { top, left, placeAbove };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function CardOwnerTooltip({ ip, anchorRect, cache, cardConfigured, onAction, onMouseEnter, onMouseLeave }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardConfigured) {
|
||||
setError('CARD not configured');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (cache.current.has(ip)) {
|
||||
const cached = cache.current.get(ip);
|
||||
if (cached.error) {
|
||||
setError(cached.error);
|
||||
setData(null);
|
||||
} else {
|
||||
setData(cached);
|
||||
setError(null);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
setError(null);
|
||||
|
||||
fetch(`${API_BASE}/card/owner-lookup/${encodeURIComponent(ip)}?quick=1`, {
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 404) {
|
||||
const result = { notFound: true };
|
||||
cache.current.set(ip, result);
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 504) {
|
||||
// Timeout — don't cache, can be retried
|
||||
setError('CARD lookup timed out — try again');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 502) {
|
||||
// CARD unreachable — don't cache
|
||||
setError('CARD unavailable');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) return res.json().then(d => { throw new Error(d.error || `HTTP ${res.status}`); });
|
||||
return res.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload) return; // 404 already handled
|
||||
cache.current.set(ip, payload);
|
||||
setData(payload);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
cache.current.set(ip, { error: err.message });
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [ip, cache, cardConfigured]);
|
||||
|
||||
if (!ip || !anchorRect) return null;
|
||||
if (!loading && !data && !error) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<TooltipBody
|
||||
data={data}
|
||||
loading={loading}
|
||||
error={error}
|
||||
anchorRect={anchorRect}
|
||||
ip={ip}
|
||||
onAction={onAction}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TooltipBody — inner component for measurement + rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
function TooltipBody({ data, loading, error, anchorRect, ip, onAction, onMouseEnter, onMouseLeave }) {
|
||||
const tooltipRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, placeAbove: true });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !anchorRect) return;
|
||||
const rect = tooltipRef.current.getBoundingClientRect();
|
||||
const vp = window.innerHeight;
|
||||
setPos(calcPosition(anchorRect, rect.height, vp));
|
||||
}, [anchorRect, data, loading, error]);
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
if (onAction && ip) {
|
||||
onAction(ip, data);
|
||||
}
|
||||
}, [onAction, ip, data]);
|
||||
|
||||
const tooltipStyle = {
|
||||
position: 'fixed',
|
||||
zIndex: 99999,
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: 340,
|
||||
minWidth: 220,
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
|
||||
border: `1.5px solid ${BORDER_COLOR}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${BORDER_COLOR}33`,
|
||||
pointerEvents: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
};
|
||||
|
||||
const arrowStyle = {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `${ARROW_SIZE}px solid transparent`,
|
||||
borderRight: `${ARROW_SIZE}px solid transparent`,
|
||||
...(pos.placeAbove
|
||||
? { bottom: -ARROW_SIZE, borderTop: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderBottom: 'none' }
|
||||
: { top: -ARROW_SIZE, borderBottom: `${ARROW_SIZE}px solid ${BORDER_COLOR}`, borderTop: 'none' }),
|
||||
};
|
||||
|
||||
const LABEL = { fontSize: '0.58rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.15rem' };
|
||||
const BADGE = (color) => ({
|
||||
display: 'inline-block', padding: '0.12rem 0.45rem', borderRadius: '0.2rem',
|
||||
fontSize: '0.68rem', fontWeight: '600', fontFamily: "'JetBrains Mono', monospace",
|
||||
background: `${color}18`, border: `1px solid ${color}50`, color,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={tooltipRef} style={tooltipStyle} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
<div style={arrowStyle} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.6rem', color: '#7C3AED', fontFamily: 'monospace', fontWeight: '700', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
CARD
|
||||
</span>
|
||||
<span style={{ fontSize: '0.72rem', color: '#E2E8F0', fontFamily: "'JetBrains Mono', monospace", fontWeight: '600' }}>
|
||||
{ip}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem 0' }}>
|
||||
<Loader style={{ width: 16, height: 16, color: '#7C3AED', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<AlertCircle style={{ width: 12, height: 12, color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#FCA5A5' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not found */}
|
||||
{data && data.notFound && !loading && (
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', fontFamily: 'monospace' }}>
|
||||
Not found in CARD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner data */}
|
||||
{data && !data.notFound && !data.error && !loading && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{/* Asset ID */}
|
||||
{data.asset_id && (
|
||||
<div>
|
||||
<div style={LABEL}>Asset ID</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#A78BFA', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{data.asset_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmed */}
|
||||
<div>
|
||||
<div style={LABEL}>Confirmed Owner</div>
|
||||
{data.confirmed ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#10B981')}>{data.confirmed.name}</span>
|
||||
{data.confirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.confirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569' }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unconfirmed */}
|
||||
{data.unconfirmed && (
|
||||
<div>
|
||||
<div style={LABEL}>Unconfirmed</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span style={BADGE('#F59E0B')}>{data.unconfirmed.name}</span>
|
||||
{data.unconfirmed.score != null && (
|
||||
<span style={{ fontSize: '0.58rem', color: '#64748B' }}>score: {data.unconfirmed.score}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candidates */}
|
||||
{data.candidate && data.candidate.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Candidates</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.candidate.filter(c => c.name !== 'CARD-UNKNOWN').map((c, i) => (
|
||||
<span key={i} style={BADGE('#94A3B8')}>{c.name} ({c.score})</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Declined */}
|
||||
{data.declined && data.declined.length > 0 && (
|
||||
<div>
|
||||
<div style={LABEL}>Declined</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{data.declined.map((d, i) => (
|
||||
<span key={i} style={BADGE('#EF4444')}>{d.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions button */}
|
||||
{onAction && (
|
||||
<div style={{ marginTop: '0.4rem', paddingTop: '0.4rem', borderTop: '1px solid rgba(124, 58, 237, 0.2)' }}>
|
||||
<button
|
||||
onClick={handleAction}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.35rem',
|
||||
padding: '0.3rem 0.65rem',
|
||||
background: 'rgba(124, 58, 237, 0.12)',
|
||||
border: '1px solid rgba(124, 58, 237, 0.4)',
|
||||
borderRadius: '0.3rem',
|
||||
color: '#A78BFA',
|
||||
fontSize: '0.65rem', fontWeight: '600', fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.25)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.6)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(124, 58, 237, 0.12)'; e.currentTarget.style.borderColor = 'rgba(124, 58, 237, 0.4)'; }}
|
||||
>
|
||||
<ExternalLink style={{ width: 11, height: 11 }} />
|
||||
Actions
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
577
frontend/src/components/ConsolidationModal.js
Normal file
577
frontend/src/components/ConsolidationModal.js
Normal file
@@ -0,0 +1,577 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, AlertCircle, Loader, FileText } from 'lucide-react';
|
||||
import {
|
||||
generateConsolidatedSummary,
|
||||
generateConsolidatedDescription,
|
||||
extractFirstCve,
|
||||
extractCommonVendor,
|
||||
} from '../utils/jiraConsolidation';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — dark theme, monospace fonts, #0EA5E9 accent, gradient backgrounds
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
},
|
||||
modal: {
|
||||
position: 'relative',
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98) 0%, rgba(15, 23, 42, 0.99) 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '16px',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '640px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(14,165,233,0.08)',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem',
|
||||
},
|
||||
title: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
color: '#0EA5E9',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.72rem',
|
||||
color: '#94A3B8',
|
||||
marginTop: '0.25rem',
|
||||
},
|
||||
closeBtn: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#64748B',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
section: {
|
||||
marginBottom: '1.25rem',
|
||||
},
|
||||
label: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 600,
|
||||
color: '#7DD3FC',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.4rem',
|
||||
display: 'block',
|
||||
},
|
||||
input: {
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.82rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
width: '100%',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.2s',
|
||||
},
|
||||
inputError: {
|
||||
borderColor: 'rgba(239, 68, 68, 0.6)',
|
||||
},
|
||||
textarea: {
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.2s',
|
||||
},
|
||||
readOnlyBadge: {
|
||||
display: 'inline-block',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: '#0EA5E9',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: '4px',
|
||||
padding: '0.25rem 0.6rem',
|
||||
},
|
||||
previewList: {
|
||||
maxHeight: '160px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
},
|
||||
previewItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.4rem 0.6rem',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.04)',
|
||||
},
|
||||
previewItemText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
previewTitle: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.72rem',
|
||||
color: '#CBD5E1',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
previewHost: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.62rem',
|
||||
color: '#64748B',
|
||||
marginTop: '1px',
|
||||
},
|
||||
removeBtn: {
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '4px',
|
||||
color: '#EF4444',
|
||||
cursor: 'pointer',
|
||||
padding: '0.15rem 0.3rem',
|
||||
marginLeft: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
errorMsg: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
color: '#EF4444',
|
||||
marginTop: '0.3rem',
|
||||
},
|
||||
warningMsg: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
color: '#F59E0B',
|
||||
background: 'rgba(245, 158, 11, 0.08)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
apiError: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.72rem',
|
||||
color: '#EF4444',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
borderRadius: '6px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
marginTop: '1.5rem',
|
||||
},
|
||||
cancelBtn: {
|
||||
flex: 1,
|
||||
padding: '0.625rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100, 116, 139, 0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
submitBtn: {
|
||||
flex: 1.5,
|
||||
padding: '0.625rem',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid #0EA5E9',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#0EA5E9',
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
charCount: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.62rem',
|
||||
color: '#64748B',
|
||||
textAlign: 'right',
|
||||
marginTop: '0.2rem',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ConsolidationModal — Creates a single Jira ticket from multiple selected
|
||||
* Ivanti queue items. Pre-populates summary, description, CVE, and vendor
|
||||
* using aggregation functions.
|
||||
*
|
||||
* Props:
|
||||
* items {Array} — The selected queue items (full objects)
|
||||
* onClose {Function} — Close handler
|
||||
* onSuccess {Function} — Called with created ticket data on success
|
||||
*/
|
||||
export default function ConsolidationModal({ items, onClose, onSuccess }) {
|
||||
// Internal state — copy of items that can be modified (items removed)
|
||||
const [selectedItems, setSelectedItems] = useState(items);
|
||||
|
||||
// Form fields
|
||||
const [summary, setSummary] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [cveId, setCveId] = useState('');
|
||||
const [vendor, setVendor] = useState('');
|
||||
|
||||
// Locked source context
|
||||
const sourceContext = 'ivanti_queue';
|
||||
|
||||
// UI state
|
||||
const [summaryError, setSummaryError] = useState(null);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize form fields on mount from the original items prop.
|
||||
// Uses items (prop) not selectedItems (state) to avoid re-triggering on removal.
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (items.length >= 2) {
|
||||
setSummary(generateConsolidatedSummary(items));
|
||||
setDescription(generateConsolidatedDescription(items));
|
||||
setCveId(extractFirstCve(items));
|
||||
setVendor(extractCommonVendor(items));
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remove an item from the selection (minimum 2 required)
|
||||
// ---------------------------------------------------------------------------
|
||||
const removeItem = useCallback((itemId) => {
|
||||
setSelectedItems((prev) => prev.filter((i) => i.id !== itemId));
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Escape key handler
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form validation
|
||||
// ---------------------------------------------------------------------------
|
||||
const canSubmit = selectedItems.length >= 2 && summary.trim().length > 0 && !submitting;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit handler
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = async () => {
|
||||
// Validate summary
|
||||
if (!summary.trim()) {
|
||||
setSummaryError('Summary is required.');
|
||||
return;
|
||||
}
|
||||
if (summary.length > 255) {
|
||||
setSummaryError('Summary must be 255 characters or fewer.');
|
||||
return;
|
||||
}
|
||||
setSummaryError(null);
|
||||
setApiError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// Step 1: Create the Jira ticket
|
||||
const createPayload = {
|
||||
summary: summary.trim(),
|
||||
description,
|
||||
cve_id: cveId.trim() || null,
|
||||
vendor: vendor.trim() || null,
|
||||
source_context: sourceContext,
|
||||
};
|
||||
|
||||
const createRes = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(createPayload),
|
||||
});
|
||||
|
||||
const createData = await createRes.json();
|
||||
|
||||
if (!createRes.ok && createRes.status !== 207) {
|
||||
throw new Error(createData.error || `Failed to create Jira ticket (HTTP ${createRes.status})`);
|
||||
}
|
||||
|
||||
const ticketId = createData.id;
|
||||
|
||||
// Step 2: Link queue items to the ticket via junction endpoint
|
||||
const linkRes = await fetch(`${API_BASE}/jira-tickets/${ticketId}/queue-items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ queue_item_ids: selectedItems.map((i) => i.id) }),
|
||||
});
|
||||
|
||||
if (!linkRes.ok) {
|
||||
const linkData = await linkRes.json();
|
||||
// Ticket was created but linking failed — partial success
|
||||
console.warn('Junction link failed:', linkData.error || linkRes.status);
|
||||
}
|
||||
|
||||
// Success — close modal and notify parent
|
||||
onSuccess?.(createData);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="consolidation-modal-title"
|
||||
style={STYLES.overlay}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
|
||||
>
|
||||
<div style={STYLES.modal}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.header}>
|
||||
<div>
|
||||
<div id="consolidation-modal-title" style={STYLES.title}>
|
||||
<FileText style={{ width: '16px', height: '16px', display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
|
||||
Create Consolidated Jira Ticket
|
||||
</div>
|
||||
<div style={STYLES.subtitle}>
|
||||
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected for consolidation
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={STYLES.closeBtn}
|
||||
title="Close"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Minimum items warning */}
|
||||
{selectedItems.length < 2 && (
|
||||
<div style={STYLES.warningMsg}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
|
||||
At least 2 items are required for consolidation. Add more items or close this modal.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API error */}
|
||||
{apiError && (
|
||||
<div style={STYLES.apiError}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected items preview list */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label}>Selected Items</label>
|
||||
<div style={STYLES.previewList}>
|
||||
{selectedItems.map((item) => (
|
||||
<div key={item.id} style={STYLES.previewItem}>
|
||||
<div style={STYLES.previewItemText}>
|
||||
<div style={STYLES.previewTitle} title={item.finding_title}>
|
||||
{item.finding_title || item.finding_id || 'Untitled'}
|
||||
</div>
|
||||
<div style={STYLES.previewHost}>
|
||||
{item.hostname || 'No hostname'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
style={STYLES.removeBtn}
|
||||
title="Remove from selection"
|
||||
aria-label={`Remove ${item.finding_title || 'item'}`}
|
||||
disabled={selectedItems.length <= 2}
|
||||
>
|
||||
<X style={{ width: '12px', height: '12px' }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary field (required, max 255 chars) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-summary">
|
||||
Summary <span style={{ color: '#EF4444' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="consolidation-summary"
|
||||
type="text"
|
||||
value={summary}
|
||||
onChange={(e) => {
|
||||
setSummary(e.target.value);
|
||||
if (summaryError) setSummaryError(null);
|
||||
}}
|
||||
maxLength={255}
|
||||
placeholder="Ticket summary (required)"
|
||||
style={{ ...STYLES.input, ...(summaryError ? STYLES.inputError : {}) }}
|
||||
/>
|
||||
<div style={STYLES.charCount}>{summary.length}/255</div>
|
||||
{summaryError && <div style={STYLES.errorMsg}>{summaryError}</div>}
|
||||
</div>
|
||||
|
||||
{/* Description textarea */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="consolidation-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Aggregated description of selected findings"
|
||||
style={STYLES.textarea}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CVE ID (optional) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-cve">
|
||||
CVE ID <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="consolidation-cve"
|
||||
type="text"
|
||||
value={cveId}
|
||||
onChange={(e) => setCveId(e.target.value)}
|
||||
placeholder="e.g. CVE-2024-12345"
|
||||
style={STYLES.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vendor (optional) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label} htmlFor="consolidation-vendor">
|
||||
Vendor <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="consolidation-vendor"
|
||||
type="text"
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
placeholder="e.g. Microsoft"
|
||||
style={STYLES.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Context (read-only) */}
|
||||
<div style={STYLES.section}>
|
||||
<label style={STYLES.label}>Source Context</label>
|
||||
<span style={STYLES.readOnlyBadge}>{sourceContext}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={STYLES.actions}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={STYLES.cancelBtn}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
|
||||
e.currentTarget.style.color = '#CBD5E1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
|
||||
e.currentTarget.style.color = '#94A3B8';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
...STYLES.submitBtn,
|
||||
...(!canSubmit ? STYLES.submitBtnDisabled : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (canSubmit) {
|
||||
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.18)';
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(14, 165, 233, 0.15)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
'Create Ticket'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/src/components/DeleteConfirmModal.js
Normal file
271
frontend/src/components/DeleteConfirmModal.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// DeleteConfirmModal.js
|
||||
// Confirmation dialog for deleting Archer templates.
|
||||
// Identifies the template by vendor/platform/model before deletion.
|
||||
// On confirm: calls DELETE API, invokes onConfirm callback, closes.
|
||||
// On cancel: dismisses dialog, leaves template unchanged.
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
/**
|
||||
* DeleteConfirmModal — confirmation dialog for deleting an Archer template.
|
||||
*
|
||||
* Props:
|
||||
* template {object|null} The template to delete (contains id, vendor, platform, model).
|
||||
* When null/undefined, modal is hidden.
|
||||
* onConfirm {function} Callback after successful delete (refresh list).
|
||||
* onCancel {function} Callback to close without deleting.
|
||||
*/
|
||||
export default function DeleteConfirmModal({ template, onConfirm, onCancel }) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const cancelRef = useRef(null);
|
||||
|
||||
// Focus cancel button on open and handle Escape key
|
||||
useEffect(() => {
|
||||
if (!template) return;
|
||||
|
||||
const timer = setTimeout(() => cancelRef.current?.focus(), 50);
|
||||
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape' && !deleting) onCancel?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [template, deleting, onCancel]);
|
||||
|
||||
// Reset state when template changes (new modal open)
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setDeleting(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!template) return;
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/archer-templates/${template.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Delete failed (${res.status})`);
|
||||
}
|
||||
|
||||
onConfirm?.();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [template, onConfirm]);
|
||||
|
||||
if (!template) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-confirm-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !deleting) onCancel?.();
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(239,68,68,0.06)',
|
||||
width: '100%',
|
||||
maxWidth: '440px',
|
||||
padding: '1.75rem 2rem',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.625rem',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<AlertTriangle style={{ width: '16px', height: '16px', color: '#EF4444' }} />
|
||||
</div>
|
||||
<div
|
||||
id="delete-confirm-title"
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: '700',
|
||||
color: '#EF4444',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Delete Template
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
fontSize: '0.82rem',
|
||||
color: '#CBD5E1',
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '1.25rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
}}>
|
||||
<p style={{ margin: '0 0 0.75rem 0' }}>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</p>
|
||||
<div style={{
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Vendor
|
||||
</span>
|
||||
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||
{template.vendor}
|
||||
</span>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '0.3rem' }}>
|
||||
Platform
|
||||
</span>
|
||||
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||
{template.platform}
|
||||
</span>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: '0.3rem' }}>
|
||||
Model
|
||||
</span>
|
||||
<span style={{ color: '#e0e0e0', fontSize: '0.82rem', fontWeight: 600 }}>
|
||||
{template.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
color: '#FCA5A5',
|
||||
fontSize: '0.78rem',
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
}}>
|
||||
<AlertTriangle style={{ width: '12px', height: '12px', flexShrink: 0 }} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
ref={cancelRef}
|
||||
onClick={onCancel}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0.625rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
cursor: deleting ? 'not-allowed' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
opacity: deleting ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!deleting) {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
|
||||
e.currentTarget.style.color = '#CBD5E1';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
|
||||
e.currentTarget.style.color = '#94A3B8';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={deleting}
|
||||
style={{
|
||||
flex: 1.5,
|
||||
padding: '0.625rem',
|
||||
background: 'rgba(239, 68, 68, 0.10)',
|
||||
border: '1px solid #EF4444',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#EF4444',
|
||||
cursor: deleting ? 'not-allowed' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.4rem',
|
||||
opacity: deleting ? 0.7 : 1,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!deleting) {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.18)';
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(239,68,68,0.15)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.10)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<Trash2 style={{ width: '13px', height: '13px' }} />
|
||||
{deleting ? 'Deleting...' : 'Delete Template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
578
frontend/src/components/LoaderModal.js
Normal file
578
frontend/src/components/LoaderModal.js
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* LoaderModal — Granite Team_Device Loader Sheet Generator
|
||||
*
|
||||
* Generates a properly formatted xlsx for upload to SNIP XperLoad.
|
||||
* Supports queue-initiated mode (pre-populated devices) and standalone mode (paste IPs).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FileSpreadsheet, Download, X, RefreshCw, Plus, Trash2, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
LOADER_COLUMNS,
|
||||
COLUMN_GROUPS,
|
||||
OPERATION_TYPES,
|
||||
getRequiredColumns,
|
||||
getColumnsByGroup,
|
||||
} from '../utils/graniteLoaderConfig';
|
||||
import { generateLoaderXlsx, generateFilename } from '../utils/graniteLoaderExport';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const OVERLAY = {
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 9999,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
};
|
||||
const MODAL = {
|
||||
background: '#1E293B', borderRadius: '0.75rem', border: '1px solid #334155',
|
||||
width: '90vw', maxWidth: '1100px', maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
const HEADER = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '1rem 1.25rem', borderBottom: '1px solid #334155',
|
||||
};
|
||||
const BODY = { flex: 1, overflow: 'auto', padding: '1.25rem' };
|
||||
const FOOTER = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '1rem 1.25rem', borderTop: '1px solid #334155',
|
||||
};
|
||||
const INPUT = {
|
||||
background: '#0F172A', border: '1px solid #334155', borderRadius: '0.375rem',
|
||||
color: '#E2E8F0', padding: '0.4rem 0.6rem', fontSize: '0.75rem', width: '100%',
|
||||
};
|
||||
const BTN = {
|
||||
padding: '0.5rem 1rem', borderRadius: '0.375rem', border: 'none',
|
||||
fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer',
|
||||
};
|
||||
const BTN_PRIMARY = { ...BTN, background: '#7C3AED', color: '#fff' };
|
||||
const BTN_SECONDARY = { ...BTN, background: '#334155', color: '#E2E8F0' };
|
||||
const BTN_SUCCESS = { ...BTN, background: '#10B981', color: '#fff' };
|
||||
|
||||
export default function LoaderModal({ isOpen, onClose, initialDevices }) {
|
||||
// --- State ---
|
||||
const [operationType, setOperationType] = useState('Change');
|
||||
const [selectedColumns, setSelectedColumns] = useState(new Set());
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [bulkDefaults, setBulkDefaults] = useState({});
|
||||
const [overrides, setOverrides] = useState({});
|
||||
const [editingCell, setEditingCell] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [enriching, setEnriching] = useState(false);
|
||||
const [enrichErrors, setEnrichErrors] = useState([]);
|
||||
const [cardConfigured, setCardConfigured] = useState(false);
|
||||
const [pasteInput, setPasteInput] = useState('');
|
||||
const [expandedGroups, setExpandedGroups] = useState(new Set(['Identification', 'Responsible Org']));
|
||||
const [validationWarnings, setValidationWarnings] = useState([]);
|
||||
|
||||
// --- Initialize ---
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// Check CARD status
|
||||
fetch(`${API_BASE}/card/status`, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(d => setCardConfigured(d.configured === true))
|
||||
.catch(() => setCardConfigured(false));
|
||||
}, [isOpen]);
|
||||
|
||||
// Populate devices from initialDevices or reset
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialDevices && initialDevices.length > 0) {
|
||||
setDevices(initialDevices.map(d => ({
|
||||
IPV4_ADDRESS: d.ip_address || '',
|
||||
EQUIP_NAME: d.hostname || '',
|
||||
})));
|
||||
} else {
|
||||
setDevices([]);
|
||||
}
|
||||
setOverrides({});
|
||||
setBulkDefaults({});
|
||||
setEnrichErrors([]);
|
||||
setValidationWarnings([]);
|
||||
}, [isOpen, initialDevices]);
|
||||
|
||||
// Auto-select required columns + useful defaults when operation type changes
|
||||
useEffect(() => {
|
||||
const required = getRequiredColumns(operationType);
|
||||
setSelectedColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
required.forEach(id => next.add(id));
|
||||
// Always include these useful columns by default
|
||||
next.add('IPV4_ADDRESS');
|
||||
next.add('EQUIP_NAME');
|
||||
next.add('RESPONSIBLE_TEAM');
|
||||
return next;
|
||||
});
|
||||
}, [operationType]);
|
||||
|
||||
// --- Column selection ---
|
||||
const toggleColumn = useCallback((colId) => {
|
||||
const required = getRequiredColumns(operationType);
|
||||
if (required.includes(colId)) return; // Can't deselect required
|
||||
setSelectedColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(colId)) next.delete(colId);
|
||||
else next.add(colId);
|
||||
return next;
|
||||
});
|
||||
}, [operationType]);
|
||||
|
||||
const toggleGroup = useCallback((group) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group)) next.delete(group);
|
||||
else next.add(group);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- Ordered selected columns (canonical order) ---
|
||||
const orderedColumns = useMemo(() => {
|
||||
return LOADER_COLUMNS.filter(col => selectedColumns.has(col.id));
|
||||
}, [selectedColumns]);
|
||||
|
||||
// --- Resolve cell value (override > bulk default > device value > empty) ---
|
||||
const getCellValue = useCallback((rowIdx, colId) => {
|
||||
if (overrides[rowIdx] && overrides[rowIdx][colId] !== undefined) {
|
||||
return overrides[rowIdx][colId];
|
||||
}
|
||||
if (bulkDefaults[colId] !== undefined && bulkDefaults[colId] !== '') {
|
||||
return bulkDefaults[colId];
|
||||
}
|
||||
return devices[rowIdx]?.[colId] || '';
|
||||
}, [overrides, bulkDefaults, devices]);
|
||||
|
||||
// --- Cell editing ---
|
||||
const startEdit = (rowIdx, colId) => {
|
||||
setEditingCell({ rowIdx, colId });
|
||||
setEditValue(getCellValue(rowIdx, colId));
|
||||
};
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editingCell) return;
|
||||
const { rowIdx, colId } = editingCell;
|
||||
const currentBulk = bulkDefaults[colId] || '';
|
||||
const currentDevice = devices[rowIdx]?.[colId] || '';
|
||||
// Only store override if different from bulk default and device value
|
||||
if (editValue !== currentBulk || currentDevice) {
|
||||
setOverrides(prev => ({
|
||||
...prev,
|
||||
[rowIdx]: { ...(prev[rowIdx] || {}), [colId]: editValue },
|
||||
}));
|
||||
}
|
||||
setEditingCell(null);
|
||||
};
|
||||
|
||||
const clearOverride = (rowIdx, colId) => {
|
||||
setOverrides(prev => {
|
||||
const next = { ...prev };
|
||||
if (next[rowIdx]) {
|
||||
const row = { ...next[rowIdx] };
|
||||
delete row[colId];
|
||||
if (Object.keys(row).length === 0) delete next[rowIdx];
|
||||
else next[rowIdx] = row;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// --- Bulk default ---
|
||||
const setBulkDefault = (colId, value) => {
|
||||
setBulkDefaults(prev => ({ ...prev, [colId]: value }));
|
||||
};
|
||||
|
||||
// --- Paste IPs (standalone mode) ---
|
||||
const loadPastedIps = () => {
|
||||
const lines = pasteInput.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const newDevices = lines.slice(0, 200).map(ip => ({ IPV4_ADDRESS: ip, EQUIP_NAME: '' }));
|
||||
setDevices(newDevices);
|
||||
setOverrides({});
|
||||
setPasteInput('');
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setDevices(prev => [...prev, { IPV4_ADDRESS: '', EQUIP_NAME: '' }]);
|
||||
};
|
||||
|
||||
const removeRow = (idx) => {
|
||||
setDevices(prev => prev.filter((_, i) => i !== idx));
|
||||
setOverrides(prev => {
|
||||
const next = {};
|
||||
Object.entries(prev).forEach(([k, v]) => {
|
||||
const ki = parseInt(k, 10);
|
||||
if (ki < idx) next[ki] = v;
|
||||
else if (ki > idx) next[ki - 1] = v;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// --- CARD Enrichment ---
|
||||
const enrichFromCard = async () => {
|
||||
const ips = devices.map(d => d.IPV4_ADDRESS).filter(Boolean);
|
||||
if (ips.length === 0) return;
|
||||
|
||||
setEnriching(true);
|
||||
setEnrichErrors([]);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/card/enrich-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ ips }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
setEnrichErrors([{ ip: 'all', error: err.error || `HTTP ${resp.status}` }]);
|
||||
setEnriching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const errors = [];
|
||||
|
||||
// Map results back to devices
|
||||
setDevices(prev => prev.map((device, idx) => {
|
||||
const result = data.results.find(r => r.ip === device.IPV4_ADDRESS);
|
||||
if (!result || !result.found) {
|
||||
if (result) errors.push({ ip: result.ip, error: result.error || 'Not found' });
|
||||
return device;
|
||||
}
|
||||
|
||||
// Only populate fields that aren't already overridden by the user
|
||||
const updated = { ...device };
|
||||
const rowOverrides = overrides[idx] || {};
|
||||
|
||||
if (result.equip_inst_id && !rowOverrides.EQUIP_INST_ID && !device.EQUIP_INST_ID) {
|
||||
updated.EQUIP_INST_ID = result.equip_inst_id;
|
||||
}
|
||||
if (result.hostname && !rowOverrides.EQUIP_NAME && !device.EQUIP_NAME) {
|
||||
updated.EQUIP_NAME = result.hostname;
|
||||
}
|
||||
if (result.site_name && !rowOverrides.SITE_NAME && !device.SITE_NAME) {
|
||||
updated.SITE_NAME = result.site_name;
|
||||
}
|
||||
if (result.mgmt_ip_asn && !rowOverrides.MGMT_IP_ASN && !device.MGMT_IP_ASN) {
|
||||
updated.MGMT_IP_ASN = result.mgmt_ip_asn;
|
||||
}
|
||||
if (result.responsible_team && !rowOverrides.RESPONSIBLE_TEAM && !device.RESPONSIBLE_TEAM) {
|
||||
updated.RESPONSIBLE_TEAM = result.responsible_team;
|
||||
}
|
||||
if (result.equip_status && !rowOverrides.EQUIP_STATUS && !device.EQUIP_STATUS) {
|
||||
updated.EQUIP_STATUS = result.equip_status;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}));
|
||||
|
||||
setEnrichErrors(errors);
|
||||
} catch (err) {
|
||||
setEnrichErrors([{ ip: 'all', error: err.message }]);
|
||||
}
|
||||
|
||||
setEnriching(false);
|
||||
};
|
||||
|
||||
// --- Validation ---
|
||||
const validate = () => {
|
||||
const required = getRequiredColumns(operationType);
|
||||
const warnings = [];
|
||||
|
||||
devices.forEach((_, rowIdx) => {
|
||||
required.forEach(colId => {
|
||||
if (colId === 'DELETE') return; // Auto-filled
|
||||
const val = getCellValue(rowIdx, colId);
|
||||
if (!val) {
|
||||
warnings.push({ rowIdx, colId });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setValidationWarnings(warnings);
|
||||
return warnings;
|
||||
};
|
||||
|
||||
// --- Download ---
|
||||
const handleDownload = () => {
|
||||
const warnings = validate();
|
||||
|
||||
// Build final rows
|
||||
const finalRows = devices.map((_, rowIdx) => {
|
||||
const row = {};
|
||||
orderedColumns.forEach(col => {
|
||||
row[col.id] = getCellValue(rowIdx, col.id);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
// Determine team name for filename (from bulk default or first row)
|
||||
const teamName = bulkDefaults.RESPONSIBLE_TEAM || finalRows[0]?.RESPONSIBLE_TEAM || '';
|
||||
|
||||
const blob = generateLoaderXlsx({
|
||||
operationType,
|
||||
columnIds: orderedColumns.map(c => c.id),
|
||||
rows: finalRows,
|
||||
});
|
||||
|
||||
const filename = generateFilename(operationType, teamName);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Keep warnings visible but don't block download
|
||||
if (warnings.length > 0) {
|
||||
// Warnings already displayed in UI
|
||||
}
|
||||
};
|
||||
|
||||
// --- Render ---
|
||||
if (!isOpen) return null;
|
||||
|
||||
const requiredCols = getRequiredColumns(operationType);
|
||||
const isStandalone = !initialDevices || initialDevices.length === 0;
|
||||
const missingCount = validationWarnings.length;
|
||||
const isCellWarning = (rowIdx, colId) => validationWarnings.some(w => w.rowIdx === rowIdx && w.colId === colId);
|
||||
const isOverridden = (rowIdx, colId) => overrides[rowIdx] && overrides[rowIdx][colId] !== undefined;
|
||||
|
||||
return (
|
||||
<div style={OVERLAY} onClick={onClose}>
|
||||
<div style={MODAL} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={HEADER}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<FileSpreadsheet style={{ width: '18px', height: '18px', color: '#7C3AED' }} />
|
||||
<span style={{ fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0' }}>Generate Granite Loader Sheet</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#64748B' }}>({devices.length} devices)</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={BODY}>
|
||||
{/* Top controls row */}
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.65rem', color: '#94A3B8', display: 'block', marginBottom: '0.2rem' }}>Operation</label>
|
||||
<select style={{ ...INPUT, width: '140px', cursor: 'pointer' }} value={operationType} onChange={e => setOperationType(e.target.value)}>
|
||||
{OPERATION_TYPES.map(op => <option key={op} value={op}>{op}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{cardConfigured && (
|
||||
<button style={{ ...BTN_SECONDARY, display: 'flex', alignItems: 'center', gap: '0.3rem' }} onClick={enrichFromCard} disabled={enriching || devices.length === 0}>
|
||||
<RefreshCw style={{ width: '12px', height: '12px', animation: enriching ? 'spin 1s linear infinite' : 'none' }} />
|
||||
{enriching ? 'Enriching...' : 'Enrich from CARD'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Standalone: paste IPs */}
|
||||
{isStandalone && devices.length === 0 && (
|
||||
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
|
||||
<label style={{ fontSize: '0.7rem', color: '#94A3B8', display: 'block', marginBottom: '0.3rem' }}>Paste IP addresses (one per line or comma-separated)</label>
|
||||
<textarea
|
||||
style={{ ...INPUT, height: '80px', resize: 'vertical', fontFamily: 'monospace' }}
|
||||
value={pasteInput}
|
||||
onChange={e => setPasteInput(e.target.value)}
|
||||
placeholder="10.240.78.110 10.240.78.111 172.16.5.20"
|
||||
/>
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<button style={BTN_PRIMARY} onClick={loadPastedIps} disabled={!pasteInput.trim()}>Load IPs</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enrich errors */}
|
||||
{enrichErrors.length > 0 && (
|
||||
<div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
<AlertCircle style={{ width: '12px', height: '12px' }} />
|
||||
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
|
||||
? enrichErrors[0].error
|
||||
: `${enrichErrors.length} device(s) not found in CARD`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column selection */}
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', marginBottom: '0.5rem', fontWeight: '600' }}>Columns</div>
|
||||
{COLUMN_GROUPS.map(group => {
|
||||
const cols = getColumnsByGroup(group);
|
||||
const selectedInGroup = cols.filter(c => selectedColumns.has(c.id)).length;
|
||||
const expanded = expandedGroups.has(group);
|
||||
return (
|
||||
<div key={group} style={{ marginBottom: '0.25rem' }}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', cursor: 'pointer', padding: '0.2rem 0', userSelect: 'none' }}
|
||||
onClick={() => toggleGroup(group)}
|
||||
>
|
||||
{expanded ? <ChevronDown style={{ width: '12px', height: '12px', color: '#64748B' }} /> : <ChevronRight style={{ width: '12px', height: '12px', color: '#64748B' }} />}
|
||||
<span style={{ fontSize: '0.7rem', color: '#E2E8F0' }}>{group}</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>({selectedInGroup} selected)</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', paddingLeft: '1.2rem', marginTop: '0.2rem' }}>
|
||||
{cols.map(col => {
|
||||
const isRequired = requiredCols.includes(col.id);
|
||||
const isChecked = selectedColumns.has(col.id);
|
||||
return (
|
||||
<label key={col.id} style={{ display: 'flex', alignItems: 'center', gap: '0.2rem', fontSize: '0.65rem', color: isRequired ? '#A78BFA' : '#94A3B8', cursor: isRequired ? 'default' : 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleColumn(col.id)}
|
||||
disabled={isRequired}
|
||||
style={{ accentColor: '#7C3AED' }}
|
||||
/>
|
||||
{col.label.length > 30 ? col.id : col.label}
|
||||
{isRequired && <span style={{ fontSize: '0.55rem', color: '#7C3AED' }}>*</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bulk defaults */}
|
||||
{orderedColumns.length > 0 && devices.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', marginBottom: '0.5rem', fontWeight: '600' }}>Bulk Defaults (applies to all rows)</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.5rem' }}>
|
||||
{orderedColumns.filter(c => c.id !== 'DELETE').map(col => (
|
||||
<div key={col.id}>
|
||||
<label style={{ fontSize: '0.6rem', color: '#64748B', display: 'block', marginBottom: '0.15rem' }}>
|
||||
{col.id}
|
||||
</label>
|
||||
<input
|
||||
style={INPUT}
|
||||
value={bulkDefaults[col.id] || ''}
|
||||
onChange={e => setBulkDefault(col.id, e.target.value)}
|
||||
placeholder={`Default for all rows`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview table */}
|
||||
{devices.length > 0 && orderedColumns.length > 0 && (
|
||||
<div style={{ border: '1px solid #334155', borderRadius: '0.5rem', overflow: 'auto', maxHeight: '300px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.7rem' }}>
|
||||
<thead>
|
||||
<tr style={{ position: 'sticky', top: 0, background: '#0F172A', zIndex: 1 }}>
|
||||
<th style={{ padding: '0.4rem', borderBottom: '1px solid #334155', color: '#64748B', textAlign: 'center', width: '30px' }}>#</th>
|
||||
{orderedColumns.map(col => (
|
||||
<th key={col.id} style={{ padding: '0.4rem 0.5rem', borderBottom: '1px solid #334155', color: '#94A3B8', textAlign: 'left', whiteSpace: 'nowrap' }}>
|
||||
{col.id}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ padding: '0.4rem', borderBottom: '1px solid #334155', width: '30px' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((_, rowIdx) => (
|
||||
<tr key={rowIdx} style={{ borderBottom: '1px solid rgba(51, 65, 85, 0.5)' }}>
|
||||
<td style={{ padding: '0.3rem', textAlign: 'center', color: '#475569', fontSize: '0.6rem' }}>{rowIdx + 1}</td>
|
||||
{orderedColumns.map(col => {
|
||||
const value = getCellValue(rowIdx, col.id);
|
||||
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colId === col.id;
|
||||
const hasOverride = isOverridden(rowIdx, col.id);
|
||||
const hasWarning = isCellWarning(rowIdx, col.id);
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.id}
|
||||
style={{
|
||||
padding: '0.2rem 0.4rem',
|
||||
position: 'relative',
|
||||
background: hasWarning ? 'rgba(239, 68, 68, 0.08)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
onClick={() => !isEditing && startEdit(rowIdx, col.id)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
style={{ ...INPUT, padding: '0.2rem 0.4rem', fontSize: '0.7rem' }}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
|
||||
{hasOverride && <span style={{ color: '#F59E0B', fontSize: '0.5rem' }}>●</span>}
|
||||
<span style={{ color: value ? '#E2E8F0' : '#475569' }}>{value || '—'}</span>
|
||||
{hasOverride && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); clearOverride(rowIdx, col.id); }}
|
||||
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', fontSize: '0.55rem', padding: '0 0.2rem' }}
|
||||
title="Revert to bulk default"
|
||||
>↻</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td style={{ padding: '0.2rem', textAlign: 'center' }}>
|
||||
<button onClick={() => removeRow(rowIdx)} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
|
||||
<Trash2 style={{ width: '11px', height: '11px' }} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add row button */}
|
||||
{devices.length > 0 && (
|
||||
<button style={{ ...BTN_SECONDARY, marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.65rem' }} onClick={addRow}>
|
||||
<Plus style={{ width: '11px', height: '11px' }} /> Add Row
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={FOOTER}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B' }}>
|
||||
{missingCount > 0 && (
|
||||
<span style={{ color: '#F59E0B' }}>⚠ {missingCount} missing required field{missingCount > 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button style={BTN_SECONDARY} onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
style={{ ...BTN_SUCCESS, display: 'flex', alignItems: 'center', gap: '0.3rem' }}
|
||||
onClick={handleDownload}
|
||||
disabled={devices.length === 0 || orderedColumns.length === 0}
|
||||
>
|
||||
<Download style={{ width: '13px', height: '13px' }} />
|
||||
Download Loader Sheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2 } from 'lucide-react';
|
||||
import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket, Building2, Layers } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
||||
{ 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' },
|
||||
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
||||
{ id: 'archer-templates', label: 'Template Mgr', icon: Layers, color: '#F472B6', description: 'Archer template library' },
|
||||
];
|
||||
|
||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||
|
||||
522
frontend/src/components/TemplateFormModal.js
Normal file
522
frontend/src/components/TemplateFormModal.js
Normal file
@@ -0,0 +1,522 @@
|
||||
// TemplateFormModal.js
|
||||
// Modal for creating, editing, and cloning Archer Risk Acceptance templates.
|
||||
// Supports three modes:
|
||||
// - create: all fields empty
|
||||
// - edit: pre-populated from existing template
|
||||
// - clone: sections pre-populated from source, hierarchy fields empty
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Save, AlertCircle, Loader } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // ⚠️ CONVENTION: Prefer relative API paths (e.g. '/api') over absolute URL fallback
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions — ordered as static first, then semi-static
|
||||
// ---------------------------------------------------------------------------
|
||||
const SECTIONS = [
|
||||
{ key: 'environment_overview', label: 'Environment Overview' },
|
||||
{ key: 'segmentation', label: 'Segmentation' },
|
||||
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
|
||||
{ key: 'additional_info', label: 'Additional Info/Background' },
|
||||
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
|
||||
{ key: 'data_classification', label: 'Data Classification' },
|
||||
{ key: 'charter_network', label: 'Charter Network' },
|
||||
{ key: 'additional_access_list', label: 'Additional Access List' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
backdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 70,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem 1rem',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
modal: {
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(0,212,255,0.08)',
|
||||
width: '100%',
|
||||
maxWidth: '700px',
|
||||
padding: '1.75rem 2rem',
|
||||
marginTop: '1rem',
|
||||
marginBottom: '2rem',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem',
|
||||
},
|
||||
title: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
},
|
||||
closeBtn: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#64748B',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fieldGroup: {
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
label: {
|
||||
display: 'block',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#94A3B8',
|
||||
marginBottom: '0.3rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '0.55rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '0.85rem',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#ef4444',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%',
|
||||
padding: '0.55rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '0.82rem',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
minHeight: '80px',
|
||||
transition: 'border-color 0.2s',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: '0.72rem',
|
||||
color: '#ef4444',
|
||||
marginTop: '0.2rem',
|
||||
},
|
||||
errorBanner: {
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
color: '#FCA5A5',
|
||||
fontSize: '0.8rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
sectionDivider: {
|
||||
margin: '1.25rem 0 0.75rem',
|
||||
padding: '0.4rem 0',
|
||||
borderTop: '1px solid rgba(0, 212, 255, 0.08)',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
},
|
||||
footer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.75rem',
|
||||
marginTop: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
borderTop: '1px solid rgba(0, 212, 255, 0.08)',
|
||||
},
|
||||
cancelBtn: {
|
||||
padding: '0.55rem 1.1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(100,116,139,0.4)',
|
||||
background: 'transparent',
|
||||
color: '#94A3B8',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
submitBtn: {
|
||||
padding: '0.55rem 1.25rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.4)',
|
||||
background: 'rgba(0, 212, 255, 0.12)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
charCount: {
|
||||
fontSize: '0.65rem',
|
||||
color: '#475569',
|
||||
textAlign: 'right',
|
||||
marginTop: '0.15rem',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* TemplateFormModal
|
||||
*
|
||||
* Props:
|
||||
* mode {'create'|'edit'|'clone'} Determines form behavior
|
||||
* template {object|null} Source template (for edit/clone)
|
||||
* onClose {function} Callback to close the modal
|
||||
* onSuccess {function} Callback after successful save (refreshes list)
|
||||
*/
|
||||
export default function TemplateFormModal({ mode = 'create', template = null, onClose, onSuccess }) {
|
||||
// Form state
|
||||
const [vendor, setVendor] = useState('');
|
||||
const [platform, setPlatform] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [sections, setSections] = useState(() => {
|
||||
const initial = {};
|
||||
for (const s of SECTIONS) {
|
||||
initial[s.key] = '';
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
// Validation and submission state
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const vendorRef = useRef(null);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialize form based on mode
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && template) {
|
||||
setVendor(template.vendor || '');
|
||||
setPlatform(template.platform || '');
|
||||
setModel(template.model || '');
|
||||
const sectionValues = {};
|
||||
for (const s of SECTIONS) {
|
||||
sectionValues[s.key] = template[s.key] || '';
|
||||
}
|
||||
setSections(sectionValues);
|
||||
} else if (mode === 'clone' && template) {
|
||||
// Clone: copy sections, leave hierarchy empty
|
||||
setVendor('');
|
||||
setPlatform('');
|
||||
setModel('');
|
||||
const sectionValues = {};
|
||||
for (const s of SECTIONS) {
|
||||
sectionValues[s.key] = template[s.key] || '';
|
||||
}
|
||||
setSections(sectionValues);
|
||||
}
|
||||
// create mode: all fields already empty (initial state)
|
||||
}, [mode, template]);
|
||||
|
||||
// Focus the vendor input on mount
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => vendorRef.current?.focus(), 80);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validation
|
||||
// -------------------------------------------------------------------------
|
||||
function validate() {
|
||||
const errors = {};
|
||||
if (!vendor.trim()) errors.vendor = 'Vendor is required';
|
||||
else if (vendor.trim().length > 100) errors.vendor = 'Vendor must be 100 characters or fewer';
|
||||
|
||||
if (!platform.trim()) errors.platform = 'Platform is required';
|
||||
else if (platform.trim().length > 100) errors.platform = 'Platform must be 100 characters or fewer';
|
||||
|
||||
if (!model.trim()) errors.model = 'Model is required';
|
||||
else if (model.trim().length > 100) errors.model = 'Model must be 100 characters or fewer';
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Submit
|
||||
// -------------------------------------------------------------------------
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setApiError(null);
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
vendor: vendor.trim(),
|
||||
platform: platform.trim(),
|
||||
model: model.trim(),
|
||||
};
|
||||
|
||||
// Include section fields
|
||||
for (const s of SECTIONS) {
|
||||
body[s.key] = sections[s.key];
|
||||
}
|
||||
|
||||
let url;
|
||||
let method;
|
||||
|
||||
if (mode === 'edit' && template) {
|
||||
// PUT to update
|
||||
url = `${API_BASE}/archer-templates/${template.id}`;
|
||||
method = 'PUT';
|
||||
} else if (mode === 'clone' && template) {
|
||||
// POST to clone endpoint
|
||||
url = `${API_BASE}/archer-templates/${template.id}/clone`;
|
||||
method = 'POST';
|
||||
// Clone endpoint only needs vendor, platform, model
|
||||
delete body.environment_overview;
|
||||
delete body.segmentation;
|
||||
delete body.mitigating_controls;
|
||||
delete body.additional_info;
|
||||
delete body.charter_network_banner;
|
||||
delete body.data_classification;
|
||||
delete body.charter_network;
|
||||
delete body.additional_access_list;
|
||||
} else {
|
||||
// POST to create
|
||||
url = `${API_BASE}/archer-templates`;
|
||||
method = 'POST';
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.status === 409) {
|
||||
setApiError(data.error || 'A template with this vendor/platform/model combination already exists');
|
||||
} else {
|
||||
setApiError(data.error || `Request failed (${res.status})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — close and refresh
|
||||
onSuccess?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setApiError(err.message || 'Network error — please try again');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Section change handler
|
||||
// -------------------------------------------------------------------------
|
||||
function handleSectionChange(key, value) {
|
||||
setSections(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Title based on mode
|
||||
// -------------------------------------------------------------------------
|
||||
const titles = {
|
||||
create: 'Create Template',
|
||||
edit: 'Edit Template',
|
||||
clone: 'Clone Template',
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="template-form-modal-title"
|
||||
style={STYLES.backdrop}
|
||||
>
|
||||
<div style={STYLES.modal}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.header}>
|
||||
<span id="template-form-modal-title" style={STYLES.title}>
|
||||
{titles[mode] || 'Template'}
|
||||
</span>
|
||||
<button
|
||||
style={STYLES.closeBtn}
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API error banner */}
|
||||
{apiError && (
|
||||
<div style={STYLES.errorBanner}>
|
||||
<AlertCircle size={14} />
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
{/* Hierarchy fields */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem' }}>
|
||||
{/* Vendor */}
|
||||
<div style={STYLES.fieldGroup}>
|
||||
<label style={STYLES.label} htmlFor="tmpl-vendor">Vendor *</label>
|
||||
<input
|
||||
ref={vendorRef}
|
||||
id="tmpl-vendor"
|
||||
type="text"
|
||||
maxLength={100}
|
||||
value={vendor}
|
||||
onChange={(e) => {
|
||||
setVendor(e.target.value);
|
||||
if (fieldErrors.vendor) setFieldErrors(prev => ({ ...prev, vendor: undefined }));
|
||||
}}
|
||||
style={{ ...STYLES.input, ...(fieldErrors.vendor ? STYLES.inputError : {}) }}
|
||||
placeholder="e.g. Harmonic"
|
||||
/>
|
||||
{fieldErrors.vendor && <div style={STYLES.errorText}>{fieldErrors.vendor}</div>}
|
||||
</div>
|
||||
|
||||
{/* Platform */}
|
||||
<div style={STYLES.fieldGroup}>
|
||||
<label style={STYLES.label} htmlFor="tmpl-platform">Platform *</label>
|
||||
<input
|
||||
id="tmpl-platform"
|
||||
type="text"
|
||||
maxLength={100}
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
setPlatform(e.target.value);
|
||||
if (fieldErrors.platform) setFieldErrors(prev => ({ ...prev, platform: undefined }));
|
||||
}}
|
||||
style={{ ...STYLES.input, ...(fieldErrors.platform ? STYLES.inputError : {}) }}
|
||||
placeholder="e.g. vCMTS"
|
||||
/>
|
||||
{fieldErrors.platform && <div style={STYLES.errorText}>{fieldErrors.platform}</div>}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div style={STYLES.fieldGroup}>
|
||||
<label style={STYLES.label} htmlFor="tmpl-model">Model *</label>
|
||||
<input
|
||||
id="tmpl-model"
|
||||
type="text"
|
||||
maxLength={100}
|
||||
value={model}
|
||||
onChange={(e) => {
|
||||
setModel(e.target.value);
|
||||
if (fieldErrors.model) setFieldErrors(prev => ({ ...prev, model: undefined }));
|
||||
}}
|
||||
style={{ ...STYLES.input, ...(fieldErrors.model ? STYLES.inputError : {}) }}
|
||||
placeholder="e.g. 3.29.1"
|
||||
/>
|
||||
{fieldErrors.model && <div style={STYLES.errorText}>{fieldErrors.model}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section textareas */}
|
||||
<div style={STYLES.sectionDivider}>Template Sections</div>
|
||||
|
||||
{SECTIONS.map((section) => (
|
||||
<div key={section.key} style={STYLES.fieldGroup}>
|
||||
<label style={STYLES.label} htmlFor={`tmpl-${section.key}`}>
|
||||
{section.label}
|
||||
</label>
|
||||
<textarea
|
||||
id={`tmpl-${section.key}`}
|
||||
value={sections[section.key]}
|
||||
onChange={(e) => handleSectionChange(section.key, e.target.value)}
|
||||
maxLength={10000}
|
||||
style={STYLES.textarea}
|
||||
placeholder={`Enter ${section.label.toLowerCase()} content...`}
|
||||
/>
|
||||
{sections[section.key].length > 9500 && (
|
||||
<div style={STYLES.charCount}>
|
||||
{sections[section.key].length}/10,000
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Footer actions */}
|
||||
<div style={STYLES.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={STYLES.cancelBtn}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
style={{
|
||||
...STYLES.submitBtn,
|
||||
...(submitting ? STYLES.submitBtnDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{submitting ? <Loader size={13} /> : <Save size={13} />}
|
||||
{submitting ? 'Saving...' : (mode === 'edit' ? 'Update Template' : 'Save Template')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
621
frontend/src/components/TemplateSelector.js
Normal file
621
frontend/src/components/TemplateSelector.js
Normal file
@@ -0,0 +1,621 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Search, ChevronDown, Loader, FileText, Clipboard, Check, Copy } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section field mapping — ordered: static first, then semi-static
|
||||
// ---------------------------------------------------------------------------
|
||||
const SECTIONS = [
|
||||
// Static sections
|
||||
{ key: 'environment_overview', label: 'Environment Overview' },
|
||||
{ key: 'segmentation', label: 'Segmentation' },
|
||||
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
|
||||
// Semi-static sections
|
||||
{ key: 'additional_info', label: 'Additional Info/Background' },
|
||||
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
|
||||
{ key: 'data_classification', label: 'Data Classification' },
|
||||
{ key: 'charter_network', label: 'Charter Network' },
|
||||
{ key: 'additional_access_list', label: 'Additional Access List' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
marginBottom: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
searchWrapper: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchIcon: {
|
||||
position: 'absolute',
|
||||
left: '0.75rem',
|
||||
color: '#64748b',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '0.625rem 2.25rem 0.625rem 2.25rem',
|
||||
background: 'rgba(15, 23, 42, 0.9)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '0.82rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: 'rgba(0, 212, 255, 0.5)',
|
||||
boxShadow: '0 0 12px rgba(0, 212, 255, 0.1)',
|
||||
},
|
||||
chevron: {
|
||||
position: 'absolute',
|
||||
right: '0.75rem',
|
||||
color: '#64748b',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
chevronOpen: {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
background: 'linear-gradient(135deg, rgba(22, 33, 62, 0.98), rgba(15, 23, 42, 0.99))',
|
||||
border: '1px solid rgba(0, 212, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
maxHeight: '240px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 50,
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.6)',
|
||||
},
|
||||
dropdownItem: {
|
||||
padding: '0.6rem 0.875rem',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
borderBottom: '1px solid rgba(100, 116, 139, 0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
dropdownItemHover: {
|
||||
background: 'rgba(0, 212, 255, 0.08)',
|
||||
},
|
||||
dropdownItemSelected: {
|
||||
background: 'rgba(0, 212, 255, 0.12)',
|
||||
color: '#00d4ff',
|
||||
},
|
||||
loadingState: {
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
color: '#64748b',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
},
|
||||
emptyState: {
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
color: '#64748b',
|
||||
fontSize: '0.8rem',
|
||||
fontStyle: 'italic',
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
},
|
||||
selectedDisplay: {
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(0, 212, 255, 0.06)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||
borderRadius: '6px',
|
||||
color: '#00d4ff',
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
},
|
||||
// Section panel styles
|
||||
sectionPanel: {
|
||||
marginTop: '1rem',
|
||||
background: 'linear-gradient(135deg, rgba(22, 33, 62, 0.6), rgba(15, 23, 42, 0.8))',
|
||||
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||
borderRadius: '10px',
|
||||
padding: '1rem',
|
||||
},
|
||||
sectionPanelHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.75rem',
|
||||
paddingBottom: '0.5rem',
|
||||
borderBottom: '1px solid rgba(100, 116, 139, 0.2)',
|
||||
},
|
||||
sectionPanelTitle: {
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
copyAllButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.35rem 0.65rem',
|
||||
background: 'rgba(0, 212, 255, 0.1)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.3)',
|
||||
borderRadius: '6px',
|
||||
color: '#00d4ff',
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s, border-color 0.2s',
|
||||
},
|
||||
copyAllButtonHover: {
|
||||
background: 'rgba(0, 212, 255, 0.18)',
|
||||
borderColor: 'rgba(0, 212, 255, 0.5)',
|
||||
},
|
||||
copyAllButtonCopied: {
|
||||
background: 'rgba(34, 197, 94, 0.15)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.4)',
|
||||
color: '#22c55e',
|
||||
},
|
||||
sectionBlock: {
|
||||
marginBottom: '0.75rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
background: 'rgba(15, 23, 42, 0.5)',
|
||||
border: '1px solid rgba(100, 116, 139, 0.15)',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
sectionBlockHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.35rem',
|
||||
},
|
||||
sectionLabel: {
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#94a3b8',
|
||||
letterSpacing: '0.02em',
|
||||
},
|
||||
sectionContent: {
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
fontSize: '0.78rem',
|
||||
color: '#e0e0e0',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
sectionEmpty: {
|
||||
fontFamily: "'Outfit', system-ui, sans-serif",
|
||||
fontSize: '0.78rem',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
copyButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: 'rgba(100, 116, 139, 0.15)',
|
||||
border: '1px solid rgba(100, 116, 139, 0.25)',
|
||||
borderRadius: '4px',
|
||||
color: '#94a3b8',
|
||||
fontSize: '0.68rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s, color 0.2s, border-color 0.2s',
|
||||
},
|
||||
copyButtonHover: {
|
||||
background: 'rgba(0, 212, 255, 0.1)',
|
||||
borderColor: 'rgba(0, 212, 255, 0.3)',
|
||||
color: '#00d4ff',
|
||||
},
|
||||
copyButtonCopied: {
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||
color: '#22c55e',
|
||||
},
|
||||
copyButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* TemplateSelector — searchable dropdown for selecting Archer templates.
|
||||
*
|
||||
* Props:
|
||||
* onSelect {function} — optional callback invoked with the full template object when a selection is made
|
||||
*/
|
||||
export default function TemplateSelector({ onSelect }) {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
|
||||
// Copy state: per-section copied confirmation + copy all
|
||||
const [copiedSections, setCopiedSections] = useState({});
|
||||
const [copyAllCopied, setCopyAllCopied] = useState(false);
|
||||
const [copyAllHovered, setCopyAllHovered] = useState(false);
|
||||
const [hoveredCopyButton, setHoveredCopyButton] = useState(null);
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Fetch all templates on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`${API_BASE}/archer-templates`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch templates (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setTemplates(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchTemplates();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Client-side filter — case-insensitive substring match on vendor, platform, or model
|
||||
const filteredTemplates = useCallback(() => {
|
||||
if (!searchText.trim()) return templates;
|
||||
const query = searchText.toLowerCase().trim();
|
||||
return templates.filter(t =>
|
||||
t.vendor.toLowerCase().includes(query) ||
|
||||
t.platform.toLowerCase().includes(query) ||
|
||||
t.model.toLowerCase().includes(query)
|
||||
);
|
||||
}, [templates, searchText])();
|
||||
|
||||
// Handle template selection
|
||||
const handleSelect = (template) => {
|
||||
setSelectedTemplate(template);
|
||||
setSearchText(`${template.vendor} / ${template.platform} / ${template.model}`);
|
||||
setIsOpen(false);
|
||||
setCopiedSections({});
|
||||
setCopyAllCopied(false);
|
||||
if (onSelect) {
|
||||
onSelect(template);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (e) => {
|
||||
setSearchText(e.target.value);
|
||||
setSelectedTemplate(null);
|
||||
setIsOpen(true);
|
||||
setHoveredIndex(-1);
|
||||
};
|
||||
|
||||
// Handle input focus
|
||||
const handleInputFocus = () => {
|
||||
setInputFocused(true);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Handle input blur
|
||||
const handleInputBlur = () => {
|
||||
setInputFocused(false);
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHoveredIndex(prev =>
|
||||
prev < filteredTemplates.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHoveredIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredTemplates.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (hoveredIndex >= 0 && hoveredIndex < filteredTemplates.length) {
|
||||
handleSelect(filteredTemplates[hoveredIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Copy a single section to clipboard
|
||||
const handleCopySection = async (sectionKey, content) => {
|
||||
if (!content) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
|
||||
}, 2000);
|
||||
} catch (_err) {
|
||||
// Clipboard API failed — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Copy All: concatenate non-empty sections with headers
|
||||
const handleCopyAll = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
const parts = [];
|
||||
for (const section of SECTIONS) {
|
||||
const content = selectedTemplate[section.key];
|
||||
if (content && content.trim()) {
|
||||
parts.push(`${section.label}\n${content}`);
|
||||
}
|
||||
}
|
||||
const combined = parts.join('\n\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(combined);
|
||||
setCopyAllCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopyAllCopied(false);
|
||||
}, 2000);
|
||||
} catch (_err) {
|
||||
// Clipboard API failed — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there are any non-empty sections
|
||||
const hasNonEmptySections = selectedTemplate && SECTIONS.some(s => {
|
||||
const val = selectedTemplate[s.key];
|
||||
return val && val.trim();
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={STYLES.container} ref={containerRef}>
|
||||
{/* Label */}
|
||||
<div style={STYLES.label}>
|
||||
<FileText size={12} />
|
||||
Template Selector
|
||||
</div>
|
||||
|
||||
{/* Search input with dropdown */}
|
||||
<div style={STYLES.searchWrapper}>
|
||||
<Search size={14} style={STYLES.searchIcon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={loading ? 'Loading templates...' : 'Search by vendor, platform, or model...'}
|
||||
value={searchText}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading}
|
||||
style={{
|
||||
...STYLES.input,
|
||||
...(inputFocused ? STYLES.inputFocused : {}),
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
aria-label="Search templates"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
...STYLES.chevron,
|
||||
...(isOpen ? STYLES.chevronOpen : {}),
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown list */}
|
||||
{isOpen && (
|
||||
<div style={STYLES.dropdown} role="listbox" aria-label="Template list">
|
||||
{loading ? (
|
||||
<div style={STYLES.loadingState}>
|
||||
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Loading templates...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ ...STYLES.emptyState, color: '#ef4444' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div style={STYLES.emptyState}>
|
||||
{searchText.trim()
|
||||
? 'No templates match your search'
|
||||
: 'No templates available'}
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((template, index) => {
|
||||
const isSelected = selectedTemplate?.id === template.id;
|
||||
const isHovered = hoveredIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
style={{
|
||||
...STYLES.dropdownItem,
|
||||
...(isHovered ? STYLES.dropdownItemHover : {}),
|
||||
...(isSelected ? STYLES.dropdownItemSelected : {}),
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(-1)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // Prevent input blur before click registers
|
||||
handleSelect(template);
|
||||
}}
|
||||
>
|
||||
<FileText size={12} style={{ opacity: 0.5, flexShrink: 0 }} />
|
||||
{template.vendor} / {template.platform} / {template.model}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section display panel — shown when a template is selected */}
|
||||
{selectedTemplate && (
|
||||
<div style={STYLES.sectionPanel}>
|
||||
{/* Panel header with Copy All button */}
|
||||
<div style={STYLES.sectionPanelHeader}>
|
||||
<span style={STYLES.sectionPanelTitle}>Template Sections</span>
|
||||
{hasNonEmptySections && (
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
onMouseEnter={() => setCopyAllHovered(true)}
|
||||
onMouseLeave={() => setCopyAllHovered(false)}
|
||||
style={{
|
||||
...STYLES.copyAllButton,
|
||||
...(copyAllCopied ? STYLES.copyAllButtonCopied : {}),
|
||||
...(!copyAllCopied && copyAllHovered ? STYLES.copyAllButtonHover : {}),
|
||||
}}
|
||||
aria-label="Copy all sections"
|
||||
>
|
||||
{copyAllCopied ? (
|
||||
<>
|
||||
<Check size={11} />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={11} />
|
||||
Copy All
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section blocks */}
|
||||
{SECTIONS.map((section) => {
|
||||
const content = selectedTemplate[section.key];
|
||||
const isEmpty = !content || !content.trim();
|
||||
const isCopied = copiedSections[section.key];
|
||||
const isButtonHovered = hoveredCopyButton === section.key;
|
||||
|
||||
return (
|
||||
<div key={section.key} style={STYLES.sectionBlock}>
|
||||
<div style={STYLES.sectionBlockHeader}>
|
||||
<span style={STYLES.sectionLabel}>{section.label}</span>
|
||||
<button
|
||||
onClick={() => handleCopySection(section.key, content)}
|
||||
disabled={isEmpty}
|
||||
onMouseEnter={() => setHoveredCopyButton(section.key)}
|
||||
onMouseLeave={() => setHoveredCopyButton(null)}
|
||||
style={{
|
||||
...STYLES.copyButton,
|
||||
...(isEmpty ? STYLES.copyButtonDisabled : {}),
|
||||
...(isCopied ? STYLES.copyButtonCopied : {}),
|
||||
...(!isEmpty && !isCopied && isButtonHovered ? STYLES.copyButtonHover : {}),
|
||||
}}
|
||||
aria-label={`Copy ${section.label}`}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check size={10} />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard size={10} />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div style={STYLES.sectionEmpty}>No content stored</div>
|
||||
) : (
|
||||
<div style={STYLES.sectionContent}>{content}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
421
frontend/src/components/pages/ArcherPage.js
Normal file
421
frontend/src/components/pages/ArcherPage.js
Normal file
@@ -0,0 +1,421 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, X, Loader, Shield, Filter, Edit2, Trash2, CheckCircle } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — matches tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
modal: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalBackdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
modalContent: {
|
||||
position: 'relative',
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.25)',
|
||||
borderRadius: '16px',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '520px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: 101,
|
||||
},
|
||||
input: {
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.85rem',
|
||||
width: '100%',
|
||||
outline: 'none',
|
||||
},
|
||||
btn: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||
background: 'rgba(139, 92, 246, 0.1)',
|
||||
color: '#C4B5FD',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnSuccess: {
|
||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
color: '#6EE7B7',
|
||||
},
|
||||
intelCard: {
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
|
||||
},
|
||||
ticketCard: {
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem',
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)',
|
||||
},
|
||||
badgeHigh: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
border: '1px solid #F59E0B',
|
||||
background: 'rgba(245, 158, 11, 0.15)',
|
||||
color: '#F59E0B',
|
||||
},
|
||||
glowDot: (color) => ({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
boxShadow: `0 0 6px ${color}, 0 0 12px ${color}`,
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArcherPage — Archer Risk Tickets panel with "Create Jira Ticket" action
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ArcherPage({
|
||||
archerTickets = [],
|
||||
onEditTicket,
|
||||
onDeleteTicket,
|
||||
onFilterByExc,
|
||||
onAddTicket,
|
||||
canDeleteTicket,
|
||||
}) {
|
||||
const { canWrite } = useAuth();
|
||||
|
||||
// Create Jira Ticket modal state
|
||||
const [showCreateJiraModal, setShowCreateJiraModal] = useState(false);
|
||||
const [createJiraForm, setCreateJiraForm] = useState({
|
||||
summary: '',
|
||||
cve_id: '',
|
||||
vendor: '',
|
||||
source_context: 'archer',
|
||||
description: '',
|
||||
project_key: '',
|
||||
issue_type: '',
|
||||
});
|
||||
const [createJiraSaving, setCreateJiraSaving] = useState(false);
|
||||
const [createJiraError, setCreateJiraError] = useState(null);
|
||||
const [summaryError, setSummaryError] = useState(null);
|
||||
const [createJiraSuccess, setCreateJiraSuccess] = useState(null);
|
||||
|
||||
// Open the Create Jira Ticket modal pre-populated with Archer ticket data
|
||||
const openCreateJiraModal = (ticket) => {
|
||||
setCreateJiraForm({
|
||||
summary: ticket.exc_number || '',
|
||||
cve_id: ticket.cve_id || '',
|
||||
vendor: ticket.vendor || '',
|
||||
source_context: 'archer',
|
||||
description: '',
|
||||
project_key: '',
|
||||
issue_type: '',
|
||||
});
|
||||
setCreateJiraError(null);
|
||||
setSummaryError(null);
|
||||
setCreateJiraSuccess(null);
|
||||
setShowCreateJiraModal(true);
|
||||
};
|
||||
|
||||
// Submit the Create Jira Ticket form
|
||||
const handleCreateJira = async () => {
|
||||
setSummaryError(null);
|
||||
const trimmedSummary = (createJiraForm.summary || '').trim();
|
||||
if (!trimmedSummary) {
|
||||
setSummaryError('Summary is required.');
|
||||
return;
|
||||
}
|
||||
if (trimmedSummary.length > 255) {
|
||||
setSummaryError('Summary must be 255 characters or fewer.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateJiraError(null);
|
||||
setCreateJiraSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
summary: trimmedSummary,
|
||||
source_context: 'archer',
|
||||
};
|
||||
if (createJiraForm.cve_id && createJiraForm.cve_id.trim()) {
|
||||
payload.cve_id = createJiraForm.cve_id.trim();
|
||||
}
|
||||
if (createJiraForm.vendor && createJiraForm.vendor.trim()) {
|
||||
payload.vendor = createJiraForm.vendor.trim();
|
||||
}
|
||||
if (createJiraForm.description && createJiraForm.description.trim()) {
|
||||
payload.description = createJiraForm.description.trim();
|
||||
}
|
||||
if (createJiraForm.project_key && createJiraForm.project_key.trim()) {
|
||||
payload.project_key = createJiraForm.project_key.trim();
|
||||
}
|
||||
if (createJiraForm.issue_type && createJiraForm.issue_type.trim()) {
|
||||
payload.issue_type = createJiraForm.issue_type.trim();
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok && res.status !== 207) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setCreateJiraSuccess(`Jira ticket ${data.ticket_key} created successfully.`);
|
||||
// Reset form after short delay so user sees success
|
||||
setTimeout(() => {
|
||||
setShowCreateJiraModal(false);
|
||||
setCreateJiraSuccess(null);
|
||||
setCreateJiraForm({ summary: '', cve_id: '', vendor: '', source_context: 'archer', description: '', project_key: '', issue_type: '' });
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setCreateJiraError(err.message);
|
||||
} finally {
|
||||
setCreateJiraSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeTickets = archerTickets.filter(t => t.status !== 'Accepted');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Archer Risk Acceptance Tickets Card */}
|
||||
<div style={{ ...STYLES.intelCard, borderLeft: '3px solid #8B5CF6' }} className="rounded-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
|
||||
<Shield className="w-5 h-5" />
|
||||
Archer Risk Tickets
|
||||
</h2>
|
||||
{canWrite() && onAddTicket && (
|
||||
<button
|
||||
onClick={onAddTicket}
|
||||
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
|
||||
{activeTickets.length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{activeTickets.slice(0, 10).map(ticket => (
|
||||
<div key={ticket.id} style={STYLES.ticketCard}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<a
|
||||
href={ticket.archer_url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-xs font-semibold text-intel-accent hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{ticket.exc_number}
|
||||
</a>
|
||||
<div className="flex gap-1">
|
||||
{onFilterByExc && (
|
||||
<button
|
||||
onClick={() => onFilterByExc(ticket.exc_number)}
|
||||
title="View findings referencing this ticket"
|
||||
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => openCreateJiraModal(ticket)}
|
||||
title="Create Jira Ticket"
|
||||
className="text-gray-400 hover:text-green-400 transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && onEditTicket && (
|
||||
<button onClick={() => onEditTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{canDeleteTicket && canDeleteTicket(ticket) && onDeleteTicket && (
|
||||
<button onClick={() => onDeleteTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||
<div className="mt-2">
|
||||
<span style={{ ...STYLES.badgeHigh, fontSize: '0.65rem', padding: '0.25rem 0.5rem', background: 'rgba(139, 92, 246, 0.2)', borderColor: '#8B5CF6' }}>
|
||||
<span style={{ ...STYLES.glowDot('#8B5CF6'), width: '6px', height: '6px' }}></span>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeTickets.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-400 italic font-mono">No active Archer tickets</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Jira Ticket Modal */}
|
||||
{showCreateJiraModal && (
|
||||
<div style={STYLES.modal}>
|
||||
<div style={STYLES.modalBackdrop} onClick={() => setShowCreateJiraModal(false)} />
|
||||
<div style={STYLES.modalContent}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem', fontFamily: 'monospace' }}>Create Jira Ticket from Archer</h3>
|
||||
<button onClick={() => setShowCreateJiraModal(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
|
||||
Creates a Jira issue linked to this Archer risk ticket.
|
||||
</p>
|
||||
|
||||
{createJiraSuccess && (
|
||||
<div style={{ color: '#6EE7B7', fontSize: '0.85rem', marginBottom: '0.75rem', padding: '0.5rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)', borderRadius: '8px' }}>
|
||||
{createJiraSuccess}
|
||||
</div>
|
||||
)}
|
||||
{createJiraError && (
|
||||
<div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem', padding: '0.5rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '8px' }}>
|
||||
{createJiraError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{/* Summary — required, pre-populated with exc_number */}
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>
|
||||
Summary <span style={{ color: '#F59E0B' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
style={{ ...STYLES.input, ...(summaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
|
||||
placeholder="Issue summary (max 255 chars)"
|
||||
value={createJiraForm.summary}
|
||||
onChange={e => { setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (summaryError) setSummaryError(null); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
{summaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{summaryError}</div>}
|
||||
</div>
|
||||
|
||||
{/* Source Context — locked to archer */}
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
|
||||
<select
|
||||
style={{ ...STYLES.input, cursor: 'not-allowed', opacity: 0.7 }}
|
||||
value={createJiraForm.source_context}
|
||||
disabled
|
||||
>
|
||||
<option value="archer">Archer Request</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* CVE ID — optional, pre-populated from Archer ticket */}
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
|
||||
<input
|
||||
style={STYLES.input}
|
||||
placeholder="e.g. CVE-2024-12345"
|
||||
value={createJiraForm.cve_id}
|
||||
onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vendor — optional, pre-populated from Archer ticket */}
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
|
||||
<input
|
||||
style={STYLES.input}
|
||||
placeholder="e.g. Microsoft"
|
||||
value={createJiraForm.vendor}
|
||||
onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description — optional */}
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description (optional)</label>
|
||||
<textarea
|
||||
style={{ ...STYLES.input, minHeight: '80px', resize: 'vertical' }}
|
||||
placeholder="Detailed description..."
|
||||
value={createJiraForm.description}
|
||||
onChange={e => setCreateJiraForm(f => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project Key and Issue Type */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
|
||||
<input
|
||||
style={STYLES.input}
|
||||
placeholder="Uses .env default"
|
||||
value={createJiraForm.project_key}
|
||||
onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
|
||||
<input
|
||||
style={STYLES.input}
|
||||
placeholder="Task"
|
||||
value={createJiraForm.issue_type}
|
||||
onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem', width: '100%' }}
|
||||
onClick={handleCreateJira}
|
||||
disabled={createJiraSaving}
|
||||
>
|
||||
{createJiraSaving ? <Loader size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
Create Jira Ticket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
569
frontend/src/components/pages/ArcherTemplatePage.js
Normal file
569
frontend/src/components/pages/ArcherTemplatePage.js
Normal file
@@ -0,0 +1,569 @@
|
||||
// ArcherTemplatePage.js
|
||||
// Full-page Template Manager — browse, create, edit, clone, and delete
|
||||
// Archer Risk Acceptance templates organized by Vendor > Platform > Model.
|
||||
// Write operations require editor/admin role (Standard_User or Admin group).
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FileText, Plus, Edit, Copy, Trash2, ChevronDown, ChevronRight,
|
||||
Loader, AlertCircle, RefreshCw, Eye, EyeOff, Clipboard, Check,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import TemplateFormModal from '../TemplateFormModal';
|
||||
import DeleteConfirmModal from '../DeleteConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// Section field mapping — ordered: static first, then semi-static
|
||||
const SECTIONS = [
|
||||
{ key: 'environment_overview', label: 'Environment Overview' },
|
||||
{ key: 'segmentation', label: 'Segmentation' },
|
||||
{ key: 'mitigating_controls', label: 'Mitigating Controls' },
|
||||
{ key: 'additional_info', label: 'Additional Info/Background' },
|
||||
{ key: 'charter_network_banner', label: 'Charter Network Banner' },
|
||||
{ key: 'data_classification', label: 'Data Classification' },
|
||||
{ key: 'charter_network', label: 'Charter Network' },
|
||||
{ key: 'additional_access_list', label: 'Additional Access List' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
page: {
|
||||
minHeight: '60vh',
|
||||
},
|
||||
card: {
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
|
||||
border: '1px solid rgba(0, 212, 255, 0.15)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
header: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: '#00d4ff',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
btn: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.3)',
|
||||
background: 'rgba(0, 212, 255, 0.1)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnDanger: {
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
color: '#FCA5A5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
btnSmall: {
|
||||
padding: '0.35rem 0.65rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(0, 212, 255, 0.25)',
|
||||
background: 'rgba(0, 212, 255, 0.08)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
vendorGroup: {
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
vendorHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.6rem 0.75rem',
|
||||
background: 'rgba(0, 212, 255, 0.05)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.12)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
},
|
||||
vendorLabel: {
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 700,
|
||||
color: '#e0e0e0',
|
||||
},
|
||||
vendorCount: {
|
||||
fontSize: '0.7rem',
|
||||
color: '#64748B',
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
platformSubgroup: {
|
||||
marginLeft: '1.25rem',
|
||||
marginTop: '0.5rem',
|
||||
paddingLeft: '0.75rem',
|
||||
borderLeft: '2px solid rgba(0, 212, 255, 0.1)',
|
||||
},
|
||||
platformLabel: {
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: '#94A3B8',
|
||||
marginBottom: '0.35rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
templateRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.45rem 0.6rem',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '0.25rem',
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
templateModel: {
|
||||
fontSize: '0.82rem',
|
||||
color: '#e0e0e0',
|
||||
fontWeight: 500,
|
||||
},
|
||||
templateActions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: '3rem 1rem',
|
||||
color: '#64748B',
|
||||
fontSize: '0.9rem',
|
||||
},
|
||||
errorBanner: {
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
color: '#FCA5A5',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
loadingState: {
|
||||
textAlign: 'center',
|
||||
padding: '3rem 1rem',
|
||||
color: '#64748B',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Group templates by vendor, then by platform within each vendor.
|
||||
* Returns: [ { vendor, platforms: [ { platform, templates: [...] } ] } ]
|
||||
*/
|
||||
function groupTemplates(templates) {
|
||||
const vendorMap = {};
|
||||
for (const t of templates) {
|
||||
if (!vendorMap[t.vendor]) vendorMap[t.vendor] = {};
|
||||
if (!vendorMap[t.vendor][t.platform]) vendorMap[t.vendor][t.platform] = [];
|
||||
vendorMap[t.vendor][t.platform].push(t);
|
||||
}
|
||||
|
||||
const vendors = Object.keys(vendorMap).sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
return vendors.map(vendor => {
|
||||
const platforms = Object.keys(vendorMap[vendor]).sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
return {
|
||||
vendor,
|
||||
platforms: platforms.map(platform => ({
|
||||
platform,
|
||||
templates: vendorMap[vendor][platform].sort((a, b) =>
|
||||
a.model.localeCompare(b.model, undefined, { sensitivity: 'base' })
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArcherTemplatePage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ArcherTemplatePage() {
|
||||
const { canWrite } = useAuth();
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [expandedVendors, setExpandedVendors] = useState({});
|
||||
|
||||
// Modal state for create/edit/clone
|
||||
const [modalState, setModalState] = useState({ open: false, mode: 'create', template: null });
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
// View panel state — which template ID is expanded for viewing
|
||||
const [viewExpandedId, setViewExpandedId] = useState(null);
|
||||
// Copy state for view panel
|
||||
const [copiedSections, setCopiedSections] = useState({});
|
||||
const [copyAllCopied, setCopyAllCopied] = useState(false);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fetch templates
|
||||
// -------------------------------------------------------------------------
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/archer-templates`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Failed to fetch templates (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setTemplates(data);
|
||||
// Expand all vendors by default on initial load
|
||||
const expanded = {};
|
||||
const grouped = groupTemplates(data);
|
||||
for (const g of grouped) {
|
||||
expanded[g.vendor] = true;
|
||||
}
|
||||
setExpandedVendors(expanded);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vendor toggle
|
||||
// -------------------------------------------------------------------------
|
||||
const toggleVendor = (vendor) => {
|
||||
setExpandedVendors(prev => ({ ...prev, [vendor]: !prev[vendor] }));
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// View panel toggle and copy handlers
|
||||
// -------------------------------------------------------------------------
|
||||
const toggleView = (templateId) => {
|
||||
setViewExpandedId(prev => prev === templateId ? null : templateId);
|
||||
setCopiedSections({});
|
||||
setCopyAllCopied(false);
|
||||
};
|
||||
|
||||
const handleCopySection = async (sectionKey, content) => {
|
||||
if (!content) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedSections(prev => ({ ...prev, [sectionKey]: false }));
|
||||
}, 2000);
|
||||
} catch (_err) { /* clipboard failed */ }
|
||||
};
|
||||
|
||||
const handleCopyAll = async (template) => {
|
||||
const parts = [];
|
||||
for (const section of SECTIONS) {
|
||||
const content = template[section.key];
|
||||
if (content && content.trim()) {
|
||||
parts.push(`${section.label}\n${content}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(parts.join('\n\n'));
|
||||
setCopyAllCopied(true);
|
||||
setTimeout(() => setCopyAllCopied(false), 2000);
|
||||
} catch (_err) { /* clipboard failed */ }
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Grouped data
|
||||
// -------------------------------------------------------------------------
|
||||
const grouped = groupTemplates(templates);
|
||||
const totalCount = templates.length;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
return (
|
||||
<div style={STYLES.page}>
|
||||
<div style={STYLES.card}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.header}>
|
||||
<FileText size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
|
||||
Archer Template Library
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={STYLES.toolbar}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ color: '#94A3B8', fontSize: '0.8rem' }}>
|
||||
{totalCount} template{totalCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
style={STYLES.btn}
|
||||
onClick={fetchTemplates}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canWrite() && (
|
||||
<button
|
||||
style={STYLES.btn}
|
||||
onClick={() => setModalState({ open: true, mode: 'create', template: null })}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create Template
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div style={STYLES.errorBanner}>
|
||||
<AlertCircle size={14} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div style={STYLES.loadingState}>
|
||||
<Loader size={24} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<span>Loading templates...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && templates.length === 0 && (
|
||||
<div style={STYLES.emptyState}>
|
||||
<FileText size={32} style={{ marginBottom: '0.75rem', opacity: 0.4 }} />
|
||||
<div>No templates found</div>
|
||||
{canWrite() && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.8rem', color: '#475569' }}>
|
||||
Click "Create Template" to add your first template.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template list grouped by vendor > platform */}
|
||||
{!loading && grouped.map(({ vendor, platforms }) => (
|
||||
<div key={vendor} style={STYLES.vendorGroup}>
|
||||
{/* Vendor header — collapsible */}
|
||||
<div
|
||||
style={STYLES.vendorHeader}
|
||||
onClick={() => toggleVendor(vendor)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.05)'; }}
|
||||
>
|
||||
{expandedVendors[vendor]
|
||||
? <ChevronDown size={14} style={{ color: '#00d4ff' }} />
|
||||
: <ChevronRight size={14} style={{ color: '#64748B' }} />
|
||||
}
|
||||
<span style={STYLES.vendorLabel}>{vendor}</span>
|
||||
<span style={STYLES.vendorCount}>
|
||||
{platforms.reduce((sum, p) => sum + p.templates.length, 0)} template{platforms.reduce((sum, p) => sum + p.templates.length, 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Platform subgroups */}
|
||||
{expandedVendors[vendor] && platforms.map(({ platform, templates: platTemplates }) => (
|
||||
<div key={platform} style={STYLES.platformSubgroup}>
|
||||
<div style={STYLES.platformLabel}>{platform}</div>
|
||||
{platTemplates.map(template => (
|
||||
<div key={template.id}>
|
||||
<div
|
||||
style={STYLES.templateRow}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0, 212, 255, 0.04)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span
|
||||
style={{ ...STYLES.templateModel, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
onClick={() => toggleView(template.id)}
|
||||
title="View template sections"
|
||||
>
|
||||
{viewExpandedId === template.id
|
||||
? <EyeOff size={13} style={{ color: '#00d4ff' }} />
|
||||
: <Eye size={13} style={{ color: '#64748B' }} />
|
||||
}
|
||||
{template.model}
|
||||
</span>
|
||||
{canWrite() && (
|
||||
<div style={STYLES.templateActions}>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'edit', template })}
|
||||
title="Edit template"
|
||||
>
|
||||
<Edit size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnSmall}
|
||||
onClick={() => setModalState({ open: true, mode: 'clone', template })}
|
||||
title="Clone template"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button
|
||||
style={STYLES.btnDanger}
|
||||
onClick={() => setDeleteTarget(template)}
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Expandable view panel */}
|
||||
{viewExpandedId === template.id && (
|
||||
<div style={{
|
||||
margin: '0.25rem 0 0.75rem 1.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.12)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
{/* Copy All button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => handleCopyAll(template)}
|
||||
style={{
|
||||
padding: '0.3rem 0.6rem',
|
||||
borderRadius: '5px',
|
||||
border: copyAllCopied ? '1px solid rgba(34, 197, 94, 0.4)' : '1px solid rgba(0, 212, 255, 0.3)',
|
||||
background: copyAllCopied ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 212, 255, 0.08)',
|
||||
color: copyAllCopied ? '#22c55e' : '#00d4ff',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
}}
|
||||
>
|
||||
{copyAllCopied ? <><Check size={11} /> Copied!</> : <><Clipboard size={11} /> Copy All</>}
|
||||
</button>
|
||||
</div>
|
||||
{/* Section blocks */}
|
||||
{SECTIONS.map(section => {
|
||||
const content = template[section.key];
|
||||
const isEmpty = !content || !content.trim();
|
||||
const isCopied = copiedSections[section.key];
|
||||
return (
|
||||
<div key={section.key} style={{
|
||||
marginBottom: '0.5rem',
|
||||
padding: '0.5rem 0.6rem',
|
||||
background: 'rgba(30, 41, 59, 0.5)',
|
||||
border: '1px solid rgba(100, 116, 139, 0.12)',
|
||||
borderRadius: '6px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{section.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopySection(section.key, content)}
|
||||
disabled={isEmpty}
|
||||
style={{
|
||||
padding: '0.2rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
border: isCopied ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(100, 116, 139, 0.25)',
|
||||
background: isCopied ? 'rgba(34, 197, 94, 0.1)' : 'rgba(100, 116, 139, 0.1)',
|
||||
color: isCopied ? '#22c55e' : '#94a3b8',
|
||||
fontSize: '0.65rem',
|
||||
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||
opacity: isEmpty ? 0.4 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
}}
|
||||
>
|
||||
{isCopied ? <><Check size={9} /> Copied!</> : <><Clipboard size={9} /> Copy</>}
|
||||
</button>
|
||||
</div>
|
||||
{isEmpty ? (
|
||||
<div style={{ fontSize: '0.78rem', color: '#475569', fontStyle: 'italic' }}>No content stored</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.78rem', color: '#e0e0e0', lineHeight: 1.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '150px', overflowY: 'auto' }}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template form modal (create/edit/clone) */}
|
||||
{modalState.open && (
|
||||
<TemplateFormModal
|
||||
mode={modalState.mode}
|
||||
template={modalState.template}
|
||||
onClose={() => setModalState({ open: false, mode: 'create', template: null })}
|
||||
onSuccess={fetchTemplates}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<DeleteConfirmModal
|
||||
template={deleteTarget}
|
||||
onConfirm={() => {
|
||||
setDeleteTarget(null);
|
||||
fetchTemplates();
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,33 @@
|
||||
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';
|
||||
|
||||
// Natural sort comparator for metric IDs like "2.3.6i", "5.2.6", "10.1.1".
|
||||
// Splits on "." and compares each segment numerically (trailing letters after digits sort after pure numbers).
|
||||
function compareMetricIds(a, b) {
|
||||
const partsA = a.split('.');
|
||||
const partsB = b.split('.');
|
||||
const len = Math.max(partsA.length, partsB.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const segA = partsA[i] || '';
|
||||
const segB = partsB[i] || '';
|
||||
const numA = parseFloat(segA) || 0;
|
||||
const numB = parseFloat(segB) || 0;
|
||||
if (numA !== numB) return numA - numB;
|
||||
// Same numeric prefix — compare the suffix (e.g. "6i" vs "6")
|
||||
const suffA = segA.replace(/^[\d.]+/, '');
|
||||
const suffB = segB.replace(/^[\d.]+/, '');
|
||||
if (suffA !== suffB) return suffA.localeCompare(suffB);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -107,7 +126,8 @@ function MetricBreakdownPanel({ metrics }) {
|
||||
if (!metrics || metrics.length === 0) return null;
|
||||
|
||||
// Only show metrics with non_compliant > 0
|
||||
const ncMetrics = metrics.filter(m => m.non_compliant > 0);
|
||||
const ncMetrics = metrics.filter(m => m.non_compliant > 0)
|
||||
.sort((a, b) => compareMetricIds(a.metric_id, b.metric_id));
|
||||
if (ncMetrics.length === 0) return null;
|
||||
|
||||
const TOP_COUNT = 8;
|
||||
@@ -393,8 +413,13 @@ function MetricTable({ metrics, onSelectMetric }) {
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE, marginTop: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
||||
Metrics Overview
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Metrics Overview
|
||||
</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748B', background: 'rgba(100, 116, 139, 0.1)', padding: '0.15rem 0.4rem', borderRadius: '3px', fontWeight: '600' }}>
|
||||
LAST REPORT
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={TABLE_STYLE}>
|
||||
@@ -624,9 +649,10 @@ function VerticalDetailView({ vertical, onBack, onSelectMetric }) {
|
||||
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: '#64748B' }}><Loader style={{ animation: 'spin 1s linear infinite' }} /> Loading...</div>;
|
||||
|
||||
// Filter metrics by team if a team filter is active
|
||||
const displayMetrics = teamFilter
|
||||
const displayMetrics = (teamFilter
|
||||
? metrics.filter(m => m.sub_teams && m.sub_teams.some(st => st.team === teamFilter))
|
||||
: metrics;
|
||||
: metrics
|
||||
).slice().sort((a, b) => compareMetricIds(a.metric_id, b.metric_id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -1187,6 +1213,327 @@ 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.slice().sort((a, b) => compareMetricIds(a.metric_id, b.metric_id)).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, compliant: (d.total_assets || 0) - (d.non_compliant || 0), isForecast: false })),
|
||||
...forecast.map(d => ({ ...d, compliant: (d.total_assets || 0) - (d.non_compliant || 0), 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 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;
|
||||
const displayValue = Number(value).toFixed(1);
|
||||
return (
|
||||
<text x={x} y={y - 22} textAnchor="middle" fill="#10B981" fillOpacity={opacity} fontSize={12} fontWeight="700">
|
||||
{displayValue}%
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ ...CARD_STYLE }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Forecast Burndown — {metricId}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.6rem', color: '#10B981', background: 'rgba(16, 185, 129, 0.1)', padding: '0.15rem 0.4rem', borderRadius: '3px', fontWeight: '600' }}>
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<ComposedChart data={combinedData} margin={{ top: 35, right: 40, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.03)" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
domain={[0, maxTotal]}
|
||||
tickCount={5}
|
||||
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]}
|
||||
ticks={[0, 25, 50, 75, 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.75rem', 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="compliant"
|
||||
name="Compliant"
|
||||
stackId="devices"
|
||||
fill="#3B82F6"
|
||||
shape={renderTotalAssetsBar}
|
||||
barSize={36}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="non_compliant"
|
||||
name="Non-Compliant"
|
||||
stackId="devices"
|
||||
fill="#F97316"
|
||||
shape={renderNonCompliantBar}
|
||||
barSize={36}
|
||||
/>
|
||||
<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 +1550,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 +1736,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}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2, Calendar, FileText, Save } from 'lucide-react';
|
||||
import ConfirmModal from '../ConfirmModal';
|
||||
import {
|
||||
formatResolutionDate,
|
||||
RESOLUTION_DATE_LABEL,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
INVALID_DATE_PLACEHOLDER,
|
||||
} from '../../utils/resolutionDate';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
@@ -38,7 +44,7 @@ function MetricChip({ metricId, category, status }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onNavigate }) {
|
||||
export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded, onMetadataSaved, onNavigate }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -55,12 +61,69 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaError, setMetaError] = useState(null);
|
||||
|
||||
const handleSaveMetadata = async (fields) => {
|
||||
// Per-metric metadata selection (separate from notes selector)
|
||||
const [metricSelection, setMetricSelection] = useState([]);
|
||||
// Track whether user has edited fields (to detect "Multiple values" untouched)
|
||||
const [resolutionDateEdited, setResolutionDateEdited] = useState(false);
|
||||
const [remediationPlanEdited, setRemediationPlanEdited] = useState(false);
|
||||
|
||||
// Compute shared values for selected metrics
|
||||
const computeSharedValues = useCallback((selectedIds, metrics) => {
|
||||
if (!metrics || selectedIds.length === 0) return { resolution_date: '', remediation_plan: '', resolutionMultiple: false, planMultiple: false };
|
||||
const selected = metrics.filter(m => selectedIds.includes(m.metric_id));
|
||||
if (selected.length === 0) return { resolution_date: '', remediation_plan: '', resolutionMultiple: false, planMultiple: false };
|
||||
|
||||
const dates = selected.map(m => m.resolution_date || '');
|
||||
const plans = selected.map(m => m.remediation_plan || '');
|
||||
|
||||
const allDatesMatch = dates.every(d => d === dates[0]);
|
||||
const allPlansMatch = plans.every(p => p === plans[0]);
|
||||
|
||||
return {
|
||||
resolution_date: allDatesMatch ? dates[0] : '',
|
||||
remediation_plan: allPlansMatch ? plans[0] : '',
|
||||
resolutionMultiple: !allDatesMatch,
|
||||
planMultiple: !allPlansMatch,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Recompute displayed values when metric selection changes
|
||||
useEffect(() => {
|
||||
if (!detail || metricSelection.length === 0) return;
|
||||
const shared = computeSharedValues(metricSelection, detail.metrics);
|
||||
setResolutionDate(shared.resolution_date);
|
||||
setRemediationPlan(shared.remediation_plan);
|
||||
setResolutionDateEdited(false);
|
||||
setRemediationPlanEdited(false);
|
||||
}, [metricSelection, detail, computeSharedValues]);
|
||||
|
||||
// Determine if "Multiple values" placeholders should show
|
||||
const sharedInfo = detail ? computeSharedValues(metricSelection, detail.metrics) : { resolutionMultiple: false, planMultiple: false };
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
setMetaSaving(true);
|
||||
setMetaError(null);
|
||||
try {
|
||||
const body = { ...fields };
|
||||
const body = {};
|
||||
|
||||
// Only include resolution_date if user edited it or it's not a "Multiple values" situation
|
||||
if (resolutionDateEdited || !sharedInfo.resolutionMultiple) {
|
||||
body.resolution_date = resolutionDate || null;
|
||||
}
|
||||
// Only include remediation_plan if user edited it or it's not a "Multiple values" situation
|
||||
if (remediationPlanEdited || !sharedInfo.planMultiple) {
|
||||
body.remediation_plan = remediationPlan || null;
|
||||
}
|
||||
|
||||
if (changeReason.trim()) body.change_reason = changeReason.trim();
|
||||
|
||||
// Per-metric scoping: omit metric_ids when all active metrics are selected (backward compat)
|
||||
const activeIds = (detail?.metrics || []).filter(m => m.status === 'active').map(m => m.metric_id);
|
||||
const allSelected = activeIds.length > 0 && activeIds.every(id => metricSelection.includes(id)) && metricSelection.length === activeIds.length;
|
||||
if (!allSelected && metricSelection.length > 0) {
|
||||
body.metric_ids = metricSelection;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
@@ -70,8 +133,11 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
|
||||
setChangeReason('');
|
||||
setResolutionDateEdited(false);
|
||||
setRemediationPlanEdited(false);
|
||||
// Re-fetch to get updated history
|
||||
await fetchDetail();
|
||||
if (onMetadataSaved) onMetadataSaved();
|
||||
} catch (err) {
|
||||
setMetaError(err.message);
|
||||
} finally {
|
||||
@@ -88,13 +154,20 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
||||
setDetail(data);
|
||||
|
||||
// Default selected metrics to first active failing metric
|
||||
// Default selected metrics to first active failing metric (for notes)
|
||||
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
|
||||
|
||||
// Populate metadata fields
|
||||
setResolutionDate(data.resolution_date || '');
|
||||
setRemediationPlan(data.remediation_plan || '');
|
||||
// Default metricSelection to ALL active metrics (for metadata editing)
|
||||
const allActiveIds = (data.metrics || []).filter(m => m.status === 'active').map(m => m.metric_id);
|
||||
setMetricSelection(allActiveIds);
|
||||
|
||||
// Populate metadata fields from shared values
|
||||
const shared = computeSharedValues(allActiveIds, data.metrics);
|
||||
setResolutionDate(shared.resolution_date);
|
||||
setRemediationPlan(shared.remediation_plan);
|
||||
setResolutionDateEdited(false);
|
||||
setRemediationPlanEdited(false);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -223,29 +296,98 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Resolved metrics */}
|
||||
{resolvedMetrics.length > 0 && (
|
||||
<Section title="Resolved Metrics" muted>
|
||||
{resolvedMetrics.map(m => (
|
||||
<MetricRow key={m.metric_id} metric={m} resolved />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Upload history summary */}
|
||||
{/* Metric Selector for Metadata Editing — placed right after Failing Metrics per issue #21 */}
|
||||
{activeMetrics.length > 0 && (
|
||||
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.map(m => (
|
||||
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||
{m.seen_count}× seen
|
||||
</span>
|
||||
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
|
||||
<Section title="Apply To Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.length > 1 && (() => {
|
||||
const allMetaSelected = activeMetrics.every(m => metricSelection.includes(m.metric_id)) && metricSelection.length === activeMetrics.length;
|
||||
return (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||
{metricSelection.length} of {activeMetrics.length} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allMetaSelected) {
|
||||
setMetricSelection([activeMetrics[0].metric_id]);
|
||||
} else {
|
||||
setMetricSelection(activeMetrics.map(m => m.metric_id));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: '0.68rem', fontFamily: 'monospace',
|
||||
color: TEAL, padding: 0,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{allMetaSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{activeMetrics.map(m => {
|
||||
const isSelected = metricSelection.includes(m.metric_id);
|
||||
const color = categoryColor(m.category);
|
||||
return (
|
||||
<button
|
||||
key={m.metric_id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
if (metricSelection.length > 1) {
|
||||
setMetricSelection(metricSelection.filter(id => id !== m.metric_id));
|
||||
}
|
||||
} else {
|
||||
setMetricSelection([...metricSelection, m.metric_id]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: isSelected ? `${color}25` : `${color}08`,
|
||||
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
|
||||
borderRadius: '0.25rem',
|
||||
color: isSelected ? color : `${color}90`,
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
cursor: (isSelected && metricSelection.length === 1) ? 'default' : 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
opacity: (isSelected && metricSelection.length === 1) ? 0.85 : 1,
|
||||
}}
|
||||
>
|
||||
{m.metric_id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{activeMetrics.length === 1 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{activeMetrics.map(m => {
|
||||
const color = categoryColor(m.category);
|
||||
return (
|
||||
<span
|
||||
key={m.metric_id}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: `${color}25`,
|
||||
border: `1px solid ${color}90`,
|
||||
borderRadius: '0.25rem',
|
||||
color: color,
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{m.metric_id}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -254,20 +396,27 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
<input
|
||||
type="date"
|
||||
value={resolutionDate}
|
||||
onChange={e => setResolutionDate(e.target.value)}
|
||||
onChange={e => { setResolutionDate(e.target.value); setResolutionDateEdited(true); }}
|
||||
placeholder={sharedInfo.resolutionMultiple ? 'Multiple values' : ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F8FAFC',
|
||||
color: sharedInfo.resolutionMultiple && !resolutionDateEdited ? '#64748B' : '#F8FAFC',
|
||||
padding: '0.5rem 0.625rem',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
colorScheme: 'dark',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||
/>
|
||||
{sharedInfo.resolutionMultiple && !resolutionDateEdited && (
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', fontStyle: 'italic', marginTop: '0.25rem' }}>
|
||||
Multiple values — leave unchanged to preserve per-metric dates
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Remediation Plan */}
|
||||
@@ -275,16 +424,16 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
<textarea
|
||||
value={remediationPlan}
|
||||
onChange={e => {
|
||||
if (e.target.value.length <= 2000) setRemediationPlan(e.target.value);
|
||||
if (e.target.value.length <= 2000) { setRemediationPlan(e.target.value); setRemediationPlanEdited(true); }
|
||||
}}
|
||||
placeholder="Describe the remediation plan…"
|
||||
placeholder={sharedInfo.planMultiple ? 'Multiple values' : 'Describe the remediation plan…'}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%', resize: 'vertical',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F8FAFC',
|
||||
color: sharedInfo.planMultiple && !remediationPlanEdited ? '#64748B' : '#F8FAFC',
|
||||
padding: '0.5rem 0.625rem',
|
||||
fontSize: '0.8rem',
|
||||
outline: 'none',
|
||||
@@ -293,12 +442,17 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
||||
/>
|
||||
{sharedInfo.planMultiple && !remediationPlanEdited && (
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748B', fontStyle: 'italic', marginTop: '0.25rem' }}>
|
||||
Multiple values — leave unchanged to preserve per-metric plans
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.4rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: remediationPlan.length > 1900 ? '#F59E0B' : '#475569' }}>
|
||||
{remediationPlan.length}/2000
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSaveMetadata({ resolution_date: resolutionDate || null, remediation_plan: remediationPlan || null })}
|
||||
onClick={() => handleSaveMetadata()}
|
||||
disabled={metaSaving}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||
@@ -342,10 +496,40 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Resolved metrics */}
|
||||
{resolvedMetrics.length > 0 && (
|
||||
<Section title="Resolved Metrics" muted>
|
||||
{resolvedMetrics.map(m => (
|
||||
<MetricRow key={m.metric_id} metric={m} resolved />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Upload history summary */}
|
||||
{activeMetrics.length > 0 && (
|
||||
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
{activeMetrics.map(m => (
|
||||
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||
{m.seen_count}× seen
|
||||
</span>
|
||||
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Change History */}
|
||||
{detail.history && detail.history.length > 0 && (
|
||||
<Section title="Change History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
||||
{(() => {
|
||||
// Build metricMap from metrics array for chip coloring
|
||||
const metricMap = {};
|
||||
(detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; });
|
||||
|
||||
// Group entries by timestamp + user (entries saved together appear as one)
|
||||
const groups = [];
|
||||
for (const h of detail.history) {
|
||||
@@ -366,7 +550,19 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
</span>
|
||||
</div>
|
||||
{group.entries.map(h => (
|
||||
<div key={h.id} style={{ marginBottom: '0.2rem' }}>
|
||||
<div key={h.id} style={{ marginBottom: '0.2rem', display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||||
{h.metric_id ? (
|
||||
<MetricChip metricId={h.metric_id} category={metricMap[h.metric_id] || ''} />
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: '0.68rem',
|
||||
color: '#64748B',
|
||||
fontStyle: 'italic',
|
||||
padding: '0.15rem 0.4rem',
|
||||
background: 'rgba(100,116,139,0.1)',
|
||||
borderRadius: '0.2rem',
|
||||
}}>All metrics</span>
|
||||
)}
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748B' }}>
|
||||
{h.field_name === 'resolution_date' ? '📅 ' : '📋 '}
|
||||
</span>
|
||||
@@ -608,6 +804,19 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
|
||||
if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
|
||||
|
||||
// Read-only estimated resolution date, shown only for active (noncompliant)
|
||||
// metrics at the top of the section. Derived solely from this metric's own
|
||||
// resolution_date — no editing, no shared/"Multiple values" collapsing.
|
||||
const resolutionDisplay = resolved ? null : formatResolutionDate(metric.resolution_date);
|
||||
const resolutionValueText = resolutionDisplay
|
||||
? (resolutionDisplay.state === 'set'
|
||||
? resolutionDisplay.value
|
||||
: resolutionDisplay.state === 'invalid'
|
||||
? INVALID_DATE_PLACEHOLDER
|
||||
: NO_DATE_PLACEHOLDER)
|
||||
: null;
|
||||
const resolutionMuted = resolutionDisplay && resolutionDisplay.state !== 'set';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||
@@ -616,6 +825,23 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
borderRadius: '0.375rem',
|
||||
opacity: resolved ? 0.5 : 1,
|
||||
}}>
|
||||
{resolutionDisplay && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
|
||||
<Calendar size={12} style={{ color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#64748B', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||
{RESOLUTION_DATE_LABEL}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '0.68rem',
|
||||
color: resolutionMuted ? '#475569' : TEAL,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: resolutionMuted ? '400' : '600',
|
||||
fontStyle: resolutionMuted ? 'italic' : 'normal',
|
||||
}}>
|
||||
{resolutionValueText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
|
||||
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
|
||||
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
|
||||
|
||||
@@ -93,6 +93,7 @@ function groupByMetricFamily(allEntries, team) {
|
||||
function VariantPill({ entry, label }) {
|
||||
const color = statusColor(entry.status);
|
||||
const isOk = entry.status === 'Meets/Exceeds Target';
|
||||
const hasRawCounts = entry.compliant != null && entry.total != null && entry.total > 0;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
@@ -116,6 +117,9 @@ function VariantPill({ entry, label }) {
|
||||
)}
|
||||
{label && <span style={{ color: '#94A3B8' }}>{label}</span>}
|
||||
<span style={{ color, fontWeight: '600' }}>{pctDisplay(entry.compliance_pct)}</span>
|
||||
{hasRawCounts && (
|
||||
<span style={{ color: '#64748B', fontSize: '0.58rem' }}>({entry.compliant}/{entry.total})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -693,6 +697,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
hostname={selectedHost}
|
||||
onClose={() => setSelectedHost(null)}
|
||||
onNoteAdded={refresh}
|
||||
onMetadataSaved={refresh}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
@@ -877,7 +882,7 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
|
||||
{/* Resolution Date */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#94A3B8' }}>
|
||||
{device.resolution_date || '—'}
|
||||
{device.resolution_date ? device.resolution_date.slice(0, 10) : '—'}
|
||||
</div>
|
||||
|
||||
{/* Remediation Plan */}
|
||||
|
||||
@@ -132,6 +132,42 @@ async function fetchAtlasStatus() {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchJiraTickets() {
|
||||
const res = await fetch(`${API_BASE}/jira-tickets`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Jira tickets returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCCPStats() {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl-multi/stats`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`CCP stats returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCCPVerticals() {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl-multi/verticals`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`CCP verticals returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCCPMetrics() {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl-multi/metrics`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`CCP metrics returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCCPTrend() {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl-multi/trend`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`CCP trend returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchCCPVerticalMetrics(code) {
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(code)}/metrics`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`CCP vertical metrics returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchAtlasAndFindings(teamsParam) {
|
||||
const [atlasRows, findings] = await Promise.all([fetchAtlasStatus(), fetchFindings(teamsParam)]);
|
||||
// Build a lookup from hostId → finding details (hostname, IP, BU, etc.)
|
||||
@@ -430,6 +466,234 @@ export default function ExportsPage() {
|
||||
toMultiXLSX(sheets, `atlas-full-report-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 7: Jira Tickets ----
|
||||
|
||||
const exportJiraAll = () => run('jira-all', async () => {
|
||||
const tickets = await fetchJiraTickets();
|
||||
const headers = ['Ticket Key', 'CVE', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced', 'Created'];
|
||||
const rows = tickets.map(t => [
|
||||
t.ticket_key, t.cve_id, t.vendor || '', t.summary || '', t.status || 'Open',
|
||||
t.source_context || 'cve', t.url || '',
|
||||
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
|
||||
t.created_at ? new Date(t.created_at).toLocaleDateString() : '',
|
||||
]);
|
||||
toXLSX([headers, ...rows], 'All Tickets', `jira-tickets-all-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportJiraOpen = () => run('jira-open', async () => {
|
||||
const tickets = await fetchJiraTickets();
|
||||
const closedStatuses = ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'];
|
||||
const open = tickets.filter(t => {
|
||||
const lower = (t.status || '').toLowerCase();
|
||||
return !closedStatuses.some(s => lower.includes(s));
|
||||
});
|
||||
const headers = ['Ticket Key', 'CVE', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced', 'Created'];
|
||||
const rows = open.map(t => [
|
||||
t.ticket_key, t.cve_id, t.vendor || '', t.summary || '', t.status || 'Open',
|
||||
t.source_context || 'cve', t.url || '',
|
||||
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
|
||||
t.created_at ? new Date(t.created_at).toLocaleDateString() : '',
|
||||
]);
|
||||
toXLSX([headers, ...rows], 'Open Tickets', `jira-tickets-open-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportJiraByCVE = () => run('jira-by-cve', async () => {
|
||||
const tickets = await fetchJiraTickets();
|
||||
const groups = {};
|
||||
tickets.forEach(t => {
|
||||
const key = t.cve_id || 'No CVE';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(t);
|
||||
});
|
||||
const headers = ['Ticket Key', 'Vendor', 'Summary', 'Status', 'Source', 'URL', 'Last Synced'];
|
||||
const sheets = Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([cve, tix]) => ({
|
||||
name: cve.slice(0, 31),
|
||||
rows: [headers, ...tix.map(t => [
|
||||
t.ticket_key, t.vendor || '', t.summary || '', t.status || 'Open',
|
||||
t.source_context || 'cve', t.url || '',
|
||||
t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never',
|
||||
])],
|
||||
}));
|
||||
if (sheets.length === 0) sheets.push({ name: 'No Data', rows: [headers] });
|
||||
toMultiXLSX(sheets, `jira-tickets-by-cve-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 8: CCP Metrics ----
|
||||
|
||||
const exportCCPSnapshot = () => run('ccp-snapshot', async () => {
|
||||
const stats = await fetchCCPStats();
|
||||
const verticals = stats.verticals || [];
|
||||
const headers = ['Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %', 'Failing Metrics', 'Report Date'];
|
||||
const rows = verticals.map(v => [
|
||||
v.vertical || v.code || '',
|
||||
v.total_devices ?? v.totalDevices ?? '',
|
||||
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
|
||||
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : (v.compliancePct != null ? `${Number(v.compliancePct).toFixed(1)}%` : ''),
|
||||
v.failing_metrics ?? v.failingMetrics ?? '',
|
||||
v.report_date ?? v.reportDate ?? '',
|
||||
]);
|
||||
toXLSX([headers, ...rows], 'CCP Snapshot', `ccp-compliance-snapshot-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportCCPNonCompliant = () => run('ccp-noncompliant', async () => {
|
||||
const verticals = await fetchCCPVerticals();
|
||||
const allRows = [];
|
||||
for (const v of verticals) {
|
||||
const code = v.code || v.vertical;
|
||||
if (!code) continue;
|
||||
try {
|
||||
const metrics = await fetchCCPVerticalMetrics(code);
|
||||
const metricList = metrics.metrics || metrics || [];
|
||||
metricList.forEach(m => {
|
||||
const devices = m.devices || [];
|
||||
devices.forEach(d => {
|
||||
allRows.push([
|
||||
code, m.metric_id || m.metricId || '', m.metric_desc || m.metricDesc || '',
|
||||
d.hostname || '', d.ip_address || d.ipAddress || '', d.device_type || d.deviceType || '',
|
||||
d.team || '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip verticals that fail
|
||||
}
|
||||
}
|
||||
const headers = ['Vertical', 'Metric ID', 'Metric Description', 'Hostname', 'IP Address', 'Device Type', 'Team'];
|
||||
toXLSX([headers, ...allRows], 'Non-Compliant Devices', `ccp-non-compliant-devices-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportCCPTrend = () => run('ccp-trend', async () => {
|
||||
const trend = await fetchCCPTrend();
|
||||
const snapshots = trend.snapshots || trend || [];
|
||||
const headers = ['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'];
|
||||
const rows = snapshots.flatMap(s => {
|
||||
const date = s.report_date || s.reportDate || s.date || '';
|
||||
const verts = s.verticals || [s];
|
||||
return verts.map(v => [
|
||||
date,
|
||||
v.vertical || v.code || '',
|
||||
v.total_devices ?? v.totalDevices ?? '',
|
||||
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
|
||||
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
|
||||
]);
|
||||
});
|
||||
toXLSX([headers, ...rows], 'Trend', `ccp-compliance-trend-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
const exportCCPFull = () => run('ccp-full', async () => {
|
||||
const [stats, trend] = await Promise.all([fetchCCPStats(), fetchCCPTrend()]);
|
||||
const verticals = stats.verticals || [];
|
||||
const snapshots = trend.snapshots || trend || [];
|
||||
|
||||
// Sheet 1: Summary
|
||||
const summaryHeaders = ['Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %', 'Failing Metrics', 'Report Date'];
|
||||
const summaryRows = verticals.map(v => [
|
||||
v.vertical || v.code || '',
|
||||
v.total_devices ?? v.totalDevices ?? '',
|
||||
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
|
||||
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
|
||||
v.failing_metrics ?? v.failingMetrics ?? '',
|
||||
v.report_date ?? v.reportDate ?? '',
|
||||
]);
|
||||
|
||||
// Sheet 2: Trend
|
||||
const trendHeaders = ['Date', 'Vertical', 'Total Devices', 'Non-Compliant', 'Compliance %'];
|
||||
const trendRows = snapshots.flatMap(s => {
|
||||
const date = s.report_date || s.reportDate || s.date || '';
|
||||
const verts = s.verticals || [s];
|
||||
return verts.map(v => [
|
||||
date, v.vertical || v.code || '',
|
||||
v.total_devices ?? v.totalDevices ?? '',
|
||||
v.non_compliant_devices ?? v.nonCompliantDevices ?? '',
|
||||
v.compliance_pct != null ? `${Number(v.compliance_pct).toFixed(1)}%` : '',
|
||||
]);
|
||||
});
|
||||
|
||||
toMultiXLSX([
|
||||
{ name: 'Summary', rows: [summaryHeaders, ...summaryRows] },
|
||||
{ name: 'Trend', rows: [trendHeaders, ...trendRows] },
|
||||
], `ccp-full-report-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Card 9: Remediation Status (Cross-Domain) ----
|
||||
|
||||
const exportRemediationStatus = () => run('remediation', async () => {
|
||||
const [cves, tickets, archer, findings] = await Promise.all([
|
||||
fetchCVEs(''),
|
||||
fetchJiraTickets(),
|
||||
fetchArcher(),
|
||||
fetchFindings(teamsParam),
|
||||
]);
|
||||
|
||||
// Build lookup maps
|
||||
const ticketsByCVE = {};
|
||||
tickets.forEach(t => {
|
||||
const key = `${t.cve_id}|${t.vendor || ''}`;
|
||||
if (!ticketsByCVE[key]) ticketsByCVE[key] = [];
|
||||
ticketsByCVE[key].push(t);
|
||||
});
|
||||
|
||||
const archerByCVE = {};
|
||||
archer.forEach(a => {
|
||||
const key = `${a.cve_id}|${a.vendor || ''}`;
|
||||
if (!archerByCVE[key]) archerByCVE[key] = [];
|
||||
archerByCVE[key].push(a);
|
||||
});
|
||||
|
||||
const findingsByCVE = {};
|
||||
findings.forEach(f => {
|
||||
(f.cves || []).forEach(cve => {
|
||||
if (!findingsByCVE[cve]) findingsByCVE[cve] = [];
|
||||
findingsByCVE[cve].push(f);
|
||||
});
|
||||
});
|
||||
|
||||
const headers = [
|
||||
'CVE ID', 'Vendor', 'Severity', 'CVE Status',
|
||||
'Jira Tickets', 'Jira Statuses',
|
||||
'Archer EXC#', 'Archer Status',
|
||||
'Ivanti Findings', 'Overdue Findings',
|
||||
'Overall Progress',
|
||||
];
|
||||
|
||||
const rows = cves.map(c => {
|
||||
const key = `${c.cve_id}|${c.vendor}`;
|
||||
const cveTickets = ticketsByCVE[key] || [];
|
||||
const cveArcher = archerByCVE[key] || [];
|
||||
const cveFindings = findingsByCVE[c.cve_id] || [];
|
||||
const today = dateStr();
|
||||
const overdueCount = cveFindings.filter(f => f.dueDate && f.dueDate < today).length;
|
||||
|
||||
// Determine overall progress
|
||||
let progress = 'Not Started';
|
||||
if (cveTickets.length > 0 || cveArcher.length > 0) {
|
||||
const closedKeywords = ['closed', 'done', 'resolved', 'complete', 'completed'];
|
||||
const allTicketsClosed = cveTickets.length > 0 && cveTickets.every(t => closedKeywords.some(s => (t.status || '').toLowerCase().includes(s)));
|
||||
const allArcherAccepted = cveArcher.length > 0 && cveArcher.every(a => a.status === 'Accepted');
|
||||
if (allTicketsClosed && (cveArcher.length === 0 || allArcherAccepted)) {
|
||||
progress = 'Complete';
|
||||
} else {
|
||||
progress = 'In Progress';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
c.cve_id, c.vendor, c.severity, c.status,
|
||||
cveTickets.map(t => t.ticket_key).join(', '),
|
||||
cveTickets.map(t => `${t.ticket_key}: ${t.status || 'Open'}`).join('; '),
|
||||
cveArcher.map(a => a.exc_number).join(', '),
|
||||
cveArcher.map(a => `${a.exc_number}: ${a.status}`).join('; '),
|
||||
cveFindings.length,
|
||||
overdueCount,
|
||||
progress,
|
||||
];
|
||||
});
|
||||
|
||||
toXLSX([headers, ...rows], 'Remediation Status', `remediation-status-${dateStr()}.xlsx`);
|
||||
});
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
if (!canExport()) {
|
||||
@@ -581,6 +845,53 @@ export default function ExportsPage() {
|
||||
</p>
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 7: Jira Tickets ── */}
|
||||
<ExportCard
|
||||
color="#7DD3FC" colorRgb="125,211,252"
|
||||
icon={FileText}
|
||||
title="Jira Tickets"
|
||||
description="Export Jira ticket tracking data. Full list, open/active only, or a multi-sheet workbook grouped by CVE for remediation status meetings."
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
||||
<ExportBtn label="All Tickets" exportKey="jira-all" loading={loading} color="#7DD3FC" colorRgb="125,211,252" onClick={exportJiraAll} />
|
||||
<ExportBtn label="Open/Active Only" exportKey="jira-open" loading={loading} color="#7DD3FC" colorRgb="125,211,252" onClick={exportJiraOpen} />
|
||||
</div>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<ExportBtn label="By CVE (multi-sheet)" exportKey="jira-by-cve" loading={loading} color="#7DD3FC" colorRgb="125,211,252" onClick={exportJiraByCVE} />
|
||||
</div>
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 8: CCP Metrics ── */}
|
||||
<ExportCard
|
||||
color="#14B8A6" colorRgb="20,184,166"
|
||||
icon={BarChart2}
|
||||
title="CCP Compliance Metrics"
|
||||
description="Export cross-vertical compliance posture data. Current snapshot, non-compliant device list, historical trend, or a combined multi-sheet report."
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
||||
<ExportBtn label="Current Snapshot" exportKey="ccp-snapshot" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPSnapshot} />
|
||||
<ExportBtn label="Non-Compliant Devices" exportKey="ccp-noncompliant" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPNonCompliant} />
|
||||
<ExportBtn label="Trend History" exportKey="ccp-trend" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPTrend} />
|
||||
<ExportBtn label="Full Report (multi-sheet)" exportKey="ccp-full" loading={loading} color="#14B8A6" colorRgb="20,184,166" onClick={exportCCPFull} />
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
"Non-Compliant Devices" fetches per-metric device lists for all verticals — may take a moment.
|
||||
</p>
|
||||
</ExportCard>
|
||||
|
||||
{/* ── Card 9: Remediation Status (Cross-Domain) ── */}
|
||||
<ExportCard
|
||||
color="#EC4899" colorRgb="236,72,153"
|
||||
icon={Shield}
|
||||
title="Remediation Status Report"
|
||||
description="Cross-domain view combining CVE entries, linked Jira tickets, Archer exceptions, and Ivanti findings into a single per-CVE/vendor row. Shows overall progress (Not Started, In Progress, Complete) based on ticket and exception statuses."
|
||||
>
|
||||
<ExportBtn label="Export Remediation Status (.xlsx)" exportKey="remediation" loading={loading} color="#EC4899" colorRgb="236,72,153" onClick={exportRemediationStatus} />
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
Pulls from CVE database, Jira tickets, Archer tickets, and Ivanti findings cache. Best for leadership status updates.
|
||||
</p>
|
||||
</ExportCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
1104
frontend/src/components/pages/IvantiTodoQueuePage.js
Normal file
1104
frontend/src/components/pages/IvantiTodoQueuePage.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,7 @@ const STYLES = {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${color}`,
|
||||
background: color.replace(')', ', 0.15)').replace('rgb', 'rgba'),
|
||||
background: `${color}26`,
|
||||
color: color,
|
||||
}),
|
||||
modal: {
|
||||
@@ -134,6 +134,94 @@ const STATUS_COLORS = {
|
||||
'Open': '#F59E0B',
|
||||
'In Progress': '#0EA5E9',
|
||||
'Closed': '#10B981',
|
||||
'Done': '#10B981',
|
||||
'Resolved': '#10B981',
|
||||
'Approval/Handoff': '#8B5CF6',
|
||||
'Prioritizing': '#0EA5E9',
|
||||
'In Review': '#0EA5E9',
|
||||
'In Development': '#0EA5E9',
|
||||
'In Testing': '#0EA5E9',
|
||||
};
|
||||
|
||||
// Determine if a status represents a "closed/done" state
|
||||
function isClosedStatus(status) {
|
||||
if (!status) return false;
|
||||
const lower = status.toLowerCase();
|
||||
return ['closed', 'done', 'resolved', 'complete', 'completed', 'cancelled', 'canceled', "won't do", 'declined'].some(s => lower.includes(s));
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
if (STATUS_COLORS[status]) return STATUS_COLORS[status];
|
||||
if (isClosedStatus(status)) return '#10B981';
|
||||
return '#F59E0B';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendor issue type configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add new vendor project keys here to enable vendor-specific issue types
|
||||
const VENDOR_PROJECT_KEYS = [
|
||||
'AA_ADTRAN',
|
||||
'AA_ADVA',
|
||||
'AA_CASA',
|
||||
'AA_CISCO',
|
||||
'AACOMMSCOP',
|
||||
'AA_COMMSCOP',
|
||||
'AA_HARMONI',
|
||||
'AA_JUNIPER',
|
||||
'AA_VECIMA',
|
||||
'AA_VIAVI',
|
||||
];
|
||||
|
||||
const VENDOR_ISSUE_TYPES = [
|
||||
'Epic',
|
||||
'Story',
|
||||
'Task',
|
||||
'Defect',
|
||||
'Production Defect/Incident Fix',
|
||||
'New Feature',
|
||||
'Spike',
|
||||
'Release Candidate',
|
||||
'Documentation',
|
||||
];
|
||||
|
||||
const STEAM_ISSUE_TYPES = [
|
||||
'Story',
|
||||
'Epic',
|
||||
'Program',
|
||||
'Project',
|
||||
'Reservation',
|
||||
'Automation Maintenance',
|
||||
];
|
||||
|
||||
/**
|
||||
* Determines whether a project key belongs to a vendor project.
|
||||
*/
|
||||
function isVendorProject(projectKey, vendorKeys) {
|
||||
if (!projectKey || typeof projectKey !== 'string') return false;
|
||||
const normalized = projectKey.trim().toUpperCase();
|
||||
if (normalized.length === 0) return false;
|
||||
return vendorKeys.includes(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate issue type list for a given project key.
|
||||
*/
|
||||
function getIssueTypesForProject(projectKey, vendorKeys, vendorTypes, steamTypes) {
|
||||
return isVendorProject(projectKey, vendorKeys) ? vendorTypes : steamTypes;
|
||||
}
|
||||
|
||||
const SOURCE_CONTEXT_CONFIG = {
|
||||
cve: { label: 'CVE', color: '#0EA5E9' },
|
||||
archer: { label: 'Archer', color: '#8B5CF6' },
|
||||
ivanti_queue: { label: 'Ivanti', color: '#F59E0B' },
|
||||
email: { label: 'Email', color: '#10B981' },
|
||||
manual: { label: 'Manual', color: '#94A3B8' },
|
||||
};
|
||||
|
||||
const getSourceBadge = (sourceContext) => {
|
||||
if (!sourceContext) return SOURCE_CONTEXT_CONFIG.cve; // legacy tickets default to CVE
|
||||
return SOURCE_CONTEXT_CONFIG[sourceContext] || SOURCE_CONTEXT_CONFIG.cve;
|
||||
};
|
||||
|
||||
|
||||
@@ -150,6 +238,7 @@ export default function JiraPage() {
|
||||
|
||||
// Filters
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterSource, setFilterSource] = useState('');
|
||||
const [filterSearch, setFilterSearch] = useState('');
|
||||
|
||||
// Connection test
|
||||
@@ -168,6 +257,9 @@ export default function JiraPage() {
|
||||
const [lookupResult, setLookupResult] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [lookupError, setLookupError] = useState(null);
|
||||
const [linkingSaving, setLinkingSaving] = useState(false);
|
||||
const [linkingError, setLinkingError] = useState(null);
|
||||
const [linkingSuccess, setLinkingSuccess] = useState(null);
|
||||
|
||||
// Add/Edit modal
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -178,9 +270,11 @@ export default function JiraPage() {
|
||||
|
||||
// Create-in-Jira modal
|
||||
const [showCreateJira, setShowCreateJira] = useState(false);
|
||||
const [createJiraForm, setCreateJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '' });
|
||||
const [createJiraForm, setCreateJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '', source_context: '' });
|
||||
const [createJiraError, setCreateJiraError] = useState(null);
|
||||
const [createJiraSaving, setCreateJiraSaving] = useState(false);
|
||||
const [createJiraLocked, setCreateJiraLocked] = useState({}); // { source_context: true } when set externally
|
||||
const [summaryError, setSummaryError] = useState(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
@@ -271,6 +365,37 @@ export default function JiraPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link existing Jira ticket — save to local DB without recreating in Jira
|
||||
// ---------------------------------------------------------------------------
|
||||
const linkExistingTicket = async (issue) => {
|
||||
setLinkingError(null);
|
||||
setLinkingSuccess(null);
|
||||
setLinkingSaving(true);
|
||||
try {
|
||||
const jiraUrl = `https://jira.charter.com/browse/${issue.key}`;
|
||||
const res = await fetch(`${API_BASE}/jira-tickets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
ticket_key: issue.key,
|
||||
url: jiraUrl,
|
||||
summary: issue.summary || '',
|
||||
status: issue.status || 'Open',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setLinkingSuccess(`${issue.key} saved to dashboard.`);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
setLinkingError(err.message);
|
||||
} finally {
|
||||
setLinkingSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD — save (create or update)
|
||||
@@ -333,21 +458,41 @@ export default function JiraPage() {
|
||||
// Create in Jira
|
||||
// ---------------------------------------------------------------------------
|
||||
const createInJira = async () => {
|
||||
// Inline summary validation
|
||||
setSummaryError(null);
|
||||
const trimmedSummary = (createJiraForm.summary || '').trim();
|
||||
if (!trimmedSummary) {
|
||||
setSummaryError('Summary is required.');
|
||||
return;
|
||||
}
|
||||
if (trimmedSummary.length > 255) {
|
||||
setSummaryError('Summary must be 255 characters or fewer.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateJiraError(null);
|
||||
setCreateJiraSaving(true);
|
||||
try {
|
||||
// Build payload — only include source_context when selected
|
||||
const payload = { ...createJiraForm };
|
||||
if (!payload.source_context) {
|
||||
delete payload.source_context;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(createJiraForm),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok && res.status !== 207) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setShowCreateJira(false);
|
||||
setCreateJiraForm({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '' });
|
||||
setCreateJiraForm({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '', source_context: '' });
|
||||
setCreateJiraLocked({});
|
||||
setSummaryError(null);
|
||||
fetchTickets();
|
||||
fetchRateLimit();
|
||||
} catch (err) {
|
||||
@@ -357,26 +502,51 @@ export default function JiraPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open Create-in-Jira modal with optional pre-populated values and locks
|
||||
// Called externally from Ivanti queue or Archer detail views
|
||||
// ---------------------------------------------------------------------------
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const openCreateJiraModal = (prePopulate = {}, locked = {}) => {
|
||||
setCreateJiraForm({
|
||||
cve_id: prePopulate.cve_id || '',
|
||||
vendor: prePopulate.vendor || '',
|
||||
summary: prePopulate.summary || '',
|
||||
description: prePopulate.description || '',
|
||||
project_key: prePopulate.project_key || '',
|
||||
issue_type: prePopulate.issue_type || '',
|
||||
source_context: prePopulate.source_context || '',
|
||||
});
|
||||
setCreateJiraLocked(locked);
|
||||
setCreateJiraError(null);
|
||||
setSummaryError(null);
|
||||
setShowCreateJira(true);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
const filtered = tickets.filter(t => {
|
||||
if (filterStatus && t.status !== filterStatus) return false;
|
||||
if (filterSource) {
|
||||
const ticketSource = t.source_context || 'cve';
|
||||
if (ticketSource !== filterSource) return false;
|
||||
}
|
||||
if (filterSearch) {
|
||||
const q = filterSearch.toLowerCase();
|
||||
return (t.ticket_key || '').toLowerCase().includes(q)
|
||||
|| (t.cve_id || '').toLowerCase().includes(q)
|
||||
|| (t.vendor || '').toLowerCase().includes(q)
|
||||
|| (t.summary || '').toLowerCase().includes(q);
|
||||
|| (t.summary || '').toLowerCase().includes(q)
|
||||
|| (t.source_context || '').toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const counts = {
|
||||
total: tickets.length,
|
||||
open: tickets.filter(t => t.status === 'Open').length,
|
||||
inProgress: tickets.filter(t => t.status === 'In Progress').length,
|
||||
closed: tickets.filter(t => t.status === 'Closed').length,
|
||||
open: tickets.filter(t => !isClosedStatus(t.status)).length,
|
||||
closed: tickets.filter(t => isClosedStatus(t.status)).length,
|
||||
};
|
||||
|
||||
|
||||
@@ -405,7 +575,7 @@ export default function JiraPage() {
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<>
|
||||
<button style={STYLES.btn} onClick={() => { setShowCreateJira(true); setCreateJiraError(null); }}>
|
||||
<button style={STYLES.btn} onClick={() => { setShowCreateJira(true); setCreateJiraError(null); setSummaryError(null); setCreateJiraLocked({}); setCreateJiraForm({ cve_id: '', vendor: '', summary: '', description: '', project_key: '', issue_type: '', source_context: '' }); }}>
|
||||
<Plus size={14} /> Create in Jira
|
||||
</button>
|
||||
<button style={STYLES.btn} onClick={() => { setEditingId(null); setForm({ cve_id: '', vendor: '', ticket_key: '', url: '', summary: '', status: 'Open' }); setFormError(null); setShowForm(true); }}>
|
||||
@@ -453,7 +623,6 @@ export default function JiraPage() {
|
||||
{[
|
||||
{ label: 'Total', value: counts.total, color: '#0EA5E9' },
|
||||
{ label: 'Open', value: counts.open, color: '#F59E0B' },
|
||||
{ label: 'In Progress', value: counts.inProgress, color: '#0EA5E9' },
|
||||
{ label: 'Closed', value: counts.closed, color: '#10B981' },
|
||||
].map(s => (
|
||||
<div key={s.label} style={STYLES.statCard}>
|
||||
@@ -490,9 +659,21 @@ export default function JiraPage() {
|
||||
onChange={e => setFilterStatus(e.target.value)}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
{[...new Set(tickets.map(t => t.status).filter(Boolean))].sort().map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
style={{ ...STYLES.input, width: 'auto', minWidth: '140px', cursor: 'pointer' }}
|
||||
value={filterSource}
|
||||
onChange={e => setFilterSource(e.target.value)}
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="cve">CVE</option>
|
||||
<option value="archer">Archer</option>
|
||||
<option value="ivanti_queue">Ivanti</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -519,9 +700,9 @@ export default function JiraPage() {
|
||||
<th style={STYLES.th}>Ticket</th>
|
||||
<th style={STYLES.th}>CVE</th>
|
||||
<th style={STYLES.th}>Vendor</th>
|
||||
<th style={STYLES.th}>Source</th>
|
||||
<th style={STYLES.th}>Summary</th>
|
||||
<th style={STYLES.th}>Status</th>
|
||||
<th style={STYLES.th}>Jira Status</th>
|
||||
<th style={STYLES.th}>Last Synced</th>
|
||||
<th style={STYLES.th}>Actions</th>
|
||||
</tr>
|
||||
@@ -544,14 +725,35 @@ export default function JiraPage() {
|
||||
</td>
|
||||
<td style={{ ...STYLES.td, fontFamily: 'monospace', fontSize: '0.8rem' }}>{t.cve_id}</td>
|
||||
<td style={STYLES.td}>{t.vendor}</td>
|
||||
<td style={STYLES.td}>
|
||||
{(() => {
|
||||
const badge = getSourceBadge(t.source_context);
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.15rem 0.5rem',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
background: `${badge.color}22`,
|
||||
color: badge.color,
|
||||
border: `1px solid ${badge.color}44`,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ ...STYLES.td, maxWidth: '250px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.summary || '-'}</td>
|
||||
<td style={STYLES.td}>
|
||||
<span style={STYLES.badge(STATUS_COLORS[t.status] || '#94A3B8')}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLORS[t.status] || '#94A3B8' }} />
|
||||
<span style={STYLES.badge(getStatusColor(t.status))}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: getStatusColor(t.status) }} />
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...STYLES.td, fontSize: '0.8rem', color: '#CBD5E1' }}>{t.jira_status || '-'}</td>
|
||||
<td style={{ ...STYLES.td, fontSize: '0.75rem', color: '#94A3B8' }}>
|
||||
{t.last_synced_at ? new Date(t.last_synced_at).toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
@@ -618,6 +820,18 @@ export default function JiraPage() {
|
||||
<div><strong>Priority:</strong> {lookupResult.priority}</div>
|
||||
<div><strong>Assignee:</strong> {lookupResult.assignee || 'Unassigned'}</div>
|
||||
<div><strong>Updated:</strong> {lookupResult.updated ? new Date(lookupResult.updated).toLocaleString() : '-'}</div>
|
||||
{canWrite() && (
|
||||
<button
|
||||
style={{ ...STYLES.btn, ...STYLES.btnSuccess, marginTop: '0.75rem' }}
|
||||
onClick={() => linkExistingTicket(lookupResult)}
|
||||
disabled={linkingSaving}
|
||||
>
|
||||
{linkingSaving ? <Loader size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
Save to Dashboard
|
||||
</button>
|
||||
)}
|
||||
{linkingError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.5rem' }}>{linkingError}</div>}
|
||||
{linkingSuccess && <div style={{ color: '#6EE7B7', fontSize: '0.75rem', marginTop: '0.5rem' }}>{linkingSuccess}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -661,6 +875,9 @@ export default function JiraPage() {
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
{form.status && !['Open', 'In Progress', 'Closed'].includes(form.status) && (
|
||||
<option value={form.status}>{form.status}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={saveTicket} disabled={formSaving}>
|
||||
@@ -682,21 +899,44 @@ export default function JiraPage() {
|
||||
<button onClick={() => setShowCreateJira(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X size={18} /></button>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
|
||||
Creates a new issue in Jira via the REST API and links it to a CVE locally.
|
||||
Creates a new issue in Jira via the REST API and tracks it locally.
|
||||
</p>
|
||||
{createJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{createJiraError}</div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID</label>
|
||||
<input style={STYLES.input} placeholder="CVE-2024-1234" value={createJiraForm.cve_id} onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
|
||||
<input style={STYLES.input} placeholder="e.g. CVE-2024-12345" value={createJiraForm.cve_id} onChange={e => setCreateJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor</label>
|
||||
<input style={STYLES.input} placeholder="Vendor name" value={createJiraForm.vendor} onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} />
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
|
||||
<input style={STYLES.input} placeholder="e.g. Microsoft" value={createJiraForm.vendor} onChange={e => setCreateJiraForm(f => ({ ...f, vendor: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary</label>
|
||||
<input style={STYLES.input} placeholder="Issue summary (max 255 chars)" value={createJiraForm.summary} onChange={e => setCreateJiraForm(f => ({ ...f, summary: e.target.value }))} maxLength={255} />
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
|
||||
<input
|
||||
style={{ ...STYLES.input, ...(summaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
|
||||
placeholder="Issue summary (max 255 chars)"
|
||||
value={createJiraForm.summary}
|
||||
onChange={e => { setCreateJiraForm(f => ({ ...f, summary: e.target.value })); if (summaryError) setSummaryError(null); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
{summaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{summaryError}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
|
||||
<select
|
||||
style={{ ...STYLES.input, cursor: createJiraLocked.source_context ? 'not-allowed' : 'pointer', opacity: createJiraLocked.source_context ? 0.7 : 1 }}
|
||||
value={createJiraForm.source_context}
|
||||
onChange={e => setCreateJiraForm(f => ({ ...f, source_context: e.target.value }))}
|
||||
disabled={createJiraLocked.source_context}
|
||||
>
|
||||
<option value="">— Select source —</option>
|
||||
<option value="cve">CVE</option>
|
||||
<option value="archer">Archer Request</option>
|
||||
<option value="ivanti_queue">Ivanti Queue</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description (optional)</label>
|
||||
@@ -705,11 +945,25 @@ export default function JiraPage() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
|
||||
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => setCreateJiraForm(f => ({ ...f, project_key: e.target.value.toUpperCase() }))} />
|
||||
<input style={STYLES.input} placeholder="Uses .env default" value={createJiraForm.project_key} onChange={e => {
|
||||
const newKey = e.target.value.toUpperCase();
|
||||
const wasVendor = isVendorProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS);
|
||||
const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS);
|
||||
setCreateJiraForm(f => ({
|
||||
...f,
|
||||
project_key: newKey,
|
||||
issue_type: (wasVendor !== isNowVendor) ? '' : f.issue_type,
|
||||
}));
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type (optional)</label>
|
||||
<input style={STYLES.input} placeholder="Task" value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))} />
|
||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
|
||||
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={createJiraForm.issue_type} onChange={e => setCreateJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
|
||||
<option value="">Story (default)</option>
|
||||
{getIssueTypesForProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES).map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button style={{ ...STYLES.btn, ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }} onClick={createInJira} disabled={createJiraSaving}>
|
||||
@@ -723,3 +977,7 @@ export default function JiraPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Named exports for reuse by other pages (e.g., IvantiTodoQueuePage)
|
||||
export { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Render and interaction tests for the per-metric estimated-resolution-date
|
||||
* line in the asset sidebar (ComplianceDetailPanel.js / MetricRow).
|
||||
*
|
||||
* Feature: compliance-metric-estimated-resolution-date
|
||||
* Spec: .kiro/specs/compliance-metric-estimated-resolution-date
|
||||
*
|
||||
* Covers tasks 3.2 (placement, labels, placeholders), 3.3 (resolved
|
||||
* suppression, read-only structure, role-independence, existing editor
|
||||
* preserved), and 3.4 (existing save round-trip).
|
||||
*
|
||||
* Requirements covered: 1.1, 1.2, 1.4, 1.5, 1.6, 2.1, 3.1, 3.4, 4.1, 4.2,
|
||||
* 4.3, 4.4, 4.5, 5.1, 5.2, 5.3, 5.4.
|
||||
*
|
||||
* The component fetches the asset detail via GET
|
||||
* `${API_BASE}/compliance/items/:hostname` (credentials included) and saves
|
||||
* metadata via PATCH `${API_BASE}/compliance/items/:hostname/metadata`, so
|
||||
* global.fetch is mocked to serve the asset detail JSON and PATCH responses.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import ComplianceDetailPanel from '../ComplianceDetailPanel';
|
||||
import {
|
||||
RESOLUTION_DATE_LABEL,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
INVALID_DATE_PLACEHOLDER,
|
||||
} from '../../../utils/resolutionDate';
|
||||
|
||||
const HOSTNAME = 'host-1.example.com';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMetric(overrides = {}) {
|
||||
return {
|
||||
metric_id: '2.3.6i',
|
||||
category: 'Vulnerability Management',
|
||||
status: 'active',
|
||||
metric_desc: 'Outbound encryption required on all endpoints',
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: 1,
|
||||
first_seen: '2025-01-01',
|
||||
resolved_on: null,
|
||||
extra: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(metrics, overrides = {}) {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'server',
|
||||
team: 'STEAM',
|
||||
metrics,
|
||||
history: [],
|
||||
notes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const jsonResponse = (body, ok = true) => ({ ok, json: async () => body });
|
||||
|
||||
/**
|
||||
* Mock global.fetch. GET requests to the detail endpoint are served from a
|
||||
* queue of detail objects (the last one is reused once the queue drains, so
|
||||
* the initial load and any re-fetch can each return a distinct snapshot).
|
||||
* PATCH requests to the metadata endpoint return the configured response.
|
||||
*/
|
||||
function mockFetch({ details, patchOk = true, patchBody = {} }) {
|
||||
const queue = [...details];
|
||||
let last = details[details.length - 1];
|
||||
global.fetch = jest.fn((url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
if (method === 'PATCH') {
|
||||
return Promise.resolve(jsonResponse(patchBody, patchOk));
|
||||
}
|
||||
// GET detail (fetchDetail)
|
||||
const next = queue.length > 0 ? queue.shift() : last;
|
||||
last = next;
|
||||
return Promise.resolve(jsonResponse(next));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM query helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The estimated-resolution date line renders as:
|
||||
// <div>
|
||||
// <svg .../> (Calendar icon)
|
||||
// <span>{RESOLUTION_DATE_LABEL}</span>
|
||||
// <span>{value | placeholder}</span>
|
||||
// </div>
|
||||
// We locate each line by its label span, which contains exactly the label text.
|
||||
|
||||
function getDateLineLabels(container) {
|
||||
return Array.from(container.querySelectorAll('span')).filter(
|
||||
(s) => s.textContent === RESOLUTION_DATE_LABEL
|
||||
);
|
||||
}
|
||||
|
||||
function getDateLineValueTexts(container) {
|
||||
return getDateLineLabels(container).map((label) =>
|
||||
label.nextElementSibling ? label.nextElementSibling.textContent : null
|
||||
);
|
||||
}
|
||||
|
||||
async function renderAndLoad(detail, props = {}) {
|
||||
const utils = render(
|
||||
<ComplianceDetailPanel
|
||||
hostname={HOSTNAME}
|
||||
onClose={() => {}}
|
||||
onNoteAdded={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
// Wait for the async detail load to complete (a metric description renders).
|
||||
await screen.findByText(detail.metrics[0].metric_desc);
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Task 3.2 — Render tests for placement, labels, and placeholders
|
||||
// ===========================================================================
|
||||
|
||||
describe('Task 3.2 — placement, labels, and placeholders', () => {
|
||||
test('placement (Req 1.2): estimated-resolution element precedes the metric description', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
const labelEl = screen.getByText(RESOLUTION_DATE_LABEL);
|
||||
const descEl = screen.getByText(detail.metrics[0].metric_desc);
|
||||
|
||||
// descEl must come AFTER labelEl in document order.
|
||||
expect(
|
||||
labelEl.compareDocumentPosition(descEl) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
|
||||
// Sanity: there is exactly one date line for the single active metric.
|
||||
expect(getDateLineLabels(container)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('label presence (Req 1.5): RESOLUTION_DATE_LABEL appears adjacent to the value', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
const labels = getDateLineLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
// The value span is the immediate next sibling of the label span.
|
||||
expect(labels[0].nextElementSibling).not.toBeNull();
|
||||
expect(labels[0].nextElementSibling.textContent).toBe('2026-07-01');
|
||||
});
|
||||
|
||||
test('set value (Req 1.1, 1.4): an active row with 2026-07-01 renders 2026-07-01', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
});
|
||||
|
||||
test('no-date placeholder (Req 2.1, 4.5): null/empty/whitespace render NO_DATE_PLACEHOLDER and keep the description', async () => {
|
||||
const metrics = [
|
||||
makeMetric({
|
||||
metric_id: '2.3.6i',
|
||||
metric_desc: 'Metric with null resolution date',
|
||||
resolution_date: null,
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: '2.3.8i',
|
||||
metric_desc: 'Metric with empty resolution date',
|
||||
resolution_date: '',
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: 'Vulns_Aging',
|
||||
metric_desc: 'Metric with whitespace resolution date',
|
||||
resolution_date: ' ',
|
||||
}),
|
||||
];
|
||||
const detail = makeDetail(metrics);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
// All three active rows show the no-date placeholder.
|
||||
expect(getDateLineValueTexts(container)).toEqual([
|
||||
NO_DATE_PLACEHOLDER,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
]);
|
||||
|
||||
// Each metric's description still renders.
|
||||
for (const m of metrics) {
|
||||
expect(screen.getByText(m.metric_desc)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid placeholder (Req 1.6): malformed date renders INVALID_DATE_PLACEHOLDER and keeps the description', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({
|
||||
metric_desc: 'Metric with a malformed resolution date',
|
||||
resolution_date: '2026-13-99',
|
||||
}),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual([INVALID_DATE_PLACEHOLDER]);
|
||||
expect(
|
||||
screen.getByText('Metric with a malformed resolution date')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Task 3.3 — resolved suppression, read-only structure, role-independence
|
||||
// ===========================================================================
|
||||
|
||||
describe('Task 3.3 — suppression, read-only structure, role-independence', () => {
|
||||
test('resolved suppression (Req 3.1, 3.4): a resolved metric with a populated date renders no estimated-resolution line', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({
|
||||
metric_id: '2.3.6i',
|
||||
status: 'resolved',
|
||||
metric_desc: 'Resolved metric with a populated resolution date',
|
||||
resolution_date: '2026-07-01',
|
||||
resolved_on: '2026-06-15',
|
||||
}),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
// No estimated-resolution line anywhere for a resolved-only asset.
|
||||
expect(getDateLineLabels(container)).toHaveLength(0);
|
||||
expect(screen.queryByText(RESOLUTION_DATE_LABEL)).toBeNull();
|
||||
});
|
||||
|
||||
test('mixed list (Req 3.4): the estimated-resolution line appears only within active rows', async () => {
|
||||
const metrics = [
|
||||
makeMetric({
|
||||
metric_id: '2.3.6i',
|
||||
status: 'active',
|
||||
metric_desc: 'Active metric one',
|
||||
resolution_date: '2026-07-01',
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: '2.3.8i',
|
||||
status: 'active',
|
||||
metric_desc: 'Active metric two',
|
||||
resolution_date: '2026-09-30',
|
||||
}),
|
||||
makeMetric({
|
||||
metric_id: 'Vulns_Aging',
|
||||
status: 'resolved',
|
||||
metric_desc: 'Resolved metric',
|
||||
resolution_date: '2026-01-15',
|
||||
resolved_on: '2026-01-10',
|
||||
}),
|
||||
];
|
||||
const detail = makeDetail(metrics);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
// Exactly two date lines (one per active metric), each its own value.
|
||||
expect(getDateLineValueTexts(container)).toEqual([
|
||||
'2026-07-01',
|
||||
'2026-09-30',
|
||||
]);
|
||||
});
|
||||
|
||||
test('read-only structure (Req 5.3): the date-line subtree has no input, button, or anchor', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
const labels = getDateLineLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
const dateLineSubtree = labels[0].parentElement;
|
||||
|
||||
// Plain text only — no interactive controls capable of modifying the field.
|
||||
expect(
|
||||
dateLineSubtree.querySelectorAll('input, button, a, select, textarea')
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('role-independence (Req 5.1, 5.2, 5.4): the date line is plain, non-interactive text', async () => {
|
||||
// ComplianceDetailPanel does not consume a role/auth context for the
|
||||
// estimated-resolution subtree: the line is derived purely from
|
||||
// metric.resolution_date and rendered as static spans. The display is
|
||||
// therefore role-independent by construction — a viewer, editor, and
|
||||
// admin all receive byte-for-byte identical output and no editing control
|
||||
// is introduced. We assert the plain-text value and the absence of any
|
||||
// interactive element in the subtree.
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
const dateLineSubtree = getDateLineLabels(container)[0].parentElement;
|
||||
expect(
|
||||
dateLineSubtree.querySelectorAll('input, button, a, select, textarea')
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('existing editor preserved (Req 4.1): the editable Resolution Date input[type=date] still renders', async () => {
|
||||
const detail = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
mockFetch({ details: [detail] });
|
||||
|
||||
const { container } = await renderAndLoad(detail);
|
||||
|
||||
expect(container.querySelector('input[type="date"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Task 3.4 — Interaction tests for the existing save round-trip
|
||||
// ===========================================================================
|
||||
|
||||
describe('Task 3.4 — save round-trip', () => {
|
||||
test('successful save (Req 4.2, 4.3): displayed estimated-resolution updates to the new date after save + re-fetch', async () => {
|
||||
const before = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
const after = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-08-15' }),
|
||||
]);
|
||||
// GET (initial) -> before, PATCH -> ok, GET (re-fetch) -> after
|
||||
mockFetch({ details: [before, after], patchOk: true });
|
||||
|
||||
const { container } = await renderAndLoad(before);
|
||||
|
||||
// Pre-condition: original date displayed.
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
// Editor updates the editable Resolution Date field and saves.
|
||||
const dateInput = container.querySelector('input[type="date"]');
|
||||
fireEvent.change(dateInput, { target: { value: '2026-08-15' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// The displayed estimated-resolution value updates from the re-fetch.
|
||||
await waitFor(() => {
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-08-15']);
|
||||
});
|
||||
});
|
||||
|
||||
test('successful clear (Req 4.5): clearing the field renders NO_DATE_PLACEHOLDER after save + re-fetch', async () => {
|
||||
const before = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
const after = makeDetail([makeMetric({ resolution_date: '' })]);
|
||||
mockFetch({ details: [before, after], patchOk: true });
|
||||
|
||||
const { container } = await renderAndLoad(before);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
const dateInput = container.querySelector('input[type="date"]');
|
||||
fireEvent.change(dateInput, { target: { value: '' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getDateLineValueTexts(container)).toEqual([NO_DATE_PLACEHOLDER]);
|
||||
});
|
||||
});
|
||||
|
||||
test('failed save (Req 4.4): previously displayed date is retained and an error is shown', async () => {
|
||||
const before = makeDetail([
|
||||
makeMetric({ resolution_date: '2026-07-01' }),
|
||||
]);
|
||||
// PATCH fails; fetchDetail is never re-issued, so the queue only needs the
|
||||
// initial detail.
|
||||
mockFetch({
|
||||
details: [before],
|
||||
patchOk: false,
|
||||
patchBody: { error: 'Save failed' },
|
||||
});
|
||||
|
||||
const { container } = await renderAndLoad(before);
|
||||
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
|
||||
const dateInput = container.querySelector('input[type="date"]');
|
||||
fireEvent.change(dateInput, { target: { value: '2099-01-01' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// Error indication appears.
|
||||
await screen.findByText('Save failed');
|
||||
|
||||
// The previously displayed estimated-resolution date is retained.
|
||||
expect(getDateLineValueTexts(container)).toEqual(['2026-07-01']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Bug Condition Exploration Property Test:
|
||||
* Compliance List Stale After Sidebar Edit
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-list-stale-after-sidebar-edit/ (bugfix)
|
||||
* Issue: #23 — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar"
|
||||
* http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23
|
||||
*
|
||||
* BUG CONDITION (bugfix.md Current Behavior 1.1–1.3):
|
||||
* isBugCondition(input) = a successful PATCH /api/compliance/items/:hostname/metadata
|
||||
* occurred from the sidebar (ComplianceDetailPanel). Under this condition the parent
|
||||
* CompliancePage list is never re-fetched: handleSaveMetadata() does not notify the
|
||||
* parent (1.1), onClose only clears selectedHost (1.2), and the row keeps the stale
|
||||
* Resolution Date / Remediation Plan held in the parent `devices` state (1.3).
|
||||
*
|
||||
* THIS TEST IS EXPECTED TO FAIL ON UNFIXED CODE — failure confirms the bug.
|
||||
* The property encodes the expected behavior (bugfix.md 2.1, 2.2): for any saved
|
||||
* metadata value, after a successful sidebar save the parent list row for that
|
||||
* hostname displays the saved value WITHOUT a manual filter/team/tab change or a
|
||||
* manual refresh click, because a parent refresh callback re-issues fetchDevices.
|
||||
* On unfixed code no parent callback fires, so the list GET is never re-issued after
|
||||
* the PATCH and the row keeps showing the stale value ("—").
|
||||
*
|
||||
* Mirrors the tagging convention of
|
||||
* backend/__tests__/compliance-remediation-display-fix.exploration.property.test.js
|
||||
*
|
||||
* **Validates: Requirements 1.1, 1.2, 1.3, 2.1, 2.2**
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
|
||||
// --- Mocks (hoisted by babel-jest above the CompliancePage import) ---
|
||||
|
||||
// Stub auth so CompliancePage renders the STEAM team with write access.
|
||||
jest.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
canWrite: () => true,
|
||||
isAdmin: () => false,
|
||||
getAvailableTeams: () => ['STEAM'],
|
||||
adminScope: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The historical charts panel is irrelevant to the list-staleness bug and pulls
|
||||
// in recharts; stub it so the test stays focused and fast across property runs.
|
||||
jest.mock('../ComplianceChartsPanel', () => () => null);
|
||||
|
||||
import CompliancePage from '../CompliancePage';
|
||||
|
||||
const HOSTNAME = 'HOST-001';
|
||||
const METRIC_ID = '2.3.6i';
|
||||
|
||||
// --- Fixtures --------------------------------------------------------------
|
||||
|
||||
function jsonResponse(body, ok = true) {
|
||||
return Promise.resolve({ ok, json: async () => body });
|
||||
}
|
||||
|
||||
function makeListDevice({ resolution_date, remediation_plan }) {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
failing_metrics: [{ metric_id: METRIC_ID, category: 'Vulnerability Management' }],
|
||||
resolution_date,
|
||||
remediation_plan,
|
||||
seen_count: 3,
|
||||
has_notes: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail() {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
team: 'STEAM',
|
||||
metrics: [
|
||||
{
|
||||
metric_id: METRIC_ID,
|
||||
category: 'Vulnerability Management',
|
||||
status: 'active',
|
||||
metric_desc: 'Outbound encryption required on all endpoints',
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: 3,
|
||||
first_seen: '2025-01-01',
|
||||
resolved_on: null,
|
||||
extra: {},
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a URL-routing global.fetch.
|
||||
*
|
||||
* The list device starts stale (resolution_date / remediation_plan = null). Only
|
||||
* AFTER a successful metadata PATCH does the list endpoint return the saved values —
|
||||
* modelling the backend, which already persists and returns the new metadata. Thus a
|
||||
* post-save re-fetch is observable in the row, while on unfixed code (no re-fetch) the
|
||||
* row keeps showing the stale value.
|
||||
*/
|
||||
function installFetchMock(savedResolutionDate, savedRemediationPlan) {
|
||||
const state = { patchOccurred: false, patchCalls: 0, listCalls: 0, listCallsAfterPatch: 0 };
|
||||
|
||||
global.fetch = jest.fn((url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
|
||||
// PATCH /compliance/items/:hostname/metadata → success
|
||||
if (method === 'PATCH' && url.includes('/compliance/items/') && url.includes('/metadata')) {
|
||||
state.patchCalls++;
|
||||
state.patchOccurred = true;
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
// GET /compliance/summary?team=STEAM → minimal valid summary
|
||||
if (url.includes('/compliance/summary')) {
|
||||
return jsonResponse({ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } });
|
||||
}
|
||||
|
||||
// GET /compliance/items?team=STEAM&status=active → device list (query string)
|
||||
if (url.includes('/compliance/items?')) {
|
||||
state.listCalls++;
|
||||
if (state.patchOccurred) state.listCallsAfterPatch++;
|
||||
const device = state.patchOccurred
|
||||
? makeListDevice({ resolution_date: savedResolutionDate, remediation_plan: savedRemediationPlan })
|
||||
: makeListDevice({ resolution_date: null, remediation_plan: null });
|
||||
return jsonResponse({ devices: [device] });
|
||||
}
|
||||
|
||||
// GET /compliance/items/:hostname → detail (one active metric)
|
||||
if (url.includes('/compliance/items/')) {
|
||||
return jsonResponse(makeDetail());
|
||||
}
|
||||
|
||||
// Any other URL (charts panel is mocked out) → safe empty payload
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// --- Generators ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* YYYY-MM-DD strings built from integer tuples. Do NOT call toISOString on shrunk
|
||||
* values (mirrors the predecessor exploration test's date-generator pattern).
|
||||
*/
|
||||
const arbResolutionDate = fc
|
||||
.tuple(
|
||||
fc.integer({ min: 2026, max: 2030 }),
|
||||
fc.integer({ min: 1, max: 12 }),
|
||||
fc.integer({ min: 1, max: 28 })
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
||||
|
||||
/** Non-empty, trimmed remediation plan strings, length-bounded 1–200. */
|
||||
const arbRemediationPlan = fc
|
||||
.string({ minLength: 1, maxLength: 200 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
// --- Setup / teardown ------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- Property Test ---------------------------------------------------------
|
||||
|
||||
describe('Bug Condition Exploration: list row stays stale after sidebar metadata save', () => {
|
||||
it(
|
||||
'Property 1: for any saved metadata, the parent list row reflects the saved Resolution Date after a successful sidebar save (no manual refresh)',
|
||||
async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (savedResolutionDate, savedRemediationPlan) => {
|
||||
const state = installFetchMock(savedResolutionDate, savedRemediationPlan);
|
||||
|
||||
try {
|
||||
const { container } = render(<CompliancePage onNavigate={() => {}} />);
|
||||
|
||||
// Wait for the (stale) device row, then capture the row element.
|
||||
const hostCell = await screen.findByText(HOSTNAME);
|
||||
const row = hostCell.parentElement;
|
||||
|
||||
// Pre-condition: the stale row does not yet show the to-be-saved date.
|
||||
expect(within(row).queryByText(savedResolutionDate)).toBeNull();
|
||||
|
||||
// Open the sidebar detail panel.
|
||||
fireEvent.click(row);
|
||||
|
||||
// Wait for the panel's editable Resolution Date input to render.
|
||||
const dateInput = await waitFor(() => {
|
||||
const el = container.querySelector('input[type="date"]');
|
||||
if (!el) throw new Error('resolution date input not ready');
|
||||
return el;
|
||||
});
|
||||
const planInput = screen.getByPlaceholderText(/Describe the remediation plan/i);
|
||||
|
||||
// Enter the generated metadata values and click Save.
|
||||
fireEvent.change(dateInput, { target: { value: savedResolutionDate } });
|
||||
fireEvent.change(planInput, { target: { value: savedRemediationPlan } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// The PATCH is issued on both fixed and unfixed code.
|
||||
await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));
|
||||
|
||||
// PROPERTY (expected behavior, bugfix.md 2.1/2.2): the parent list row
|
||||
// reflects the saved Resolution Date without a manual refresh. This holds
|
||||
// only if a parent refresh callback re-issued fetchDevices after the save.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(within(row).getByText(savedResolutionDate)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
|
||||
// Supporting: the list endpoint was re-issued after the save.
|
||||
expect(state.listCallsAfterPatch).toBeGreaterThan(0);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 10, endOnFailure: true }
|
||||
);
|
||||
},
|
||||
60000
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Preservation / Regression Property Tests:
|
||||
* Compliance List Stale After Sidebar Edit
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-list-stale-after-sidebar-edit/ (bugfix)
|
||||
* Issue: #23 — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar"
|
||||
* http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23
|
||||
*
|
||||
* Property 2 (Preservation): the behaviors below must hold AFTER the fix. Following
|
||||
* observation-first methodology, the preservation properties (note-add refresh, failed
|
||||
* save surfaces an error without falsely updating the list, close-without-change clears
|
||||
* selection, other row fields render) were observed to hold on UNFIXED code and must
|
||||
* keep holding. The "regression guard" (the list row reflects a saved metadata value)
|
||||
* is the one behavior that flips from failing (pre-fix) to passing (post-fix); it is
|
||||
* kept here as a standing guard against re-introducing the bug.
|
||||
*
|
||||
* Mirrors the tagging convention of
|
||||
* backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js
|
||||
*
|
||||
* Properties tested:
|
||||
* P2.1 — Regression guard: after a successful sidebar save the list row shows the
|
||||
* saved Resolution Date with no manual refresh (bugfix.md 2.1, 2.2)
|
||||
* P2.2 — Note-add still triggers a list re-fetch via onNoteAdded (bugfix.md 3.1)
|
||||
* P2.3 — Failed metadata save surfaces metaError and does NOT
|
||||
* falsely update the list row (bugfix.md 3.3)
|
||||
* P2.4 — Close-without-change clears the selection, no PATCH (bugfix.md 3.4)
|
||||
* P2.5 — Other row fields (hostname, IP, type, failing metrics,
|
||||
* seen count) render unchanged (bugfix.md 3.5)
|
||||
*
|
||||
* **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6**
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
|
||||
// --- Mocks (hoisted by babel-jest above the CompliancePage import) ---
|
||||
|
||||
// Stub auth so CompliancePage renders the STEAM team with write access.
|
||||
jest.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
canWrite: () => true,
|
||||
isAdmin: () => false,
|
||||
getAvailableTeams: () => ['STEAM'],
|
||||
adminScope: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The historical charts panel is irrelevant to the list-staleness bug and pulls
|
||||
// in recharts; stub it so the tests stay focused and fast across property runs.
|
||||
jest.mock('../ComplianceChartsPanel', () => () => null);
|
||||
|
||||
import CompliancePage from '../CompliancePage';
|
||||
|
||||
const HOSTNAME = 'HOST-001';
|
||||
const METRIC_ID = '2.3.6i';
|
||||
|
||||
// --- Fixtures --------------------------------------------------------------
|
||||
|
||||
function jsonResponse(body, ok = true) {
|
||||
return Promise.resolve({ ok, json: async () => body });
|
||||
}
|
||||
|
||||
function makeListDevice({ resolution_date, remediation_plan }) {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
failing_metrics: [{ metric_id: METRIC_ID, category: 'Vulnerability Management' }],
|
||||
resolution_date,
|
||||
remediation_plan,
|
||||
seen_count: 3,
|
||||
has_notes: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail() {
|
||||
return {
|
||||
hostname: HOSTNAME,
|
||||
ip_address: '10.0.0.1',
|
||||
device_type: 'Switch',
|
||||
team: 'STEAM',
|
||||
metrics: [
|
||||
{
|
||||
metric_id: METRIC_ID,
|
||||
category: 'Vulnerability Management',
|
||||
status: 'active',
|
||||
metric_desc: 'Outbound encryption required on all endpoints',
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: 3,
|
||||
first_seen: '2025-01-01',
|
||||
resolved_on: null,
|
||||
extra: {},
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a URL-routing global.fetch shared by every property.
|
||||
*
|
||||
* Options:
|
||||
* savedResolutionDate / savedRemediationPlan — values the list endpoint returns
|
||||
* AFTER a successful metadata PATCH (models the backend persisting the new value).
|
||||
* patchOk — whether PATCH /compliance/items/:hostname/metadata succeeds (default true).
|
||||
* noteOk — whether POST /compliance/notes succeeds (default true).
|
||||
* fixedDevice — when provided, the list always returns this device unchanged (used by
|
||||
* the row-field rendering property which never edits metadata).
|
||||
*
|
||||
* The list device starts stale (resolution_date / remediation_plan = null) and only
|
||||
* returns the saved values once a SUCCESSFUL patch has occurred, so a post-save
|
||||
* re-fetch is observable in the rendered row.
|
||||
*/
|
||||
function installFetchMock(opts = {}) {
|
||||
const {
|
||||
savedResolutionDate = '2027-06-15',
|
||||
savedRemediationPlan = 'Patch firmware',
|
||||
patchOk = true,
|
||||
noteOk = true,
|
||||
fixedDevice = null,
|
||||
} = opts;
|
||||
|
||||
const state = {
|
||||
patchOccurred: false,
|
||||
patchCalls: 0,
|
||||
noteOccurred: false,
|
||||
noteCalls: 0,
|
||||
listCalls: 0,
|
||||
listCallsAfterPatch: 0,
|
||||
listCallsAfterNote: 0,
|
||||
};
|
||||
|
||||
global.fetch = jest.fn((url, options = {}) => {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
|
||||
// POST /compliance/notes → success / failure
|
||||
if (method === 'POST' && url.includes('/compliance/notes')) {
|
||||
state.noteCalls++;
|
||||
if (noteOk) {
|
||||
state.noteOccurred = true;
|
||||
return jsonResponse({ ok: true, id: 1 });
|
||||
}
|
||||
return jsonResponse({ error: 'Failed to save note' }, false);
|
||||
}
|
||||
|
||||
// PATCH /compliance/items/:hostname/metadata → success / failure
|
||||
if (method === 'PATCH' && url.includes('/compliance/items/') && url.includes('/metadata')) {
|
||||
state.patchCalls++;
|
||||
if (patchOk) {
|
||||
state.patchOccurred = true;
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
return jsonResponse({ error: 'Failed to save metadata' }, false);
|
||||
}
|
||||
|
||||
// GET /compliance/summary?team=STEAM → minimal valid summary
|
||||
if (url.includes('/compliance/summary')) {
|
||||
return jsonResponse({ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } });
|
||||
}
|
||||
|
||||
// GET /compliance/items?team=STEAM&status=active → device list (query string)
|
||||
if (url.includes('/compliance/items?')) {
|
||||
state.listCalls++;
|
||||
if (state.patchOccurred) state.listCallsAfterPatch++;
|
||||
if (state.noteOccurred) state.listCallsAfterNote++;
|
||||
let device;
|
||||
if (fixedDevice) {
|
||||
device = fixedDevice;
|
||||
} else if (state.patchOccurred) {
|
||||
device = makeListDevice({ resolution_date: savedResolutionDate, remediation_plan: savedRemediationPlan });
|
||||
} else {
|
||||
device = makeListDevice({ resolution_date: null, remediation_plan: null });
|
||||
}
|
||||
return jsonResponse({ devices: [device] });
|
||||
}
|
||||
|
||||
// GET /compliance/items/:hostname → detail (one active metric)
|
||||
if (url.includes('/compliance/items/')) {
|
||||
return jsonResponse(makeDetail());
|
||||
}
|
||||
|
||||
// Any other URL (charts panel is mocked out) → safe empty payload
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// --- Generators ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* YYYY-MM-DD strings built from integer tuples. Do NOT call toISOString on shrunk
|
||||
* values (mirrors the predecessor exploration test's date-generator pattern).
|
||||
*/
|
||||
const arbResolutionDate = fc
|
||||
.tuple(
|
||||
fc.integer({ min: 2026, max: 2030 }),
|
||||
fc.integer({ min: 1, max: 12 }),
|
||||
fc.integer({ min: 1, max: 28 })
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
||||
|
||||
/** Non-empty, trimmed remediation plan strings, length-bounded 1–200. */
|
||||
const arbRemediationPlan = fc
|
||||
.string({ minLength: 1, maxLength: 200 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
/** Non-empty, trimmed note text, length-bounded 1–200. */
|
||||
const arbNoteText = fc
|
||||
.string({ minLength: 1, maxLength: 200 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
/** Hostnames like HOST-0001 — unique, regex-safe, distinct from IP/type cells. */
|
||||
const arbHostname = fc
|
||||
.integer({ min: 1, max: 9999 })
|
||||
.map((n) => `HOST-${String(n).padStart(4, '0')}`);
|
||||
|
||||
/** IPv4 dotted-quad strings built from integer tuples. */
|
||||
const arbIp = fc
|
||||
.tuple(
|
||||
fc.integer({ min: 1, max: 254 }),
|
||||
fc.integer({ min: 0, max: 255 }),
|
||||
fc.integer({ min: 0, max: 255 }),
|
||||
fc.integer({ min: 1, max: 254 })
|
||||
)
|
||||
.map(([a, b, c, d]) => `${a}.${b}.${c}.${d}`);
|
||||
|
||||
const arbDeviceType = fc.constantFrom('Switch', 'Router', 'Firewall', 'Server', 'Workstation');
|
||||
|
||||
/** metric_id like "7.1.3", built from integer tuples. */
|
||||
const arbMetricId = fc
|
||||
.tuple(fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 }))
|
||||
.map(([a, b, c]) => `${a}.${b}.${c}`);
|
||||
|
||||
/** 1–3 unique metric ids (unique to avoid duplicate React keys / ambiguous queries). */
|
||||
const arbMetricIds = fc.uniqueArray(arbMetricId, { minLength: 1, maxLength: 3 });
|
||||
|
||||
const arbSeenCount = fc.integer({ min: 1, max: 20 });
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
|
||||
/** Render CompliancePage, wait for the device row, return { container, row }. */
|
||||
async function renderAndGetRow(hostname = HOSTNAME) {
|
||||
const utils = render(<CompliancePage onNavigate={() => {}} />);
|
||||
const hostCell = await screen.findByText(hostname);
|
||||
return { ...utils, row: hostCell.parentElement };
|
||||
}
|
||||
|
||||
/** Open the sidebar by clicking the row, wait for the editable date input. */
|
||||
async function openPanel(row, container) {
|
||||
fireEvent.click(row);
|
||||
const dateInput = await waitFor(() => {
|
||||
const el = container.querySelector('input[type="date"]');
|
||||
if (!el) throw new Error('resolution date input not ready');
|
||||
return el;
|
||||
});
|
||||
return dateInput;
|
||||
}
|
||||
|
||||
// --- Setup / teardown ------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.1 — Regression guard: list row reflects a saved Resolution Date
|
||||
// after a successful sidebar save, with no manual refresh.
|
||||
// (bugfix.md 2.1, 2.2 — kept as a standing regression guard)
|
||||
// ===========================================================================
|
||||
describe('P2.1 — Regression guard: list row reflects saved metadata after a successful save', () => {
|
||||
it('for any saved metadata, the parent row shows the saved Resolution Date without a manual refresh', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (savedResolutionDate, savedRemediationPlan) => {
|
||||
const state = installFetchMock({ savedResolutionDate, savedRemediationPlan });
|
||||
try {
|
||||
const { container, row } = await renderAndGetRow();
|
||||
|
||||
// Pre-condition: the stale row does not yet show the to-be-saved date.
|
||||
expect(within(row).queryByText(savedResolutionDate)).toBeNull();
|
||||
|
||||
const dateInput = await openPanel(row, container);
|
||||
const planInput = screen.getByPlaceholderText(/Describe the remediation plan/i);
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: savedResolutionDate } });
|
||||
fireEvent.change(planInput, { target: { value: savedRemediationPlan } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));
|
||||
|
||||
// Regression guard: the row reflects the saved value (the fix re-fetches).
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(within(row).getByText(savedResolutionDate)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
expect(state.listCallsAfterPatch).toBeGreaterThan(0);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 6, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.2 — Note-add refresh still works: adding a note re-issues the list GET
|
||||
// via the existing onNoteAdded callback. (bugfix.md 3.1)
|
||||
// ===========================================================================
|
||||
describe('P2.2 — Note-add still triggers a list re-fetch (onNoteAdded preserved)', () => {
|
||||
it('for any note text, a successful note add re-issues GET /compliance/items', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbNoteText, async (noteText) => {
|
||||
const state = installFetchMock({ noteOk: true });
|
||||
try {
|
||||
const { container, row } = await renderAndGetRow();
|
||||
await openPanel(row, container);
|
||||
|
||||
const noteInput = await screen.findByPlaceholderText(/Add a note/i);
|
||||
fireEvent.change(noteInput, { target: { value: noteText } });
|
||||
|
||||
const addButton = container.querySelector('.lucide-send').closest('button');
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => expect(state.noteCalls).toBeGreaterThan(0));
|
||||
// Preservation: the list is re-fetched after the note add.
|
||||
await waitFor(() => expect(state.listCallsAfterNote).toBeGreaterThan(0), { timeout: 2000 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 6, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.3 — Failed metadata save surfaces metaError and does NOT falsely
|
||||
// update the list row. (bugfix.md 3.3)
|
||||
// ===========================================================================
|
||||
describe('P2.3 — Failed save shows an error and does not falsely update the list', () => {
|
||||
it('for any attempted value, a non-OK PATCH surfaces metaError and the row stays stale', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(arbResolutionDate, async (attemptedResolutionDate) => {
|
||||
const state = installFetchMock({ savedResolutionDate: attemptedResolutionDate, patchOk: false });
|
||||
try {
|
||||
const { container, row } = await renderAndGetRow();
|
||||
const dateInput = await openPanel(row, container);
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: attemptedResolutionDate } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));
|
||||
|
||||
// The panel surfaces the error from the failed save.
|
||||
await waitFor(() => expect(screen.getByText('Failed to save metadata')).toBeInTheDocument());
|
||||
|
||||
// The list row was NOT updated to a value that never persisted.
|
||||
expect(within(row).queryByText(attemptedResolutionDate)).toBeNull();
|
||||
expect(state.listCallsAfterPatch).toBe(0);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 6, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.4 — Close-without-change clears the selection and issues no PATCH.
|
||||
// (bugfix.md 3.4) — example-style assertion (no value in varying inputs).
|
||||
// ===========================================================================
|
||||
describe('P2.4 — Close without change clears selection and saves nothing', () => {
|
||||
it('clicking the close (X) removes the panel and triggers no metadata save', async () => {
|
||||
const state = installFetchMock();
|
||||
const { container, row } = await renderAndGetRow();
|
||||
|
||||
// Open the panel and confirm the editable date input rendered.
|
||||
await openPanel(row, container);
|
||||
|
||||
// Click the close (X) control without saving anything.
|
||||
const closeButton = container.querySelector('.lucide-x').closest('button');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// The panel is gone (selection cleared) — its date input no longer exists.
|
||||
await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull());
|
||||
|
||||
// The row remains and no metadata save was attempted.
|
||||
expect(screen.getByText(HOSTNAME)).toBeInTheDocument();
|
||||
expect(state.patchCalls).toBe(0);
|
||||
}, 30000);
|
||||
|
||||
it('clicking the backdrop also clears selection and triggers no metadata save', async () => {
|
||||
const state = installFetchMock();
|
||||
const { container, row } = await renderAndGetRow();
|
||||
|
||||
await openPanel(row, container);
|
||||
|
||||
// The first fixed/inset overlay is the backdrop (onClick={onClose}).
|
||||
const backdrop = container.querySelector('div[style*="position: fixed"]');
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull());
|
||||
expect(screen.getByText(HOSTNAME)).toBeInTheDocument();
|
||||
expect(state.patchCalls).toBe(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// P2.5 — Other row fields render unchanged: hostname, IP, device type,
|
||||
// failing metrics, seen count. (bugfix.md 3.5)
|
||||
// ===========================================================================
|
||||
describe('P2.5 — Other row fields render correctly and unchanged', () => {
|
||||
it('for any generated device, the row displays hostname, IP, type, metrics, and seen count', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
arbHostname,
|
||||
arbIp,
|
||||
arbDeviceType,
|
||||
arbMetricIds,
|
||||
arbSeenCount,
|
||||
async (hostname, ip, deviceType, metricIds, seenCount) => {
|
||||
const device = {
|
||||
hostname,
|
||||
ip_address: ip,
|
||||
device_type: deviceType,
|
||||
failing_metrics: metricIds.map((id) => ({ metric_id: id, category: 'Vulnerability Management' })),
|
||||
resolution_date: null,
|
||||
remediation_plan: null,
|
||||
seen_count: seenCount,
|
||||
has_notes: false,
|
||||
};
|
||||
installFetchMock({ fixedDevice: device });
|
||||
try {
|
||||
const { row } = await renderAndGetRow(hostname);
|
||||
|
||||
// Hostname, IP, and device type render verbatim.
|
||||
expect(within(row).getByText(hostname)).toBeInTheDocument();
|
||||
expect(within(row).getByText(ip)).toBeInTheDocument();
|
||||
expect(within(row).getByText(deviceType)).toBeInTheDocument();
|
||||
|
||||
// Every failing-metric badge renders its metric_id.
|
||||
for (const id of metricIds) {
|
||||
expect(within(row).getByText(id)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// The seen-count badge renders "<count>×".
|
||||
expect(within(row).getByText(`${seenCount}\u00D7`)).toBeInTheDocument();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 8, endOnFailure: true }
|
||||
);
|
||||
}, 60000);
|
||||
});
|
||||
300
frontend/src/utils/__tests__/resolutionDate.property.test.js
Normal file
300
frontend/src/utils/__tests__/resolutionDate.property.test.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Property-Based Tests: Resolution-date helper
|
||||
*
|
||||
* Feature: compliance-metric-estimated-resolution-date
|
||||
*
|
||||
* Exercises the pure helper `formatResolutionDate(raw)` from
|
||||
* `frontend/src/utils/resolutionDate.js`, which classifies a raw per-metric
|
||||
* `resolution_date` value into a discriminated union:
|
||||
* { state: 'set', value } | { state: 'none' } | { state: 'invalid' }
|
||||
*
|
||||
* Library: fast-check (v4) with Jest (react-scripts test). Generators are built
|
||||
* from fast-check arbitraries only — none are hand-rolled. Each property runs a
|
||||
* minimum of 100 iterations.
|
||||
*
|
||||
* Validates: Requirements 1.1, 1.3, 1.4, 1.6, 2.1, 2.2, 3.2, 3.3, 4.5
|
||||
*/
|
||||
|
||||
import * as fc from 'fast-check';
|
||||
import { formatResolutionDate } from '../resolutionDate';
|
||||
|
||||
const NUM_RUNS = 200;
|
||||
const VALID_STATES = ['set', 'none', 'invalid'];
|
||||
const SHARED_SENTINEL = 'Multiple values';
|
||||
|
||||
// --- Independent oracle (NOT the function under test) ----------------------
|
||||
// Used only to filter generated inputs so we never assert the wrong class.
|
||||
|
||||
function daysInMonthOracle(year, month) {
|
||||
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
const lengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return lengths[month - 1];
|
||||
}
|
||||
|
||||
// True iff `s` is a strict YYYY-MM-DD string that names a real calendar date.
|
||||
function isValidCalendarYmd(s) {
|
||||
if (typeof s !== 'string') return false;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
||||
const year = Number(s.slice(0, 4));
|
||||
const month = Number(s.slice(5, 7));
|
||||
const day = Number(s.slice(8, 10));
|
||||
if (month < 1 || month > 12) return false;
|
||||
if (day < 1 || day > daysInMonthOracle(year, month)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// True iff `s` is an ISO datetime string whose date prefix is a valid calendar date.
|
||||
// e.g. "2026-07-01T00:00:00.000Z" → true (the helper now extracts the date prefix).
|
||||
function isIsoDateTimeWithValidDate(s) {
|
||||
if (typeof s !== 'string') return false;
|
||||
if (!/^\d{4}-\d{2}-\d{2}T/.test(s)) return false;
|
||||
return isValidCalendarYmd(s.slice(0, 10));
|
||||
}
|
||||
|
||||
// --- Shared arbitraries -----------------------------------------------------
|
||||
|
||||
// Four-digit zero-padded year string (0000–9999) — always matches \d{4}.
|
||||
const year4Arb = fc.integer({ min: 0, max: 9999 }).map(y => String(y).padStart(4, '0'));
|
||||
|
||||
// Valid calendar dates spanning years, all months, month-length boundaries
|
||||
// (28/29/30/31), and leap days. The `dim` boundary value guarantees the true
|
||||
// last day of each month is exercised, including Feb 29 in leap years.
|
||||
const validDateStringArb = year4Arb.chain(y =>
|
||||
fc.integer({ min: 1, max: 12 }).chain(m => {
|
||||
const dim = daysInMonthOracle(Number(y), m);
|
||||
const dayArb = fc.oneof(
|
||||
fc.constant(1),
|
||||
fc.constant(dim),
|
||||
fc.integer({ min: 1, max: dim })
|
||||
);
|
||||
return dayArb.map(
|
||||
d => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// null / undefined / empty / whitespace-only (spaces, tabs, newlines).
|
||||
const whitespaceChar = fc.constantFrom(' ', '\t', '\n');
|
||||
const whitespaceStringArb = fc
|
||||
.array(whitespaceChar, { minLength: 1, maxLength: 20 })
|
||||
.map(chars => chars.join(''));
|
||||
const absentArb = fc.oneof(
|
||||
fc.constantFrom(null, undefined, ''),
|
||||
whitespaceStringArb
|
||||
);
|
||||
|
||||
// Non-empty, non-whitespace-only strings that are NOT valid YYYY-MM-DD dates.
|
||||
// Built from several invalid-by-construction families, then defensively
|
||||
// filtered against the oracle to drop any accidentally-valid value.
|
||||
const twoDigitArb = fc.integer({ min: 0, max: 99 }).map(n => String(n).padStart(2, '0'));
|
||||
const nonLeapYear4Arb = fc
|
||||
.integer({ min: 0, max: 9999 })
|
||||
.filter(y => !((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0))
|
||||
.map(y => String(y).padStart(4, '0'));
|
||||
|
||||
// Valid shape but month out of range (00 or 13–99).
|
||||
const badMonthArb = fc
|
||||
.tuple(
|
||||
year4Arb,
|
||||
fc.oneof(fc.constant('00'), fc.integer({ min: 13, max: 99 }).map(n => String(n).padStart(2, '0'))),
|
||||
fc.integer({ min: 1, max: 28 }).map(n => String(n).padStart(2, '0'))
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${m}-${d}`);
|
||||
|
||||
// Valid shape but day out of range (00 or 32–99).
|
||||
const badDayArb = fc
|
||||
.tuple(
|
||||
year4Arb,
|
||||
fc.integer({ min: 1, max: 12 }).map(n => String(n).padStart(2, '0')),
|
||||
fc.oneof(fc.constant('00'), fc.integer({ min: 32, max: 99 }).map(n => String(n).padStart(2, '0')))
|
||||
)
|
||||
.map(([y, m, d]) => `${y}-${m}-${d}`);
|
||||
|
||||
// Valid shape but impossible calendar day (Feb 30/31, 31 in 30-day months,
|
||||
// Feb 29 in a non-leap year).
|
||||
const impossibleDayArb = fc.oneof(
|
||||
fc.tuple(year4Arb, fc.constant('02'), fc.constantFrom('30', '31')).map(([y, m, d]) => `${y}-${m}-${d}`),
|
||||
fc.tuple(year4Arb, fc.constantFrom('04', '06', '09', '11'), fc.constant('31')).map(([y, m, d]) => `${y}-${m}-${d}`),
|
||||
nonLeapYear4Arb.map(y => `${y}-02-29`)
|
||||
);
|
||||
|
||||
// Wrong shapes (not matching ^\d{4}-\d{2}-\d{2}$).
|
||||
const wrongShapeArb = fc.oneof(
|
||||
fc.constantFrom(
|
||||
'2026-7-1',
|
||||
'2026-7-01',
|
||||
'2026-07-1',
|
||||
'07/01/2026',
|
||||
'2026/07/01',
|
||||
'20260701',
|
||||
'2026-07',
|
||||
'2026-07-01T00:00:00',
|
||||
'2026-07-01 ', // trailing handled by trim, still wrong below
|
||||
'not-a-date',
|
||||
'July 1 2026'
|
||||
),
|
||||
fc.string({ minLength: 1, maxLength: 30 })
|
||||
);
|
||||
|
||||
const invalidStringArb = fc
|
||||
.oneof(wrongShapeArb, badMonthArb, badDayArb, impossibleDayArb, twoDigitArb)
|
||||
.filter(s => typeof s === 'string' && s.trim() !== '' && !isValidCalendarYmd(s.trim()) && !isIsoDateTimeWithValidDate(s.trim()));
|
||||
|
||||
// Any input category (used for totality / independence properties).
|
||||
const anyInputArb = fc.oneof(
|
||||
validDateStringArb,
|
||||
absentArb,
|
||||
invalidStringArb
|
||||
);
|
||||
|
||||
// --- Property 1 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD
|
||||
describe('Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD', () => {
|
||||
/**
|
||||
* **Validates: Requirements 1.1, 1.4**
|
||||
*
|
||||
* For any valid calendar date in YYYY-MM-DD form, formatResolutionDate
|
||||
* returns { state: 'set', value } where value matches ^\d{4}-\d{2}-\d{2}$
|
||||
* and equals the canonical normalized form (the input itself).
|
||||
*/
|
||||
test('valid calendar dates are classified set and normalized to YYYY-MM-DD', () => {
|
||||
fc.assert(
|
||||
fc.property(validDateStringArb, dateStr => {
|
||||
const result = formatResolutionDate(dateStr);
|
||||
expect(result.state).toBe('set');
|
||||
expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(result.value).toBe(dateStr);
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 2 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 2: Absent values classify as "none"
|
||||
describe('Property 2: Absent values classify as "none"', () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.1, 4.5**
|
||||
*
|
||||
* For any input that is null, undefined, the empty string, or a string
|
||||
* composed entirely of whitespace, formatResolutionDate returns
|
||||
* { state: 'none' }.
|
||||
*/
|
||||
test('null, undefined, empty, and whitespace-only inputs classify as none', () => {
|
||||
fc.assert(
|
||||
fc.property(absentArb, raw => {
|
||||
const result = formatResolutionDate(raw);
|
||||
expect(result.state).toBe('none');
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 3 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 3: Non-empty non-calendar-date values classify as "invalid"
|
||||
describe('Property 3: Non-empty non-calendar-date values classify as "invalid"', () => {
|
||||
/**
|
||||
* **Validates: Requirements 1.6**
|
||||
*
|
||||
* For any non-empty, non-whitespace-only string that is not a valid
|
||||
* YYYY-MM-DD calendar date — wrong shapes, out-of-range months/days,
|
||||
* impossible days such as 2026-02-30, and arbitrary text —
|
||||
* formatResolutionDate returns { state: 'invalid' }.
|
||||
*/
|
||||
test('non-empty non-calendar-date strings classify as invalid', () => {
|
||||
fc.assert(
|
||||
fc.property(invalidStringArb, raw => {
|
||||
const result = formatResolutionDate(raw);
|
||||
expect(result.state).toBe('invalid');
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 4 -------------------------------------------------------------
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 4: Classification is total over any metric list
|
||||
describe('Property 4: Classification is total over any metric list', () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.2**
|
||||
*
|
||||
* For any array of resolution_date values drawn from all categories,
|
||||
* formatResolutionDate never throws, every result's state is one of
|
||||
* { 'set', 'none', 'invalid' }, and the number of classified results
|
||||
* equals the number of inputs.
|
||||
*/
|
||||
test('classification is total: no throw, valid state, one result per input', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(anyInputArb, { maxLength: 30 }), inputs => {
|
||||
const results = inputs.map(raw => formatResolutionDate(raw));
|
||||
expect(results).toHaveLength(inputs.length);
|
||||
results.forEach(result => {
|
||||
expect(VALID_STATES).toContain(result.state);
|
||||
});
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Property 5 -------------------------------------------------------------
|
||||
|
||||
// Metric-like object with an independently chosen resolution_date.
|
||||
const metricArb = fc.record({
|
||||
metric_id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
resolution_date: anyInputArb,
|
||||
});
|
||||
|
||||
// General metric arrays plus arrays forced to contain two differing dates.
|
||||
const differingMetricsArb = fc
|
||||
.tuple(validDateStringArb, validDateStringArb)
|
||||
.filter(([a, b]) => a !== b)
|
||||
.chain(([a, b]) =>
|
||||
fc.array(metricArb, { maxLength: 5 }).map(rest => [
|
||||
{ metric_id: 'm-a', resolution_date: a },
|
||||
{ metric_id: 'm-b', resolution_date: b },
|
||||
...rest,
|
||||
])
|
||||
);
|
||||
const metricsArrayArb = fc.oneof(
|
||||
fc.array(metricArb, { maxLength: 30 }),
|
||||
differingMetricsArb
|
||||
);
|
||||
|
||||
// Feature: compliance-metric-estimated-resolution-date, Property 5: Each metric's display derives only from its own field (no collapsing)
|
||||
describe('Property 5: Each metric\'s display derives only from its own field (no collapsing)', () => {
|
||||
/**
|
||||
* **Validates: Requirements 1.3, 3.2, 3.3**
|
||||
*
|
||||
* For any array of metrics — including arrays where metrics carry different
|
||||
* resolution_date values — the derived display for each metric equals
|
||||
* formatResolutionDate applied to that same metric's own field in isolation,
|
||||
* independent of every sibling, and no result collapses to a shared
|
||||
* "Multiple values" sentinel.
|
||||
*/
|
||||
test('each metric maps to its own field with no shared/collapsed value', () => {
|
||||
fc.assert(
|
||||
fc.property(metricsArrayArb, metrics => {
|
||||
const displayed = metrics.map(m => formatResolutionDate(m.resolution_date));
|
||||
expect(displayed).toHaveLength(metrics.length);
|
||||
|
||||
metrics.forEach((metric, index) => {
|
||||
// Computed in isolation from this metric's own field only.
|
||||
const isolated = formatResolutionDate(metric.resolution_date);
|
||||
expect(displayed[index]).toEqual(isolated);
|
||||
|
||||
// No collapsing to a shared "Multiple values" sentinel.
|
||||
expect(displayed[index].state).not.toBe(SHARED_SENTINEL);
|
||||
expect(displayed[index].value).not.toBe(SHARED_SENTINEL);
|
||||
expect(VALID_STATES).toContain(displayed[index].state);
|
||||
});
|
||||
}),
|
||||
{ numRuns: NUM_RUNS }
|
||||
);
|
||||
});
|
||||
});
|
||||
95
frontend/src/utils/__tests__/resolutionDate.test.js
Normal file
95
frontend/src/utils/__tests__/resolutionDate.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Example and edge-case unit tests for the resolution-date helper.
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-metric-estimated-resolution-date
|
||||
* Task: 1.7 — Example and edge-case unit tests for the helper
|
||||
* Requirements: 1.1, 1.4, 1.6, 2.1
|
||||
*
|
||||
* These concrete fixtures anchor the contract of `formatResolutionDate` and
|
||||
* double as regression cases. The universal behavior is covered separately by
|
||||
* the property-based tests in `resolutionDate.property.test.js`.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatResolutionDate,
|
||||
RESOLUTION_DATE_LABEL,
|
||||
NO_DATE_PLACEHOLDER,
|
||||
INVALID_DATE_PLACEHOLDER,
|
||||
} from '../resolutionDate';
|
||||
|
||||
describe('formatResolutionDate', () => {
|
||||
describe('set — valid calendar dates (Requirements 1.1, 1.4)', () => {
|
||||
it("classifies '2026-07-01' as set with the normalized YYYY-MM-DD value", () => {
|
||||
expect(formatResolutionDate('2026-07-01')).toEqual({
|
||||
state: 'set',
|
||||
value: '2026-07-01',
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies the leap day '2024-02-29' as set (2024 is a leap year)", () => {
|
||||
expect(formatResolutionDate('2024-02-29')).toEqual({
|
||||
state: 'set',
|
||||
value: '2024-02-29',
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an ISO datetime '2026-07-03T00:00:00.000Z' as set with the date prefix", () => {
|
||||
expect(formatResolutionDate('2026-07-03T00:00:00.000Z')).toEqual({
|
||||
state: 'set',
|
||||
value: '2026-07-03',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid — present but not a valid calendar date (Requirement 1.6)', () => {
|
||||
it("classifies '2026-7-1' as invalid (components not zero-padded)", () => {
|
||||
expect(formatResolutionDate('2026-7-1')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '07/01/2026' as invalid (wrong shape / separators)", () => {
|
||||
expect(formatResolutionDate('07/01/2026')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2023-02-29' as invalid (2023 is not a leap year)", () => {
|
||||
expect(formatResolutionDate('2023-02-29')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2026-13-01' as invalid (month out of range)", () => {
|
||||
expect(formatResolutionDate('2026-13-01')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2026-00-10' as invalid (month below range)", () => {
|
||||
expect(formatResolutionDate('2026-00-10')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
|
||||
it("classifies '2026-01-32' as invalid (day above month length)", () => {
|
||||
expect(formatResolutionDate('2026-01-32')).toEqual({ state: 'invalid' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('none — absent values (Requirements 2.1)', () => {
|
||||
it('classifies whitespace-only input as none', () => {
|
||||
expect(formatResolutionDate(' ')).toEqual({ state: 'none' });
|
||||
});
|
||||
|
||||
it('classifies null as none', () => {
|
||||
expect(formatResolutionDate(null)).toEqual({ state: 'none' });
|
||||
});
|
||||
|
||||
it('classifies undefined as none', () => {
|
||||
expect(formatResolutionDate(undefined)).toEqual({ state: 'none' });
|
||||
});
|
||||
|
||||
it('classifies the empty string as none', () => {
|
||||
expect(formatResolutionDate('')).toEqual({ state: 'none' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('display constants', () => {
|
||||
it('exposes the expected label and placeholder strings', () => {
|
||||
expect(RESOLUTION_DATE_LABEL).toBe('Est. Resolution');
|
||||
expect(NO_DATE_PLACEHOLDER).toBe('not set');
|
||||
expect(INVALID_DATE_PLACEHOLDER).toBe('invalid date');
|
||||
});
|
||||
});
|
||||
});
|
||||
77
frontend/src/utils/graniteLoaderConfig.js
Normal file
77
frontend/src/utils/graniteLoaderConfig.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Granite Team_Device Loader column configuration.
|
||||
* Defines all 41 columns in canonical order, their groupings,
|
||||
* and which are required for each operation type.
|
||||
*/
|
||||
|
||||
export const LOADER_COLUMNS = [
|
||||
{ id: 'DELETE', label: 'DELETE', group: 'Identification', requiredFor: ['Delete'] },
|
||||
{ id: 'SET_CONFIRMED', label: 'SET_CONFIRMED', group: 'Identification', requiredFor: [] },
|
||||
{ id: 'EQUIPMENT_CLASS', label: 'EQUIPMENT CLASS', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_INST_ID', label: 'EQUIP_INST_ID', group: 'Identification', requiredFor: ['Change', 'Move', 'Delete'] },
|
||||
{ id: 'SITE_NAME', label: 'SITE_NAME', group: 'Identification', requiredFor: ['Add', 'Move'] },
|
||||
{ id: 'EQUIP_NAME', label: 'EQUIP_NAME', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_TEMPLATE', label: 'EQUIP_TEMPLATE', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'EQUIP_STATUS', label: 'EQUIP_STATUS', group: 'Identification', requiredFor: ['Add'] },
|
||||
{ id: 'RESPONSIBLE_TEAM', label: 'UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM', group: 'Responsible Org', requiredFor: ['Add'] },
|
||||
{ id: 'IPV4_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ADDRESS', group: 'IP Addressing', requiredFor: ['Add'] },
|
||||
{ id: 'MAC_ADDRESS', label: 'UDA#IP_ADDRESSING#MAC ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'MGMT_IP_ASN', label: 'UDA#IP_ADDRESSING#MGMT_IP_ASN', group: 'IP Addressing', requiredFor: ['Add'] },
|
||||
{ id: 'SERIALNUMBER', label: 'SERIALNUMBER', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EXCLUDED_DISCOVERY', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY', group: 'Discovery', requiredFor: [] },
|
||||
{ id: 'EXCLUDED_REASON', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY REASON', group: 'Discovery', requiredFor: [] },
|
||||
{ id: 'IPV6_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV6_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'ILOM_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ILOM_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'APP_ID_ASSET_TAG', label: 'UDA#CHERWELL_CMDB#APP ID ASSET TAG', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'DEVICE_FUNCTION', label: 'UDA#CHERWELL_CMDB#DEVICE_FUNCTION', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'ENVIRONMENT', label: 'UDA#CHERWELL_CMDB#ENVIRONMENT', group: 'Cyber Metrics', requiredFor: [] },
|
||||
{ id: 'SECONDARY_MGMT_IP', label: 'UDA#IP_ADDRESSING#SECONDARY_MGMT_IP_ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'VIP', label: 'UDA#IP_ADDRESSING#VIP', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'FLOATING_IP', label: 'UDA#IP_ADDRESSING#FLOATING IP ADDRESS', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_1', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 1', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_2', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 2', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'SCAN_IP_3', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 3', group: 'IP Addressing', requiredFor: [] },
|
||||
{ id: 'EQUIP_MODEL', label: 'EQUIP_MODEL', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EQUIP_COMMENTS', label: 'EQUIP_COMMENTS', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'EQUIP_PARTNUMBER', label: 'EQUIP_PARTNUMBER', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'OS', label: 'UDA#CONTROLLER CONFIG#OS', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'OS_VERSION', label: 'UDA#CONTROLLER CONFIG#OS VERSION', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'CPU_CORES', label: 'UDA#CONTROLLER CONFIG#TOTAL CPU CORES', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'RAM_GB', label: 'UDA#CONTROLLER CONFIG#RAM IN GB', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'STORAGE_GB', label: 'UDA#CONTROLLER CONFIG#STORAGE IN GB', group: 'Equipment Info', requiredFor: [] },
|
||||
{ id: 'ARCHER_ID', label: 'UDA#WIFI EQUIP INFO#ARCHER ID', group: 'Other', requiredFor: [] },
|
||||
{ id: 'INSTALL_LOCATION', label: 'UDA#WIFI EQUIP INFO#INSTALL LOCATION', group: 'Other', requiredFor: [] },
|
||||
{ id: 'SYSNAME', label: 'UDA#EQUIPMENT_INFO#SYSNAME', group: 'Other', requiredFor: [] },
|
||||
{ id: 'LATITUDE', label: 'UDA#WIFI EQUIP INFO#LATITUDE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'LONGITUDE', label: 'UDA#WIFI EQUIP INFO#LONGITUDE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'OSTYPE', label: 'UDA#EQUIP MIGRATION#OSTYPE', group: 'Other', requiredFor: [] },
|
||||
{ id: 'OSVERSION', label: 'UDA#EQUIP MIGRATION#OSVERSION', group: 'Other', requiredFor: [] },
|
||||
];
|
||||
|
||||
export const COLUMN_GROUPS = [
|
||||
'Identification',
|
||||
'Responsible Org',
|
||||
'IP Addressing',
|
||||
'Discovery',
|
||||
'Cyber Metrics',
|
||||
'Equipment Info',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const OPERATION_TYPES = ['Change', 'Add', 'Delete', 'Move'];
|
||||
|
||||
/**
|
||||
* Returns column IDs required for the given operation type.
|
||||
*/
|
||||
export function getRequiredColumns(operationType) {
|
||||
return LOADER_COLUMNS
|
||||
.filter(col => col.requiredFor.includes(operationType))
|
||||
.map(col => col.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns columns belonging to a specific group.
|
||||
*/
|
||||
export function getColumnsByGroup(group) {
|
||||
return LOADER_COLUMNS.filter(col => col.group === group);
|
||||
}
|
||||
82
frontend/src/utils/graniteLoaderExport.js
Normal file
82
frontend/src/utils/graniteLoaderExport.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Granite Team_Device Loader xlsx generation.
|
||||
* Produces a properly formatted xlsx file for upload to SNIP XperLoad.
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { LOADER_COLUMNS } from './graniteLoaderConfig';
|
||||
|
||||
/**
|
||||
* Generate a Granite Loader Sheet xlsx file.
|
||||
*
|
||||
* @param {Object} config
|
||||
* @param {string} config.operationType - 'Change' | 'Add' | 'Delete' | 'Move'
|
||||
* @param {Array<string>} config.columnIds - selected column IDs (in any order; output uses canonical order)
|
||||
* @param {Array<Object>} config.rows - device rows, each keyed by column ID with string values
|
||||
* @returns {Blob} xlsx file as a Blob for browser download
|
||||
*/
|
||||
export function generateLoaderXlsx(config) {
|
||||
const { operationType, columnIds, rows } = config;
|
||||
|
||||
// Filter LOADER_COLUMNS to only selected columns, preserving canonical order
|
||||
const selectedColumns = LOADER_COLUMNS.filter(col => columnIds.includes(col.id));
|
||||
|
||||
// Build header row from canonical labels
|
||||
const headers = selectedColumns.map(col => col.label);
|
||||
|
||||
// Build data rows
|
||||
const dataRows = rows.map(row => {
|
||||
return selectedColumns.map(col => {
|
||||
// DELETE column auto-fill for Delete operations
|
||||
if (col.id === 'DELETE' && operationType === 'Delete') {
|
||||
return 'X';
|
||||
}
|
||||
|
||||
// Get the value from the row
|
||||
let value = row[col.id];
|
||||
|
||||
// EQUIPMENT CLASS defaults to "S" if not explicitly set
|
||||
if (col.id === 'EQUIPMENT_CLASS' && (value === undefined || value === null || value === '')) {
|
||||
value = 'S';
|
||||
}
|
||||
|
||||
// Convert null/undefined to empty string (not "null" or "undefined")
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
});
|
||||
});
|
||||
|
||||
// Combine headers + data into array-of-arrays
|
||||
const aoa = [headers, ...dataRows];
|
||||
|
||||
// Create workbook and worksheet
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Load_Sheet');
|
||||
|
||||
// Write to array buffer
|
||||
const wbOut = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||
|
||||
// Return as Blob
|
||||
return new Blob([wbOut], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a descriptive filename for the loader sheet.
|
||||
*
|
||||
* @param {string} operationType - 'Change' | 'Add' | 'Delete' | 'Move'
|
||||
* @param {string} [teamName] - optional team name to include in filename
|
||||
* @returns {string} filename like "Loader_Change_NTS-AEO-STEAM_2026-05-27.xlsx"
|
||||
*/
|
||||
export function generateFilename(operationType, teamName) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const parts = ['Loader', operationType];
|
||||
if (teamName) {
|
||||
parts.push(teamName.replace(/[^a-zA-Z0-9_-]/g, '-'));
|
||||
}
|
||||
parts.push(date);
|
||||
return parts.join('_') + '.xlsx';
|
||||
}
|
||||
92
frontend/src/utils/jiraConsolidation.js
Normal file
92
frontend/src/utils/jiraConsolidation.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pure utility functions for consolidating multiple Ivanti queue items
|
||||
* into a single Jira ticket's summary, description, CVE, and vendor fields.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a consolidated summary for a multi-item Jira ticket.
|
||||
* Format: "[N findings] vendor - first_finding_title", truncated to 255 chars.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} Generated summary, at most 255 characters
|
||||
*/
|
||||
export function generateConsolidatedSummary(items) {
|
||||
const count = items.length;
|
||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||
const vendorLabel = vendors.length === 1 ? vendors[0] : 'Multiple Vendors';
|
||||
const firstTitle = items[0]?.finding_title || 'Untitled';
|
||||
const raw = `[${count} findings] ${vendorLabel} - ${firstTitle}`;
|
||||
return raw.slice(0, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a structured description grouped by vendor for a consolidated Jira ticket.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} Structured description with header and vendor-grouped items
|
||||
*/
|
||||
export function generateConsolidatedDescription(items) {
|
||||
const header = `Consolidated Jira ticket covering ${items.length} Ivanti queue findings.\n\n`;
|
||||
|
||||
// Group by vendor
|
||||
const grouped = {};
|
||||
for (const item of items) {
|
||||
const vendor = item.vendor || 'Unknown Vendor';
|
||||
if (!grouped[vendor]) grouped[vendor] = [];
|
||||
grouped[vendor].push(item);
|
||||
}
|
||||
|
||||
let body = '';
|
||||
for (const [vendor, vendorItems] of Object.entries(grouped)) {
|
||||
body += `== ${vendor} ==\n`;
|
||||
for (const item of vendorItems) {
|
||||
let cves = 'None';
|
||||
if (item.cves_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.cves_json);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
cves = parsed.join(', ');
|
||||
}
|
||||
} catch (e) {
|
||||
cves = 'None';
|
||||
}
|
||||
}
|
||||
body += `- ${item.finding_title}\n`;
|
||||
body += ` CVEs: ${cves}\n`;
|
||||
body += ` Host: ${item.hostname || 'N/A'} (${item.ip_address || 'N/A'})\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return header + body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first CVE from the first item that has a non-empty cves_json array.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} First CVE ID found, or empty string if none
|
||||
*/
|
||||
export function extractFirstCve(items) {
|
||||
for (const item of items) {
|
||||
if (item.cves_json) {
|
||||
try {
|
||||
const cves = JSON.parse(item.cves_json);
|
||||
if (Array.isArray(cves) && cves.length > 0) return cves[0];
|
||||
} catch (e) {
|
||||
// Skip items with invalid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the common vendor if all items share the same vendor.
|
||||
*
|
||||
* @param {Array} items - Array of queue item objects
|
||||
* @returns {string} Common vendor name if all items share it, empty string otherwise
|
||||
*/
|
||||
export function extractCommonVendor(items) {
|
||||
const vendors = [...new Set(items.map(i => i.vendor).filter(Boolean))];
|
||||
return vendors.length === 1 ? vendors[0] : '';
|
||||
}
|
||||
175
frontend/src/utils/jiraConsolidation.test.js
Normal file
175
frontend/src/utils/jiraConsolidation.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
generateConsolidatedSummary,
|
||||
generateConsolidatedDescription,
|
||||
extractFirstCve,
|
||||
extractCommonVendor,
|
||||
} from './jiraConsolidation';
|
||||
|
||||
describe('generateConsolidatedSummary', () => {
|
||||
it('formats summary with count, common vendor, and first title', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
|
||||
{ vendor: 'Microsoft', finding_title: 'XSS in Outlook' },
|
||||
];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Microsoft - RCE in Exchange'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses "Multiple Vendors" when vendors differ', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'RCE in Exchange' },
|
||||
{ vendor: 'Adobe', finding_title: 'Buffer overflow' },
|
||||
];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Multiple Vendors - RCE in Exchange'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses "Multiple Vendors" when vendor is null/empty', () => {
|
||||
const items = [
|
||||
{ vendor: null, finding_title: 'Finding A' },
|
||||
{ vendor: '', finding_title: 'Finding B' },
|
||||
];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Multiple Vendors - Finding A'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses "Untitled" when first item has no finding_title', () => {
|
||||
const items = [{ vendor: 'Cisco' }, { vendor: 'Cisco', finding_title: 'Bug' }];
|
||||
expect(generateConsolidatedSummary(items)).toBe(
|
||||
'[2 findings] Cisco - Untitled'
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates to 255 characters', () => {
|
||||
const longTitle = 'A'.repeat(300);
|
||||
const items = [{ vendor: 'V', finding_title: longTitle }];
|
||||
const result = generateConsolidatedSummary(items);
|
||||
expect(result.length).toBeLessThanOrEqual(255);
|
||||
expect(result).toMatch(/^\[1 findings\] V - /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConsolidatedDescription', () => {
|
||||
it('includes header with item count', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'Bug', cves_json: '["CVE-2024-001"]', hostname: 'host1', ip_address: '10.0.0.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('Consolidated Jira ticket covering 1 Ivanti queue findings.');
|
||||
});
|
||||
|
||||
it('groups items by vendor', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft', finding_title: 'Bug A', hostname: 'h1', ip_address: '10.0.0.1' },
|
||||
{ vendor: 'Adobe', finding_title: 'Bug B', hostname: 'h2', ip_address: '10.0.0.2' },
|
||||
{ vendor: 'Microsoft', finding_title: 'Bug C', hostname: 'h3', ip_address: '10.0.0.3' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('== Microsoft ==');
|
||||
expect(result).toContain('== Adobe ==');
|
||||
expect(result).toContain('Bug A');
|
||||
expect(result).toContain('Bug B');
|
||||
expect(result).toContain('Bug C');
|
||||
});
|
||||
|
||||
it('uses "Unknown Vendor" for null/empty vendor', () => {
|
||||
const items = [
|
||||
{ vendor: null, finding_title: 'Bug', hostname: 'h1', ip_address: '10.0.0.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('== Unknown Vendor ==');
|
||||
});
|
||||
|
||||
it('includes CVEs, hostname, and IP for each item', () => {
|
||||
const items = [
|
||||
{ vendor: 'Cisco', finding_title: 'Vuln', cves_json: '["CVE-2024-100","CVE-2024-101"]', hostname: 'server1', ip_address: '192.168.1.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('CVEs: CVE-2024-100, CVE-2024-101');
|
||||
expect(result).toContain('Host: server1 (192.168.1.1)');
|
||||
});
|
||||
|
||||
it('shows "None" for items without CVEs', () => {
|
||||
const items = [
|
||||
{ vendor: 'Cisco', finding_title: 'Vuln', hostname: 'h1', ip_address: '10.0.0.1' },
|
||||
];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('CVEs: None');
|
||||
});
|
||||
|
||||
it('shows "N/A" for missing hostname and ip_address', () => {
|
||||
const items = [{ vendor: 'Cisco', finding_title: 'Vuln' }];
|
||||
const result = generateConsolidatedDescription(items);
|
||||
expect(result).toContain('Host: N/A (N/A)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFirstCve', () => {
|
||||
it('returns first CVE from first item with non-empty cves_json', () => {
|
||||
const items = [
|
||||
{ cves_json: null },
|
||||
{ cves_json: '["CVE-2024-200","CVE-2024-201"]' },
|
||||
{ cves_json: '["CVE-2024-300"]' },
|
||||
];
|
||||
expect(extractFirstCve(items)).toBe('CVE-2024-200');
|
||||
});
|
||||
|
||||
it('returns empty string when no items have CVEs', () => {
|
||||
const items = [
|
||||
{ cves_json: null },
|
||||
{ cves_json: '[]' },
|
||||
];
|
||||
expect(extractFirstCve(items)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for empty array', () => {
|
||||
expect(extractFirstCve([])).toBe('');
|
||||
});
|
||||
|
||||
it('skips items with invalid JSON in cves_json', () => {
|
||||
const items = [
|
||||
{ cves_json: 'not-json' },
|
||||
{ cves_json: '["CVE-2024-500"]' },
|
||||
];
|
||||
expect(extractFirstCve(items)).toBe('CVE-2024-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCommonVendor', () => {
|
||||
it('returns vendor when all items share the same vendor', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft' },
|
||||
{ vendor: 'Microsoft' },
|
||||
{ vendor: 'Microsoft' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('Microsoft');
|
||||
});
|
||||
|
||||
it('returns empty string when vendors differ', () => {
|
||||
const items = [
|
||||
{ vendor: 'Microsoft' },
|
||||
{ vendor: 'Adobe' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when all vendors are null/empty', () => {
|
||||
const items = [
|
||||
{ vendor: null },
|
||||
{ vendor: '' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('');
|
||||
});
|
||||
|
||||
it('returns vendor when some items have null vendor but all non-null are same', () => {
|
||||
const items = [
|
||||
{ vendor: 'Cisco' },
|
||||
{ vendor: null },
|
||||
{ vendor: 'Cisco' },
|
||||
];
|
||||
expect(extractCommonVendor(items)).toBe('Cisco');
|
||||
});
|
||||
});
|
||||
63
frontend/src/utils/queueGrouping.js
Normal file
63
frontend/src/utils/queueGrouping.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Queue grouping utility — extracts the hybrid Inventory + vendor grouping logic
|
||||
* from IvantiTodoQueuePage into a testable pure function.
|
||||
*
|
||||
* Spec: .kiro/specs/queue-collapsible-sections
|
||||
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7
|
||||
*/
|
||||
|
||||
const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']);
|
||||
|
||||
/**
|
||||
* Groups visible queue items into the hybrid section layout.
|
||||
*
|
||||
* @param {Array} visibleItems - Queue items with status 'pending'
|
||||
* @returns {Array<{key: string, label: string, type: string, items: Array}>}
|
||||
*
|
||||
* Rules:
|
||||
* - Items with workflow_type CARD, GRANITE, or DECOM → Inventory section
|
||||
* - Items with workflow_type FP or Archer → grouped by vendor field
|
||||
* - Items with null/undefined/empty vendor → placed in "Unknown" vendor section
|
||||
* - Inventory section appears first (if non-empty)
|
||||
* - Vendor sections sorted alphabetically by label
|
||||
* - Sections with zero items are omitted from output
|
||||
*/
|
||||
export function groupQueueItems(visibleItems) {
|
||||
const inventoryItems = [];
|
||||
const vendorMap = new Map();
|
||||
|
||||
for (const item of visibleItems) {
|
||||
if (INVENTORY_TYPES.has(item.workflow_type)) {
|
||||
inventoryItems.push(item);
|
||||
} else {
|
||||
const vendor = item.vendor?.trim() || 'Unknown';
|
||||
if (!vendorMap.has(vendor)) vendorMap.set(vendor, []);
|
||||
vendorMap.get(vendor).push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
|
||||
if (inventoryItems.length > 0) {
|
||||
sections.push({
|
||||
key: 'inventory',
|
||||
label: 'Inventory',
|
||||
type: 'inventory',
|
||||
items: inventoryItems,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedVendors = [...vendorMap.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
for (const [vendor, items] of sortedVendors) {
|
||||
sections.push({
|
||||
key: `vendor:${vendor}`,
|
||||
label: vendor,
|
||||
type: 'vendor',
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
92
frontend/src/utils/resolutionDate.js
Normal file
92
frontend/src/utils/resolutionDate.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Resolution-date helper — classifies and formats a raw per-metric
|
||||
* `resolution_date` value for read-only display in the asset sidebar.
|
||||
*
|
||||
* Spec: .kiro/specs/compliance-metric-estimated-resolution-date
|
||||
* Requirements: 1.1, 1.4, 1.6, 2.1
|
||||
*
|
||||
* Pure and deterministic: the result depends only on `raw`. It does not read
|
||||
* the system clock, timezone, or locale. Validation is strict YYYY-MM-DD with
|
||||
* a real-calendar-date check (correct month lengths and leap years), which
|
||||
* matches how the value is produced by the <input type="date"> editor.
|
||||
*/
|
||||
|
||||
// Display constants (single source of truth for component + tests)
|
||||
export const RESOLUTION_DATE_LABEL = 'Est. Resolution';
|
||||
export const NO_DATE_PLACEHOLDER = 'not set';
|
||||
export const INVALID_DATE_PLACEHOLDER = 'invalid date';
|
||||
|
||||
// Strict YYYY-MM-DD shape: four-digit year, two-digit month, two-digit day.
|
||||
const YMD_SHAPE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Returns the number of days in the given month for the given year,
|
||||
* accounting for leap years. `month` is 1-based (1 = January).
|
||||
*
|
||||
* @param {number} year
|
||||
* @param {number} month - 1-based month (1–12)
|
||||
* @returns {number} days in that month
|
||||
*/
|
||||
function daysInMonth(year, month) {
|
||||
// Leap year: divisible by 4, except centuries not divisible by 400.
|
||||
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
const lengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return lengths[month - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify and format a raw per-metric resolution_date value for display.
|
||||
*
|
||||
* @param {string|null|undefined} raw - the metric's resolution_date field
|
||||
* @returns {{ state: 'set', value: string } | { state: 'none' } | { state: 'invalid' }}
|
||||
* - { state: 'set', value } the value is a valid calendar date; `value` is YYYY-MM-DD
|
||||
* - { state: 'none' } the value is null, undefined, empty, or whitespace-only
|
||||
* - { state: 'invalid' } the value is non-empty but not a valid calendar date
|
||||
*/
|
||||
export function formatResolutionDate(raw) {
|
||||
// Null/undefined → no date set.
|
||||
if (raw === null || raw === undefined) {
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
// Anything that is not a string is treated as not-a-valid-date once it is
|
||||
// non-empty; coerce defensively so the helper never throws on bad input.
|
||||
if (typeof raw !== 'string') {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Empty or whitespace-only → no date set.
|
||||
if (trimmed === '') {
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
// Must match the strict YYYY-MM-DD shape.
|
||||
// Also accept ISO datetime strings (e.g. "2026-07-03T00:00:00.000Z") by
|
||||
// extracting the date prefix — the pg driver returns DATE columns this way.
|
||||
let candidate = trimmed;
|
||||
if (!YMD_SHAPE.test(candidate) && /^\d{4}-\d{2}-\d{2}T/.test(candidate)) {
|
||||
candidate = candidate.slice(0, 10);
|
||||
}
|
||||
if (!YMD_SHAPE.test(candidate)) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
// Shape is correct; verify it is a real calendar date.
|
||||
const year = Number(candidate.slice(0, 4));
|
||||
const month = Number(candidate.slice(5, 7));
|
||||
const day = Number(candidate.slice(8, 10));
|
||||
|
||||
if (month < 1 || month > 12) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
if (day < 1 || day > daysInMonth(year, month)) {
|
||||
return { state: 'invalid' };
|
||||
}
|
||||
|
||||
// Valid calendar date; the candidate value is the canonical
|
||||
// zero-padded YYYY-MM-DD form.
|
||||
return { state: 'set', value: candidate };
|
||||
}
|
||||
Reference in New Issue
Block a user