From 181b3e2878fc80adda2b2eeb8b360f6becedd4e7 Mon Sep 17 00:00:00 2001 From: JSC Date: Thu, 10 Jul 2025 22:57:22 +0200 Subject: [PATCH] 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. --- bun.lock | 139 +++++++++++++++ package.json | 15 +- src/App.tsx | 34 +++- src/components/Layout.tsx | 113 ++++++++++++ src/components/ui/badge.tsx | 40 +++++ src/components/ui/card.tsx | 78 +++++++++ src/components/ui/table.tsx | 113 ++++++++++++ src/contexts/WebSocketContext.tsx | 27 +++ src/hooks/useWebSocket.ts | 116 +++++++++++++ src/lib/api.ts | 213 +++++++++++++++++++++++ src/main.tsx | 5 +- src/pages/Dashboard.tsx | 276 ++++++++++++++++++++++++++++++ src/pages/IgnoreRules.tsx | 181 ++++++++++++++++++++ src/pages/Images.tsx | 115 +++++++++++++ src/pages/Projects.tsx | 108 ++++++++++++ src/pages/ScanJobs.tsx | 189 ++++++++++++++++++++ src/pages/Vulnerabilities.tsx | 177 +++++++++++++++++++ 17 files changed, 1931 insertions(+), 8 deletions(-) create mode 100644 src/components/Layout.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/contexts/WebSocketContext.tsx create mode 100644 src/hooks/useWebSocket.ts create mode 100644 src/lib/api.ts create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/IgnoreRules.tsx create mode 100644 src/pages/Images.tsx create mode 100644 src/pages/Projects.tsx create mode 100644 src/pages/ScanJobs.tsx create mode 100644 src/pages/Vulnerabilities.tsx diff --git a/bun.lock b/bun.lock index f32c378..c18840d 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 63141ad..fb77f04 100644 --- a/package.json +++ b/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", diff --git a/src/App.tsx b/src/App.tsx index 442b0d9..8c94709 100644 --- a/src/App.tsx +++ b/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 ( - <> -
App
- + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..9784963 --- /dev/null +++ b/src/components/Layout.tsx @@ -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 ( +
+
+ {/* Sidebar */} +
+
+
+
+

+ GitLab Docker Tracker +

+
+
+ {isConnected ? ( +
+ + Connected +
+ ) : ( +
+ + Disconnected +
+ )} + {hasRunningScans && ( + + + Scanning + + )} +
+
+
+ +
+
+
+ + {/* Main content */} +
+
+
+
+ {children} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..2dfb621 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..8fce582 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,78 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..80980cb --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,113 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", className)} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} \ No newline at end of file diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx new file mode 100644 index 0000000..11a5ab8 --- /dev/null +++ b/src/contexts/WebSocketContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, ReactNode } from 'react' +import { useWebSocket } from '@/hooks/useWebSocket' + +interface WebSocketContextType { + isConnected: boolean + hasRunningScans: boolean +} + +const WebSocketContext = createContext(undefined) + +export function WebSocketProvider({ children }: { children: ReactNode }) { + const webSocketState = useWebSocket() + + return ( + + {children} + + ) +} + +export function useWebSocketContext() { + const context = useContext(WebSocketContext) + if (context === undefined) { + throw new Error('useWebSocketContext must be used within a WebSocketProvider') + } + return context +} \ No newline at end of file diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..2bf2691 --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -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(null) + const toastIdsRef = useRef>(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, + } +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..631c6b3 --- /dev/null +++ b/src/lib/api.ts @@ -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(endpoint: string, options: RequestInit = {}): Promise { + 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 { + return this.request('/dashboard') + } + + // Projects + async getProjects(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise { + 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(`/projects?${searchParams}`) + } + + async getProject(id: number): Promise { + return this.request(`/projects/${id}`) + } + + // Images + async getImages(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise { + 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(`/images?${searchParams}`) + } + + async getImage(id: number): Promise { + return this.request(`/images/${id}`) + } + + async getImageVulnerabilities(id: number, params?: { skip?: number; limit?: number }): Promise { + 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(`/images/${id}/vulnerabilities?${searchParams}`) + } + + // Vulnerabilities + async getVulnerabilities(params?: { skip?: number; limit?: number; severity?: string }): Promise { + 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(`/vulnerabilities?${searchParams}`) + } + + // Ignore Rules + async getIgnoreRules(params?: { ignore_type?: string; project_id?: number }): Promise { + 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(`/ignore-rules?${searchParams}`) + } + + async createIgnoreRule(rule: { + ignore_type: string + target: string + reason?: string + created_by?: string + project_id?: number + }): Promise { + return this.request('/ignore-rules', { + method: 'POST', + body: JSON.stringify(rule), + }) + } + + async deleteIgnoreRule(id: number): Promise { + await this.request(`/ignore-rules/${id}`, { + method: 'DELETE', + }) + } + + // Scan Jobs + async getScanJobs(params?: { skip?: number; limit?: number }): Promise { + 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(`/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 { + return this.request(`/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() \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 0f63f20..a757447 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - - - , + ) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..790c15d --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -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(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 ( +
+ +
+ ) + } + + if (!stats) { + return ( +
+ Failed to load dashboard statistics +
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Dashboard

+

+ Overview of your GitLab Docker images and vulnerabilities +

+
+
+ + +
+
+ + {/* Stats Cards */} +
+ + + Total Projects + + + +
{stats.total_projects}
+

+ {stats.active_projects} active +

+
+
+ + + + Docker Images + + + +
{stats.total_images}
+

+ {stats.active_images} active +

+
+
+ + + + Vulnerabilities + + + +
{stats.total_vulnerabilities}
+

+ Total found +

+
+
+ + + + Critical Issues + + + +
+ {stats.critical_vulnerabilities} +
+

+ Require immediate attention +

+
+
+
+ + {/* Vulnerability Breakdown */} +
+ + + Vulnerability Breakdown + + Distribution of vulnerabilities by severity + + + +
+
+
+ Critical + High risk vulnerabilities +
+ {stats.critical_vulnerabilities} +
+
+
+ High + Significant security issues +
+ {stats.high_vulnerabilities} +
+
+
+ Medium + Moderate security concerns +
+ {stats.medium_vulnerabilities} +
+
+
+ Low + Minor security issues +
+ {stats.low_vulnerabilities} +
+
+
+
+ + + + Recent Activity + + Latest scan information + + + +
+
+

Last Scan

+

+ {stats.last_scan + ? new Date(stats.last_scan).toLocaleString() + : 'No scans performed yet' + } +

+
+
+

System Status

+

+ All services operational +

+
+
+

Next Actions

+
    +
  • • Review critical vulnerabilities
  • +
  • • Update images with available fixes
  • +
  • • Configure ignore rules for false positives
  • +
+
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/IgnoreRules.tsx b/src/pages/IgnoreRules.tsx new file mode 100644 index 0000000..8eb8a59 --- /dev/null +++ b/src/pages/IgnoreRules.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [selectedType, setSelectedType] = useState('') + + 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 ( +
+ +
+ ) + } + + return ( +
+
+
+

Ignore Rules

+

+ Manage rules to exclude projects, files, or images from scanning +

+
+ +
+ + {/* Type Filter */} + + + Filter by Type + + +
+ + {Object.keys(ignoreTypeColors).map((type) => ( + + ))} +
+
+
+ + + + + + Active Ignore Rules + + + Rules that exclude items from scanning and vulnerability checking + + + + + + + Type + Target + Reason + Created By + Created + Actions + + + + {ignoreRules.map((rule) => ( + + + + {rule.ignore_type.charAt(0).toUpperCase() + rule.ignore_type.slice(1)} + + + +
+ {rule.target} +
+
+ +
+ {rule.reason || ( + No reason provided + )} +
+
+ + {rule.created_by || ( + Unknown + )} + + + {new Date(rule.created_at).toLocaleDateString()} + + + + +
+ ))} +
+
+ {ignoreRules.length === 0 && ( +
+ {selectedType + ? `No ${selectedType} ignore rules found.` + : 'No ignore rules configured. Add rules to exclude items from scanning.' + } +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/Images.tsx b/src/pages/Images.tsx new file mode 100644 index 0000000..a70020b --- /dev/null +++ b/src/pages/Images.tsx @@ -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([]) + 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 ( +
+ +
+ ) + } + + return ( +
+
+

Docker Images

+

+ All Docker images discovered across your projects +

+
+ + + + + + Image Inventory + + + Docker images found in Dockerfiles, docker-compose files, and CI configurations + + + + + + + Image + Registry + Tag + Usage + Status + Last Seen + + + + {images.map((image) => ( + + +
+ {image.image_name} +
+
+ {image.full_image_name} +
+
+ + {image.registry ? ( + {image.registry} + ) : ( + Docker Hub + )} + + + + {image.tag || 'latest'} + + + + + {image.usage_count || 0} files + + + + + {image.is_active ? "Active" : "Inactive"} + + + + {new Date(image.last_seen).toLocaleDateString()} + +
+ ))} +
+
+ {images.length === 0 && ( +
+ No images found. Run a project scan to discover Docker images. +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx new file mode 100644 index 0000000..6c2d185 --- /dev/null +++ b/src/pages/Projects.tsx @@ -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([]) + 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 ( +
+ +
+ ) + } + + return ( +
+
+

Projects

+

+ GitLab projects being monitored for Docker images +

+
+ + + + Project List + + All projects discovered in your GitLab instance + + + + + + + Name + Path + Status + Last Scanned + Actions + + + + {projects.map((project) => ( + + + {project.name} + + + + {project.path} + + + + + {project.is_active ? "Active" : "Inactive"} + + + + {project.last_scanned + ? new Date(project.last_scanned).toLocaleDateString() + : 'Never' + } + + + + + View + + + + ))} + +
+ {projects.length === 0 && ( +
+ No projects found. Run a project scan to discover projects. +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/ScanJobs.tsx b/src/pages/ScanJobs.tsx new file mode 100644 index 0000000..2ba3823 --- /dev/null +++ b/src/pages/ScanJobs.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const socketRef = useRef(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 ( +
+ +
+ ) + } + + return ( +
+
+

Scan Jobs

+

+ History and status of scanning operations +

+
+ + + + + + Job History + + + Recent project discovery and vulnerability scanning jobs + + + + + + + Job ID + Type + Status + Started + Duration + Error + + + + {scanJobs.map((job) => { + const StatusIcon = statusIcons[job.status as keyof typeof statusIcons] + return ( + + +
#{job.id}
+
+ + + {job.job_type === 'discovery' ? 'Project Discovery' : 'Vulnerability Scan'} + + + +
+ + + {job.status.charAt(0).toUpperCase() + job.status.slice(1)} + +
+
+ + {job.started_at + ? new Date(job.started_at).toLocaleString() + : 'Not started' + } + + + {getDuration(job)} + + + {job.error_message ? ( +
+ {job.error_message} +
+ ) : ( + - + )} +
+
+ ) + })} +
+
+ {scanJobs.length === 0 && ( +
+ No scan jobs found. Start a scan to see job history. +
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/Vulnerabilities.tsx b/src/pages/Vulnerabilities.tsx new file mode 100644 index 0000000..e684aa0 --- /dev/null +++ b/src/pages/Vulnerabilities.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [selectedSeverity, setSelectedSeverity] = useState('') + + 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 ( +
+ +
+ ) + } + + return ( +
+
+

Vulnerabilities

+

+ Security vulnerabilities discovered in Docker images +

+
+ + {/* Severity Filter */} + + + Filter by Severity + + +
+ + {Object.keys(severityColors).map((severity) => ( + + ))} +
+
+
+ + + + + + Vulnerability Report + + + Security issues found in your Docker images + + + + + + + Vulnerability ID + Severity + Title + CVSS Score + Fixed Version + Scan Date + + + + {vulnerabilities.map((vuln) => ( + + +
{vuln.vulnerability_id}
+
+ + + {vuln.severity.charAt(0).toUpperCase() + vuln.severity.slice(1)} + + + +
+
+ {vuln.title || 'No title available'} +
+ {vuln.description && ( +
+ {vuln.description.slice(0, 100)}... +
+ )} +
+
+ + {vuln.cvss_score ? ( +
+ {vuln.cvss_score} + {parseFloat(vuln.cvss_score) >= 7 && ( + + )} +
+ ) : ( + N/A + )} +
+ + {vuln.fixed_version ? ( + + {vuln.fixed_version} + + ) : ( + No fix available + )} + + + {new Date(vuln.scan_date).toLocaleDateString()} + +
+ ))} +
+
+ {vulnerabilities.length === 0 && ( +
+ {selectedSeverity + ? `No ${selectedSeverity} vulnerabilities found.` + : 'No vulnerabilities found. Run a vulnerability scan to check for security issues.' + } +
+ )} +
+
+
+ ) +} \ No newline at end of file