Compare commits
48 Commits
33e449f520
...
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
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -71,3 +71,4 @@ docs/data-exports/
|
|||||||
|
|
||||||
# Python cache
|
# Python cache
|
||||||
__pycache__/
|
__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
|
# GitLab CI/CD Pipeline — STEAM Security Dashboard
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
# Executor: Docker (LXC 108 — 71.85.90.8)
|
||||||
# Pipeline stages:
|
# Build/test jobs run in node:18 containers.
|
||||||
# 1. install — install dependencies for backend and frontend
|
# Release: v2.1.0
|
||||||
# 2. lint — run linters / static checks
|
# Deploy jobs run in alpine with SSH/rsync, targeting staging (71.85.90.9)
|
||||||
# 3. test — run backend (Jest) and frontend (react-scripts) tests
|
# and production (71.85.90.6) via SSH.
|
||||||
# 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)
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Variables
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
variables:
|
variables:
|
||||||
PROD_HOST: "71.85.90.6"
|
PROD_HOST: "71.85.90.6"
|
||||||
PROD_USER: "root"
|
PROD_USER: "root"
|
||||||
PROD_DIR: "/home/cve-dashboard"
|
PROD_DIR: "/home/cve-dashboard"
|
||||||
|
STAGING_HOST: "71.85.90.9"
|
||||||
|
STAGING_USER: "root"
|
||||||
STAGING_DIR: "/home/cve-dashboard-staging"
|
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:
|
stages:
|
||||||
- install
|
- install
|
||||||
- lint
|
- lint
|
||||||
@@ -48,44 +25,45 @@ stages:
|
|||||||
- verify
|
- verify
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 1: Install dependencies
|
# STAGE 1: Install
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
install-backend:
|
install-backend:
|
||||||
stage: install
|
stage: install
|
||||||
|
image: node:18
|
||||||
script:
|
script:
|
||||||
- npm ci --prefer-offline
|
- npm ci
|
||||||
cache:
|
cache:
|
||||||
key: ${CI_COMMIT_REF_SLUG}
|
key: backend-${CI_COMMIT_REF_SLUG}
|
||||||
paths:
|
paths:
|
||||||
- node_modules/
|
- node_modules/
|
||||||
policy: pull-push
|
policy: push
|
||||||
|
|
||||||
install-frontend:
|
install-frontend:
|
||||||
stage: install
|
stage: install
|
||||||
|
image: node:18
|
||||||
script:
|
script:
|
||||||
- cd frontend && npm ci --prefer-offline
|
- cd frontend && npm ci
|
||||||
cache:
|
cache:
|
||||||
key: ${CI_COMMIT_REF_SLUG}
|
key: frontend-${CI_COMMIT_REF_SLUG}
|
||||||
paths:
|
paths:
|
||||||
- frontend/node_modules/
|
- 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:
|
lint-backend:
|
||||||
stage: lint
|
stage: lint
|
||||||
|
image: node:18
|
||||||
|
cache:
|
||||||
|
key: backend-${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
policy: pull
|
||||||
script:
|
script:
|
||||||
- npm ci --prefer-offline
|
- test -d node_modules || npm ci
|
||||||
- node -c backend/server.js
|
- node -c backend/server.js
|
||||||
- node -c backend/routes/*.js
|
- node -c backend/routes/*.js
|
||||||
- node -c backend/helpers/*.js
|
- node -c backend/helpers/*.js
|
||||||
@@ -93,14 +71,35 @@ lint-backend:
|
|||||||
needs:
|
needs:
|
||||||
- install-backend
|
- 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:
|
test-backend:
|
||||||
stage: test
|
stage: test
|
||||||
|
image: node:18
|
||||||
|
variables:
|
||||||
|
DATABASE_URL: $DATABASE_URL
|
||||||
|
cache:
|
||||||
|
key: backend-${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
policy: pull
|
||||||
script:
|
script:
|
||||||
- npm ci --prefer-offline
|
- test -d node_modules || npm ci
|
||||||
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
|
- ./node_modules/.bin/jest --ci --forceExit backend/__tests__/
|
||||||
timeout: 5 minutes
|
timeout: 5 minutes
|
||||||
needs:
|
needs:
|
||||||
@@ -108,9 +107,18 @@ test-backend:
|
|||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
stage: test
|
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:
|
script:
|
||||||
- npm ci --prefer-offline
|
- 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
|
||||||
- cd frontend && npm ci --prefer-offline && CI=true npx react-scripts test --watchAll=false --ci
|
|
||||||
timeout: 5 minutes
|
timeout: 5 minutes
|
||||||
needs:
|
needs:
|
||||||
- install-frontend
|
- install-frontend
|
||||||
@@ -121,8 +129,14 @@ test-frontend:
|
|||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
stage: build
|
stage: build
|
||||||
|
image: node:18
|
||||||
|
cache:
|
||||||
|
key: frontend-${CI_COMMIT_REF_SLUG}
|
||||||
|
paths:
|
||||||
|
- frontend/node_modules/
|
||||||
|
policy: pull
|
||||||
script:
|
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:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- frontend/build/
|
- frontend/build/
|
||||||
@@ -132,26 +146,30 @@ build-frontend:
|
|||||||
- lint-frontend
|
- lint-frontend
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 5: Deploy
|
# STAGE 5: Deploy (SSH from container)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
.deploy-base: &deploy-base
|
||||||
# Staging — auto-deploys on main/master to dashboard-dev:3100
|
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-staging:
|
||||||
|
<<: *deploy-base
|
||||||
stage: deploy
|
stage: deploy
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
when: on_success
|
when: on_success
|
||||||
environment:
|
environment:
|
||||||
name: staging
|
name: staging
|
||||||
url: http://localhost:3100
|
url: http://71.85.90.9:3100
|
||||||
script:
|
script:
|
||||||
- echo "Deploying to staging (dashboard-dev:3100)..."
|
- echo "Deploying to staging (${STAGING_HOST})..."
|
||||||
# Ensure staging directory exists
|
- rsync -az --delete
|
||||||
- mkdir -p ${STAGING_DIR}
|
|
||||||
# Sync code (exclude .git, node_modules, uploads, logs)
|
|
||||||
- rsync -a --delete
|
|
||||||
--exclude='.git'
|
--exclude='.git'
|
||||||
--exclude='node_modules'
|
--exclude='node_modules'
|
||||||
--exclude='frontend/node_modules'
|
--exclude='frontend/node_modules'
|
||||||
@@ -160,26 +178,16 @@ deploy-staging:
|
|||||||
--exclude='*.log'
|
--exclude='*.log'
|
||||||
--exclude='*.db'
|
--exclude='*.db'
|
||||||
--exclude='.env'
|
--exclude='.env'
|
||||||
${CI_PROJECT_DIR}/ ${STAGING_DIR}/
|
./ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/
|
||||||
# Copy built frontend
|
- rsync -az frontend/build/ ${STAGING_USER}@${STAGING_HOST}:${STAGING_DIR}/frontend/build/
|
||||||
- cp -r ${CI_PROJECT_DIR}/frontend/build ${STAGING_DIR}/frontend/build
|
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR} && npm ci --prefer-offline"
|
||||||
# Install deps in staging
|
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/frontend && npm ci --prefer-offline"
|
||||||
- cd ${STAGING_DIR} && npm ci --prefer-offline
|
- ssh ${STAGING_USER}@${STAGING_HOST} "cd ${STAGING_DIR}/backend && node migrations/run-all.js"
|
||||||
- cd ${STAGING_DIR}/frontend && npm ci --prefer-offline
|
- ssh ${STAGING_USER}@${STAGING_HOST} "systemctl restart cve-backend-staging || systemctl start cve-backend-staging || true"
|
||||||
# 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
|
|
||||||
- echo "Staging deploy complete."
|
- echo "Staging deploy complete."
|
||||||
after_script:
|
after_script:
|
||||||
- |
|
- |
|
||||||
|
apk add --no-cache curl > /dev/null 2>&1
|
||||||
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
||||||
for ISSUE in $ISSUES; do
|
for ISSUE in $ISSUES; do
|
||||||
curl --silent --request POST \
|
curl --silent --request POST \
|
||||||
@@ -192,10 +200,8 @@ deploy-staging:
|
|||||||
- build-frontend
|
- build-frontend
|
||||||
- test-backend
|
- test-backend
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Production — manual trigger, SSH to 71.85.90.6
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
|
<<: *deploy-base
|
||||||
stage: deploy
|
stage: deploy
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
@@ -205,10 +211,8 @@ deploy-production:
|
|||||||
url: http://71.85.90.6:3001
|
url: http://71.85.90.6:3001
|
||||||
script:
|
script:
|
||||||
- echo "Deploying to production (${PROD_HOST})..."
|
- 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
|
- 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)"
|
- echo "Previous production commit:$(cat /tmp/prod-prev-commit)"
|
||||||
# Sync code to production (exclude local-only files)
|
|
||||||
- rsync -az --delete
|
- rsync -az --delete
|
||||||
--exclude='.git'
|
--exclude='.git'
|
||||||
--exclude='node_modules'
|
--exclude='node_modules'
|
||||||
@@ -219,20 +223,17 @@ deploy-production:
|
|||||||
--exclude='*.db'
|
--exclude='*.db'
|
||||||
--exclude='.env'
|
--exclude='.env'
|
||||||
--exclude='.compliance-staging'
|
--exclude='.compliance-staging'
|
||||||
${CI_PROJECT_DIR}/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
|
./ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/
|
||||||
# Copy built frontend
|
- rsync -az frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
|
||||||
- rsync -az ${CI_PROJECT_DIR}/frontend/build/ ${PROD_USER}@${PROD_HOST}:${PROD_DIR}/frontend/build/
|
|
||||||
# Install deps on production
|
|
||||||
- ssh ${PROD_USER}@${PROD_HOST} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
- 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"
|
- 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"
|
- 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 || true"
|
||||||
- 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} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend"
|
- ssh ${PROD_USER}@${PROD_HOST} "systemctl daemon-reload && systemctl enable cve-backend && systemctl restart cve-backend"
|
||||||
- echo "Production deploy complete."
|
- echo "Production deploy complete."
|
||||||
after_script:
|
after_script:
|
||||||
- |
|
- |
|
||||||
|
apk add --no-cache curl > /dev/null 2>&1
|
||||||
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
ISSUES=$(git log --format=%B -1 | grep -oP '#\d+' | tr -d '#' | sort -u)
|
||||||
for ISSUE in $ISSUES; do
|
for ISSUE in $ISSUES; do
|
||||||
curl --silent --request POST \
|
curl --silent --request POST \
|
||||||
@@ -246,23 +247,22 @@ deploy-production:
|
|||||||
- test-backend
|
- test-backend
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# STAGE 6: Post-deploy verification
|
# STAGE 6: Verify
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Staging health check
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
verify-staging:
|
verify-staging:
|
||||||
stage: verify
|
stage: verify
|
||||||
|
image: alpine:latest
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
when: on_success
|
when: on_success
|
||||||
script:
|
script:
|
||||||
|
- apk add --no-cache curl
|
||||||
- echo "Verifying staging..."
|
- echo "Verifying staging..."
|
||||||
- sleep 3
|
- sleep 3
|
||||||
- |
|
- |
|
||||||
for i in 1 2 3 4 5; do
|
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
|
if [ "$STATUS" = "200" ]; then
|
||||||
echo "Staging health check passed (attempt $i)"
|
echo "Staging health check passed (attempt $i)"
|
||||||
break
|
break
|
||||||
@@ -274,19 +274,28 @@ verify-staging:
|
|||||||
echo "FAILED: Staging health check failed after 5 attempts"
|
echo "FAILED: Staging health check failed after 5 attempts"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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."
|
- echo "Staging verification passed."
|
||||||
needs:
|
needs:
|
||||||
- deploy-staging
|
- deploy-staging
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Production health check — rolls back on failure
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
verify-production:
|
verify-production:
|
||||||
stage: verify
|
stage: verify
|
||||||
|
image: alpine:latest
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"
|
||||||
when: on_success
|
when: on_success
|
||||||
script:
|
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..."
|
- echo "Verifying production..."
|
||||||
- sleep 3
|
- sleep 3
|
||||||
- |
|
- |
|
||||||
@@ -304,7 +313,6 @@ verify-production:
|
|||||||
PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "")
|
PREV_COMMIT=$(cat /tmp/prod-prev-commit 2>/dev/null || echo "")
|
||||||
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then
|
if [ -n "$PREV_COMMIT" ] && [ "$PREV_COMMIT" != "none" ]; then
|
||||||
echo "Rolling back to $PREV_COMMIT..."
|
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} && 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} "cd ${PROD_DIR} && npm ci --prefer-offline"
|
||||||
ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend"
|
ssh ${PROD_USER}@${PROD_HOST} "systemctl restart cve-backend"
|
||||||
@@ -314,6 +322,12 @@ verify-production:
|
|||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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."
|
- echo "Production verification passed."
|
||||||
needs:
|
needs:
|
||||||
- deploy-production
|
- deploy-production
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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
|
## Ports
|
||||||
|
|
||||||
| Environment | URL | Notes |
|
| 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 |
|
| 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.
|
||||||
|
|||||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -6,27 +6,82 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [2.0.0] — 2026-05-19
|
## [2.2.0] — 2026-06-04
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
### 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.
|
- **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.
|
- **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
|
### Features
|
||||||
|
|
||||||
- **In-app notification system** — replaces Webex bot integration with native notifications
|
- **Jira integration overhaul**
|
||||||
- **Screenshot uploads** in feedback modal, Webex bot DM on issue close
|
- 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
|
- **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
|
- **VCL compliance reporting** — exec report page, device metadata fields, bulk upload
|
||||||
- **Aggregated burndown forecast** on CCP Metrics overview page
|
|
||||||
- **Sub-team drill-down** — metric sub-team intermediate view with per-team breakdowns
|
|
||||||
- **Metric breakdown panel** — Non-Compliant stat clickable, reveals metric breakdown buttons, compact grid with top 8 and show-all toggle
|
|
||||||
- **Remediation plan and resolution date history tracking**
|
|
||||||
- **Data management panel** — delete vertical, rollback upload, and reset all
|
- **Data management panel** — delete vertical, rollback upload, and reset all
|
||||||
- **VCL vertical metadata** — inline-editable team fields on compliance routes
|
- **In-app notification system** — replaces Webex bot integration with native notifications
|
||||||
- **Re-queue findings** from rejected FP submissions
|
- **Remediation plan and resolution date history tracking**
|
||||||
- **FP submissions cleanup** — auto-clear approved, dismiss rejected, collapsible section
|
- **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
|
- **DECOM workflow type** — auto-note/hide on decom, show CVEs on CARD queue items
|
||||||
- **Interactive configuration wizard** for deployment setup
|
- **Interactive configuration wizard** for deployment setup
|
||||||
- **Unified setup script** (`configure.js`) merging deploy + config wizard
|
- **Unified setup script** (`configure.js`) merging deploy + config wizard
|
||||||
@@ -34,12 +89,23 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
|||||||
- **Multi-select BU picker** replacing binary scope toggle
|
- **Multi-select BU picker** replacing binary scope toggle
|
||||||
- **Configurable IVANTI_MANAGED_BUS** env var for multi-tenant drift classification
|
- **Configurable IVANTI_MANAGED_BUS** env var for multi-tenant drift classification
|
||||||
- **Pipeline-to-issue traceability** via `after_script` comments in CI/CD
|
- **Pipeline-to-issue traceability** via `after_script` comments in CI/CD
|
||||||
- **CI/CD pipeline** with feedback modal, Atlas `qualys_id` fallback, and health endpoint
|
- **CI/CD pipeline** with health endpoint and automated deploy stages
|
||||||
- **Docker Compose** and `deploy-postgres.sh` for production cutover
|
- **Docker Compose** and `deploy-postgres.sh` for production cutover
|
||||||
- **Systemd service scripts** for start/stop management
|
- **Systemd service scripts** for start/stop management
|
||||||
|
- **VCL vertical metadata** — inline-editable team fields on compliance routes
|
||||||
|
|
||||||
### Bug Fixes
|
### 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 failing metrics on same asset across compliance endpoints
|
||||||
- Fix duplicate chart entries on compliance page when multiple verticals share a report_date
|
- 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 requeue inserting Postgres array literal instead of JSON into `cves_json`
|
||||||
@@ -51,21 +117,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
|||||||
- Fix requeue: fallback to `finding_ids_json` when queue items are deleted or absent
|
- Fix requeue: fallback to `finding_ids_json` when queue items are deleted or absent
|
||||||
- Sync FP submission `lifecycle_status` from Ivanti `currentState` on fetch
|
- Sync FP submission `lifecycle_status` from Ivanti `currentState` on fetch
|
||||||
- Fix History tab crash: coerce Ivanti note fields to strings before rendering
|
- Fix History tab crash: coerce Ivanti note fields to strings before rendering
|
||||||
- Fix archive bar chart: `fmtDate` now handles ISO datetime strings from PostgreSQL date columns
|
- Fix archive bar chart: `fmtDate` now handles ISO datetime strings from PostgreSQL
|
||||||
- Fix Ivanti panel bugs: Invalid Date, wrong workflow count, crash on archive click, BU scope filtering
|
- 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 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 null `bu_teams` in postgres migration, add retry logic to deploy script
|
||||||
- Fix missing `created_by` column in `archer_tickets` table
|
- Fix missing `created_by` column in `archer_tickets` table
|
||||||
- Fix FP workflow counts donut scoped by BU
|
- Fix FP workflow counts donut scoped by BU
|
||||||
- Fix `dotenv` loading in `db.js` so `DATABASE_URL` is available on import
|
- Fix `dotenv` loading in `db.js` so `DATABASE_URL` is available on import
|
||||||
- Fix property test CI failure: mock db module before importing route
|
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
|
|
||||||
- Track `package-lock.json` files for deterministic CI installs
|
- Track `package-lock.json` files for deterministic CI installs
|
||||||
- Remove unused icon imports and unused imports to satisfy ESLint thresholds
|
- Remove unused imports to satisfy ESLint thresholds
|
||||||
- CI pipeline fixes: dependency installation, lint thresholds, test isolation
|
- CI pipeline fixes: dependency installation, lint thresholds, test isolation
|
||||||
- Auto-run migrations in pipeline
|
- Auto-run migrations in pipeline
|
||||||
|
- Strengthen migration registration hook
|
||||||
- Documentation updates for PostgreSQL migration, systemd scripts, and reference manual
|
- Documentation updates for PostgreSQL migration, systemd scripts, and reference manual
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
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.' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
|
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
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
|
// Configuration — read from process.env at module load
|
||||||
@@ -57,12 +62,13 @@ function acquireToken(timeout) {
|
|||||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
path: fullUrl.pathname + fullUrl.search,
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
||||||
headers: {
|
headers: {
|
||||||
'accept': 'application/json',
|
'accept': 'application/json',
|
||||||
'authorization': 'Basic ' + authString,
|
'authorization': 'Basic ' + authString,
|
||||||
'content-length': '0',
|
'content-length': '0',
|
||||||
},
|
},
|
||||||
timeout: timeout || 15000,
|
timeout: timeout || 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isHttps) {
|
if (isHttps) {
|
||||||
@@ -123,7 +129,7 @@ async function ensureToken(timeout) {
|
|||||||
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
// Generic request — supports GET and POST with Bearer auth + 401 retry
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function cardRequest(method, urlPath, body, options) {
|
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;
|
const skipAuth = (options && options.skipAuth) || false;
|
||||||
|
|
||||||
async function doRequest(bearerToken) {
|
async function doRequest(bearerToken) {
|
||||||
@@ -150,6 +156,7 @@ async function cardRequest(method, urlPath, body, options) {
|
|||||||
port: fullUrl.port || (isHttps ? 443 : 80),
|
port: fullUrl.port || (isHttps ? 443 : 80),
|
||||||
path: fullUrl.pathname + fullUrl.search,
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
method,
|
method,
|
||||||
|
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
||||||
headers,
|
headers,
|
||||||
timeout,
|
timeout,
|
||||||
};
|
};
|
||||||
@@ -245,8 +252,8 @@ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) {
|
|||||||
/**
|
/**
|
||||||
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
* GET /api/v1/owner/{assetId} — get owner record including update_token.
|
||||||
*/
|
*/
|
||||||
async function getOwner(assetId) {
|
async function getOwner(assetId, options) {
|
||||||
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`);
|
const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`, options);
|
||||||
return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 };
|
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 };
|
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 = {
|
module.exports = {
|
||||||
isConfigured,
|
isConfigured,
|
||||||
missingVars,
|
missingVars,
|
||||||
@@ -302,4 +365,5 @@ module.exports = {
|
|||||||
declineAsset,
|
declineAsset,
|
||||||
redirectAsset,
|
redirectAsset,
|
||||||
invalidateToken,
|
invalidateToken,
|
||||||
|
resolveAssetId,
|
||||||
};
|
};
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ const POSTGRES_MIGRATIONS = [
|
|||||||
'add_flexible_jira_ticket_creation.js',
|
'add_flexible_jira_ticket_creation.js',
|
||||||
'add_multi_item_jira_ticket.js',
|
'add_multi_item_jira_ticket.js',
|
||||||
'drop_jira_status_check_constraint.js',
|
'drop_jira_status_check_constraint.js',
|
||||||
|
'add_compliance_history_metric_id.js',
|
||||||
|
'add_archer_templates_table.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runAll() {
|
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,
|
confirmAsset,
|
||||||
declineAsset,
|
declineAsset,
|
||||||
redirectAsset,
|
redirectAsset,
|
||||||
|
resolveAssetId,
|
||||||
} = require('../helpers/cardApi');
|
} = require('../helpers/cardApi');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -52,7 +53,14 @@ function handleCardError(err, res) {
|
|||||||
function createCardApiRouter() {
|
function createCardApiRouter() {
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/status', requireAuth(), (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
||||||
@@ -60,7 +68,14 @@ function createCardApiRouter() {
|
|||||||
res.json({ configured: true });
|
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) => {
|
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { queueItemId } = req.params;
|
const { queueItemId } = req.params;
|
||||||
const { teamName, assetId, comment } = req.body;
|
const { teamName, assetId: rawAssetId, comment } = req.body;
|
||||||
|
|
||||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||||
return res.status(400).json({ error: 'teamName is required.' });
|
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.' });
|
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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
'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) => {
|
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { queueItemId } = req.params;
|
const { queueItemId } = req.params;
|
||||||
const { teamName, assetId, comment } = req.body;
|
const { teamName, assetId: rawAssetId, comment } = req.body;
|
||||||
|
|
||||||
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
||||||
return res.status(400).json({ error: 'teamName is required.' });
|
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.' });
|
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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
'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;
|
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
||||||
|
|
||||||
if (!updateToken) {
|
if (!updateToken) {
|
||||||
const errMsg = 'update_token not found in owner record.';
|
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 }, ipAddress: req.ip });
|
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 });
|
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) => {
|
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { queueItemId } = req.params;
|
const { queueItemId } = req.params;
|
||||||
const { fromTeam, toTeam, assetId } = req.body;
|
const { fromTeam, toTeam, assetId: rawAssetId } = req.body;
|
||||||
|
|
||||||
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
||||||
return res.status(400).json({ error: 'fromTeam is required.' });
|
return res.status(400).json({ error: 'fromTeam is required.' });
|
||||||
@@ -323,10 +426,20 @@ function createCardApiRouter() {
|
|||||||
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
||||||
return res.status(400).json({ error: 'toTeam is required.' });
|
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.' });
|
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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
'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;
|
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;
|
module.exports = createCardApiRouter;
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ function groupByHostname(rows, noteHostnames) {
|
|||||||
seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
|
seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
|
||||||
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
|
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
|
||||||
has_notes: noteHostnames.has(row.hostname),
|
has_notes: noteHostnames.has(row.hostname),
|
||||||
|
resolution_date: null, remediation_plan: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const dev = deviceMap[row.hostname];
|
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.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.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.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);
|
return Object.values(deviceMap).map(({ _seenMetricIds, ...dev }) => dev);
|
||||||
}
|
}
|
||||||
@@ -598,6 +604,7 @@ function createComplianceRouter(upload) {
|
|||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT DISTINCT ON (ci.hostname, ci.metric_id)
|
`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.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
|
fu.report_date AS first_seen, lu.report_date AS last_seen, ru.report_date AS resolved_on
|
||||||
FROM compliance_items ci
|
FROM compliance_items ci
|
||||||
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
@@ -665,7 +672,7 @@ function createComplianceRouter(upload) {
|
|||||||
let history = [];
|
let history = [];
|
||||||
try {
|
try {
|
||||||
const { rows: historyRows } = await pool.query(
|
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`,
|
FROM compliance_item_history WHERE hostname = $1 ORDER BY changed_at DESC LIMIT 10`,
|
||||||
[hostname]
|
[hostname]
|
||||||
);
|
);
|
||||||
@@ -943,13 +950,14 @@ function createComplianceRouter(upload) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /items/:hostname/metadata
|
* 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.
|
* Records field-level change history in compliance_item_history for each modified field.
|
||||||
*
|
*
|
||||||
* @param hostname — the device hostname
|
* @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 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 404 { error } — device not found
|
||||||
* @response 500 { error } — update failure
|
* @response 500 { error } — update failure
|
||||||
*/
|
*/
|
||||||
@@ -957,7 +965,7 @@ function createComplianceRouter(upload) {
|
|||||||
const hostname = req.params.hostname;
|
const hostname = req.params.hostname;
|
||||||
if (!hostname || hostname.length > 300) return res.status(400).json({ error: 'Invalid 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
|
// Validate resolution_date: must be a valid ISO date string or null
|
||||||
if (resolution_date !== undefined && resolution_date !== 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' });
|
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 setClauses = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
let paramIdx = 1;
|
let paramIdx = 1;
|
||||||
@@ -1000,7 +1033,86 @@ function createComplianceRouter(upload) {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Get current values before updating
|
const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
const { rows: currentRows } = await client.query(
|
||||||
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
`SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||||
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
FROM compliance_items WHERE hostname = $1 AND status = 'active'
|
||||||
@@ -1019,15 +1131,14 @@ function createComplianceRouter(upload) {
|
|||||||
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
? (typeof current.resolution_date === 'string' ? current.resolution_date : current.resolution_date.toISOString().slice(0, 10))
|
||||||
: null;
|
: null;
|
||||||
const currentPlan = current.remediation_plan || null;
|
const currentPlan = current.remediation_plan || null;
|
||||||
const reasonText = change_reason && change_reason.trim() ? change_reason.trim() : null;
|
|
||||||
|
|
||||||
// Insert history for each changed field
|
// Insert history for each changed field with NULL metric_id
|
||||||
if (resolution_date !== undefined) {
|
if (resolution_date !== undefined) {
|
||||||
const newVal = resolution_date || null;
|
const newVal = resolution_date || null;
|
||||||
if (currentResDate !== newVal) {
|
if (currentResDate !== newVal) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||||
VALUES ($1, 'resolution_date', $2, $3, $4, $5)`,
|
VALUES ($1, NULL, 'resolution_date', $2, $3, $4, $5)`,
|
||||||
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
[hostname, currentResDate, newVal, reasonText, req.user.username]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1036,17 +1147,17 @@ function createComplianceRouter(upload) {
|
|||||||
const newVal = remediation_plan || null;
|
const newVal = remediation_plan || null;
|
||||||
if (currentPlan !== newVal) {
|
if (currentPlan !== newVal) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
`INSERT INTO compliance_item_history (hostname, metric_id, field_name, old_value, new_value, change_reason, changed_by)
|
||||||
VALUES ($1, 'remediation_plan', $2, $3, $4, $5)`,
|
VALUES ($1, NULL, 'remediation_plan', $2, $3, $4, $5)`,
|
||||||
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
[hostname, currentPlan, newVal, reasonText, req.user.username]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the items
|
// Update all active items for hostname
|
||||||
values.push(hostname);
|
values.push(hostname);
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx}`,
|
`UPDATE compliance_items SET ${setClauses.join(', ')} WHERE hostname = $${paramIdx} AND status = 'active'`,
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1063,6 +1174,7 @@ function createComplianceRouter(upload) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json({ updated: result.rowCount });
|
res.json({ updated: result.rowCount });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
console.error('[Compliance] PATCH /items/:hostname/metadata error:', err.message);
|
||||||
@@ -1631,4 +1743,4 @@ function createComplianceRouter(upload) {
|
|||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload };
|
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall, persistUpload, groupByHostname };
|
||||||
|
|||||||
@@ -318,16 +318,17 @@ function createIvantiTodoQueueRouter() {
|
|||||||
/**
|
/**
|
||||||
* POST /api/ivanti/todo-queue/:id/redirect
|
* POST /api/ivanti/todo-queue/:id/redirect
|
||||||
*
|
*
|
||||||
* Redirects a completed queue item to a different workflow by creating a new
|
* Redirects a queue item to a different workflow type. If the item is pending,
|
||||||
* pending queue item with the same finding data but a new workflow type/vendor.
|
* 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.
|
* 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}
|
* @body {Object}
|
||||||
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
* - workflow_type {string} Required. One of: FP, Archer, CARD, GRANITE, DECOM
|
||||||
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
* - vendor {string} Required for FP, Archer, and DECOM workflows; max 200 chars
|
||||||
* @returns {Object} The newly created queue item with parsed `cves` array
|
* @returns {Object} The updated or newly created queue item with parsed `cves` array
|
||||||
* @error 400 Invalid input or item not in complete status
|
* @error 400 Invalid input
|
||||||
* @error 404 Queue item not found
|
* @error 404 Queue item not found
|
||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
*/
|
*/
|
||||||
@@ -358,10 +359,38 @@ function createIvantiTodoQueueRouter() {
|
|||||||
if (!original) {
|
if (!original) {
|
||||||
return res.status(404).json({ error: 'Queue item not found.' });
|
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(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO ivanti_todo_queue
|
`INSERT INTO ivanti_todo_queue
|
||||||
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
(user_id, finding_id, finding_title, cves_json, ip_address, hostname, vendor, workflow_type)
|
||||||
@@ -379,6 +408,7 @@ function createIvantiTodoQueueRouter() {
|
|||||||
details: {
|
details: {
|
||||||
original_workflow_type: original.workflow_type,
|
original_workflow_type: original.workflow_type,
|
||||||
target_workflow_type: workflow_type,
|
target_workflow_type: workflow_type,
|
||||||
|
method: 'new_item_from_complete',
|
||||||
new_item_id: rows[0].id,
|
new_item_id: rows[0].id,
|
||||||
vendor: vendorVal,
|
vendor: vendorVal,
|
||||||
},
|
},
|
||||||
@@ -441,15 +471,43 @@ function createIvantiTodoQueueRouter() {
|
|||||||
* @error 500 Internal server error
|
* @error 500 Internal server error
|
||||||
*/
|
*/
|
||||||
router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
router.delete('/completed', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
await client.query('BEGIN');
|
||||||
"DELETE FROM ivanti_todo_queue WHERE user_id = $1 AND status = 'complete'",
|
|
||||||
|
// 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]
|
[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) {
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
console.error('Error clearing completed queue items:', err);
|
console.error('Error clearing completed queue items:', err);
|
||||||
res.status(500).json({ error: 'Internal server error.' });
|
res.status(500).json({ error: 'Internal server error.' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
// CVE Management Backend API
|
// CVE Management Backend API
|
||||||
// Install: npm install express pg multer cors dotenv bcryptjs cookie-parser
|
// 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();
|
require('dotenv').config();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -22,6 +26,7 @@ const logAudit = require('./helpers/auditLog');
|
|||||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
|
const createArcherTemplatesRouter = require('./routes/archerTemplates');
|
||||||
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
||||||
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
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)
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||||
app.use('/api/archer-tickets', createArcherTicketsRouter());
|
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)
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||||
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
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
|
- **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
|
- **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.
|
**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).
|
**Rate limit monitoring (Admin)** — view current burst and daily rate limit usage against Charter's posted limits (60/minute burst, 1 440/day).
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"react-app",
|
||||||
"react-app/jest"
|
"react-app/jest"
|
||||||
]
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": ["warn", { "vars": "all", "args": "after-used", "ignoreRestSiblings": true, "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import JiraPage from './components/pages/JiraPage';
|
|||||||
import AdminPage from './components/pages/AdminPage';
|
import AdminPage from './components/pages/AdminPage';
|
||||||
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
import ArchiveSummaryBar from './components/pages/ArchiveSummaryBar';
|
||||||
import ArcherPage from './components/pages/ArcherPage';
|
import ArcherPage from './components/pages/ArcherPage';
|
||||||
|
import ArcherTemplatePage from './components/pages/ArcherTemplatePage';
|
||||||
import FeedbackModal from './components/FeedbackModal';
|
import FeedbackModal from './components/FeedbackModal';
|
||||||
import NotificationBell from './components/NotificationBell';
|
import NotificationBell from './components/NotificationBell';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -199,7 +200,7 @@ export default function App() {
|
|||||||
const [cveDocuments, setCveDocuments] = useState({});
|
const [cveDocuments, setCveDocuments] = useState({});
|
||||||
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
const [quickCheckCVE, setQuickCheckCVE] = useState('');
|
||||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
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(() => {
|
const [currentPage, setCurrentPageRaw] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('cve-dashboard-page');
|
const saved = localStorage.getItem('cve-dashboard-page');
|
||||||
@@ -1105,6 +1106,7 @@ export default function App() {
|
|||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
{currentPage === 'jira' && <JiraPage />}
|
{currentPage === 'jira' && <JiraPage />}
|
||||||
|
{currentPage === 'archer-templates' && <ArcherTemplatePage />}
|
||||||
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
{currentPage === 'admin' && isAdmin() && <AdminPage />}
|
||||||
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
{currentPage === 'admin' && !isAdmin() && (() => { setCurrentPage('home'); return null; })()}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 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';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
|||||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
{ 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: '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: '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' };
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,26 @@ const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|||||||
const TEAL = '#14B8A6';
|
const TEAL = '#14B8A6';
|
||||||
const PURPLE = '#A78BFA';
|
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
|
// Styles
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -106,7 +126,8 @@ function MetricBreakdownPanel({ metrics }) {
|
|||||||
if (!metrics || metrics.length === 0) return null;
|
if (!metrics || metrics.length === 0) return null;
|
||||||
|
|
||||||
// Only show metrics with non_compliant > 0
|
// 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;
|
if (ncMetrics.length === 0) return null;
|
||||||
|
|
||||||
const TOP_COUNT = 8;
|
const TOP_COUNT = 8;
|
||||||
@@ -392,8 +413,13 @@ function MetricTable({ metrics, onSelectMetric }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...CARD_STYLE, marginTop: '1.5rem' }}>
|
<div style={{ ...CARD_STYLE, marginTop: '1.5rem' }}>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
<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
|
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>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={TABLE_STYLE}>
|
<table style={TABLE_STYLE}>
|
||||||
@@ -623,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>;
|
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
|
// 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.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -1277,7 +1304,7 @@ function MetricSelector({ onMetricSelect, selectedMetric }) {
|
|||||||
minWidth: '200px',
|
minWidth: '200px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{metrics.map(m => (
|
{metrics.slice().sort((a, b) => compareMetricIds(a.metric_id, b.metric_id)).map(m => (
|
||||||
<option key={m.metric_id} value={m.metric_id}>
|
<option key={m.metric_id} value={m.metric_id}>
|
||||||
{m.metric_id} — {m.device_count} device{m.device_count !== 1 ? 's' : ''}
|
{m.metric_id} — {m.device_count} device{m.device_count !== 1 ? 's' : ''}
|
||||||
</option>
|
</option>
|
||||||
@@ -1422,8 +1449,13 @@ function ForecastBurndownChart({ metricId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...CARD_STYLE }}>
|
<div style={{ ...CARD_STYLE }}>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
|
<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}
|
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>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
<ComposedChart data={combinedData} margin={{ top: 35, right: 40, left: 10, bottom: 5 }}>
|
<ComposedChart data={combinedData} margin={{ top: 35, right: 40, left: 10, bottom: 5 }}>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2, Calendar, FileText, Save } from 'lucide-react';
|
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2, Calendar, FileText, Save } from 'lucide-react';
|
||||||
import ConfirmModal from '../ConfirmModal';
|
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';
|
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 [detail, setDetail] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -55,12 +61,69 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
const [metaSaving, setMetaSaving] = useState(false);
|
const [metaSaving, setMetaSaving] = useState(false);
|
||||||
const [metaError, setMetaError] = useState(null);
|
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);
|
setMetaSaving(true);
|
||||||
setMetaError(null);
|
setMetaError(null);
|
||||||
try {
|
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();
|
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`, {
|
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -70,8 +133,11 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
|
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
|
||||||
setChangeReason('');
|
setChangeReason('');
|
||||||
|
setResolutionDateEdited(false);
|
||||||
|
setRemediationPlanEdited(false);
|
||||||
// Re-fetch to get updated history
|
// Re-fetch to get updated history
|
||||||
await fetchDetail();
|
await fetchDetail();
|
||||||
|
if (onMetadataSaved) onMetadataSaved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMetaError(err.message);
|
setMetaError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,13 +154,20 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
||||||
setDetail(data);
|
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');
|
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||||
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
|
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
|
||||||
|
|
||||||
// Populate metadata fields
|
// Default metricSelection to ALL active metrics (for metadata editing)
|
||||||
setResolutionDate(data.resolution_date || '');
|
const allActiveIds = (data.metrics || []).filter(m => m.status === 'active').map(m => m.metric_id);
|
||||||
setRemediationPlan(data.remediation_plan || '');
|
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) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -223,29 +296,98 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resolved metrics */}
|
{/* Metric Selector for Metadata Editing — placed right after Failing Metrics per issue #21 */}
|
||||||
{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 && (
|
{activeMetrics.length > 0 && (
|
||||||
<Section title="History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
<Section title="Apply To Metrics" icon={<Shield style={{ width: '14px', height: '14px' }} />}>
|
||||||
{activeMetrics.map(m => (
|
{activeMetrics.length > 1 && (() => {
|
||||||
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
const allMetaSelected = activeMetrics.every(m => metricSelection.includes(m.metric_id)) && metricSelection.length === activeMetrics.length;
|
||||||
<MetricChip metricId={m.metric_id} category={m.category} />
|
return (
|
||||||
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||||
{m.seen_count}× seen
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||||
|
{metricSelection.length} of {activeMetrics.length} selected
|
||||||
</span>
|
</span>
|
||||||
{m.first_seen && <span style={{ marginLeft: '0.5rem' }}>since {m.first_seen}</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>
|
||||||
</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>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -254,20 +396,27 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={resolutionDate}
|
value={resolutionDate}
|
||||||
onChange={e => setResolutionDate(e.target.value)}
|
onChange={e => { setResolutionDate(e.target.value); setResolutionDateEdited(true); }}
|
||||||
|
placeholder={sharedInfo.resolutionMultiple ? 'Multiple values' : ''}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
background: 'rgba(15,23,42,0.8)',
|
background: 'rgba(15,23,42,0.8)',
|
||||||
border: '1px solid rgba(20,184,166,0.25)',
|
border: '1px solid rgba(20,184,166,0.25)',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
color: '#F8FAFC',
|
color: sharedInfo.resolutionMultiple && !resolutionDateEdited ? '#64748B' : '#F8FAFC',
|
||||||
padding: '0.5rem 0.625rem',
|
padding: '0.5rem 0.625rem',
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
|
colorScheme: 'dark',
|
||||||
}}
|
}}
|
||||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
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>
|
</Section>
|
||||||
|
|
||||||
{/* Remediation Plan */}
|
{/* Remediation Plan */}
|
||||||
@@ -275,16 +424,16 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
<textarea
|
<textarea
|
||||||
value={remediationPlan}
|
value={remediationPlan}
|
||||||
onChange={e => {
|
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}
|
rows={4}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', resize: 'vertical',
|
width: '100%', resize: 'vertical',
|
||||||
background: 'rgba(15,23,42,0.8)',
|
background: 'rgba(15,23,42,0.8)',
|
||||||
border: '1px solid rgba(20,184,166,0.25)',
|
border: '1px solid rgba(20,184,166,0.25)',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
color: '#F8FAFC',
|
color: sharedInfo.planMultiple && !remediationPlanEdited ? '#64748B' : '#F8FAFC',
|
||||||
padding: '0.5rem 0.625rem',
|
padding: '0.5rem 0.625rem',
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
@@ -293,12 +442,17 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
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' }}>
|
<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' }}>
|
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: remediationPlan.length > 1900 ? '#F59E0B' : '#475569' }}>
|
||||||
{remediationPlan.length}/2000
|
{remediationPlan.length}/2000
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSaveMetadata({ resolution_date: resolutionDate || null, remediation_plan: remediationPlan || null })}
|
onClick={() => handleSaveMetadata()}
|
||||||
disabled={metaSaving}
|
disabled={metaSaving}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||||
@@ -342,10 +496,40 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</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 */}
|
{/* Change History */}
|
||||||
{detail.history && detail.history.length > 0 && (
|
{detail.history && detail.history.length > 0 && (
|
||||||
<Section title="Change History" icon={<Clock style={{ width: '14px', height: '14px' }} />}>
|
<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)
|
// Group entries by timestamp + user (entries saved together appear as one)
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for (const h of detail.history) {
|
for (const h of detail.history) {
|
||||||
@@ -366,7 +550,19 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{group.entries.map(h => (
|
{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' }}>
|
<span style={{ fontSize: '0.65rem', color: '#64748B' }}>
|
||||||
{h.field_name === 'resolution_date' ? '📅 ' : '📋 '}
|
{h.field_name === 'resolution_date' ? '📅 ' : '📋 '}
|
||||||
</span>
|
</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['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'] });
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||||
@@ -616,6 +825,23 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
|||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
opacity: resolved ? 0.5 : 1,
|
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 }}>
|
<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} />
|
<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>}
|
{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 }) {
|
function VariantPill({ entry, label }) {
|
||||||
const color = statusColor(entry.status);
|
const color = statusColor(entry.status);
|
||||||
const isOk = entry.status === 'Meets/Exceeds Target';
|
const isOk = entry.status === 'Meets/Exceeds Target';
|
||||||
|
const hasRawCounts = entry.compliant != null && entry.total != null && entry.total > 0;
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
@@ -116,6 +117,9 @@ function VariantPill({ entry, label }) {
|
|||||||
)}
|
)}
|
||||||
{label && <span style={{ color: '#94A3B8' }}>{label}</span>}
|
{label && <span style={{ color: '#94A3B8' }}>{label}</span>}
|
||||||
<span style={{ color, fontWeight: '600' }}>{pctDisplay(entry.compliance_pct)}</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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -693,6 +697,7 @@ export default function CompliancePage({ onNavigate }) {
|
|||||||
hostname={selectedHost}
|
hostname={selectedHost}
|
||||||
onClose={() => setSelectedHost(null)}
|
onClose={() => setSelectedHost(null)}
|
||||||
onNoteAdded={refresh}
|
onNoteAdded={refresh}
|
||||||
|
onMetadataSaved={refresh}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -877,7 +882,7 @@ function DeviceRow({ device, selected, onClick }) {
|
|||||||
|
|
||||||
{/* Resolution Date */}
|
{/* Resolution Date */}
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#94A3B8' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#94A3B8' }}>
|
||||||
{device.resolution_date || '—'}
|
{device.resolution_date ? device.resolution_date.slice(0, 10) : '—'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remediation Plan */}
|
{/* Remediation Plan */}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle } from 'lucide-react';
|
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText } from 'lucide-react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import ConsolidationModal from '../ConsolidationModal';
|
import ConsolidationModal from '../ConsolidationModal';
|
||||||
|
import LoaderModal from '../LoaderModal';
|
||||||
|
import TemplateSelector from '../TemplateSelector';
|
||||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||||
|
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||||
|
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
@@ -234,6 +238,45 @@ const STYLES = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
},
|
},
|
||||||
|
sectionHeaderInventory: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
borderBottom: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#10B981',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
},
|
||||||
|
sectionHeaderVendor: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
borderBottom: '1px solid rgba(148, 163, 184, 0.15)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#94A3B8',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
},
|
||||||
|
sectionCount: {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#64748B',
|
||||||
|
marginLeft: '0.25rem',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -259,12 +302,19 @@ export default function IvantiTodoQueuePage() {
|
|||||||
|
|
||||||
// Single-item Jira creation modal state (Requirement 2.4)
|
// Single-item Jira creation modal state (Requirement 2.4)
|
||||||
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
||||||
|
const [showLoaderModal, setShowLoaderModal] = useState(false);
|
||||||
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
||||||
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue' });
|
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
|
||||||
const [singleJiraError, setSingleJiraError] = useState(null);
|
const [singleJiraError, setSingleJiraError] = useState(null);
|
||||||
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
|
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
|
||||||
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
|
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
|
||||||
|
|
||||||
|
// Collapse state for grouped sections (Requirement 2.2, 2.7)
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState({});
|
||||||
|
|
||||||
|
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
|
||||||
|
const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Data fetching
|
// Data fetching
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -323,6 +373,28 @@ export default function IvantiTodoQueuePage() {
|
|||||||
return queueItems.filter((item) => item.status === 'pending');
|
return queueItems.filter((item) => item.status === 'pending');
|
||||||
}, [queueItems]);
|
}, [queueItems]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Grouped sections — hybrid Inventory + vendor grouping (Requirements 1.1–1.7)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const groupedSections = useMemo(() => groupQueueItems(visibleItems), [visibleItems]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Toggle section collapse (Requirement 2.2, 2.7)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const toggleSection = useCallback((sectionKey) => {
|
||||||
|
setCollapsedSections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[sectionKey]: !prev[sectionKey],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Toggle Archer Template Selector panel (Requirement 5.1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const toggleTemplatePanel = useCallback((itemId) => {
|
||||||
|
setTemplatePanelOpenId((prev) => (prev === itemId ? null : itemId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Selection mode toggle (Requirement 1.1, 1.5)
|
// Selection mode toggle (Requirement 1.1, 1.5)
|
||||||
// When deactivated, clear all selections
|
// When deactivated, clear all selections
|
||||||
@@ -422,6 +494,8 @@ export default function IvantiTodoQueuePage() {
|
|||||||
summary: generateConsolidatedSummary(items),
|
summary: generateConsolidatedSummary(items),
|
||||||
description: generateConsolidatedDescription(items),
|
description: generateConsolidatedDescription(items),
|
||||||
source_context: 'ivanti_queue',
|
source_context: 'ivanti_queue',
|
||||||
|
project_key: '',
|
||||||
|
issue_type: '',
|
||||||
});
|
});
|
||||||
setSingleJiraError(null);
|
setSingleJiraError(null);
|
||||||
setSingleJiraSummaryError(null);
|
setSingleJiraSummaryError(null);
|
||||||
@@ -627,18 +701,49 @@ export default function IvantiTodoQueuePage() {
|
|||||||
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span>
|
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Queue item rows */}
|
{/* Grouped sections with collapsible headers (Requirements 2.1, 2.3–2.6, 3.1, 3.2) */}
|
||||||
{visibleItems.map((item) => {
|
{groupedSections.map((section) => {
|
||||||
|
const isCollapsed = !!collapsedSections[section.key];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.key}>
|
||||||
|
{/* Section Header */}
|
||||||
|
<div
|
||||||
|
onClick={() => toggleSection(section.key)}
|
||||||
|
style={section.type === 'inventory' ? STYLES.sectionHeaderInventory : STYLES.sectionHeaderVendor}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSection(section.key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
aria-label={`${section.label} section, ${section.items.length} items`}
|
||||||
|
>
|
||||||
|
{isCollapsed
|
||||||
|
? <ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||||
|
: <ChevronDown style={{ width: '14px', height: '14px' }} />
|
||||||
|
}
|
||||||
|
<span>{section.label}</span>
|
||||||
|
<span style={STYLES.sectionCount}>({section.items.length})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Body — only rendered when expanded */}
|
||||||
|
{!isCollapsed && section.items.map((item) => {
|
||||||
const isSelected = selectedIds.has(item.id);
|
const isSelected = selectedIds.has(item.id);
|
||||||
const wfColor = getWorkflowColor(item.workflow_type);
|
const wfColor = getWorkflowColor(item.workflow_type);
|
||||||
const cves = item.cves || [];
|
const cves = item.cves || [];
|
||||||
const cveDisplay = cves.length > 0
|
const cveDisplay = cves.length > 0
|
||||||
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
||||||
: '';
|
: '';
|
||||||
|
const isArcherItem = item.workflow_type === 'Archer';
|
||||||
|
const isTemplatePanelOpen = templatePanelOpenId === item.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
|
||||||
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
|
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
|
||||||
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
|
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
|
||||||
role={selectionMode ? 'button' : undefined}
|
role={selectionMode ? 'button' : undefined}
|
||||||
@@ -685,6 +790,39 @@ export default function IvantiTodoQueuePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Archer Template toggle button (Requirement 5.1) */}
|
||||||
|
{isArcherItem && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleTemplatePanel(item.id); }}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.3rem',
|
||||||
|
padding: '0.2rem 0.5rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: isTemplatePanelOpen
|
||||||
|
? '1px solid rgba(0, 212, 255, 0.5)'
|
||||||
|
: '1px solid rgba(0, 212, 255, 0.2)',
|
||||||
|
background: isTemplatePanelOpen
|
||||||
|
? 'rgba(0, 212, 255, 0.15)'
|
||||||
|
: 'rgba(0, 212, 255, 0.05)',
|
||||||
|
color: isTemplatePanelOpen ? '#00d4ff' : '#7DD3FC',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.62rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
title={isTemplatePanelOpen ? 'Hide template selector' : 'Show template selector'}
|
||||||
|
aria-expanded={isTemplatePanelOpen}
|
||||||
|
aria-label="Toggle template selector"
|
||||||
|
>
|
||||||
|
<FileText style={{ width: '11px', height: '11px' }} />
|
||||||
|
{isTemplatePanelOpen ? 'Hide' : 'Template'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
||||||
{ticketLinks[item.id] && (
|
{ticketLinks[item.id] && (
|
||||||
<a
|
<a
|
||||||
@@ -780,6 +918,25 @@ export default function IvantiTodoQueuePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Archer Template Selector expandable panel (Requirement 5.1) */}
|
||||||
|
{isArcherItem && isTemplatePanelOpen && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
marginLeft: selectionMode ? '1.625rem' : '0',
|
||||||
|
padding: '0.75rem',
|
||||||
|
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)',
|
||||||
|
borderTop: 'none',
|
||||||
|
borderRadius: '0 0 8px 8px',
|
||||||
|
}}>
|
||||||
|
<TemplateSelector />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@@ -815,6 +972,20 @@ export default function IvantiTodoQueuePage() {
|
|||||||
<Plus style={{ width: '14px', height: '14px' }} />
|
<Plus style={{ width: '14px', height: '14px' }} />
|
||||||
Create Jira Ticket
|
Create Jira Ticket
|
||||||
</button>
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const selectedItems = queueItems.filter(i => selectedIds.has(i.id));
|
||||||
|
const hasCardGranite = selectedItems.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||||||
|
return hasCardGranite ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoaderModal(true)}
|
||||||
|
style={STYLES.btnSuccess}
|
||||||
|
title="Generate Granite Team_Device Loader Sheet from selected items"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet style={{ width: '14px', height: '14px' }} />
|
||||||
|
Generate Loader Sheet
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<button
|
<button
|
||||||
onClick={cancelSelection}
|
onClick={cancelSelection}
|
||||||
style={STYLES.btnCancel}
|
style={STYLES.btnCancel}
|
||||||
@@ -876,6 +1047,30 @@ export default function IvantiTodoQueuePage() {
|
|||||||
onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))}
|
onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<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={singleJiraForm.project_key} onChange={e => {
|
||||||
|
const newKey = e.target.value.toUpperCase();
|
||||||
|
const wasVendor = isVendorProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS);
|
||||||
|
const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS);
|
||||||
|
setSingleJiraForm(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</label>
|
||||||
|
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={singleJiraForm.issue_type} onChange={e => setSingleJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
|
||||||
|
<option value="">Story (default)</option>
|
||||||
|
{getIssueTypesForProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES).map(type => (
|
||||||
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
|
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
|
||||||
onClick={submitSingleJira}
|
onClick={submitSingleJira}
|
||||||
@@ -897,6 +1092,13 @@ export default function IvantiTodoQueuePage() {
|
|||||||
onSuccess={handleConsolidationSuccess}
|
onSuccess={handleConsolidationSuccess}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Granite Loader Sheet Modal */}
|
||||||
|
<LoaderModal
|
||||||
|
isOpen={showLoaderModal}
|
||||||
|
onClose={() => setShowLoaderModal(false)}
|
||||||
|
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,61 @@ function getStatusColor(status) {
|
|||||||
return '#F59E0B';
|
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 = {
|
const SOURCE_CONTEXT_CONFIG = {
|
||||||
cve: { label: 'CVE', color: '#0EA5E9' },
|
cve: { label: 'CVE', color: '#0EA5E9' },
|
||||||
archer: { label: 'Archer', color: '#8B5CF6' },
|
archer: { label: 'Archer', color: '#8B5CF6' },
|
||||||
@@ -890,18 +945,24 @@ export default function JiraPage() {
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
|
<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 }))}>
|
<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>
|
<option value="">Story (default)</option>
|
||||||
<option value="Story">Story</option>
|
{getIssueTypesForProject(createJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES).map(type => (
|
||||||
<option value="Epic">Epic</option>
|
<option key={type} value={type}>{type}</option>
|
||||||
<option value="Program">Program</option>
|
))}
|
||||||
<option value="Project">Project</option>
|
|
||||||
<option value="Reservation">Reservation</option>
|
|
||||||
<option value="Automation Maintenance">Automation Maintenance</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -916,3 +977,7 @@ export default function JiraPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Named exports for reuse by other pages (e.g., IvantiTodoQueuePage)
|
||||||
|
export { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject };
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database, Plus, FileSpreadsheet, Layers } from 'lucide-react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import IvantiCountsChart from './IvantiCountsChart';
|
import IvantiCountsChart from './IvantiCountsChart';
|
||||||
import AnomalyBanner from './AnomalyBanner';
|
import AnomalyBanner from './AnomalyBanner';
|
||||||
import CveTooltip from '../CveTooltip';
|
import CveTooltip from '../CveTooltip';
|
||||||
|
import CardOwnerTooltip from '../CardOwnerTooltip';
|
||||||
|
import CardDetailModal from '../CardDetailModal';
|
||||||
import RedirectModal from '../RedirectModal';
|
import RedirectModal from '../RedirectModal';
|
||||||
import AtlasBadge from '../AtlasBadge';
|
import AtlasBadge from '../AtlasBadge';
|
||||||
|
import LoaderModal from '../LoaderModal';
|
||||||
|
import CardActionModal from '../CardActionModal';
|
||||||
import ConsolidationModal from '../ConsolidationModal';
|
import ConsolidationModal from '../ConsolidationModal';
|
||||||
import AtlasSlideOutPanel from '../AtlasSlideOutPanel';
|
import AtlasSlideOutPanel from '../AtlasSlideOutPanel';
|
||||||
import AtlasIcon from '../AtlasIcon';
|
import AtlasIcon from '../AtlasIcon';
|
||||||
@@ -1184,7 +1188,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Render a single table cell by column key
|
// Render a single table cell by column key
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
|
function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave, onIpMouseEnter, onIpMouseLeave, fpSubmissions, onEditSubmission, atlasStatusMap, onAtlasBadgeClick }) {
|
||||||
switch (colKey) {
|
switch (colKey) {
|
||||||
case 'findingId':
|
case 'findingId':
|
||||||
return (
|
return (
|
||||||
@@ -1257,7 +1261,11 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
|||||||
);
|
);
|
||||||
case 'ipAddress':
|
case 'ipAddress':
|
||||||
return (
|
return (
|
||||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
<td
|
||||||
|
style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', cursor: finding.ipAddress ? 'help' : 'default' }}
|
||||||
|
onMouseEnter={onIpMouseEnter && finding.ipAddress ? (e) => onIpMouseEnter(finding.ipAddress, e) : undefined}
|
||||||
|
onMouseLeave={onIpMouseLeave || undefined}
|
||||||
|
>
|
||||||
{finding.ipAddress || '—'}
|
{finding.ipAddress || '—'}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -1537,6 +1545,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
const [cardActionLoading, setCardActionLoading] = useState(false);
|
const [cardActionLoading, setCardActionLoading] = useState(false);
|
||||||
const [cardActionError, setCardActionError] = useState(null);
|
const [cardActionError, setCardActionError] = useState(null);
|
||||||
|
|
||||||
|
// Granite Loader Sheet modal state
|
||||||
|
const [showLoaderModal, setShowLoaderModal] = useState(false);
|
||||||
|
|
||||||
|
// CARD Action Modal state
|
||||||
|
const [cardModalItem, setCardModalItem] = useState(null);
|
||||||
|
const [cardModalAction, setCardModalAction] = useState('confirm');
|
||||||
|
|
||||||
// Create Jira modal state
|
// Create Jira modal state
|
||||||
const [createJiraOpen, setCreateJiraOpen] = useState(false);
|
const [createJiraOpen, setCreateJiraOpen] = useState(false);
|
||||||
const [createJiraForm, setCreateJiraForm] = useState({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' });
|
const [createJiraForm, setCreateJiraForm] = useState({ summary: '', cve_id: '', vendor: '', source_context: 'ivanti_queue', description: '', project_key: '', issue_type: '' });
|
||||||
@@ -1584,6 +1599,12 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true');
|
const [submissionsCollapsed, setSubmissionsCollapsed] = useState(() => localStorage.getItem('steam_submissions_collapsed') === 'true');
|
||||||
const [dismissError, setDismissError] = useState(null);
|
const [dismissError, setDismissError] = useState(null);
|
||||||
|
|
||||||
|
// Collapsible section state for queue groups
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState({});
|
||||||
|
const toggleSectionCollapse = (sectionKey) => {
|
||||||
|
setCollapsedSections((prev) => ({ ...prev, [sectionKey]: !prev[sectionKey] }));
|
||||||
|
};
|
||||||
|
|
||||||
const toggleSubmissionsCollapsed = () => {
|
const toggleSubmissionsCollapsed = () => {
|
||||||
setSubmissionsCollapsed(prev => {
|
setSubmissionsCollapsed(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -1618,14 +1639,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
setTimeout(() => setRedirectSuccess(null), 3000);
|
setTimeout(() => setRedirectSuccess(null), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// CARD action handlers
|
// CARD action handlers — open the CardActionModal instead of inline form
|
||||||
const openCardAction = (itemId, type) => {
|
const openCardAction = (itemId, type) => {
|
||||||
setCardAction({ itemId, type });
|
const targetItem = items.find(i => i.id === itemId);
|
||||||
setCardFormTeam('');
|
if (targetItem) {
|
||||||
setCardFormComment('');
|
setCardModalItem(targetItem);
|
||||||
setCardFormFromTeam('');
|
setCardModalAction(type);
|
||||||
setCardFormToTeam('');
|
}
|
||||||
setCardActionError(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCardAction = () => {
|
const closeCardAction = () => {
|
||||||
@@ -1640,6 +1660,10 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
|
|
||||||
const handleCardConfirmDecline = async (item, actionType) => {
|
const handleCardConfirmDecline = async (item, actionType) => {
|
||||||
if (!cardFormTeam) return;
|
if (!cardFormTeam) return;
|
||||||
|
if (!item.ip_address) {
|
||||||
|
setCardActionError('No IP address on this queue item — cannot resolve CARD asset.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCardActionLoading(true);
|
setCardActionLoading(true);
|
||||||
setCardActionError(null);
|
setCardActionError(null);
|
||||||
try {
|
try {
|
||||||
@@ -1655,7 +1679,8 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setCardActionError(data.error || `${actionType} failed.`);
|
const errorMsg = data.error || data.message || (typeof data === 'string' ? data : `${actionType} failed.`);
|
||||||
|
setCardActionError(errorMsg);
|
||||||
setCardActionLoading(false);
|
setCardActionLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1670,6 +1695,10 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
|
|
||||||
const handleCardRedirect = async (item) => {
|
const handleCardRedirect = async (item) => {
|
||||||
if (!cardFormFromTeam || !cardFormToTeam) return;
|
if (!cardFormFromTeam || !cardFormToTeam) return;
|
||||||
|
if (!item.ip_address) {
|
||||||
|
setCardActionError('No IP address on this queue item — cannot resolve CARD asset.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCardActionLoading(true);
|
setCardActionLoading(true);
|
||||||
setCardActionError(null);
|
setCardActionError(null);
|
||||||
try {
|
try {
|
||||||
@@ -1685,7 +1714,8 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setCardActionError(data.error || 'Redirect failed.');
|
const errorMsg = data.error || data.message || (typeof data === 'string' ? data : JSON.stringify(data));
|
||||||
|
setCardActionError(errorMsg);
|
||||||
setCardActionLoading(false);
|
setCardActionLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1978,13 +2008,13 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Redirect button — completed items only */}
|
{/* Redirect button — available on all items */}
|
||||||
{canWrite && done && (
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setRedirectItem(item)}
|
onClick={() => setRedirectItem(item)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: done ? '#334155' : '#475569', padding: '1px', lineHeight: 1, flexShrink: 0 }}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
onMouseEnter={(e) => e.currentTarget.style.color = '#0EA5E9'}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.color = '#334155'}
|
onMouseLeave={(e) => e.currentTarget.style.color = done ? '#334155' : '#475569'}
|
||||||
title="Redirect to another workflow"
|
title="Redirect to another workflow"
|
||||||
>
|
>
|
||||||
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
<CornerUpRight style={{ width: '13px', height: '13px' }} />
|
||||||
@@ -2353,13 +2383,26 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
</div>
|
</div>
|
||||||
) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems, decomItems }) => (
|
) : grouped.map(({ key, label, items: groupItems, isInventory, cardItems, graniteItems, decomItems }) => (
|
||||||
<div key={key} style={{ marginBottom: '1.25rem' }}>
|
<div key={key} style={{ marginBottom: '1.25rem' }}>
|
||||||
{/* Group header */}
|
{/* Group header — clickable to collapse/expand */}
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
onClick={() => toggleSectionCollapse(key)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
padding: '0.3rem 0', marginBottom: '0.375rem',
|
padding: '0.3rem 0', marginBottom: '0.375rem',
|
||||||
borderBottom: `1px solid ${isInventory ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
|
borderBottom: `1px solid ${isInventory ? 'rgba(16,185,129,0.2)' : 'rgba(255,255,255,0.06)'}`,
|
||||||
}}>
|
cursor: 'pointer', userSelect: 'none',
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isInventory ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSectionCollapse(key); } }}
|
||||||
|
aria-expanded={!collapsedSections[key]}
|
||||||
|
aria-label={`${label} section, ${groupItems.length} items`}
|
||||||
|
>
|
||||||
|
{collapsedSections[key]
|
||||||
|
? <ChevronRight style={{ width: '12px', height: '12px', color: isInventory ? '#10B981' : '#64748B' }} />
|
||||||
|
: <ChevronDown style={{ width: '12px', height: '12px', color: isInventory ? '#10B981' : '#64748B' }} />
|
||||||
|
}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '700', color: isInventory ? '#10B981' : '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em', flex: 1 }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
<span style={{ fontFamily: 'monospace', fontSize: '0.62rem', color: '#334155' }}>
|
||||||
@@ -2367,8 +2410,8 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items — Inventory section renders CARD then GRANITE then DECOM with optional sub-dividers */}
|
{/* Items — only rendered when section is expanded */}
|
||||||
{isInventory ? (
|
{!collapsedSections[key] && (isInventory ? (
|
||||||
<>
|
<>
|
||||||
{cardItems.map((item) => (
|
{cardItems.map((item) => (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
@@ -2395,7 +2438,7 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))
|
groupItems.map((item) => renderQueueItem(item, { done: item.status === 'complete', selectedIds, toggleSelect, onUpdate, onDelete, setRedirectItem, canWrite }))
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2792,6 +2835,33 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
+ Jira ({items.filter(i => selectedIds.has(i.id) && i.status === 'pending').length})
|
+ Jira ({items.filter(i => selectedIds.has(i.id) && i.status === 'pending').length})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Generate Loader Sheet — visible when CARD/GRANITE/DECOM items are selected or as standalone */}
|
||||||
|
{(() => {
|
||||||
|
const selectedCardGranite = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||||||
|
const hasCardGraniteItems = items.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||||||
|
const isEnabled = selectedCardGranite.length > 0 || hasCardGraniteItems;
|
||||||
|
return isEnabled ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoaderModal(true)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '0.45rem',
|
||||||
|
background: 'rgba(124,58,237,0.1)',
|
||||||
|
border: '1px solid rgba(124,58,237,0.35)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: '#A78BFA',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.3rem',
|
||||||
|
}}
|
||||||
|
title="Generate Granite Team_Device Loader Sheet"
|
||||||
|
>
|
||||||
|
<FileSpreadsheet style={{ width: '12px', height: '12px' }} />
|
||||||
|
Loader
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteSelected}
|
onClick={handleDeleteSelected}
|
||||||
@@ -3097,6 +3167,31 @@ function QueuePanel({ open, items, onClose, onUpdate, onDelete, onDeleteMany, on
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Granite Loader Sheet Modal */}
|
||||||
|
<LoaderModal
|
||||||
|
isOpen={showLoaderModal}
|
||||||
|
onClose={() => setShowLoaderModal(false)}
|
||||||
|
initialDevices={showLoaderModal ? (() => {
|
||||||
|
const selected = items.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
|
||||||
|
if (selected.length > 0) return selected.map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' }));
|
||||||
|
// Standalone: use all CARD/GRANITE/DECOM items
|
||||||
|
return items.filter(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' }));
|
||||||
|
})() : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* CARD Action Modal */}
|
||||||
|
<CardActionModal
|
||||||
|
isOpen={!!cardModalItem}
|
||||||
|
onClose={() => setCardModalItem(null)}
|
||||||
|
item={cardModalItem}
|
||||||
|
initialAction={cardModalAction}
|
||||||
|
cardTeams={cardTeams}
|
||||||
|
onSuccess={(itemId, _action) => {
|
||||||
|
onUpdate(itemId, { status: 'complete' });
|
||||||
|
setCardModalItem(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5743,6 +5838,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
const tooltipCacheRef = useRef(new Map());
|
const tooltipCacheRef = useRef(new Map());
|
||||||
const hoverTimerRef = useRef(null);
|
const hoverTimerRef = useRef(null);
|
||||||
|
|
||||||
|
// CARD owner tooltip state & refs
|
||||||
|
const [cardTooltipIp, setCardTooltipIp] = useState(null);
|
||||||
|
const [cardTooltipAnchorRect, setCardTooltipAnchorRect] = useState(null);
|
||||||
|
const cardTooltipCacheRef = useRef(new Map());
|
||||||
|
const cardHoverTimerRef = useRef(null);
|
||||||
|
|
||||||
// Atlas action plan state
|
// Atlas action plan state
|
||||||
const [metricsTab, setMetricsTab] = useState('ivanti');
|
const [metricsTab, setMetricsTab] = useState('ivanti');
|
||||||
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
const [atlasStatusMap, setAtlasStatusMap] = useState(new Map());
|
||||||
@@ -5763,6 +5864,10 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
const [cardConfigured, setCardConfigured] = useState(false);
|
const [cardConfigured, setCardConfigured] = useState(false);
|
||||||
const [cardTeams, setCardTeams] = useState([]);
|
const [cardTeams, setCardTeams] = useState([]);
|
||||||
|
|
||||||
|
// Group-by-host toggle state
|
||||||
|
const [groupByHost, setGroupByHost] = useState(false);
|
||||||
|
const [expandedHosts, setExpandedHosts] = useState(new Set());
|
||||||
|
|
||||||
const updateColumns = useCallback((newOrder) => {
|
const updateColumns = useCallback((newOrder) => {
|
||||||
setColumnOrder(newOrder);
|
setColumnOrder(newOrder);
|
||||||
saveColumnOrder(newOrder);
|
saveColumnOrder(newOrder);
|
||||||
@@ -5831,6 +5936,49 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
setTooltipAnchorRect(null);
|
setTooltipAnchorRect(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// CARD owner tooltip hover handlers (with hover bridge — stays open when hovering tooltip)
|
||||||
|
const handleIpMouseEnter = useCallback((ip, e) => {
|
||||||
|
if (!ip) return;
|
||||||
|
clearTimeout(cardHoverTimerRef.current);
|
||||||
|
cardHoverTimerRef.current = setTimeout(() => {
|
||||||
|
setCardTooltipIp(ip);
|
||||||
|
setCardTooltipAnchorRect(e.target.getBoundingClientRect());
|
||||||
|
}, 400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleIpMouseLeave = useCallback(() => {
|
||||||
|
clearTimeout(cardHoverTimerRef.current);
|
||||||
|
// Delay hiding to allow mouse to move into tooltip
|
||||||
|
cardHoverTimerRef.current = setTimeout(() => {
|
||||||
|
setCardTooltipIp(null);
|
||||||
|
setCardTooltipAnchorRect(null);
|
||||||
|
}, 150);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCardTooltipEnter = useCallback(() => {
|
||||||
|
// Mouse entered tooltip — cancel the hide timer
|
||||||
|
clearTimeout(cardHoverTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCardTooltipLeave = useCallback(() => {
|
||||||
|
// Mouse left tooltip — hide it
|
||||||
|
clearTimeout(cardHoverTimerRef.current);
|
||||||
|
setCardTooltipIp(null);
|
||||||
|
setCardTooltipAnchorRect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// CARD action — open CardActionModal from tooltip
|
||||||
|
const [cardActionIp, setCardActionIp] = useState(null);
|
||||||
|
const [cardActionData, setCardActionData] = useState(null);
|
||||||
|
|
||||||
|
const handleCardAction = useCallback((ip, data) => {
|
||||||
|
setCardActionIp(ip);
|
||||||
|
setCardActionData(data);
|
||||||
|
// Close the tooltip
|
||||||
|
setCardTooltipIp(null);
|
||||||
|
setCardTooltipAnchorRect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyState = (data) => {
|
const applyState = (data) => {
|
||||||
setTotal(data.total ?? 0);
|
setTotal(data.total ?? 0);
|
||||||
setFindings(data.findings || []);
|
setFindings(data.findings || []);
|
||||||
@@ -5911,6 +6059,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
|
|
||||||
// CARD API — fetch status and teams (session-level caching)
|
// CARD API — fetch status and teams (session-level caching)
|
||||||
const cardTeamsFetchedRef = useRef(false);
|
const cardTeamsFetchedRef = useRef(false);
|
||||||
|
const cardTeamsRetryRef = useRef(0);
|
||||||
const fetchCardStatus = useCallback(async () => {
|
const fetchCardStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
|
const res = await fetch(`${API_BASE}/card/status`, { credentials: 'include' });
|
||||||
@@ -5918,19 +6067,30 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setCardConfigured(data.configured === true);
|
setCardConfigured(data.configured === true);
|
||||||
if (data.configured && !cardTeamsFetchedRef.current) {
|
if (data.configured && !cardTeamsFetchedRef.current) {
|
||||||
cardTeamsFetchedRef.current = true;
|
|
||||||
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
|
const teamsRes = await fetch(`${API_BASE}/card/teams`, { credentials: 'include' });
|
||||||
if (teamsRes.ok) {
|
if (teamsRes.ok) {
|
||||||
const teamsData = await teamsRes.json();
|
const teamsData = await teamsRes.json();
|
||||||
const teams = Array.isArray(teamsData)
|
const teams = Array.isArray(teamsData)
|
||||||
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
|
? teamsData.map(t => t.card_team_name || t._id).filter(Boolean).sort()
|
||||||
: [];
|
: [];
|
||||||
|
if (teams.length > 0) {
|
||||||
setCardTeams(teams);
|
setCardTeams(teams);
|
||||||
|
cardTeamsFetchedRef.current = true;
|
||||||
|
}
|
||||||
|
} else if (cardTeamsRetryRef.current < 3) {
|
||||||
|
// Retry silently after a delay (CARD teams endpoint can be slow)
|
||||||
|
cardTeamsRetryRef.current += 1;
|
||||||
|
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[card-api] Failed to fetch CARD status:', err.message);
|
console.error('[card-api] Failed to fetch CARD status:', err.message);
|
||||||
|
// Retry on network error too
|
||||||
|
if (cardTeamsRetryRef.current < 3) {
|
||||||
|
cardTeamsRetryRef.current += 1;
|
||||||
|
setTimeout(() => fetchCardStatus(), 15000 * cardTeamsRetryRef.current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -6076,6 +6236,67 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
return sort.dir === 'asc' ? cmp : -cmp;
|
return sort.dir === 'asc' ? cmp : -cmp;
|
||||||
}), [filtered, sort]);
|
}), [filtered, sort]);
|
||||||
|
|
||||||
|
// Grouped view — aggregate findings by hostName + ipAddress
|
||||||
|
const groupedByHost = useMemo(() => {
|
||||||
|
if (!groupByHost) return { groups: [], singles: [] };
|
||||||
|
const map = new Map();
|
||||||
|
sorted.forEach(f => {
|
||||||
|
const hostKey = `${(f.overrides?.hostName || f.hostName || '').toLowerCase()}||${(f.ipAddress || '').toLowerCase()}`;
|
||||||
|
if (!map.has(hostKey)) {
|
||||||
|
map.set(hostKey, {
|
||||||
|
hostKey,
|
||||||
|
hostName: f.overrides?.hostName || f.hostName || '',
|
||||||
|
ipAddress: f.ipAddress || '',
|
||||||
|
findings: [],
|
||||||
|
highestSeverity: 0,
|
||||||
|
highestVrrGroup: '',
|
||||||
|
cveSet: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const group = map.get(hostKey);
|
||||||
|
group.findings.push(f);
|
||||||
|
if (f.severity > group.highestSeverity) {
|
||||||
|
group.highestSeverity = f.severity;
|
||||||
|
group.highestVrrGroup = f.vrrGroup || '';
|
||||||
|
}
|
||||||
|
(f.cves || []).forEach(c => group.cveSet.add(c));
|
||||||
|
});
|
||||||
|
// Separate: groups with 2+ findings vs singles that stay flat
|
||||||
|
const groups = [];
|
||||||
|
const singles = [];
|
||||||
|
for (const g of map.values()) {
|
||||||
|
if (g.findings.length > 1) groups.push(g);
|
||||||
|
else singles.push(g.findings[0]);
|
||||||
|
}
|
||||||
|
groups.sort((a, b) => b.highestSeverity - a.highestSeverity);
|
||||||
|
return { groups, singles };
|
||||||
|
}, [sorted, groupByHost]);
|
||||||
|
|
||||||
|
// Combined render order for grouped mode: grouped hosts first, then singles
|
||||||
|
const groupedRenderList = useMemo(() => {
|
||||||
|
if (!groupByHost) return [];
|
||||||
|
const list = [];
|
||||||
|
groupedByHost.groups.forEach(g => list.push({ type: 'group', group: g }));
|
||||||
|
groupedByHost.singles.forEach(f => list.push({ type: 'single', finding: f }));
|
||||||
|
return list;
|
||||||
|
}, [groupByHost, groupedByHost]);
|
||||||
|
|
||||||
|
const toggleHostExpand = useCallback((hostKey) => {
|
||||||
|
setExpandedHosts(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(hostKey)) next.delete(hostKey); else next.add(hostKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const expandAllHosts = useCallback(() => {
|
||||||
|
setExpandedHosts(new Set(groupedByHost.groups.map(g => g.hostKey)));
|
||||||
|
}, [groupedByHost]);
|
||||||
|
|
||||||
|
const collapseAllHosts = useCallback(() => {
|
||||||
|
setExpandedHosts(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Select/deselect all visible rows
|
// Select/deselect all visible rows
|
||||||
const toggleSelectAll = useCallback(() => {
|
const toggleSelectAll = useCallback(() => {
|
||||||
const allVisibleIds = sorted.map(f => String(f.id));
|
const allVisibleIds = sorted.map(f => String(f.id));
|
||||||
@@ -6819,6 +7040,24 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setGroupByHost(g => !g); setExpandedHosts(new Set()); }}
|
||||||
|
title={groupByHost ? 'Switch to flat view' : 'Group findings by host'}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: groupByHost ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.06)',
|
||||||
|
border: `1px solid rgba(139,92,246,${groupByHost ? '0.5' : '0.2'})`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: groupByHost ? '#A78BFA' : '#7C3AED',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layers style={{ width: '13px', height: '13px' }} />
|
||||||
|
{groupByHost ? 'Grouped' : 'Group'}
|
||||||
|
</button>
|
||||||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||||
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
|
<RowVisibilityManager hiddenRowIds={hiddenRowIds} findings={findings} onRestore={restoreRow} onRestoreAll={restoreAllRows} />
|
||||||
<button
|
<button
|
||||||
@@ -7047,6 +7286,178 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{groupByHost ? (
|
||||||
|
/* ---- Grouped-by-host view ---- */
|
||||||
|
<>
|
||||||
|
{groupedRenderList.length > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={visibleCols.length + 3} style={{ padding: '0.4rem 0.75rem', background: 'rgba(139,92,246,0.04)', borderBottom: '1px solid rgba(139,92,246,0.15)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#A78BFA', fontWeight: '600' }}>
|
||||||
|
{groupedByHost.groups.length} grouped host{groupedByHost.groups.length !== 1 ? 's' : ''} · {groupedByHost.singles.length} single{groupedByHost.singles.length !== 1 ? 's' : ''} · {sorted.length} total
|
||||||
|
</span>
|
||||||
|
<button onClick={expandAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>expand all</button>
|
||||||
|
<button onClick={collapseAllHosts} style={{ background: 'none', border: 'none', color: '#64748B', fontFamily: 'monospace', fontSize: '0.62rem', cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>collapse all</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{groupedRenderList.map((item, itemIdx) => {
|
||||||
|
if (item.type === 'single') {
|
||||||
|
// Render single-finding hosts as normal flat rows
|
||||||
|
const finding = item.finding;
|
||||||
|
const isSelected = selectedIds.has(finding.id);
|
||||||
|
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (itemIdx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
||||||
|
const queued = isQueued(finding.id);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={finding.id}
|
||||||
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
|
||||||
|
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||||||
|
{selectedRowIds.has(String(finding.id))
|
||||||
|
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||||||
|
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||||
|
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||||||
|
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
|
||||||
|
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||||
|
</td>
|
||||||
|
{visibleCols.map((col) => (
|
||||||
|
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Render grouped host header + expandable sub-rows
|
||||||
|
const group = item.group;
|
||||||
|
const isExpanded = expandedHosts.has(group.hostKey);
|
||||||
|
const sc = severityColor(group.highestVrrGroup);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={group.hostKey}>
|
||||||
|
{/* Host group header — uses same columns as regular rows */}
|
||||||
|
<tr
|
||||||
|
onClick={() => toggleHostExpand(group.hostKey)}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid rgba(139,92,246,0.15)',
|
||||||
|
background: isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(139,92,246,0.08)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = isExpanded ? 'rgba(139,92,246,0.06)' : 'rgba(15,26,46,0.5)'; }}
|
||||||
|
>
|
||||||
|
{/* Expand/collapse icon in first fixed column */}
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronDown style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||||||
|
: <ChevronRight style={{ width: '13px', height: '13px', color: '#A78BFA' }} />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
{/* Empty cells for hide + checkbox columns */}
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', width: '36px' }} />
|
||||||
|
{/* Render each column cell — show host-level summary data in the matching column positions */}
|
||||||
|
{visibleCols.map((col) => {
|
||||||
|
switch (col.key) {
|
||||||
|
case 'findingId':
|
||||||
|
return (
|
||||||
|
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', padding: '0.15rem 0.5rem', borderRadius: '0.25rem', background: 'rgba(139,92,246,0.12)', border: '1px solid rgba(139,92,246,0.3)', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700', color: '#A78BFA' }}>
|
||||||
|
{group.findings.length} findings
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
case 'severity':
|
||||||
|
return (
|
||||||
|
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
||||||
|
{group.highestSeverity.toFixed(2)}
|
||||||
|
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{group.highestVrrGroup}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
case 'hostName':
|
||||||
|
return (
|
||||||
|
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||||||
|
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600' }}>
|
||||||
|
{group.hostName || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
case 'ipAddress':
|
||||||
|
return (
|
||||||
|
<td key={col.key} style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ color: '#0EA5E9', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||||
|
{group.ipAddress || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
case 'cves':
|
||||||
|
return (
|
||||||
|
<td key={col.key} style={{ padding: '0.45rem 0.75rem' }}>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#64748B' }}>
|
||||||
|
{group.cveSet.size} CVE{group.cveSet.size !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <td key={col.key} style={{ padding: '0.45rem 0.75rem' }} />;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{/* Expanded sub-rows — individual findings */}
|
||||||
|
{isExpanded && group.findings.map((finding, idx) => {
|
||||||
|
const isSelected = selectedIds.has(finding.id);
|
||||||
|
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(20,30,50,0.5)' : 'rgba(15,24,42,0.5)');
|
||||||
|
const queued = isQueued(finding.id);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={finding.id}
|
||||||
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.03)', background: rowBg, borderLeft: '3px solid rgba(139,92,246,0.25)' }}
|
||||||
|
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px', cursor: 'pointer' }} onClick={() => toggleRowSelection(finding.id)}>
|
||||||
|
{selectedRowIds.has(String(finding.id))
|
||||||
|
? <CheckSquare style={{ width: '13px', height: '13px', color: '#4fc3f7' }} />
|
||||||
|
: <Square style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }}>
|
||||||
|
<button onClick={() => hideRow(finding.id)} title="Hide this row" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} onMouseEnter={(e) => { e.currentTarget.querySelector('svg').style.color = '#4fc3f7'; }} onMouseLeave={(e) => { e.currentTarget.querySelector('svg').style.color = '#8892a2'; }}>
|
||||||
|
<EyeOff style={{ width: '13px', height: '13px', color: '#8892a2' }} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'center', width: '36px' }} onClick={() => { if (queued) return; setSelectedIds((prev) => { const next = new Set(prev); if (next.has(finding.id)) next.delete(finding.id); else next.add(finding.id); return next; }); setLastClickedId(finding.id); }}>
|
||||||
|
<input type="checkbox" readOnly checked={queued || isSelected} style={{ accentColor: queued ? '#10B981' : '#0EA5E9', width: '13px', height: '13px', cursor: queued ? 'default' : 'pointer', pointerEvents: 'none' }} />
|
||||||
|
</td>
|
||||||
|
{visibleCols.map((col) => (
|
||||||
|
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{groupedRenderList.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={visibleCols.length + 3} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||||
|
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* ---- Flat view (default) ---- */
|
||||||
|
<>
|
||||||
{sorted.map((finding, idx) => {
|
{sorted.map((finding, idx) => {
|
||||||
const isSelected = selectedIds.has(finding.id);
|
const isSelected = selectedIds.has(finding.id);
|
||||||
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
const rowBg = isSelected ? 'rgba(14,165,233,0.12)' : (idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)');
|
||||||
@@ -7129,7 +7540,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
{visibleCols.map((col) => (
|
{visibleCols.map((col) => (
|
||||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} onCveMouseEnter={handleCveMouseEnter} onCveMouseLeave={handleCveMouseLeave} onIpMouseEnter={handleIpMouseEnter} onIpMouseLeave={handleIpMouseLeave} fpSubmissions={fpSubmissions} onEditSubmission={handleEditSubmission} atlasStatusMap={atlasStatusMap} onAtlasBadgeClick={(hostId) => { setAtlasSelectedHostId(hostId); setAtlasSelectedHostName(finding.hostName || finding.ipAddress || ''); setAtlasSelectedFindingId(finding.id || null); setAtlasPanelOpen(true); }} />
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -7141,6 +7552,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -7184,10 +7597,18 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
onDeleteMany={deleteQueueItems}
|
onDeleteMany={deleteQueueItems}
|
||||||
onClearCompleted={clearCompleted}
|
onClearCompleted={clearCompleted}
|
||||||
onCreateFpWorkflow={handleCreateFpWorkflow}
|
onCreateFpWorkflow={handleCreateFpWorkflow}
|
||||||
onRedirectComplete={(newItem) => {
|
onRedirectComplete={(updatedItem) => {
|
||||||
setQueueItems((prev) => [...prev, newItem].sort((a, b) =>
|
setQueueItems((prev) => {
|
||||||
|
// If item already exists (in-place update), replace it
|
||||||
|
const exists = prev.some(i => i.id === updatedItem.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map(i => i.id === updatedItem.id ? updatedItem : i);
|
||||||
|
}
|
||||||
|
// Otherwise it's a new item (redirect from completed), add it
|
||||||
|
return [...prev, updatedItem].sort((a, b) =>
|
||||||
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
(a.vendor || '').localeCompare(b.vendor || '') || a.id - b.id
|
||||||
));
|
);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
fpSubmissions={fpSubmissionsFiltered}
|
fpSubmissions={fpSubmissionsFiltered}
|
||||||
@@ -7215,6 +7636,22 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
|||||||
anchorRect={tooltipAnchorRect}
|
anchorRect={tooltipAnchorRect}
|
||||||
cache={tooltipCacheRef}
|
cache={tooltipCacheRef}
|
||||||
/>
|
/>
|
||||||
|
<CardOwnerTooltip
|
||||||
|
ip={cardTooltipIp}
|
||||||
|
anchorRect={cardTooltipAnchorRect}
|
||||||
|
cache={cardTooltipCacheRef}
|
||||||
|
cardConfigured={cardConfigured}
|
||||||
|
onAction={handleCardAction}
|
||||||
|
onMouseEnter={handleCardTooltipEnter}
|
||||||
|
onMouseLeave={handleCardTooltipLeave}
|
||||||
|
/>
|
||||||
|
<CardDetailModal
|
||||||
|
isOpen={!!cardActionIp}
|
||||||
|
onClose={() => { setCardActionIp(null); setCardActionData(null); }}
|
||||||
|
ip={cardActionIp}
|
||||||
|
ownerData={cardActionData}
|
||||||
|
cardTeams={cardTeams}
|
||||||
|
/>
|
||||||
{atlasPanelOpen && atlasSelectedHostId && (
|
{atlasPanelOpen && atlasSelectedHostId && (
|
||||||
<AtlasSlideOutPanel
|
<AtlasSlideOutPanel
|
||||||
hostId={atlasSelectedHostId}
|
hostId={atlasSelectedHostId}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
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