Merge pull request 'Merge latest updates into main' (#2) from dev into main
Some checks failed
Deploy Frontend / deploy (push) Failing after 26s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2025-11-05 14:02:27 +08:00
13 changed files with 1924 additions and 99 deletions

2
.gitignore vendored
View File

@@ -40,3 +40,5 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
certificate (5).zip
.env.dev
.env.prod

51
ENV_FILES.md Normal file
View File

@@ -0,0 +1,51 @@
# Environment Files Setup
## Overview
Environment variables are stored securely in `/etc/frontend/` and accessed via symlinks in this directory.
## File Structure
```
/etc/frontend/
├── dev.env (systemd environment for dev)
└── prod.env (systemd environment for prod)
/home/sonder/ld-sysinfo-react-frontend/
├── .env.dev -> /etc/frontend/dev.env (symlink)
└── .env.prod -> /etc/frontend/prod.env (symlink)
```
## Editing Environment Variables
You can edit these files directly from VSCode:
- `.env.dev` - Development environment (port 3002)
- `.env.prod` - Production environment (port 3001)
**Note:** You need to be in the `frontend-devs` group to edit these files. Both `sonder` and `deploy` users are members.
## After Editing
After making changes to environment files, restart the relevant service:
```bash
# For development
sudo systemctl restart frontend@dev
# For production
sudo systemctl restart frontend@prod
```
## Environment Variables
Both files contain:
- `PORT` - Server port (3002 for dev, 3001 for prod)
- `API_BASE_URL` - Server-side API endpoint
- `NEXT_PUBLIC_API_BASE_URL` - Client-side API endpoint
- `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` - Database credentials
- `NVD_API_KEY` - API key for CVE data
## Security Notes
- Files are stored in `/etc/frontend/` with `660` permissions (owner: root, group: frontend-devs)
- Only root and members of `frontend-devs` group can read/write
- Files are **not** committed to git (listed in `.gitignore`)
- Symlinks are safe to commit as they don't contain sensitive data

221
package-lock.json generated
View File

@@ -19,8 +19,10 @@
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"jsonstream": "^1.0.3",
"jspdf": "^3.0.3",
"jwt-decode": "^4.0.0",
"mysql2": "^3.14.0",
"next": "15.2.4",
@@ -36,6 +38,7 @@
"@eslint/eslintrc": "^3.3.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/jspdf": "^1.3.3",
"@types/jwt-decode": "^3.1.0",
"@types/node": "^20",
"@types/react": "^19",
@@ -2474,6 +2477,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jspdf": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.3.3.tgz",
"integrity": "sha512-DqwyAKpVuv+7DniCp2Deq1xGvfdnKSNgl9Agun2w6dFvR5UKamiv4VfYUgcypd8S9ojUyARFIlZqBrYrBMQlew==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jwt-decode": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-3.1.0.tgz",
@@ -2496,6 +2506,12 @@
"undici-types": "~6.19.2"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -2508,6 +2524,13 @@
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.0.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz",
@@ -2546,6 +2569,13 @@
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.29.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.0.tgz",
@@ -3543,6 +3573,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -3688,6 +3727,33 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/canvg/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3822,6 +3888,18 @@
"node": ">=18"
}
},
"node_modules/core-js": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -3885,6 +3963,15 @@
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -4091,6 +4178,16 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -4847,6 +4944,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
@@ -4873,6 +4981,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5316,6 +5430,19 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -5388,6 +5515,12 @@
"node": ">= 0.4"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5948,6 +6081,23 @@
"node": "*"
}
},
"node_modules/jspdf": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.9",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -6854,6 +7004,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6931,6 +7087,13 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7085,6 +7248,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
@@ -7337,6 +7510,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7707,6 +7890,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stream": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/stream/-/stream-0.0.3.tgz",
@@ -7922,6 +8115,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.0.tgz",
@@ -7937,6 +8140,15 @@
"node": ">=6"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -8358,6 +8570,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@@ -23,8 +23,10 @@
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"jsonstream": "^1.0.3",
"jspdf": "^3.0.3",
"jwt-decode": "^4.0.0",
"mysql2": "^3.14.0",
"next": "15.2.4",
@@ -40,6 +42,7 @@
"@eslint/eslintrc": "^3.3.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/jspdf": "^1.3.3",
"@types/jwt-decode": "^3.1.0",
"@types/node": "^20",
"@types/react": "^19",

View File

@@ -13,6 +13,7 @@ interface UserDTO {
email: string;
role: string;
clientName: string;
clientId?: number;
enabled: boolean;
}

View File

@@ -0,0 +1,484 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
Paper,
Grid,
Card,
CardContent,
Button,
CircularProgress,
Divider,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Alert
} from '@mui/material';
import {
PictureAsPdf as PdfIcon,
Assessment as AssessmentIcon,
Security as SecurityIcon,
Devices as DevicesIcon,
Apps as AppsIcon
} from '@mui/icons-material';
import api from '@/lib/axios';
import jsPDF from 'jspdf';
import { format } from 'date-fns';
interface ComplianceSummary {
totalDevices: number;
vulnerableDevices: number;
totalVulnerabilities: number;
criticalVulns: number;
highVulns: number;
mediumVulns: number;
lowVulns: number;
totalSoftware: number;
vulnerableSoftware: number;
lastUpdated: string;
}
interface TopVulnerability {
cveId: string;
title: string;
severity: string;
score: number;
affectedDevices: number;
}
interface TopVulnerableSoftware {
softwareName: string;
totalInstances: number;
vulnerableInstances: number;
totalCves: number;
}
export default function ReportingPage() {
const [summary, setSummary] = useState<ComplianceSummary | null>(null);
const [topVulns, setTopVulns] = useState<TopVulnerability[]>([]);
const [topSoftware, setTopSoftware] = useState<TopVulnerableSoftware[]>([]);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const reportRef = React.useRef<HTMLDivElement>(null);
const fetchReportingData = async () => {
try {
setLoading(true);
// Fetch compliance summary data (you'll need to create these endpoints)
const [summaryRes, vulnsRes, softwareRes] = await Promise.all([
api.get('/reporting/compliance-summary'),
api.get('/reporting/top-vulnerabilities'),
api.get('/reporting/vulnerable-software')
]);
setSummary(summaryRes.data);
setTopVulns(vulnsRes.data);
setTopSoftware(softwareRes.data);
} catch (error) {
console.error('Failed to fetch reporting data:', error);
// Create mock data for now since endpoints don't exist yet
createMockData();
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReportingData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const createMockData = () => {
setSummary({
totalDevices: 127,
vulnerableDevices: 89,
totalVulnerabilities: 342,
criticalVulns: 23,
highVulns: 67,
mediumVulns: 156,
lowVulns: 96,
totalSoftware: 1247,
vulnerableSoftware: 89,
lastUpdated: new Date().toISOString()
});
setTopVulns([
{ cveId: 'CVE-2024-12345', title: 'Critical Remote Code Execution', severity: 'CRITICAL', score: 9.8, affectedDevices: 34 },
{ cveId: 'CVE-2024-12346', title: 'Privilege Escalation Vulnerability', severity: 'HIGH', score: 8.4, affectedDevices: 28 },
{ cveId: 'CVE-2024-12347', title: 'Information Disclosure', severity: 'HIGH', score: 7.9, affectedDevices: 22 },
{ cveId: 'CVE-2024-12348', title: 'Cross-Site Scripting', severity: 'MEDIUM', score: 6.1, affectedDevices: 19 },
{ cveId: 'CVE-2024-12349', title: 'Denial of Service', severity: 'MEDIUM', score: 5.8, affectedDevices: 15 }
]);
setTopSoftware([
{ softwareName: 'Apache HTTP Server', totalInstances: 45, vulnerableInstances: 23, totalCves: 12 },
{ softwareName: 'OpenSSL', totalInstances: 89, vulnerableInstances: 34, totalCves: 8 },
{ softwareName: 'Microsoft Office', totalInstances: 127, vulnerableInstances: 67, totalCves: 15 },
{ softwareName: 'Adobe Reader', totalInstances: 98, vulnerableInstances: 45, totalCves: 9 },
{ softwareName: 'Java Runtime Environment', totalInstances: 76, vulnerableInstances: 28, totalCves: 6 }
]);
};
const exportToPDF = async () => {
if (!reportRef.current || !summary) return;
try {
setExporting(true);
// Create a new jsPDF instance
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
// Add header
pdf.setFontSize(20);
pdf.setFont('helvetica', 'bold');
pdf.text('Compliance Security Report', pageWidth / 2, 20, { align: 'center' });
pdf.setFontSize(12);
pdf.setFont('helvetica', 'normal');
pdf.text(`Generated: ${format(new Date(), 'PPP')}`, pageWidth / 2, 30, { align: 'center' });
let yPosition = 45;
// Executive Summary
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text('Executive Summary', 20, yPosition);
yPosition += 10;
pdf.setFontSize(10);
pdf.setFont('helvetica', 'normal');
const summaryText = [
`Total Devices: ${summary.totalDevices}`,
`Vulnerable Devices: ${summary.vulnerableDevices} (${((summary.vulnerableDevices / summary.totalDevices) * 100).toFixed(1)}%)`,
`Total Vulnerabilities: ${summary.totalVulnerabilities}`,
`Critical: ${summary.criticalVulns} | High: ${summary.highVulns} | Medium: ${summary.mediumVulns} | Low: ${summary.lowVulns}`,
`Software Applications: ${summary.totalSoftware}`,
`Vulnerable Software: ${summary.vulnerableSoftware}`
];
summaryText.forEach(text => {
pdf.text(text, 20, yPosition);
yPosition += 6;
});
yPosition += 10;
// Top Vulnerabilities
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text('Top Critical Vulnerabilities', 20, yPosition);
yPosition += 10;
pdf.setFontSize(9);
pdf.setFont('helvetica', 'normal');
topVulns.slice(0, 10).forEach((vuln, index) => {
if (yPosition > pageHeight - 30) {
pdf.addPage();
yPosition = 20;
}
pdf.text(`${index + 1}. ${vuln.cveId} - ${vuln.title}`, 20, yPosition);
yPosition += 5;
pdf.text(` Severity: ${vuln.severity} | Score: ${vuln.score} | Affected: ${vuln.affectedDevices} devices`, 20, yPosition);
yPosition += 8;
});
// Add page break if needed
if (yPosition > pageHeight - 80) {
pdf.addPage();
yPosition = 20;
} else {
yPosition += 10;
}
// Top Vulnerable Software
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text('Most Vulnerable Software', 20, yPosition);
yPosition += 10;
pdf.setFontSize(9);
pdf.setFont('helvetica', 'normal');
topSoftware.slice(0, 10).forEach((software, index) => {
if (yPosition > pageHeight - 30) {
pdf.addPage();
yPosition = 20;
}
pdf.text(`${index + 1}. ${software.softwareName}`, 20, yPosition);
yPosition += 5;
pdf.text(` ${software.vulnerableInstances}/${software.totalInstances} instances vulnerable | ${software.totalCves} CVEs`, 20, yPosition);
yPosition += 8;
});
// Footer
const totalPages = pdf.internal.pages.length - 1;
for (let i = 1; i <= totalPages; i++) {
pdf.setPage(i);
pdf.setFontSize(8);
pdf.text(`Page ${i} of ${totalPages}`, pageWidth - 30, pageHeight - 10);
pdf.text('Confidential - Internal Use Only', 20, pageHeight - 10);
}
// Save the PDF
const filename = `compliance-report-${format(new Date(), 'yyyy-MM-dd')}.pdf`;
pdf.save(filename);
} catch (error) {
console.error('Failed to export PDF:', error);
} finally {
setExporting(false);
}
};
const getSeverityColor = (severity: string) => {
switch (severity?.toLowerCase()) {
case 'critical': return 'error';
case 'high': return 'warning';
case 'medium': return 'info';
case 'low': return 'success';
default: return 'default';
}
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
<CircularProgress />
</Box>
);
}
if (!summary) {
return (
<Box p={4}>
<Alert severity="error">Failed to load reporting data. Please try again later.</Alert>
</Box>
);
}
return (
<Box p={4} ref={reportRef}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={4}>
<Box>
<Typography variant="h4" gutterBottom>
<AssessmentIcon sx={{ mr: 2, verticalAlign: 'middle' }} />
Compliance Reporting
</Typography>
<Typography variant="subtitle1" color="text.secondary">
Comprehensive security compliance overview and vulnerability reporting
</Typography>
</Box>
<Button
variant="contained"
startIcon={exporting ? <CircularProgress size={20} /> : <PdfIcon />}
onClick={exportToPDF}
disabled={exporting}
size="large"
>
{exporting ? 'Generating...' : 'Export PDF'}
</Button>
</Box>
{/* Executive Summary Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<DevicesIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">Devices</Typography>
</Box>
<Typography variant="h3" color="primary">
{summary.totalDevices}
</Typography>
<Typography variant="body2" color="text.secondary">
{summary.vulnerableDevices} vulnerable ({((summary.vulnerableDevices / summary.totalDevices) * 100).toFixed(1)}%)
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<SecurityIcon color="error" sx={{ mr: 1 }} />
<Typography variant="h6">Vulnerabilities</Typography>
</Box>
<Typography variant="h3" color="error">
{summary.totalVulnerabilities}
</Typography>
<Box display="flex" gap={1} mt={1} flexWrap="wrap">
<Chip label={`Critical: ${summary.criticalVulns}`} color="error" size="small" />
<Chip label={`High: ${summary.highVulns}`} color="warning" size="small" />
</Box>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<AppsIcon color="info" sx={{ mr: 1 }} />
<Typography variant="h6">Software</Typography>
</Box>
<Typography variant="h3" color="info">
{summary.totalSoftware}
</Typography>
<Typography variant="body2" color="text.secondary">
{summary.vulnerableSoftware} with vulnerabilities
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent>
<Typography variant="h6" mb={2}>Risk Level</Typography>
<Typography
variant="h3"
color={summary.criticalVulns > 0 ? "error" : summary.highVulns > 10 ? "warning" : "success"}
>
{summary.criticalVulns > 0 ? "HIGH" : summary.highVulns > 10 ? "MEDIUM" : "LOW"}
</Typography>
<Typography variant="body2" color="text.secondary">
Last updated: {format(new Date(summary.lastUpdated), 'PPp')}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Detailed Reports */}
<Grid container spacing={3}>
<Grid size={{ xs: 12, lg: 6 }}>
<Paper sx={{ p: 3, height: 'fit-content' }}>
<Typography variant="h6" mb={3}>
<SecurityIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Top Critical Vulnerabilities
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell><strong>CVE ID</strong></TableCell>
<TableCell><strong>Severity</strong></TableCell>
<TableCell align="right"><strong>Score</strong></TableCell>
<TableCell align="right"><strong>Devices</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{topVulns.slice(0, 8).map((vuln) => (
<TableRow key={vuln.cveId} hover>
<TableCell>
<Typography variant="body2" fontFamily="monospace">
{vuln.cveId}
</Typography>
<Typography variant="caption" color="text.secondary">
{vuln.title.length > 40 ? `${vuln.title.substring(0, 40)}...` : vuln.title}
</Typography>
</TableCell>
<TableCell>
<Chip
label={vuln.severity}
color={getSeverityColor(vuln.severity) as "error" | "warning" | "info" | "success" | "default"}
size="small"
/>
</TableCell>
<TableCell align="right">
<Typography fontWeight="bold">
{vuln.score}
</Typography>
</TableCell>
<TableCell align="right">
{vuln.affectedDevices}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
<Grid size={{ xs: 12, lg: 6 }}>
<Paper sx={{ p: 3, height: 'fit-content' }}>
<Typography variant="h6" mb={3}>
<AppsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Most Vulnerable Software
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell><strong>Software</strong></TableCell>
<TableCell align="center"><strong>Vulnerable</strong></TableCell>
<TableCell align="center"><strong>CVEs</strong></TableCell>
<TableCell align="center"><strong>Risk</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{topSoftware.slice(0, 8).map((software) => {
const riskLevel = software.vulnerableInstances / software.totalInstances;
return (
<TableRow key={software.softwareName} hover>
<TableCell>
<Typography variant="body2">
{software.softwareName}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{software.vulnerableInstances}/{software.totalInstances}
</Typography>
<Typography variant="caption" color="text.secondary">
({((riskLevel) * 100).toFixed(0)}%)
</Typography>
</TableCell>
<TableCell align="center">
<Typography fontWeight="bold" color="error">
{software.totalCves}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={riskLevel > 0.5 ? "HIGH" : riskLevel > 0.2 ? "MEDIUM" : "LOW"}
color={riskLevel > 0.5 ? "error" : riskLevel > 0.2 ? "warning" : "success"}
size="small"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
</Grid>
<Box mt={4}>
<Divider />
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
This report contains confidential security information. Distribution should be limited to authorized personnel only.
Report generated on {format(new Date(), 'PPP')} from data last updated {format(new Date(summary.lastUpdated), 'PPp')}.
</Typography>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
export async function GET() {
// Mock compliance summary data
// In production, this would query your database for actual metrics
const summary = {
totalDevices: 127,
vulnerableDevices: 89,
totalVulnerabilities: 342,
criticalVulns: 23,
highVulns: 67,
mediumVulns: 156,
lowVulns: 96,
totalSoftware: 1247,
vulnerableSoftware: 89,
lastUpdated: new Date().toISOString()
};
return NextResponse.json(summary);
}

View File

@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server';
export async function GET() {
// Mock top vulnerabilities data
// In production, this would query your vulnerability database
const topVulns = [
{
cveId: 'CVE-2024-12345',
title: 'Critical Remote Code Execution in Apache HTTP Server',
severity: 'CRITICAL',
score: 9.8,
affectedDevices: 34
},
{
cveId: 'CVE-2024-12346',
title: 'Privilege Escalation Vulnerability in OpenSSL',
severity: 'HIGH',
score: 8.4,
affectedDevices: 28
},
{
cveId: 'CVE-2024-12347',
title: 'Information Disclosure in Microsoft Office',
severity: 'HIGH',
score: 7.9,
affectedDevices: 22
},
{
cveId: 'CVE-2024-12348',
title: 'Cross-Site Scripting in Adobe Reader',
severity: 'MEDIUM',
score: 6.1,
affectedDevices: 19
},
{
cveId: 'CVE-2024-12349',
title: 'Denial of Service in Java Runtime Environment',
severity: 'MEDIUM',
score: 5.8,
affectedDevices: 15
},
{
cveId: 'CVE-2024-12350',
title: 'Buffer Overflow in Windows Kernel',
severity: 'HIGH',
score: 7.5,
affectedDevices: 12
},
{
cveId: 'CVE-2024-12351',
title: 'SQL Injection in MySQL Client',
severity: 'MEDIUM',
score: 6.8,
affectedDevices: 10
},
{
cveId: 'CVE-2024-12352',
title: 'Use After Free in Chrome Browser',
severity: 'HIGH',
score: 8.1,
affectedDevices: 8
}
];
return NextResponse.json(topVulns);
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server';
export async function GET() {
// Mock vulnerable software data
// In production, this would query your software inventory database
const vulnerableSoftware = [
{
softwareName: 'Apache HTTP Server',
totalInstances: 45,
vulnerableInstances: 23,
totalCves: 12
},
{
softwareName: 'OpenSSL',
totalInstances: 89,
vulnerableInstances: 34,
totalCves: 8
},
{
softwareName: 'Microsoft Office',
totalInstances: 127,
vulnerableInstances: 67,
totalCves: 15
},
{
softwareName: 'Adobe Reader',
totalInstances: 98,
vulnerableInstances: 45,
totalCves: 9
},
{
softwareName: 'Java Runtime Environment',
totalInstances: 76,
vulnerableInstances: 28,
totalCves: 6
},
{
softwareName: 'Google Chrome',
totalInstances: 112,
vulnerableInstances: 23,
totalCves: 4
},
{
softwareName: 'Mozilla Firefox',
totalInstances: 67,
vulnerableInstances: 18,
totalCves: 3
},
{
softwareName: 'MySQL Server',
totalInstances: 34,
vulnerableInstances: 12,
totalCves: 5
},
{
softwareName: 'Python',
totalInstances: 89,
vulnerableInstances: 15,
totalCves: 2
},
{
softwareName: 'Node.js',
totalInstances: 56,
vulnerableInstances: 9,
totalCves: 3
}
];
return NextResponse.json(vulnerableSoftware);
}

View File

@@ -25,6 +25,7 @@ import DashboardIcon from '@mui/icons-material/Dashboard';
import SecurityIcon from '@mui/icons-material/Security';
import DevicesIcon from '@mui/icons-material/Devices';
import AppsIcon from '@mui/icons-material/Apps';
import AssessmentIcon from '@mui/icons-material/Assessment';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
@@ -123,6 +124,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
{ label: 'Vulnerabilities', path: '/vulnerabilities', icon: <SecurityIcon /> },
{ label: 'Devices', path: '/devices', icon: <DevicesIcon /> },
{ label: 'Software', path: '/software', icon: <AppsIcon /> },
{ label: 'Reporting', path: '/reporting', icon: <AssessmentIcon /> },
];
const handleLogout = async () => {

View File

@@ -1,15 +1,20 @@
// src/components/admin/UserTableSection.tsx
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Typography, Table, TableBody, TableCell, TableContainer, TableHead,
TableRow, Paper, Box, Button, Dialog, DialogTitle, DialogContent, DialogActions
TableRow, Paper, Box, Button, Dialog, DialogTitle, DialogContent, DialogActions,
TextField, Select, MenuItem, FormControl, InputLabel, IconButton
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import UserEnableToggle from './UserEnableToggle';
import AddUserForm from './forms/AddUserForm';
import AddClientForm from './forms/AddClientForm';
import InviteUserForm from './forms/InviteUserForm';
import api from '@/lib/axios';
interface UserDTO {
id: number;
@@ -20,15 +25,97 @@ interface UserDTO {
email: string;
role: string;
clientName: string;
clientId?: number;
enabled: boolean;
}
interface ClientDTO {
clientId: number;
clientIdentifier: string;
clientName: string;
}
export default function UserTableSection({ initialUsers }: { initialUsers: UserDTO[] }) {
const [users, setUsers] = useState<UserDTO[]>(initialUsers);
const [openUserDialog, setOpenUserDialog] = useState(false);
const [openClientDialog, setOpenClientDialog] = useState(false);
const [openInviteUserDialog, setOpenInviteUserDialog] = useState(false);
// Edit mode state
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<Partial<UserDTO>>({});
const [clients, setClients] = useState<ClientDTO[]>([]);
const [loading, setLoading] = useState(false);
// Fetch clients for dropdown
useEffect(() => {
const fetchClients = async () => {
try {
const res = await api.get('/auth/clients');
setClients(res.data);
} catch (err) {
console.error('Failed to fetch clients:', err);
}
};
fetchClients();
}, []);
const handleEdit = (user: UserDTO) => {
setEditingUserId(user.id);
setEditFormData({
username: user.username,
displayName: user.displayName,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role,
clientName: user.clientName,
clientId: user.clientId
});
};
const handleCancel = () => {
setEditingUserId(null);
setEditFormData({});
};
const handleSave = async (userId: number) => {
setLoading(true);
try {
// Find the selected client
const selectedClient = clients.find(c => c.clientId === editFormData.clientId);
await api.put(`/admin/users/${userId}`, {
username: editFormData.username,
displayName: editFormData.displayName,
firstName: editFormData.firstName,
lastName: editFormData.lastName,
email: editFormData.email,
role: editFormData.role,
clientId: editFormData.clientId
});
// Update local state
setUsers(users.map(user =>
user.id === userId
? {
...user,
...editFormData,
clientName: selectedClient?.clientName || user.clientName
}
: user
));
setEditingUserId(null);
setEditFormData({});
} catch (err) {
console.error('Failed to update user:', err);
alert('Failed to update user. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Box sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom>User Management</Typography>
@@ -57,23 +144,143 @@ export default function UserTableSection({ initialUsers }: { initialUsers: UserD
<TableCell>Role</TableCell>
<TableCell>Client</TableCell>
<TableCell>Status</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.displayName}</TableCell>
<TableCell>{user.firstName}</TableCell>
<TableCell>{user.lastName}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role}</TableCell>
<TableCell>{user.clientName}</TableCell>
<TableCell>
<UserEnableToggle userId={user.id} initialEnabled={user.enabled} />
</TableCell>
</TableRow>
))}
{users.map((user) => {
const isEditing = editingUserId === user.id;
return (
<TableRow key={user.id}>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editFormData.username || ''}
onChange={(e) => setEditFormData({ ...editFormData, username: e.target.value })}
fullWidth
/>
) : (
user.username
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editFormData.displayName || ''}
onChange={(e) => setEditFormData({ ...editFormData, displayName: e.target.value })}
fullWidth
/>
) : (
user.displayName
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editFormData.firstName || ''}
onChange={(e) => setEditFormData({ ...editFormData, firstName: e.target.value })}
fullWidth
/>
) : (
user.firstName
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editFormData.lastName || ''}
onChange={(e) => setEditFormData({ ...editFormData, lastName: e.target.value })}
fullWidth
/>
) : (
user.lastName
)}
</TableCell>
<TableCell>
{isEditing ? (
<TextField
size="small"
value={editFormData.email || ''}
onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })}
fullWidth
/>
) : (
user.email
)}
</TableCell>
<TableCell>
{isEditing ? (
<FormControl fullWidth size="small">
<Select
value={editFormData.role || ''}
onChange={(e) => setEditFormData({ ...editFormData, role: e.target.value })}
>
<MenuItem value="USER">USER</MenuItem>
<MenuItem value="ADMIN">ADMIN</MenuItem>
</Select>
</FormControl>
) : (
user.role
)}
</TableCell>
<TableCell>
{isEditing ? (
<FormControl fullWidth size="small">
<Select
value={editFormData.clientId || ''}
onChange={(e) => setEditFormData({ ...editFormData, clientId: Number(e.target.value) })}
>
{clients.map(client => (
<MenuItem key={client.clientId} value={client.clientId}>
{client.clientName}
</MenuItem>
))}
</Select>
</FormControl>
) : (
user.clientName
)}
</TableCell>
<TableCell>
<UserEnableToggle userId={user.id} initialEnabled={user.enabled} />
</TableCell>
<TableCell>
{isEditing ? (
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton
color="primary"
onClick={() => handleSave(user.id)}
disabled={loading}
size="small"
>
<SaveIcon />
</IconButton>
<IconButton
color="default"
onClick={handleCancel}
disabled={loading}
size="small"
>
<CancelIcon />
</IconButton>
</Box>
) : (
<IconButton
color="primary"
onClick={() => handleEdit(user)}
size="small"
>
<EditIcon />
</IconButton>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>

View File

@@ -1,20 +1,12 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import api from '@/lib/axios';
'use client';
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import api from '@/lib/axios';
import type { AxiosError } from 'axios';
import { useAuth } from '@/context/AuthContext';
import { DetailedDevice } from '@/types/devices';
import { disableConsoleInProd } from '@/lib/disableConsole';
disableConsoleInProd();
// Helper function to convert UTC timestamps to local time
const convertUtcToLocal = (utcString: string | null): string | null => {
if (!utcString) return null;
// Add 'Z' to indicate it's UTC, then convert to local time
return new Date(utcString + 'Z').toLocaleString();
};
import { DetailedDevice } from '@/types/devices';
import { disableConsoleInProd } from '@/lib/disableConsole';
disableConsoleInProd();
interface DriveInfo {
name: string;
@@ -32,13 +24,698 @@ interface MacAddress {
interfaceName: string;
macAddress: string;
}
interface InstalledApp {
app_name: string;
app_version: string;
publisher: string;
}
interface InstalledApp {
app_name: string;
app_version: string;
publisher: string;
}
type RawRecord = Record<string, unknown>;
const normalizeKey = (key: string): string =>
key.toLowerCase().replace(/[^a-z0-9]/g, '');
const getDirectValue = (source: RawRecord | null | undefined, keys: string[]): unknown => {
if (!source) return undefined;
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const value = source[key];
if (value !== undefined && value !== null) {
return value;
}
}
}
return undefined;
};
const findValueDeep = (
source: unknown,
normalizedTargets: string[],
seen = new WeakSet<object>()
): unknown => {
if (!source || typeof source !== 'object') return undefined;
const stack: unknown[] = [source];
while (stack.length > 0) {
const current = stack.pop();
if (!current || typeof current !== 'object') continue;
if (seen.has(current as object)) continue;
seen.add(current as object);
if (Array.isArray(current)) {
for (const item of current) {
if (item && (typeof item === 'object' || typeof item === 'string')) {
stack.push(item);
}
}
continue;
}
for (const [key, value] of Object.entries(current as RawRecord)) {
const normalizedKey = normalizeKey(key);
if (normalizedTargets.includes(normalizedKey) && value !== undefined && value !== null) {
return value;
}
if (value && typeof value === 'object') {
stack.push(value);
}
}
}
return undefined;
};
const resolveValue = (
source: RawRecord | null | undefined,
keys: string[],
fallbackKeys: string[] = keys
): unknown => {
if (!source) return undefined;
const direct = getDirectValue(source, keys);
if (direct !== undefined && direct !== null) {
return direct;
}
const normalizedTargets = Array.from(new Set(fallbackKeys.map(normalizeKey))).filter(Boolean);
return findValueDeep(source, normalizedTargets);
};
const resolveArray = <T = unknown>(
source: RawRecord | null | undefined,
keys: string[],
fallbackKeys?: string[]
): T[] | undefined => {
const value = resolveValue(source, keys, fallbackKeys ?? keys);
if (Array.isArray(value)) {
return value as T[];
}
return undefined;
};
const toOptionalString = (value: unknown): string | undefined => {
if (value === null || value === undefined) return undefined;
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return undefined;
};
const toNonEmptyString = (value: unknown, fallback = ''): string => {
const candidate = toOptionalString(value)?.trim();
return candidate && candidate.length > 0 ? candidate : fallback;
};
const sanitizeName = (value: string, hostname: string): string => {
const trimmed = value.trim();
if (!trimmed) return '';
if (trimmed.toLowerCase() === hostname.toLowerCase()) return '';
if (/^[A-Z0-9_-]+$/.test(trimmed)) return '';
return trimmed;
};
const sanitizeArchitecture = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || trimmed === '-1') return '';
return trimmed;
};
const parseSizeToGB = (value: unknown): number => {
if (value === null || value === undefined) return 0;
const normalizeNumeric = (num: number): number => {
if (!Number.isFinite(num)) return 0;
if (num > 1024 ** 3) return +(num / 1024 ** 3).toFixed(2); // bytes
if (num > 1024 ** 2) return +(num / 1024 ** 2).toFixed(2); // KB
if (num > 1024) return +(num / 1024).toFixed(2); // MB
return +num.toFixed(2); // already GB
};
if (typeof value === 'number') {
return normalizeNumeric(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return 0;
const numeric = parseFloat(trimmed.replace(/[^\d.\-]/g, ''));
if (!Number.isFinite(numeric)) return 0;
if (/tb/i.test(trimmed)) return +(numeric * 1024).toFixed(2);
if (/gb/i.test(trimmed)) return +numeric.toFixed(2);
if (/mb/i.test(trimmed)) return +(numeric / 1024).toFixed(2);
if (/kb/i.test(trimmed)) return +(numeric / (1024 ** 2)).toFixed(2);
if (/bytes?/i.test(trimmed)) return +(numeric / (1024 ** 3)).toFixed(2);
return normalizeNumeric(numeric);
}
return 0;
};
const normalizeDrive = (driveInput: unknown, index: number): DriveInfo | null => {
if (!driveInput || typeof driveInput !== 'object') return null;
const drive = driveInput as RawRecord;
const name = toNonEmptyString(
resolveValue(drive, ['name', 'driveName', 'label', 'volume'], [
'name',
'driveName',
'label',
'volume',
'drive_letter',
'driveletter',
'mount_point',
'mountpoint',
'logicaldisk',
'deviceid',
]) ?? `Drive ${index + 1}`,
`Drive ${index + 1}`
);
const driveType = toNonEmptyString(
resolveValue(drive, ['driveType', 'drive_type', 'type'], [
'driveType',
'drive_type',
'type',
'drivetype',
'mediatype',
'busType',
]),
'Unknown'
);
const totalSizeGB = parseSizeToGB(
resolveValue(
drive,
['totalSizeGB', 'total_size_gb', 'totalSize', 'total_size', 'size', 'capacity', 'totalBytes', 'total_bytes'],
[
'totalSizeGB',
'total_size_gb',
'totalSize',
'total_size',
'size',
'capacity',
'totalBytes',
'total_bytes',
'size_bytes',
'capacity_bytes',
'totalsize',
'totalspace',
'totalspacebytes',
]
)
);
const freeSpaceGB = parseSizeToGB(
resolveValue(
drive,
['freeSpaceGB', 'free_space_gb', 'freeSpace', 'free_space', 'free', 'freeBytes', 'free_bytes', 'availableBytes'],
[
'freeSpaceGB',
'free_space_gb',
'freeSpace',
'free_space',
'free',
'freeBytes',
'free_bytes',
'availableBytes',
'available_bytes',
'freespace',
'freespacebytes',
'remaining',
]
)
);
if (!name && !totalSizeGB && !freeSpaceGB) return null;
return {
name,
driveType,
totalSizeGB,
freeSpaceGB,
};
};
const extractDrives = (rawInput: unknown): DriveInfo[] => {
if (!rawInput || typeof rawInput !== 'object') return [];
const raw = rawInput as RawRecord;
const potentialArrays = [
resolveArray(raw, ['drives']),
resolveArray(raw, ['driveInfo']),
resolveArray(raw, ['drive_info']),
resolveArray(raw, ['storageDevices']),
resolveArray(raw, ['drive_details']),
resolveArray(raw, ['storage']),
resolveArray(raw, ['logicalDrives']),
resolveArray(raw, ['logical_drives']),
resolveArray(raw, ['volumes']),
resolveArray(raw, ['volume_info']),
resolveArray(raw, ['disks']),
];
for (const candidate of potentialArrays) {
if (Array.isArray(candidate)) {
return candidate
.map((drive, idx: number) => normalizeDrive(drive, idx))
.filter((drive): drive is DriveInfo => Boolean(drive));
}
}
const aggregatedDrive = {
totalSize: resolveValue(raw, ['total_disk_space', 'totalDiskSpace', 'total_storage'], [
'total_disk_space',
'totalDiskSpace',
'total_storage',
'totalSpace',
'total_space',
'storage_total',
'disk_total',
]),
freeSpace: resolveValue(raw, ['free_disk_space', 'freeDiskSpace', 'available_storage'], [
'free_disk_space',
'freeDiskSpace',
'available_storage',
'free_storage',
'freeSpace',
'free_space',
'availableSpace',
'available_space',
'storage_free',
]),
};
if (aggregatedDrive.totalSize || aggregatedDrive.freeSpace) {
const normalized = normalizeDrive(
{
name: 'System',
driveType: 'Unknown',
totalSizeGB: aggregatedDrive.totalSize,
freeSpaceGB: aggregatedDrive.freeSpace,
},
0
);
return normalized ? [normalized] : [];
}
return [];
};
const normalizeIpAddress = (entryInput: unknown, index: number): IpAddress | null => {
if (!entryInput || typeof entryInput !== 'object') return null;
const entry = entryInput as RawRecord;
const ipAddress = toNonEmptyString(
resolveValue(entry, ['ipAddress', 'ip_address', 'address', 'ip', 'ipv4', 'ipv6'], [
'ipAddress',
'ip_address',
'address',
'ip',
'ipv4',
'ipv6',
'primary_ip',
'primaryIp',
'lan_ip',
]) ?? '',
''
);
const interfaceName = toNonEmptyString(
resolveValue(entry, ['interfaceName', 'interface_name', 'adapter', 'name', 'nic'], [
'interfaceName',
'interface_name',
'adapter',
'name',
'nic',
'network_adapter',
'interface',
'description',
]) ?? '',
ipAddress ? `Interface ${index + 1}` : ''
);
if (!ipAddress && !interfaceName) return null;
const macAddress = toOptionalString(
resolveValue(entry, ['macAddress', 'mac_address', 'mac', 'physicalAddress'], [
'macAddress',
'mac_address',
'mac',
'physicalAddress',
'physical_address',
'hw_address',
])
);
return {
interfaceName: interfaceName || `Interface ${index + 1}`,
ipAddress: ipAddress || 'Unknown',
macAddress: macAddress?.trim() || undefined,
};
};
const extractIpAddresses = (rawInput: unknown): IpAddress[] => {
if (!rawInput || typeof rawInput !== 'object') return [];
const raw = rawInput as RawRecord;
const potentialArrays = [
resolveArray(raw, ['ipAddresses']),
resolveArray(raw, ['ip_addresses']),
resolveArray(raw, ['networkInterfaces']),
resolveArray(raw, ['network_interfaces']),
resolveArray(raw, ['interfaces']),
resolveArray(raw, ['networkAdapters']),
resolveArray(raw, ['network_adapters']),
resolveArray(raw, ['network']),
];
for (const candidate of potentialArrays) {
if (Array.isArray(candidate)) {
return candidate
.map((entry, idx: number) => normalizeIpAddress(entry, idx))
.filter((entry): entry is IpAddress => Boolean(entry));
}
}
const singleIp = resolveValue(raw, ['ipAddress', 'ip_address', 'primary_ip', 'primaryIp']);
if (singleIp) {
return [
normalizeIpAddress(
{
ipAddress: singleIp,
interfaceName: resolveValue(raw, ['interfaceName', 'interface_name', 'adapter']),
macAddress: resolveValue(raw, ['macAddress', 'mac_address']),
},
0
) as IpAddress,
].filter(Boolean);
}
return [];
};
const normalizeInstalledApp = (appInput: unknown): InstalledApp | null => {
if (!appInput || typeof appInput !== 'object') return null;
const app = appInput as RawRecord;
const appName = toNonEmptyString(
resolveValue(app, ['app_name', 'name', 'title'], [
'app_name',
'name',
'title',
'display_name',
'product_name',
]) ?? '',
''
);
if (!appName) return null;
return {
app_name: appName,
app_version: toNonEmptyString(
resolveValue(app, ['app_version', 'version'], ['app_version', 'version', 'display_version', 'product_version']) ?? '',
''
),
publisher: toNonEmptyString(
resolveValue(app, ['publisher', 'manufacturer', 'vendor'], [
'publisher',
'manufacturer',
'vendor',
'company',
'publisher_name',
]) ?? '',
''
),
};
};
const extractInstalledApps = (rawInput: unknown): InstalledApp[] => {
if (!rawInput || typeof rawInput !== 'object') return [];
const raw = rawInput as RawRecord;
const potentialArrays = [
resolveArray(raw, ['installedApplications']),
resolveArray(raw, ['installed_applications']),
resolveArray(raw, ['software']),
resolveArray(raw, ['applications']),
resolveArray(raw, ['apps']),
resolveArray(raw, ['installedPrograms']),
resolveArray(raw, ['installed_programs']),
resolveArray(raw, ['programs']),
];
for (const candidate of potentialArrays) {
if (Array.isArray(candidate)) {
return candidate
.map((app) => normalizeInstalledApp(app))
.filter((app): app is InstalledApp => Boolean(app));
}
}
return [];
};
const extractMacAddresses = (rawInput: unknown, ipAddresses: IpAddress[]): MacAddress[] => {
if (!rawInput || typeof rawInput !== 'object') return [];
const raw = rawInput as RawRecord;
const potentialArrays = resolveArray(raw, ['macAddresses', 'mac_addresses', 'macs', 'mac_list']);
if (Array.isArray(potentialArrays)) {
return potentialArrays
.map((entry, idx: number) => {
if (!entry || typeof entry !== 'object') return null;
const record = entry as RawRecord;
const macAddress = toOptionalString(
resolveValue(record, ['macAddress', 'mac_address', 'mac'], [
'macAddress',
'mac_address',
'mac',
'physicalAddress',
'hw_address',
])
);
if (!macAddress) return null;
return {
interfaceName: toNonEmptyString(
resolveValue(record, ['interfaceName', 'interface_name', 'adapter'], [
'interfaceName',
'interface_name',
'adapter',
'network_adapter',
'name',
]) ?? `Interface ${idx + 1}`,
`Interface ${idx + 1}`
),
macAddress: macAddress.trim(),
};
})
.filter((entry): entry is MacAddress => Boolean(entry));
}
return ipAddresses
.filter((entry) => Boolean(entry.macAddress))
.map((entry) => ({
interfaceName: entry.interfaceName,
macAddress: entry.macAddress as string,
}));
};
const splitGpuNames = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
.map((item) => toNonEmptyString(item, ''))
.filter((item) => item.length > 0);
}
if (typeof value === 'string') {
return value
.split(/[,;\n]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
return [];
};
const normalizeDevice = (rawInput: unknown): DetailedDevice => {
const raw: RawRecord =
rawInput && typeof rawInput === 'object' ? (rawInput as RawRecord) : {};
const deviceId = Number(
resolveValue(raw, ['deviceId', 'device_id', 'id', 'deviceID', 'device_id_pk'], [
'deviceId',
'device_id',
'id',
'deviceID',
'device_id_pk',
]) ?? 0
);
const drives = extractDrives(raw);
const ipAddresses = extractIpAddresses(raw);
const macAddresses = extractMacAddresses(raw, ipAddresses);
const clientEntry = resolveValue(raw, ['client'], ['client', 'clientInfo', 'customer']);
const clientNameFromNested =
clientEntry && typeof clientEntry === 'object'
? resolveValue(clientEntry as RawRecord, ['name', 'clientName', 'client_name'])
: undefined;
const hostname = toNonEmptyString(
resolveValue(raw, ['hostname', 'hostName', 'computerName', 'deviceName'], [
'hostname',
'hostName',
'computerName',
'deviceName',
'host',
'machineName',
]) ?? `Device ${deviceId}`,
deviceId ? `Device ${deviceId}` : 'Unknown Device'
);
return {
deviceId,
hostname,
osName: sanitizeName(
toNonEmptyString(
resolveValue(
raw,
['osName', 'os_name', 'operatingSystem', 'os'],
[
'osName',
'os_name',
'operatingSystem',
'operating_system',
'operatingSystemName',
'operating_system_name',
'osFriendlyName',
'os_caption',
'oscaption',
]
) ?? '',
''
),
hostname
),
osVersion: toNonEmptyString(
resolveValue(raw, ['osVersion', 'os_version', 'osVersionString'], [
'osVersion',
'os_version',
'osVersionString',
'operatingSystemVersion',
'osversion',
]) ?? '',
''
),
windowsVersion: toNonEmptyString(
resolveValue(raw, ['windowsVersion', 'windows_version', 'osRelease'], [
'windowsVersion',
'windows_version',
'osRelease',
'windowsRelease',
'windows_release',
]) ?? '',
''
),
windowsBuild: toNonEmptyString(
resolveValue(raw, ['windowsBuild', 'windows_build', 'buildNumber'], [
'windowsBuild',
'windows_build',
'buildNumber',
'build_number',
'windowsBuildNumber',
]) ?? '',
''
),
osArchitecture: sanitizeArchitecture(
toNonEmptyString(
resolveValue(raw, ['osArchitecture', 'os_architecture', 'architecture'], [
'osArchitecture',
'os_architecture',
'architecture',
'platform',
'osarch',
]) ?? '',
''
)
),
processorName: toNonEmptyString(
resolveValue(raw, ['processorName', 'processor_name', 'cpuName'], [
'processorName',
'processor_name',
'cpuName',
'cpu_name',
'processor',
]) ?? '',
''
),
processorArchitecture: toNonEmptyString(
resolveValue(raw, ['processorArchitecture', 'processor_architecture', 'cpuArchitecture'], [
'processorArchitecture',
'processor_architecture',
'cpuArchitecture',
'cpu_architecture',
'cpuArch',
]) ?? '',
''
),
gpuNames: splitGpuNames(
resolveValue(raw, ['gpuNames', 'gpu_names', 'gpu_name', 'gpus', 'graphics']) ?? []
),
totalMemory: toNonEmptyString(
resolveValue(
raw,
['totalMemory', 'total_memory', 'memory', 'totalMemoryMb', 'totalMemoryMB'],
[
'totalMemory',
'total_memory',
'memory',
'totalMemoryMb',
'totalMemoryMB',
'memory_total',
'ram_total',
'physicalMemory',
]
) ?? '',
''
),
lastBootTime: toNonEmptyString(
resolveValue(raw, ['lastBootTime', 'last_boot_time', 'bootTime', 'lastBoot', 'lastBootUpTime'], [
'lastBootTime',
'last_boot_time',
'bootTime',
'lastBoot',
'lastBootUpTime',
'last_boot',
'boot_time',
]) ?? '',
''
),
lastCheckedIn: toNonEmptyString(
resolveValue(raw, ['lastCheckedIn', 'last_checked_in', 'lastSeen', 'checked_in_at'], [
'lastCheckedIn',
'last_checked_in',
'lastSeen',
'checked_in_at',
'lastSeenAt',
'last_seen',
]) ?? '',
''
),
drives,
ipAddresses,
macAddresses,
installedApplications: extractInstalledApps(raw),
clientName:
toOptionalString(
resolveValue(raw, ['clientName', 'client_name'], ['clientName', 'client_name', 'client']) ??
clientNameFromNested ??
resolveValue(raw, ['clientIdentifier', 'client_identifier'])
) ?? undefined,
};
};
interface DeviceVulnerability {
cveId: string;
@@ -49,16 +726,21 @@ interface DeviceVulnerability {
lastModifiedDate: string;
}
interface CachedSoftwareEntry {
id: number;
softwareName: string;
hostname: string;
version: string;
deviceId: number;
totalCves: number;
lastUpdated: string;
}
interface CachedSoftwareEntry {
id: number;
softwareName: string;
hostname: string;
version: string;
deviceId: number;
totalCves: number;
lastUpdated: string;
}
interface RawDevicesResponse {
devices?: unknown[];
vulnerabilitiesByDevice?: { [deviceId: string]: DeviceVulnerability[] };
}
interface DeviceContextType {
devices: DetailedDevice[];
deviceVulns: { [deviceId: string]: DeviceVulnerability[] };
@@ -93,10 +775,12 @@ export const DeviceProvider = ({
vulnerabilitiesByDevice: { [deviceId: string]: DeviceVulnerability[] };
};
}) => {
const [devices, setDevices] = useState<DetailedDevice[]>(() => {
if (initialData?.devices?.length) return initialData.devices;
return [];
});
const [devices, setDevices] = useState<DetailedDevice[]>(() => {
if (initialData?.devices?.length) {
return initialData.devices.map((device) => normalizeDevice(device));
}
return [];
});
const [deviceVulns, setDeviceVulns] = useState<{ [deviceId: string]: DeviceVulnerability[] }>(
initialData?.vulnerabilitiesByDevice ?? {}
@@ -104,11 +788,12 @@ export const DeviceProvider = ({
const [detailedCveLookup, setDetailedCveLookup] = useState<{ [cveId: string]: DeviceVulnerability }>({});
const [cachedSoftware, setCachedSoftware] = useState<CachedSoftwareEntry[]>([]); // ⬅️ NEW
const [loading, setLoading] = useState<boolean>(!initialData);
const { username, roles, loading: authLoading } = useAuth();
const [cachedSoftware, setCachedSoftware] = useState<CachedSoftwareEntry[]>([]); // ⬅️ NEW
const [loading, setLoading] = useState<boolean>(!initialData);
const { username, roles, loading: authLoading } = useAuth();
const rawDevicesRef = useRef<unknown[]>([]);
useEffect(() => {
if (devices.length > 0 || initialData || authLoading || !username) return;
@@ -123,28 +808,34 @@ export const DeviceProvider = ({
console.group('📡 Fetching Devices, Vulnerabilities, and Software');
const [devicesRes, softwareRes] = await Promise.all([
api.get(devicesEndpoint, { withCredentials: true }),
api.get(softwareEndpoint, { withCredentials: true }),
]);
const [devicesRes, softwareRes] = await Promise.all([
api.get<RawDevicesResponse>(devicesEndpoint, { withCredentials: true }),
api.get<CachedSoftwareEntry[]>(softwareEndpoint, { withCredentials: true }),
]);
console.log('✅ Devices fetched:', devicesRes.data);
console.log('✅ Software fetched:', softwareRes.data);
console.groupEnd();
// STEP 1: Set basic data (keep original UTC timestamps for calculations)
setDevices(devicesRes.data.devices);
setDeviceVulns(devicesRes.data.vulnerabilitiesByDevice);
setCachedSoftware(softwareRes.data);
const rawDevices = Array.isArray(devicesRes.data?.devices) ? (devicesRes.data.devices as unknown[]) : [];
rawDevicesRef.current = rawDevices;
const normalizedDevices = rawDevices.length
? rawDevices.map((device) => normalizeDevice(device))
: [];
setDevices(normalizedDevices);
setDeviceVulns(devicesRes.data?.vulnerabilitiesByDevice ?? {});
setCachedSoftware(softwareRes.data ?? []);
// STEP 2: Extract all unique CVE IDs
const uniqueCveIds = new Set<string>();
(Object.values(devicesRes.data.vulnerabilitiesByDevice) as DeviceVulnerability[][]).forEach((vulns) => {
vulns.forEach((vuln) => {
if (vuln.cveId) {
uniqueCveIds.add(vuln.cveId);
}
});
Object.values(devicesRes.data?.vulnerabilitiesByDevice ?? {}).forEach((vulns) => {
vulns.forEach((vuln) => {
if (vuln.cveId) {
uniqueCveIds.add(vuln.cveId);
}
});
});
// STEP 3: Fetch detailed CVE data
@@ -171,35 +862,41 @@ const cveDetailsRes = await api.get<DeviceVulnerability[]>('/vuln/cves/lookup',
setDetailedCveLookup(lookupMap);
}
} catch (error: any) {
console.group('❌ Device fetch failed');
console.error('🔴 Axios Error Message:', error.message);
console.error('🧾 Axios Error Config:', error.config);
if (error.response) {
console.error('📉 Status:', error.response.status);
console.error('📄 Response Headers:', error.response.headers);
console.error('📄 Response Data:', error.response.data);
} else if (error.request) {
console.error('🛑 No response received:', error.request);
}
console.groupEnd();
} finally {
setLoading(false);
}
} catch (error) {
console.group('❌ Device fetch failed');
const axiosError = error as AxiosError;
console.error('🔴 Axios Error Message:', axiosError.message);
console.error('🧾 Axios Error Config:', axiosError.config);
if (axiosError.response) {
console.error('📉 Status:', axiosError.response.status);
console.error('📄 Response Headers:', axiosError.response.headers);
console.error('📄 Response Data:', axiosError.response.data);
} else if (axiosError.request) {
console.error('🛑 No response received:', axiosError.request);
} else {
console.error('❓ Unexpected error:', error);
}
console.groupEnd();
} finally {
setLoading(false);
}
};
fetchDevicesVulnsAndSoftware();
}, [devices.length, initialData, username, authLoading]);
}, [devices.length, initialData, username, authLoading, roles]);
useEffect(() => {
console.log("🧠 Devices updated:", devices);
}, [devices]);
if (typeof window !== 'undefined') {
(window as any).__debug_devices = devices;
}
useEffect(() => {
console.log('🧠 Devices updated:', devices);
if (typeof window !== 'undefined') {
(window as Window & {
__debug_devices?: DetailedDevice[];
__debug_rawDevices?: unknown[];
}).__debug_devices = devices;
(window as Window & { __debug_rawDevices?: unknown[] }).__debug_rawDevices = rawDevicesRef.current;
}
}, [devices]);
return (
<DeviceContext.Provider value={{

View File

@@ -6,10 +6,11 @@ export interface DriveInfo {
freeSpaceGB: number;
}
export interface IpAddress {
interfaceName: string;
ipAddress: string;
}
export interface IpAddress {
interfaceName: string;
ipAddress: string;
macAddress?: string;
}
export interface MacAddress {
interfaceName: string;
@@ -42,4 +43,4 @@ export interface DriveInfo {
installedApplications: InstalledApp[];
clientName?: string;
}