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:
JSC
2025-07-10 22:57:22 +02:00
parent 8fe6ee937b
commit 181b3e2878
17 changed files with 1931 additions and 8 deletions

139
bun.lock
View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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
View 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>
)
}

View 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 }

View 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
View 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,
}

View 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
View 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
View 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()

View File

@@ -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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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>
)
}