feat: Implement API client and dashboard functionality
- Added api.ts to handle API requests and define data models for Project, Image, Vulnerability, IgnoreRule, ScanJob, and DashboardStats. - Created Dashboard component to display statistics and initiate scans for projects and vulnerabilities. - Developed IgnoreRules component for managing ignore rules with filtering options. - Implemented Images component to list discovered Docker images. - Added Projects component to display monitored GitLab projects. - Created ScanJobs component to show history and status of scanning operations. - Developed Vulnerabilities component to report security vulnerabilities found in Docker images. - Removed BrowserRouter from main.tsx as routing is not currently implemented.
This commit is contained in:
139
bun.lock
139
bun.lock
@@ -4,7 +4,17 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -12,6 +22,9 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.6.3",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
},
|
||||
@@ -106,6 +119,14 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
@@ -130,10 +151,84 @@
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
|
||||
|
||||
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@remix-run/router": ["@remix-run/router@1.23.0", "", {}, "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.11", "", {}, "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.44.2", "", { "os": "android", "cpu": "arm" }, "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q=="],
|
||||
@@ -176,6 +271,8 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.2", "", { "os": "win32", "cpu": "x64" }, "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA=="],
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@swc/core": ["@swc/core@1.12.11", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.12.11", "@swc/core-darwin-x64": "1.12.11", "@swc/core-linux-arm-gnueabihf": "1.12.11", "@swc/core-linux-arm64-gnu": "1.12.11", "@swc/core-linux-arm64-musl": "1.12.11", "@swc/core-linux-x64-gnu": "1.12.11", "@swc/core-linux-x64-musl": "1.12.11", "@swc/core-win32-arm64-msvc": "1.12.11", "@swc/core-win32-ia32-msvc": "1.12.11", "@swc/core-win32-x64-msvc": "1.12.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-P3GM+0lqjFctcp5HhR9mOcvLSX3SptI9L1aux0Fuvgt8oH4f92rCUrkodAa0U2ktmdjcyIiG37xg2mb/dSCYSA=="],
|
||||
|
||||
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.12.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J19Jj9Y5x/N0loExH7W0OI9OwwoVyxutDdkyq1o/kgXyBqmmzV7Y/Q9QekI2Fm/qc5mNeAdP7aj4boY4AY/JPw=="],
|
||||
@@ -274,6 +371,8 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
@@ -308,6 +407,12 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.6", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.6", "@esbuild/android-arm": "0.25.6", "@esbuild/android-arm64": "0.25.6", "@esbuild/android-x64": "0.25.6", "@esbuild/darwin-arm64": "0.25.6", "@esbuild/darwin-x64": "0.25.6", "@esbuild/freebsd-arm64": "0.25.6", "@esbuild/freebsd-x64": "0.25.6", "@esbuild/linux-arm": "0.25.6", "@esbuild/linux-arm64": "0.25.6", "@esbuild/linux-ia32": "0.25.6", "@esbuild/linux-loong64": "0.25.6", "@esbuild/linux-mips64el": "0.25.6", "@esbuild/linux-ppc64": "0.25.6", "@esbuild/linux-riscv64": "0.25.6", "@esbuild/linux-s390x": "0.25.6", "@esbuild/linux-x64": "0.25.6", "@esbuild/netbsd-arm64": "0.25.6", "@esbuild/netbsd-x64": "0.25.6", "@esbuild/openbsd-arm64": "0.25.6", "@esbuild/openbsd-x64": "0.25.6", "@esbuild/openharmony-arm64": "0.25.6", "@esbuild/sunos-x64": "0.25.6", "@esbuild/win32-arm64": "0.25.6", "@esbuild/win32-ia32": "0.25.6", "@esbuild/win32-x64": "0.25.6" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg=="],
|
||||
@@ -358,6 +463,8 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
|
||||
@@ -474,8 +581,16 @@
|
||||
|
||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-router": ["react-router@7.6.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0", "react-router": "6.30.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
@@ -494,6 +609,12 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"socket.io-client": ["socket.io-client@4.8.1", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="],
|
||||
|
||||
"sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
@@ -514,6 +635,8 @@
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.3.5", "", {}, "sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
@@ -526,12 +649,20 @@
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -560,10 +691,18 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"react-router-dom/react-router": ["react-router@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ=="],
|
||||
|
||||
"socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
15
package.json
15
package.json
@@ -11,6 +11,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -18,8 +28,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.6.3",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11"
|
||||
"tailwindcss": "^4.1.11",
|
||||
"sonner": "^1.5.0",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
|
||||
34
src/App.tsx
34
src/App.tsx
@@ -1,8 +1,36 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { Layout } from './components/Layout'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Projects } from './pages/Projects'
|
||||
import { Images } from './pages/Images'
|
||||
import { Vulnerabilities } from './pages/Vulnerabilities'
|
||||
import { IgnoreRules } from './pages/IgnoreRules'
|
||||
import { ScanJobs } from './pages/ScanJobs'
|
||||
import { WebSocketProvider } from './contexts/WebSocketContext'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<div>App</div>
|
||||
</>
|
||||
<WebSocketProvider>
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/images" element={<Images />} />
|
||||
<Route path="/vulnerabilities" element={<Vulnerabilities />} />
|
||||
<Route path="/ignore-rules" element={<IgnoreRules />} />
|
||||
<Route path="/scan-jobs" element={<ScanJobs />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors
|
||||
closeButton
|
||||
expand={true}
|
||||
/>
|
||||
</WebSocketProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
113
src/components/Layout.tsx
Normal file
113
src/components/Layout.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWebSocketContext } from '@/contexts/WebSocketContext'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Container,
|
||||
Shield,
|
||||
Settings,
|
||||
Activity,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Projects', href: '/projects', icon: FolderOpen },
|
||||
{ name: 'Images', href: '/images', icon: Container },
|
||||
{ name: 'Vulnerabilities', href: '/vulnerabilities', icon: Shield },
|
||||
{ name: 'Ignore Rules', href: '/ignore-rules', icon: Settings },
|
||||
{ name: 'Scan Jobs', href: '/scan-jobs', icon: Activity },
|
||||
]
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const { isConnected, hasRunningScans } = useWebSocketContext()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar */}
|
||||
<div className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-gray-800">
|
||||
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-gray-900">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-white text-lg font-semibold">
|
||||
GitLab Docker Tracker
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isConnected ? (
|
||||
<div className="flex items-center text-green-400 text-xs">
|
||||
<Wifi className="h-3 w-3 mr-1" />
|
||||
Connected
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-red-400 text-xs">
|
||||
<WifiOff className="h-3 w-3 mr-1" />
|
||||
Disconnected
|
||||
</div>
|
||||
)}
|
||||
{hasRunningScans && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
Scanning
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-2 py-2 text-sm font-medium rounded-md',
|
||||
isActive
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'mr-3 flex-shrink-0 h-5 w-5',
|
||||
isActive
|
||||
? 'text-gray-300'
|
||||
: 'text-gray-400 group-hover:text-gray-300'
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="md:pl-64 flex flex-col flex-1">
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
src/components/ui/badge.tsx
Normal file
40
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
critical: "border-transparent bg-red-500 text-white hover:bg-red-600",
|
||||
high: "border-transparent bg-orange-500 text-white hover:bg-orange-600",
|
||||
medium: "border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
|
||||
low: "border-transparent bg-blue-500 text-white hover:bg-blue-600",
|
||||
unspecified: "border-transparent bg-gray-500 text-white hover:bg-gray-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
113
src/components/ui/table.tsx
Normal file
113
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
27
src/contexts/WebSocketContext.tsx
Normal file
27
src/contexts/WebSocketContext.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
|
||||
interface WebSocketContextType {
|
||||
isConnected: boolean
|
||||
hasRunningScans: boolean
|
||||
}
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
|
||||
|
||||
export function WebSocketProvider({ children }: { children: ReactNode }) {
|
||||
const webSocketState = useWebSocket()
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={webSocketState}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWebSocketContext() {
|
||||
const context = useContext(WebSocketContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useWebSocketContext must be used within a WebSocketProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
116
src/hooks/useWebSocket.ts
Normal file
116
src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface ScanUpdate {
|
||||
type: 'scan_started' | 'scan_completed' | 'scan_failed'
|
||||
timestamp: string
|
||||
data: {
|
||||
job_type: string
|
||||
job_id: number
|
||||
message: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [hasRunningScans, setHasRunningScans] = useState(false)
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const toastIdsRef = useRef<Map<number, string | number>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to WebSocket
|
||||
const socket = io('http://localhost:5000', {
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
|
||||
socketRef.current = socket
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to WebSocket')
|
||||
setIsConnected(true)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from WebSocket')
|
||||
setIsConnected(false)
|
||||
})
|
||||
|
||||
socket.on('connected', (data) => {
|
||||
console.log('WebSocket connection confirmed:', data)
|
||||
})
|
||||
|
||||
socket.on('scan_update', (update: ScanUpdate) => {
|
||||
console.log('Scan update received:', update)
|
||||
handleScanUpdate(update)
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Dismiss all running scan toasts when disconnecting
|
||||
toastIdsRef.current.forEach((toastId) => {
|
||||
toast.dismiss(toastId)
|
||||
})
|
||||
toastIdsRef.current.clear()
|
||||
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleScanUpdate = (update: ScanUpdate) => {
|
||||
const { type, data } = update
|
||||
const { job_type, job_id, message, status } = data
|
||||
|
||||
switch (type) {
|
||||
case 'scan_started':
|
||||
setHasRunningScans(true)
|
||||
|
||||
// Check if we already have a toast for this job (avoid duplicates on reconnect)
|
||||
if (!toastIdsRef.current.has(job_id)) {
|
||||
const loadingToastId = toast.loading(message, {
|
||||
description: `${job_type} scan is running...`,
|
||||
duration: Infinity, // Keep until scan completes
|
||||
})
|
||||
// Store the toast ID for this job
|
||||
toastIdsRef.current.set(job_id, loadingToastId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'scan_completed':
|
||||
setHasRunningScans(false)
|
||||
// Dismiss the loading toast
|
||||
const completedToastId = toastIdsRef.current.get(job_id)
|
||||
if (completedToastId) {
|
||||
toast.dismiss(completedToastId)
|
||||
toastIdsRef.current.delete(job_id)
|
||||
}
|
||||
// Show success toast
|
||||
toast.success(message, {
|
||||
description: `${job_type} scan finished successfully`,
|
||||
duration: 5000,
|
||||
})
|
||||
break
|
||||
|
||||
case 'scan_failed':
|
||||
setHasRunningScans(false)
|
||||
// Dismiss the loading toast
|
||||
const failedToastId = toastIdsRef.current.get(job_id)
|
||||
if (failedToastId) {
|
||||
toast.dismiss(failedToastId)
|
||||
toastIdsRef.current.delete(job_id)
|
||||
}
|
||||
// Show error toast
|
||||
toast.error('Scan Failed', {
|
||||
description: message,
|
||||
duration: 8000,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
hasRunningScans,
|
||||
socket: socketRef.current,
|
||||
}
|
||||
}
|
||||
213
src/lib/api.ts
Normal file
213
src/lib/api.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
const API_BASE_URL = 'http://localhost:5000'
|
||||
|
||||
export interface Project {
|
||||
id: number
|
||||
gitlab_id: number
|
||||
name: string
|
||||
path: string
|
||||
web_url: string
|
||||
last_scanned: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
id: number
|
||||
image_name: string
|
||||
tag: string | null
|
||||
registry: string | null
|
||||
full_image_name: string
|
||||
last_seen: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
usage_count?: number
|
||||
}
|
||||
|
||||
export interface Vulnerability {
|
||||
id: number
|
||||
image_id: number
|
||||
vulnerability_id: string
|
||||
severity: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
cvss_score: string | null
|
||||
published_date: string | null
|
||||
fixed_version: string | null
|
||||
scan_date: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface IgnoreRule {
|
||||
id: number
|
||||
project_id: number | null
|
||||
ignore_type: string
|
||||
target: string
|
||||
reason: string | null
|
||||
created_by: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ScanJob {
|
||||
id: number
|
||||
job_type: string
|
||||
status: string
|
||||
project_id: number | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_projects: number
|
||||
active_projects: number
|
||||
total_images: number
|
||||
active_images: number
|
||||
total_vulnerabilities: number
|
||||
critical_vulnerabilities: number
|
||||
high_vulnerabilities: number
|
||||
medium_vulnerabilities: number
|
||||
low_vulnerabilities: number
|
||||
last_scan: string | null
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
return this.request<DashboardStats>('/dashboard')
|
||||
}
|
||||
|
||||
// Projects
|
||||
async getProjects(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise<Project[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
|
||||
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString())
|
||||
if (params?.active_only !== undefined) searchParams.append('active_only', params.active_only.toString())
|
||||
|
||||
return this.request<Project[]>(`/projects?${searchParams}`)
|
||||
}
|
||||
|
||||
async getProject(id: number): Promise<Project> {
|
||||
return this.request<Project>(`/projects/${id}`)
|
||||
}
|
||||
|
||||
// Images
|
||||
async getImages(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise<Image[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
|
||||
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString())
|
||||
if (params?.active_only !== undefined) searchParams.append('active_only', params.active_only.toString())
|
||||
|
||||
return this.request<Image[]>(`/images?${searchParams}`)
|
||||
}
|
||||
|
||||
async getImage(id: number): Promise<Image> {
|
||||
return this.request<Image>(`/images/${id}`)
|
||||
}
|
||||
|
||||
async getImageVulnerabilities(id: number, params?: { skip?: number; limit?: number }): Promise<Vulnerability[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
|
||||
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString())
|
||||
|
||||
return this.request<Vulnerability[]>(`/images/${id}/vulnerabilities?${searchParams}`)
|
||||
}
|
||||
|
||||
// Vulnerabilities
|
||||
async getVulnerabilities(params?: { skip?: number; limit?: number; severity?: string }): Promise<Vulnerability[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
|
||||
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString())
|
||||
if (params?.severity) searchParams.append('severity', params.severity)
|
||||
|
||||
return this.request<Vulnerability[]>(`/vulnerabilities?${searchParams}`)
|
||||
}
|
||||
|
||||
// Ignore Rules
|
||||
async getIgnoreRules(params?: { ignore_type?: string; project_id?: number }): Promise<IgnoreRule[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.ignore_type) searchParams.append('ignore_type', params.ignore_type)
|
||||
if (params?.project_id !== undefined) searchParams.append('project_id', params.project_id.toString())
|
||||
|
||||
return this.request<IgnoreRule[]>(`/ignore-rules?${searchParams}`)
|
||||
}
|
||||
|
||||
async createIgnoreRule(rule: {
|
||||
ignore_type: string
|
||||
target: string
|
||||
reason?: string
|
||||
created_by?: string
|
||||
project_id?: number
|
||||
}): Promise<IgnoreRule> {
|
||||
return this.request<IgnoreRule>('/ignore-rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(rule),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteIgnoreRule(id: number): Promise<void> {
|
||||
await this.request(`/ignore-rules/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// Scan Jobs
|
||||
async getScanJobs(params?: { skip?: number; limit?: number }): Promise<ScanJob[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
|
||||
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString())
|
||||
|
||||
return this.request<ScanJob[]>(`/scan/jobs?${searchParams}`)
|
||||
}
|
||||
|
||||
async scanProjects(): Promise<{ message: string; job_id: number; status: string }> {
|
||||
return this.request<{ message: string; job_id: number; status: string }>('/scan/projects', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async scanVulnerabilities(): Promise<{ message: string; job_id: number; status: string }> {
|
||||
return this.request<{ message: string; job_id: number; status: string }>('/scan/vulnerabilities', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async getScanJob(id: number): Promise<ScanJob> {
|
||||
return this.request<ScanJob>(`/scan/jobs/${id}`)
|
||||
}
|
||||
|
||||
async getScanStatus(): Promise<{
|
||||
has_running_scans: boolean
|
||||
running_jobs: Array<{
|
||||
id: number
|
||||
job_type: string
|
||||
status: string
|
||||
started_at: string | null
|
||||
created_at: string
|
||||
}>
|
||||
}> {
|
||||
return this.request('/scan/status')
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
@@ -1,10 +1,7 @@
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>,
|
||||
<App />
|
||||
)
|
||||
|
||||
276
src/pages/Dashboard.tsx
Normal file
276
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { apiClient, DashboardStats } from '@/lib/api'
|
||||
import { useWebSocketContext } from '@/contexts/WebSocketContext'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
FolderOpen,
|
||||
Container,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
|
||||
export function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const { isConnected, hasRunningScans } = useWebSocketContext()
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const data = await apiClient.getDashboardStats()
|
||||
setStats(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard stats:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleScanProjects = async () => {
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const response = await apiClient.scanProjects()
|
||||
toast.success('Scan Started', {
|
||||
description: `${response.message}`,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to start project scan:', error)
|
||||
if (error.message.includes('409')) {
|
||||
toast.error('Cannot Start Scan', {
|
||||
description: 'Another scan is already running',
|
||||
})
|
||||
} else {
|
||||
toast.error('Scan Failed', {
|
||||
description: 'Failed to start project scan',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanVulnerabilities = async () => {
|
||||
setIsScanning(true)
|
||||
try {
|
||||
const response = await apiClient.scanVulnerabilities()
|
||||
toast.success('Scan Started', {
|
||||
description: `${response.message}`,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to start vulnerability scan:', error)
|
||||
if (error.message.includes('409')) {
|
||||
toast.error('Cannot Start Scan', {
|
||||
description: 'Another scan is already running',
|
||||
})
|
||||
} else {
|
||||
toast.error('Scan Failed', {
|
||||
description: 'Failed to start vulnerability scan',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="text-center text-gray-500">
|
||||
Failed to load dashboard statistics
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-gray-600">
|
||||
Overview of your GitLab Docker images and vulnerabilities
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={handleScanProjects}
|
||||
disabled={isScanning || hasRunningScans}
|
||||
variant="outline"
|
||||
>
|
||||
{isScanning ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Projects
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleScanVulnerabilities}
|
||||
disabled={isScanning || hasRunningScans}
|
||||
>
|
||||
{isScanning ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Scan Vulnerabilities
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total_projects}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.active_projects} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Docker Images</CardTitle>
|
||||
<Container className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total_images}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.active_images} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Vulnerabilities</CardTitle>
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total_vulnerabilities}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total found
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Critical Issues</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{stats.critical_vulnerabilities}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Require immediate attention
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Vulnerability Breakdown */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vulnerability Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of vulnerabilities by severity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="critical">Critical</Badge>
|
||||
<span className="text-sm">High risk vulnerabilities</span>
|
||||
</div>
|
||||
<span className="font-bold">{stats.critical_vulnerabilities}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="high">High</Badge>
|
||||
<span className="text-sm">Significant security issues</span>
|
||||
</div>
|
||||
<span className="font-bold">{stats.high_vulnerabilities}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="medium">Medium</Badge>
|
||||
<span className="text-sm">Moderate security concerns</span>
|
||||
</div>
|
||||
<span className="font-bold">{stats.medium_vulnerabilities}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="low">Low</Badge>
|
||||
<span className="text-sm">Minor security issues</span>
|
||||
</div>
|
||||
<span className="font-bold">{stats.low_vulnerabilities}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Latest scan information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Last Scan</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.last_scan
|
||||
? new Date(stats.last_scan).toLocaleString()
|
||||
: 'No scans performed yet'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">System Status</p>
|
||||
<p className="text-xs text-green-600">
|
||||
All services operational
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Next Actions</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>• Review critical vulnerabilities</li>
|
||||
<li>• Update images with available fixes</li>
|
||||
<li>• Configure ignore rules for false positives</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
src/pages/IgnoreRules.tsx
Normal file
181
src/pages/IgnoreRules.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { apiClient, IgnoreRule } from '@/lib/api'
|
||||
import { Settings, RefreshCw, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
const ignoreTypeColors = {
|
||||
image: 'default',
|
||||
file: 'secondary',
|
||||
project: 'outline',
|
||||
} as const
|
||||
|
||||
export function IgnoreRules() {
|
||||
const [ignoreRules, setIgnoreRules] = useState<IgnoreRule[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedType, setSelectedType] = useState<string>('')
|
||||
|
||||
const fetchIgnoreRules = async (ignoreType?: string) => {
|
||||
try {
|
||||
const data = await apiClient.getIgnoreRules({
|
||||
ignore_type: ignoreType || undefined
|
||||
})
|
||||
setIgnoreRules(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ignore rules:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRule = async (id: number) => {
|
||||
try {
|
||||
await apiClient.deleteIgnoreRule(id)
|
||||
await fetchIgnoreRules(selectedType)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete ignore rule:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchIgnoreRules(selectedType)
|
||||
}, [selectedType])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Ignore Rules</h1>
|
||||
<p className="text-gray-600">
|
||||
Manage rules to exclude projects, files, or images from scanning
|
||||
</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filter by Type</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setSelectedType('')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
selectedType === ''
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{Object.keys(ignoreTypeColors).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
selectedType === type
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={ignoreTypeColors[type as keyof typeof ignoreTypeColors]}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2" />
|
||||
Active Ignore Rules
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Rules that exclude items from scanning and vulnerability checking
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Created By</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ignoreRules.map((rule) => (
|
||||
<TableRow key={rule.id}>
|
||||
<TableCell>
|
||||
<Badge variant={ignoreTypeColors[rule.ignore_type as keyof typeof ignoreTypeColors]}>
|
||||
{rule.ignore_type.charAt(0).toUpperCase() + rule.ignore_type.slice(1)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-mono text-sm max-w-md break-words">
|
||||
{rule.target}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-md">
|
||||
{rule.reason || (
|
||||
<span className="text-gray-400 italic">No reason provided</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rule.created_by || (
|
||||
<span className="text-gray-400">Unknown</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{new Date(rule.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{ignoreRules.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{selectedType
|
||||
? `No ${selectedType} ignore rules found.`
|
||||
: 'No ignore rules configured. Add rules to exclude items from scanning.'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/pages/Images.tsx
Normal file
115
src/pages/Images.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { apiClient, Image } from '@/lib/api'
|
||||
import { Container, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function Images() {
|
||||
const [images, setImages] = useState<Image[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchImages = async () => {
|
||||
try {
|
||||
const data = await apiClient.getImages()
|
||||
setImages(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch images:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages()
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Docker Images</h1>
|
||||
<p className="text-gray-600">
|
||||
All Docker images discovered across your projects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Container className="h-5 w-5 mr-2" />
|
||||
Image Inventory
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Docker images found in Dockerfiles, docker-compose files, and CI configurations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Image</TableHead>
|
||||
<TableHead>Registry</TableHead>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Usage</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Seen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{images.map((image) => (
|
||||
<TableRow key={image.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
{image.image_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 font-mono">
|
||||
{image.full_image_name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{image.registry ? (
|
||||
<Badge variant="outline">{image.registry}</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">Docker Hub</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{image.tag || 'latest'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{image.usage_count || 0} files
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={image.is_active ? "default" : "secondary"}>
|
||||
{image.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{new Date(image.last_seen).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{images.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No images found. Run a project scan to discover Docker images.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
src/pages/Projects.tsx
Normal file
108
src/pages/Projects.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { apiClient, Project } from '@/lib/api'
|
||||
import { ExternalLink, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function Projects() {
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const data = await apiClient.getProjects()
|
||||
setProjects(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
|
||||
<p className="text-gray-600">
|
||||
GitLab projects being monitored for Docker images
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project List</CardTitle>
|
||||
<CardDescription>
|
||||
All projects discovered in your GitLab instance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Path</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Scanned</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="font-medium">
|
||||
{project.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{project.path}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={project.is_active ? "default" : "secondary"}>
|
||||
{project.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{project.last_scanned
|
||||
? new Date(project.last_scanned).toLocaleDateString()
|
||||
: 'Never'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={project.web_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</a>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No projects found. Run a project scan to discover projects.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
src/pages/ScanJobs.tsx
Normal file
189
src/pages/ScanJobs.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { apiClient, ScanJob } from '@/lib/api'
|
||||
import { useWebSocketContext } from '@/contexts/WebSocketContext'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { Activity, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react'
|
||||
|
||||
const statusColors = {
|
||||
pending: 'secondary',
|
||||
running: 'default',
|
||||
completed: 'outline',
|
||||
failed: 'destructive',
|
||||
} as const
|
||||
|
||||
const statusIcons = {
|
||||
pending: Clock,
|
||||
running: RefreshCw,
|
||||
completed: CheckCircle,
|
||||
failed: XCircle,
|
||||
}
|
||||
|
||||
interface ScanUpdate {
|
||||
type: 'scan_started' | 'scan_completed' | 'scan_failed'
|
||||
timestamp: string
|
||||
data: {
|
||||
job_type: string
|
||||
job_id: number
|
||||
message: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
export function ScanJobs() {
|
||||
const [scanJobs, setScanJobs] = useState<ScanJob[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
|
||||
const fetchScanJobs = async () => {
|
||||
try {
|
||||
const data = await apiClient.getScanJobs({ limit: 50 })
|
||||
setScanJobs(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch scan jobs:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchScanJobs()
|
||||
|
||||
// Set up WebSocket connection to listen for scan events
|
||||
const socket = io('http://localhost:5000', {
|
||||
transports: ['websocket', 'polling'],
|
||||
})
|
||||
|
||||
socketRef.current = socket
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('ScanJobs: Connected to WebSocket')
|
||||
})
|
||||
|
||||
socket.on('scan_update', (update: ScanUpdate) => {
|
||||
console.log('ScanJobs: Scan update received:', update)
|
||||
// Refetch scan jobs when any scan event occurs
|
||||
fetchScanJobs()
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('ScanJobs: Disconnected from WebSocket')
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getDuration = (job: ScanJob): string => {
|
||||
if (!job.started_at) return 'N/A'
|
||||
|
||||
const start = new Date(job.started_at)
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date()
|
||||
const duration = Math.round((end.getTime() - start.getTime()) / 1000)
|
||||
|
||||
if (duration < 60) return `${duration}s`
|
||||
if (duration < 3600) return `${Math.round(duration / 60)}m`
|
||||
return `${Math.round(duration / 3600)}h`
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Scan Jobs</h1>
|
||||
<p className="text-gray-600">
|
||||
History and status of scanning operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
Job History
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recent project discovery and vulnerability scanning jobs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Job ID</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scanJobs.map((job) => {
|
||||
const StatusIcon = statusIcons[job.status as keyof typeof statusIcons]
|
||||
return (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>
|
||||
<div className="font-mono text-sm">#{job.id}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{job.job_type === 'discovery' ? 'Project Discovery' : 'Vulnerability Scan'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<StatusIcon
|
||||
className={`h-4 w-4 ${
|
||||
job.status === 'running' ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
<Badge variant={statusColors[job.status as keyof typeof statusColors]}>
|
||||
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{job.started_at
|
||||
? new Date(job.started_at).toLocaleString()
|
||||
: 'Not started'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{getDuration(job)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{job.error_message ? (
|
||||
<div className="max-w-md text-sm text-red-600 truncate" title={job.error_message}>
|
||||
{job.error_message}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{scanJobs.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No scan jobs found. Start a scan to see job history.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
src/pages/Vulnerabilities.tsx
Normal file
177
src/pages/Vulnerabilities.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { apiClient, Vulnerability } from '@/lib/api'
|
||||
import { Shield, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
|
||||
const severityColors = {
|
||||
critical: 'critical',
|
||||
high: 'high',
|
||||
medium: 'medium',
|
||||
low: 'low',
|
||||
unspecified: 'unspecified',
|
||||
} as const
|
||||
|
||||
export function Vulnerabilities() {
|
||||
const [vulnerabilities, setVulnerabilities] = useState<Vulnerability[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedSeverity, setSelectedSeverity] = useState<string>('')
|
||||
|
||||
const fetchVulnerabilities = async (severity?: string) => {
|
||||
try {
|
||||
const data = await apiClient.getVulnerabilities({
|
||||
severity: severity || undefined,
|
||||
limit: 100
|
||||
})
|
||||
setVulnerabilities(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch vulnerabilities:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchVulnerabilities(selectedSeverity)
|
||||
}, [selectedSeverity])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Vulnerabilities</h1>
|
||||
<p className="text-gray-600">
|
||||
Security vulnerabilities discovered in Docker images
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filter by Severity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setSelectedSeverity('')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
selectedSeverity === ''
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{Object.keys(severityColors).map((severity) => (
|
||||
<button
|
||||
key={severity}
|
||||
onClick={() => setSelectedSeverity(severity)}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
selectedSeverity === severity
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={severityColors[severity as keyof typeof severityColors]}>
|
||||
{severity.charAt(0).toUpperCase() + severity.slice(1)}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Shield className="h-5 w-5 mr-2" />
|
||||
Vulnerability Report
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Security issues found in your Docker images
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vulnerability ID</TableHead>
|
||||
<TableHead>Severity</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>CVSS Score</TableHead>
|
||||
<TableHead>Fixed Version</TableHead>
|
||||
<TableHead>Scan Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vulnerabilities.map((vuln) => (
|
||||
<TableRow key={vuln.id}>
|
||||
<TableCell>
|
||||
<div className="font-mono text-sm">{vuln.vulnerability_id}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={severityColors[vuln.severity as keyof typeof severityColors]}>
|
||||
{vuln.severity.charAt(0).toUpperCase() + vuln.severity.slice(1)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-md">
|
||||
<div className="font-medium truncate">
|
||||
{vuln.title || 'No title available'}
|
||||
</div>
|
||||
{vuln.description && (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{vuln.description.slice(0, 100)}...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{vuln.cvss_score ? (
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{vuln.cvss_score}</span>
|
||||
{parseFloat(vuln.cvss_score) >= 7 && (
|
||||
<AlertTriangle className="h-4 w-4 ml-1 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">N/A</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{vuln.fixed_version ? (
|
||||
<code className="text-sm bg-green-100 text-green-800 px-1 rounded">
|
||||
{vuln.fixed_version}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-gray-400">No fix available</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{new Date(vuln.scan_date).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{vulnerabilities.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{selectedSeverity
|
||||
? `No ${selectedSeverity} vulnerabilities found.`
|
||||
: 'No vulnerabilities found. Run a vulnerability scan to check for security issues.'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user