diff --git a/.gitignore b/.gitignore index aaadf73..853bdb7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,36 @@ go.work.sum # env file .env -# Editor/IDE -# .idea/ -# .vscode/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env* +!config.yaml.example +docker-compose.*.yml + +data/ +meta/ +garage.toml + +docs/ \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..fc6dee6 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,171 @@ +# Garage UI - Frontend + +Modern React frontend for Garage S3-compatible object storage management. + +## Features + +- **Dashboard** - Overview of storage metrics and recent activity +- **Bucket Management** - Create, view, and manage S3 buckets +- **Object Browser** - Navigate folders, upload/download files with drag-and-drop +- **Access Control** - Manage API keys and permissions +- **Dark/Light Mode** - System-aware theme with manual toggle +- **Data-Dense UI** - Optimized for power users with comprehensive tables + +## Tech Stack + +- **Framework**: React 19 + TypeScript +- **Build Tool**: Vite 7 +- **Routing**: React Router v6 +- **State Management**: Zustand +- **UI Components**: shadcn/ui (built on Radix UI primitives) +- **Styling**: Tailwind CSS v4 +- **Forms**: React Hook Form + Zod +- **Tables**: TanStack Table +- **File Upload**: React Dropzone +- **Icons**: Lucide React + +## Getting Started + +### Prerequisites + +- Node.js 18+ and npm + +### Installation + +1. Install dependencies: +```bash +npm install +``` + +2. Create environment file: +```bash +cp .env.example .env +``` + +3. Update `.env` with your backend API URL: +``` +VITE_API_URL=http://localhost:8080/api +``` + +### Development + +Start the development server: +```bash +npm run dev +``` + +The app will be available at http://localhost:3000 + +### Building for Production + +Build the application: +```bash +npm run build +``` + +Preview the production build: +```bash +npm run preview +``` + +## Project Structure + +``` +src/ +├── components/ +│ ├── ui/ # shadcn/ui base components +│ ├── layout/ # Layout components (Sidebar, Header) +│ ├── buckets/ # Bucket-specific components +│ ├── objects/ # Object browser components +│ └── access/ # Access control components +├── pages/ # Page components +│ ├── Dashboard.tsx +│ ├── Buckets.tsx +│ ├── Objects.tsx +│ └── AccessControl.tsx +├── lib/ +│ ├── api.ts # API client with mock data +│ └── utils.ts # Utility functions +├── hooks/ # Custom React hooks +├── types/ # TypeScript type definitions +└── stores/ # Zustand stores +``` + +## API Integration + +The app is currently using mock data. To connect to your Go backend: + +1. Ensure your backend is running on `http://localhost:8080` +2. In `src/lib/api.ts`, uncomment the real API calls +3. Comment out or remove the mock data returns + +Example: +```typescript +// Before (mock data) +list: async (): Promise => { + return mockBuckets; +}, + +// After (real API) +list: async (): Promise => { + const response = await api.get('/buckets'); + return response.data; +}, +``` + +## Features by Page + +### Dashboard +- Storage metrics overview +- Bucket count and object statistics +- Storage distribution visualization +- Recent buckets list + +### Buckets +- List all buckets with search +- Create new buckets +- View bucket details +- Delete buckets +- Region information + +### Objects +- Browse objects and folders +- Breadcrumb navigation +- Drag-and-drop file upload +- Multiple file selection +- Download objects +- Delete objects +- Object metadata + +### Access Control +- API key management +- Create keys with permissions +- Activate/deactivate keys +- Copy access key IDs +- Permission configuration +- Bucket policies (coming soon) + +## Theme Customization + +The app uses CSS variables for theming. Customize colors in `src/index.css`: + +```css +:root { + --primary: 240 5.9% 10%; + --secondary: 240 4.8% 95.9%; + /* ... more variables */ +} +``` + +## Contributing + +This is a custom frontend for Garage storage. Feel free to extend with additional features: +- Analytics dashboards +- Versioning management +- Lifecycle policies UI +- Replication configuration +- Metrics visualization + +## License + +MIT diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f49ebcf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Garage UI + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..84686bb --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5532 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.10", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.554.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", + "react-hook-form": "^7.66.1", + "react-router-dom": "^7.9.6", + "recharts": "^3.5.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "zod": "^4.1.13", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz", + "integrity": "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.0.tgz", + "integrity": "sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", + "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz", + "integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", + "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", + "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/type-utils": "8.47.0", + "@typescript-eslint/utils": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.47.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", + "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", + "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.47.0", + "@typescript-eslint/types": "^8.47.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", + "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", + "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", + "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/utils": "8.47.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", + "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", + "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.47.0", + "@typescript-eslint/tsconfig-utils": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", + "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", + "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.259", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", + "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-perf": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-perf/-/eslint-plugin-react-perf-3.3.3.tgz", + "integrity": "sha512-EzPdxsRJg5IllCAH9ny/3nK7sv9251tvKmi/d3Ouv5KzI8TB3zNhzScxL9wnh9Hvv8GYC5LEtzTauynfOEYiAw==", + "license": "MIT", + "engines": { + "node": ">=6.9.1" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.554.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz", + "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.66.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz", + "integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", + "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.0.tgz", + "integrity": "sha512-jWqBtu8L3VICXWa3g/y+bKjL8DDHSRme7DHD/70LQ/Tk0di1h11Y0kKC0nPh6YJ2oaa0k6anIFNhg6SfzHWdEA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eslint-plugin-react-perf": "^3.3.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz", + "integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.47.0", + "@typescript-eslint/parser": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/utils": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cd92ee0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.10", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.554.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", + "react-hook-form": "^7.66.1", + "react-router-dom": "^7.9.6", + "recharts": "^3.5.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "zod": "^4.1.13", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..a7f73a2 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/frontend/public/garage.png b/frontend/public/garage.png new file mode 100644 index 0000000..c68e11e Binary files /dev/null and b/frontend/public/garage.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..457a320 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,38 @@ +import {BrowserRouter, Route, Routes} from 'react-router-dom'; +import {QueryClientProvider} from '@tanstack/react-query'; +import {ThemeProvider, useTheme} from '@/components/theme-provider'; +import {Layout} from '@/components/layout/layout'; +import {Dashboard} from '@/pages/Dashboard'; +import {Buckets} from '@/pages/Buckets'; +import {Cluster} from '@/pages/Cluster'; +import {AccessControl} from '@/pages/AccessControl'; +import {Toaster} from 'sonner'; +import {queryClient} from '@/lib/query-client'; + +function ThemedToaster() { + const { theme } = useTheme(); + + return ; +} + +function App() { + return ( + + + + + }> + } /> + } /> + } /> + } /> + + + + + + + ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/buckets/BucketListView.tsx b/frontend/src/components/buckets/BucketListView.tsx new file mode 100644 index 0000000..c217173 --- /dev/null +++ b/frontend/src/components/buckets/BucketListView.tsx @@ -0,0 +1,147 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react'; +import { formatBytes, formatDate } from '@/lib/utils'; +import type { Bucket } from '@/types'; + +interface BucketListViewProps { + buckets: Bucket[]; + searchQuery: string; + isLoading?: boolean; + onSearchChange: (query: string) => void; + onViewBucket: (bucketName: string) => void; + onOpenSettings: (bucket: Bucket) => void; + onCreateBucket: () => void; + onDeleteBucket: (bucket: Bucket) => void; +} + +export function BucketListView({ + buckets, + searchQuery, + isLoading = false, + onSearchChange, + onViewBucket, + onOpenSettings, + onCreateBucket, + onDeleteBucket, +}: BucketListViewProps) { + const filteredBuckets = buckets.filter((bucket) => + bucket.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+ {/* Toolbar */} +
+
+ + onSearchChange(e.target.value)} + className="pl-8" + /> +
+ +
+ + {/* Buckets Table */} +
+ + + + Name + Region + Objects + Size + Created + + + + + {isLoading ? ( + + +
+ + Loading buckets... +
+
+
+ ) : filteredBuckets.length === 0 ? ( + + + {searchQuery ? 'No buckets found matching your search' : 'No buckets yet'} + + + ) : ( + filteredBuckets.map((bucket) => ( + onViewBucket(bucket.name)} + > + {bucket.name} + + {bucket.region || 'default'} + + {bucket.objectCount?.toLocaleString() || 0} + {bucket.size ? formatBytes(bucket.size) : '0 B'} + {formatDate(bucket.creationDate)} + + + e.stopPropagation()}> + + + + { + e.stopPropagation(); + onViewBucket(bucket.name); + }}> + + View Objects + + { + e.stopPropagation(); + onOpenSettings(bucket); + }}> + + Settings + + + { + e.stopPropagation(); + onDeleteBucket(bucket); + }} + > + + Delete + + + + + + )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/buckets/BucketSettingsDialog.tsx b/frontend/src/components/buckets/BucketSettingsDialog.tsx new file mode 100644 index 0000000..7712313 --- /dev/null +++ b/frontend/src/components/buckets/BucketSettingsDialog.tsx @@ -0,0 +1,206 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Select, SelectOption } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { accessApi } from '@/lib/api'; +import type { AccessKey, Bucket } from '@/types'; +import { toast } from 'sonner'; + +interface BucketSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bucket: Bucket | null; + onGrantPermission: (bucketName: string, accessKeyId: string, permissions: { read: boolean; write: boolean; owner: boolean }) => Promise; +} + +export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) { + const [availableKeys, setAvailableKeys] = useState([]); + const [selectedAccessKey, setSelectedAccessKey] = useState(''); + const [permissionRead, setPermissionRead] = useState(false); + const [permissionWrite, setPermissionWrite] = useState(false); + const [permissionOwner, setPermissionOwner] = useState(false); + + useEffect(() => { + if (open && bucket) { + loadAccessKeys(); + resetForm(); + } + }, [open, bucket]); + + const loadAccessKeys = async () => { + try { + const keys = await accessApi.listKeys(); + setAvailableKeys(keys); + } catch (error) { + console.error('Failed to load access keys:', error); + } + }; + + const resetForm = () => { + setSelectedAccessKey(''); + setPermissionRead(false); + setPermissionWrite(false); + setPermissionOwner(false); + }; + + const handleAccessKeyChange = (accessKeyId: string) => { + setSelectedAccessKey(accessKeyId); + + if (!accessKeyId) { + setPermissionRead(false); + setPermissionWrite(false); + setPermissionOwner(false); + return; + } + + const selectedKey = availableKeys.find(key => key.accessKeyId === accessKeyId); + if (selectedKey && bucket) { + const bucketPermission = selectedKey.permissions.find( + perm => perm.bucketName === bucket.name || perm.bucketId === bucket.name + ); + + if (bucketPermission) { + setPermissionRead(bucketPermission.read); + setPermissionWrite(bucketPermission.write); + setPermissionOwner(bucketPermission.owner); + } else { + setPermissionRead(false); + setPermissionWrite(false); + setPermissionOwner(false); + } + } + }; + + const handleGrantPermission = async () => { + if (!bucket || !selectedAccessKey) { + toast.error('Please select an access key'); + return; + } + + if (!permissionRead && !permissionWrite && !permissionOwner) { + toast.error('Please select at least one permission'); + return; + } + + const success = await onGrantPermission(bucket.name, selectedAccessKey, { + read: permissionRead, + write: permissionWrite, + owner: permissionOwner, + }); + + if (success) { + resetForm(); + onOpenChange(false); + } + }; + + return ( + + + + Bucket Settings - {bucket?.name} + + Grant access key permissions for this bucket + + +
+
+ + +

+ Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected. +

+
+ +
+ +
+
+ setPermissionRead(checked as boolean)} + /> +
+ +

+ Allows reading objects from the bucket (GetObject, HeadObject, ListObjects) +

+
+
+ +
+ setPermissionWrite(checked as boolean)} + /> +
+ +

+ Allows writing and deleting objects in the bucket (PutObject, DeleteObject) +

+
+
+ +
+ setPermissionOwner(checked as boolean)} + /> +
+ +

+ Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy) +

+
+
+
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/buckets/CreateBucketDialog.tsx b/frontend/src/components/buckets/CreateBucketDialog.tsx new file mode 100644 index 0000000..6084cb3 --- /dev/null +++ b/frontend/src/components/buckets/CreateBucketDialog.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from 'sonner'; + +interface CreateBucketDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreateBucket: (name: string) => Promise; +} + +export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) { + const [bucketName, setBucketName] = useState(''); + + const handleCreate = async () => { + if (!bucketName) { + toast.error('Please enter a bucket name'); + return; + } + + const success = await onCreateBucket(bucketName); + if (success) { + setBucketName(''); + onOpenChange(false); + } + }; + + return ( + + + + Create New Bucket + + Create a new storage bucket for your objects + + +
+
+ + setBucketName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreate(); + } + }} + /> +

+ Must be unique and follow DNS naming conventions +

+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/buckets/CreateDirectoryDialog.tsx b/frontend/src/components/buckets/CreateDirectoryDialog.tsx new file mode 100644 index 0000000..cc1a61f --- /dev/null +++ b/frontend/src/components/buckets/CreateDirectoryDialog.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { toast } from 'sonner'; + +interface CreateDirectoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentPath: string; + onCreateDirectory: (name: string) => Promise; +} + +export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) { + const [dirName, setDirName] = useState(''); + + const handleCreate = async () => { + if (!dirName) { + toast.error('Please enter a directory name'); + return; + } + + const success = await onCreateDirectory(dirName); + if (success) { + setDirName(''); + onOpenChange(false); + } + }; + + return ( + + + + Create Directory + + Create a new directory in {currentPath || 'the root'} + + +
+
+ + setDirName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreate(); + } + }} + /> +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/buckets/DeleteBucketDialog.tsx b/frontend/src/components/buckets/DeleteBucketDialog.tsx new file mode 100644 index 0000000..af519ad --- /dev/null +++ b/frontend/src/components/buckets/DeleteBucketDialog.tsx @@ -0,0 +1,49 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import type { Bucket } from '@/types'; + +interface DeleteBucketDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bucket: Bucket | null; + onDeleteBucket: (name: string) => Promise; +} + +export function DeleteBucketDialog({ open, onOpenChange, bucket, onDeleteBucket }: DeleteBucketDialogProps) { + const handleDelete = async () => { + if (!bucket) return; + + const success = await onDeleteBucket(bucket.name); + if (success) { + onOpenChange(false); + } + }; + + return ( + + + + Delete Bucket + + Are you sure you want to delete "{bucket?.name}"? This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/components/buckets/DeleteObjectDialog.tsx b/frontend/src/components/buckets/DeleteObjectDialog.tsx new file mode 100644 index 0000000..7fa4179 --- /dev/null +++ b/frontend/src/components/buckets/DeleteObjectDialog.tsx @@ -0,0 +1,49 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import type { S3Object } from '@/types'; + +interface DeleteObjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + object: S3Object | null; + onDeleteObject: (key: string) => Promise; +} + +export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject }: DeleteObjectDialogProps) { + const handleDelete = async () => { + if (!object) return; + + const success = await onDeleteObject(object.key); + if (success) { + onOpenChange(false); + } + }; + + return ( + + + + Delete Object + + Are you sure you want to delete "{object?.key}"? This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/components/buckets/ObjectBrowserView.tsx b/frontend/src/components/buckets/ObjectBrowserView.tsx new file mode 100644 index 0000000..fb2a138 --- /dev/null +++ b/frontend/src/components/buckets/ObjectBrowserView.tsx @@ -0,0 +1,386 @@ +import { useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Header } from '@/components/layout/header'; +import { ObjectsTable } from './ObjectsTable'; +import { CreateDirectoryDialog } from './CreateDirectoryDialog'; +import { DeleteObjectDialog } from './DeleteObjectDialog'; +import { ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload } from 'lucide-react'; +import { getBreadcrumbs } from '@/lib/file-utils'; +import type { S3Object } from '@/types'; + +interface ObjectBrowserViewProps { + bucketName: string; + objects: S3Object[]; + currentPath: string; + searchQuery: string; + isLoading?: boolean; + isTruncated?: boolean; + nextContinuationToken?: string; + itemsPerPage: number; + onSearchChange: (query: string) => void; + onNavigateToFolder: (path: string) => void; + onBackToBuckets: () => void; + onUploadFiles: (files: File[]) => Promise; + onDeleteObject: (key: string) => Promise; + onDeleteMultipleObjects: (keys: string[]) => Promise; + onCreateDirectory: (name: string) => Promise; + onRefresh: () => Promise; + onPageChange: (token?: string) => void; + onItemsPerPageChange: (count: number) => void; + isRefreshing: boolean; + isNavigating: boolean; + initialPageToken?: string; + initialItemsPerPage?: number; +} + +export function ObjectBrowserView({ + bucketName, + objects, + currentPath, + searchQuery, + isLoading = false, + isTruncated = false, + nextContinuationToken, + itemsPerPage, + onSearchChange, + onNavigateToFolder, + onBackToBuckets, + onUploadFiles, + onDeleteObject, + onDeleteMultipleObjects, + onCreateDirectory, + onRefresh, + onPageChange, + onItemsPerPageChange, + isRefreshing, + isNavigating, + initialPageToken, + initialItemsPerPage, +}: ObjectBrowserViewProps) { + const [showUploadZone, setShowUploadZone] = useState(false); + const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false); + const [selectedObject, setSelectedObject] = useState(null); + const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false); + const [selectedFileKeys, setSelectedFileKeys] = useState>(new Set()); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop: async (acceptedFiles, fileRejections, event) => { + // Get files with their full paths from DataTransferItems API + const filesWithPaths: File[] = []; + + if (event.dataTransfer?.items) { + // Use DataTransferItemList API to preserve folder structure + const items = Array.from(event.dataTransfer.items); + await Promise.all(items.map(async (item) => { + if (item.kind === 'file') { + const entry = item.webkitGetAsEntry?.(); + if (entry) { + await traverseFileTree(entry, '', filesWithPaths); + } + } + })); + } else { + // Fallback to standard files + filesWithPaths.push(...acceptedFiles); + } + + await onUploadFiles(filesWithPaths.length > 0 ? filesWithPaths : acceptedFiles); + setShowUploadZone(false); + }, + noClick: true, + }); + + // Helper function to traverse file/directory tree + const traverseFileTree = async (item: any, path: string, files: File[]): Promise => { + return new Promise((resolve) => { + if (item.isFile) { + item.file((file: File) => { + // Add the relative path to the file object + const fullPath = path + file.name; + Object.defineProperty(file, 'webkitRelativePath', { + value: fullPath, + writable: false + }); + files.push(file); + resolve(); + }); + } else if (item.isDirectory) { + const dirReader = item.createReader(); + dirReader.readEntries(async (entries: any[]) => { + for (const entry of entries) { + await traverseFileTree(entry, path + item.name + '/', files); + } + resolve(); + }); + } else { + resolve(); + } + }); + }; + + const handleToggleFileSelection = (key: string) => { + const newSelected = new Set(selectedFileKeys); + if (newSelected.has(key)) { + newSelected.delete(key); + } else { + newSelected.add(key); + } + setSelectedFileKeys(newSelected); + }; + + const handleSelectAllFiles = () => { + const fileKeys = objects + .filter(obj => !obj.isFolder) + .map(obj => obj.key); + + if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) { + setSelectedFileKeys(new Set()); + } else { + setSelectedFileKeys(new Set(fileKeys)); + } + }; + + const handleBulkDeleteFiles = async () => { + if (selectedFileKeys.size === 0) return; + + await onDeleteMultipleObjects(Array.from(selectedFileKeys)); + setSelectedFileKeys(new Set()); + }; + + const handleDeleteObject = async (key: string): Promise => { + const success = await onDeleteObject(key); + if (success) { + setDeleteObjectDialogOpen(false); + setSelectedObject(null); + } + return success; + }; + + const uploadFiles = async (files: File[]) => { + await onUploadFiles(files); + setShowUploadZone(false); + }; + + return ( +
+
+
+ {/* Back Button */} + + + {/* Breadcrumb Navigation */} +
+ + {getBreadcrumbs(currentPath).map((crumb, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ + {/* Toolbar */} +
+
+ + onSearchChange(e.target.value)} + className="pl-8" + /> +
+
+ {selectedFileKeys.size > 0 && ( + + )} + + + +
+
+ + {/* Upload Zone */} + {showUploadZone && ( +
+
+
+
+ + + + + +
+
+ +
+
+ +

+ Drag and drop files/folders or{' '} + + {' / '} + +

+ { + if (e.target.files) { + const files = Array.from(e.target.files); + uploadFiles(files); + e.target.value = ''; + } + }} + style={{ display: 'none' }} + /> + { + if (e.target.files) { + const files = Array.from(e.target.files); + uploadFiles(files); + e.target.value = ''; + } + }} + style={{ display: 'none' }} + /> +
+
+
+
+ )} + + {/* Objects Table with Drag & Drop */} +
+ + + {/* Drag & Drop Overlay */} + {isDragActive && ( +
+
+
+
+ +
+ +
+
+
+

Drop files here to upload

+

Files will be uploaded to {currentPath || 'root'}

+
+
+
+
+ )} + + { + setSelectedObject(obj); + setDeleteObjectDialogOpen(true); + }} + onToggleFileSelection={handleToggleFileSelection} + onSelectAllFiles={handleSelectAllFiles} + onPageChange={onPageChange} + onItemsPerPageChange={onItemsPerPageChange} + initialPageToken={initialPageToken} + initialItemsPerPage={initialItemsPerPage} + /> +
+
+ + {/* Create Directory Dialog */} + + + {/* Delete Object Dialog */} + +
+ ); +} diff --git a/frontend/src/components/buckets/ObjectsTable.tsx b/frontend/src/components/buckets/ObjectsTable.tsx new file mode 100644 index 0000000..f196fa4 --- /dev/null +++ b/frontend/src/components/buckets/ObjectsTable.tsx @@ -0,0 +1,424 @@ +import {useEffect, useState} from 'react'; +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Checkbox} from '@/components/ui/checkbox'; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table'; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import {ChevronLeft, ChevronRight, Download, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react'; +import {Select, SelectOption} from '@/components/ui/select'; +import {formatBytes} from '@/lib/utils'; +import {formatRelativeTime, getFileType} from '@/lib/file-utils'; +import type {S3Object} from '@/types'; + +interface ObjectsTableProps { + objects: S3Object[]; + currentPath: string; + searchQuery: string; + selectedFileKeys: Set; + isDragActive: boolean; + isLoading?: boolean; + isTruncated?: boolean; + nextContinuationToken?: string; + itemsPerPage: number; + onNavigateToFolder: (key: string) => void; + onDeleteObject: (object: S3Object) => void; + onToggleFileSelection: (key: string) => void; + onSelectAllFiles: () => void; + onPageChange: (token?: string) => void; + onItemsPerPageChange: (count: number) => void; + initialPageToken?: string; + initialItemsPerPage?: number; +} + +type SortColumn = 'name' | 'size' | 'modified'; +type SortDirection = 'asc' | 'desc'; + +export function ObjectsTable({ + objects, + currentPath, + searchQuery, + selectedFileKeys, + isDragActive, + isLoading = false, + isTruncated = false, + nextContinuationToken, + itemsPerPage, + onNavigateToFolder, + onDeleteObject, + onToggleFileSelection, + onSelectAllFiles, + onPageChange, + onItemsPerPageChange, + initialPageToken, + initialItemsPerPage, +}: ObjectsTableProps) { + const [sortColumn, setSortColumn] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); + const [filteredObjects, setFilteredObjects] = useState([]); + // Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...] + const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]); + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const [initialized, setInitialized] = useState(false); + + // Initialize from URL params on first load + useEffect(() => { + if (!initialized && initialItemsPerPage && initialItemsPerPage !== itemsPerPage) { + onItemsPerPageChange(initialItemsPerPage); + setInitialized(true); + } + if (!initialized && initialPageToken && initialPageToken !== nextContinuationToken) { + // If we have an initial page token, trigger page change + onPageChange(initialPageToken); + setInitialized(true); + } + if (!initialized && !initialPageToken && !initialItemsPerPage) { + setInitialized(true); + } + }, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]); + + const sortObjects = (objList: S3Object[]): S3Object[] => { + const sorted = [...objList].sort((a, b) => { + // Always put folders before files + const aIsFolder = a.isFolder ? 1 : 0; + const bIsFolder = b.isFolder ? 1 : 0; + if (aIsFolder !== bIsFolder) { + return bIsFolder - aIsFolder; + } + + let compareValue = 0; + switch (sortColumn) { + case 'name': { + const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase(); + const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase(); + compareValue = aName.localeCompare(bName); + break; + } + case 'size': + compareValue = a.size - b.size; + break; + case 'modified': { + const aDate = new Date(a.lastModified).getTime(); + const bDate = new Date(b.lastModified).getTime(); + compareValue = aDate - bDate; + break; + } + } + + return sortDirection === 'asc' ? compareValue : -compareValue; + }); + + return sorted; + }; + + useEffect(() => { + const filtered = objects.filter((obj) => + obj.key.toLowerCase().includes(searchQuery.toLowerCase()) + ); + const sorted = sortObjects(filtered); + setFilteredObjects(sorted); + // Reset pagination when path/search changes + setPageTokens([undefined]); + setCurrentPageIndex(0); + }, [searchQuery, objects, sortColumn, sortDirection, currentPath]); + + // Update page tokens when we get a new next token + useEffect(() => { + if (nextContinuationToken && isTruncated) { + setPageTokens(prev => { + const newTokens = [...prev]; + // Only add the token if we don't have it yet + const nextIndex = currentPageIndex + 1; + if (nextIndex >= newTokens.length) { + newTokens[nextIndex] = nextContinuationToken; + } + return newTokens; + }); + } + }, [nextContinuationToken, isTruncated, currentPageIndex]); + + const hasPrevious = currentPageIndex > 0; + const hasNext = isTruncated; + + const handleNextPage = () => { + if (hasNext && nextContinuationToken) { + const nextIndex = currentPageIndex + 1; + setCurrentPageIndex(nextIndex); + onPageChange(nextContinuationToken); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const handlePreviousPage = () => { + if (hasPrevious) { + const prevIndex = currentPageIndex - 1; + setCurrentPageIndex(prevIndex); + const previousToken = pageTokens[prevIndex]; + onPageChange(previousToken); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const handleItemsPerPageChange = (value: string) => { + onItemsPerPageChange(Number(value)); + setPageTokens([undefined]); // Reset to first page + setCurrentPageIndex(0); + }; + + const handleSort = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortDirection('asc'); + } + }; + + return ( + <> +
+ + + + + !obj.isFolder).length > 0 && + selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length + } + onCheckedChange={onSelectAllFiles} + aria-label="Select all files" + /> + + handleSort('name')} + > + Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')} + + Type + Storage Class + handleSort('size')} + > + Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')} + + handleSort('modified')} + > + Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')} + + + + + + {isLoading ? ( + + +
+ + Loading objects... +
+
+
+ ) : filteredObjects.length === 0 ? ( + + + {searchQuery + ? 'No objects found matching your search' + : isDragActive + ? 'Drop files or folders here' + : 'No objects in this location'} + + + ) : ( + filteredObjects.map((obj) => ( + + + {obj.isFolder ? ( + + ) : ( + onToggleFileSelection(obj.key)} + aria-label={`Select file ${obj.key}`} + /> + )} + + +
+ {obj.isFolder ? ( + + ) : ( + + )} + {obj.isFolder ? ( + + ) : ( + + {obj.key.replace(currentPath, '')} + + )} +
+
+ + {obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))} + + + {obj.storageClass && ( + {obj.storageClass} + )} + + {obj.isFolder ? null : formatBytes(obj.size)} + + {obj.lastModified ? + + + +
+ {new Date(obj.lastModified).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + })} {new Date(obj.lastModified).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + })} CET +
+
+ +
+
+ UTC + + {new Date(obj.lastModified).toLocaleString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: 'UTC', + })} UTC + +
+
+ Relative + + {formatRelativeTime(new Date(obj.lastModified))} + +
+
+ Timestamp + + {new Date(obj.lastModified).toISOString()} + +
+
+
+
+
: null} +
+ + {!obj.isFolder && ( + + + + + + + + Download + + + onDeleteObject(obj)} + > + + Delete + + + + )} + +
+ )) + )} +
+
+
+ + {/* Pagination Controls */} + {(filteredObjects.length > 0 || hasPrevious) && ( +
+ {/* Items per page selector */} +
+ Items per page: + +
+ + {/* Pagination info and controls */} +
+ + Page {currentPageIndex + 1} • Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''} + + +
+ + + +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/buckets/index.ts b/frontend/src/components/buckets/index.ts new file mode 100644 index 0000000..736dfcc --- /dev/null +++ b/frontend/src/components/buckets/index.ts @@ -0,0 +1,8 @@ +export { BucketListView } from './BucketListView'; +export { ObjectBrowserView } from './ObjectBrowserView'; +export { ObjectsTable } from './ObjectsTable'; +export { CreateBucketDialog } from './CreateBucketDialog'; +export { DeleteBucketDialog } from './DeleteBucketDialog'; +export { BucketSettingsDialog } from './BucketSettingsDialog'; +export { CreateDirectoryDialog } from './CreateDirectoryDialog'; +export { DeleteObjectDialog } from './DeleteObjectDialog'; diff --git a/frontend/src/components/charts/BucketUsageChart.tsx b/frontend/src/components/charts/BucketUsageChart.tsx new file mode 100644 index 0000000..470e9f3 --- /dev/null +++ b/frontend/src/components/charts/BucketUsageChart.tsx @@ -0,0 +1,65 @@ +import {useEffect, useState} from 'react'; +import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts'; +import type {BucketUsage} from '@/types'; +import {formatBytes} from '@/lib/utils'; +import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors'; + +interface BucketUsageChartProps { + data: BucketUsage[]; +} + +export function BucketUsageChart({ data }: BucketUsageChartProps) { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + // Check if dark mode is enabled + const checkDarkMode = () => { + setIsDark(document.documentElement.classList.contains('dark')); + }; + + checkDarkMode(); + + // Listen for theme changes + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + + return () => observer.disconnect(); + }, []); + + const colors = isDark ? chartColorPalette.dark : chartColorPalette.light; + const textColor = getTextColor(isDark); + const tooltipStyle = getTooltipStyle(isDark); + + const chartData = data.map(item => ({ + name: item.bucketName, + value: item.size, + displaySize: formatBytes(item.size), + })); + + return ( + + + `${name}`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {chartData.map((_, index) => ( + + ))} + + formatBytes(value as number)} + contentStyle={tooltipStyle as React.CSSProperties} + labelStyle={{ color: textColor }} + /> + + + + ); +} diff --git a/frontend/src/components/charts/ClusterHealthChart.tsx b/frontend/src/components/charts/ClusterHealthChart.tsx new file mode 100644 index 0000000..486ee0e --- /dev/null +++ b/frontend/src/components/charts/ClusterHealthChart.tsx @@ -0,0 +1,66 @@ +import {useEffect, useState} from 'react'; +import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'; +import type {ClusterHealth} from '@/types'; +import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors'; + +interface ClusterHealthChartProps { + data: ClusterHealth; +} + +export function ClusterHealthChart({ data }: ClusterHealthChartProps) { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const checkDarkMode = () => { + setIsDark(document.documentElement.classList.contains('dark')); + }; + + checkDarkMode(); + + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + + return () => observer.disconnect(); + }, []); + + const colors = isDark ? grafanaColors.dark : grafanaColors.light; + const textColor = getTextColor(isDark); + const gridColor = getGridColor(isDark); + const tooltipStyle = getTooltipStyle(isDark); + const unhealthyColor = isDark ? '#e8e8e8' : '#d1d5db'; + + const chartData = [ + { + metric: 'Nodes', + healthy: data.storageNodesUp, + unhealthy: data.storageNodes - data.storageNodesUp, + }, + { + metric: 'Partitions', + healthy: data.partitionsAllOk, + unhealthy: data.partitions - data.partitionsAllOk, + }, + { + metric: 'Connected', + healthy: data.connectedNodes, + unhealthy: data.knownNodes - data.connectedNodes, + }, + ]; + + return ( + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/charts/RequestMetricsChart.tsx b/frontend/src/components/charts/RequestMetricsChart.tsx new file mode 100644 index 0000000..9f25209 --- /dev/null +++ b/frontend/src/components/charts/RequestMetricsChart.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import type { RequestMetrics } from '@/types'; +import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors'; + +interface RequestMetricsChartProps { + data: RequestMetrics; +} + +export function RequestMetricsChart({ data }: RequestMetricsChartProps) { + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const checkDarkMode = () => { + setIsDark(document.documentElement.classList.contains('dark')); + }; + + checkDarkMode(); + + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + + return () => observer.disconnect(); + }, []); + + const colors = isDark ? grafanaColors.dark : grafanaColors.light; + const textColor = getTextColor(isDark); + const gridColor = getGridColor(isDark); + const tooltipStyle = getTooltipStyle(isDark); + + const chartData = [ + { + name: 'Requests (24h)', + GET: data.getRequests, + PUT: data.putRequests, + DELETE: data.deleteRequests, + LIST: data.listRequests, + }, + ]; + + return ( + + + + + + (value as number).toLocaleString()} + contentStyle={tooltipStyle as React.CSSProperties} + labelStyle={{ color: textColor }} + /> + + + + + + + + ); +} diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx new file mode 100644 index 0000000..5a70feb --- /dev/null +++ b/frontend/src/components/layout/header.tsx @@ -0,0 +1,30 @@ +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { ThemeToggle } from './theme-toggle'; + +interface HeaderProps { + title: string; +} + +export function Header({ title }: HeaderProps) { + return ( +
+
+
+

{title}

+
+
+
+ + +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx new file mode 100644 index 0000000..c5122d0 --- /dev/null +++ b/frontend/src/components/layout/layout.tsx @@ -0,0 +1,36 @@ +import { Outlet } from 'react-router-dom'; +import { Sidebar } from './sidebar'; +import { useState } from 'react'; +import { Menu } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export function Layout() { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {/* Mobile menu button */} + + + {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + setSidebarOpen(false)} /> +
+ +
+
+ ); +} diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..8d5c3a3 --- /dev/null +++ b/frontend/src/components/layout/sidebar.tsx @@ -0,0 +1,92 @@ +import {Link, useLocation} from 'react-router-dom'; +import {cn} from '@/lib/utils'; +import {Database, Key, LayoutDashboard, Server} from 'lucide-react'; + +interface NavItem { + title: string; + href: string; + icon: React.ComponentType<{ className?: string }>; +} + +const navItems: NavItem[] = [ + { + title: 'Dashboard', + href: '/', + icon: LayoutDashboard, + }, + { + title: 'Buckets', + href: '/buckets', + icon: Database, + }, + { + title: 'Cluster', + href: '/cluster', + icon: Server, + }, + { + title: 'Access Control', + href: '/access', + icon: Key, + }, +]; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; +} + +export function Sidebar({ isOpen, onClose }: SidebarProps) { + const location = useLocation(); + + return ( +
+
+ Garage UI Logo + Garage UI +
+ + {/*
*/} + {/*
*/} + {/*
*/} + {/* AD*/} + {/*
*/} + {/*
*/} + {/*

Admin User

*/} + {/*

admin@garage.local

*/} + {/*
*/} + {/*
*/} + {/*
*/} +
+ ); +} diff --git a/frontend/src/components/layout/theme-toggle.tsx b/frontend/src/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..817c760 --- /dev/null +++ b/frontend/src/components/layout/theme-toggle.tsx @@ -0,0 +1,30 @@ +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useTheme } from '@/components/theme-provider'; + +export function ThemeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme('light')}>Light + setTheme('dark')}>Dark + setTheme('system')}>System + + + ); +} diff --git a/frontend/src/components/theme-provider.tsx b/frontend/src/components/theme-provider.tsx new file mode 100644 index 0000000..b3396ce --- /dev/null +++ b/frontend/src/components/theme-provider.tsx @@ -0,0 +1,71 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'dark' | 'light' | 'system'; + +interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +interface ThemeProviderState { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const initialState: ThemeProviderState = { + theme: 'system', + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'garage-ui-theme', + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove('light', 'dark'); + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider'); + + return context; +}; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..50624aa --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,32 @@ +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', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..62d6a63 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none', + { + variants: { + variant: { + default: 'bg-[#ff9329] text-black hover:bg-[#e58625] cursor-pointer', + default_disabled: 'bg-[#ff9329] text-black opacity-50 cursor-not-allowed', + secondary: 'border border-[#ff9329] text-[#ff9329] cursor-pointer', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive cursor-pointer', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground cursor-pointer', + outline_disabled: 'border border-input bg-background text-muted-foreground opacity-50 cursor-not-allowed', + ghost: 'hover:bg-accent hover:text-accent-foreground cursor-pointer', + link: 'text-primary underline-offset-4 hover:underline cursor-pointer', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( + +
+
+ + ); + } +); +DialogContent.displayName = 'DialogContent'; + +const DialogHeader: React.FC> = ({ + className, + ...props +}) =>
; +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter: React.FC> = ({ + className, + ...props +}) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +DialogTitle.displayName = 'DialogTitle'; + +const DialogDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +DialogDescription.displayName = 'DialogDescription'; + +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..99e3b6e --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,181 @@ +import * as React from 'react'; +import {createPortal} from 'react-dom'; +import {cn} from '@/lib/utils'; + +interface DropdownMenuContextValue { + open: boolean; + setOpen: (open: boolean) => void; + triggerRef: React.RefObject; +} + +const DropdownMenuContext = React.createContext(undefined); + +function useDropdownMenu() { + const context = React.useContext(DropdownMenuContext); + if (!context) { + throw new Error('useDropdownMenu must be used within a DropdownMenu'); + } + return context; +} + +interface DropdownMenuProps { + children: React.ReactNode; +} + +const DropdownMenu: React.FC = ({ children }) => { + const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); + return ( + +

{children}
+ + ); +}; + +const DropdownMenuTrigger = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>(({ onClick, ...props }, ref) => { + const { open, setOpen, triggerRef } = useDropdownMenu(); + + // Merge the forwarded ref with the context triggerRef + React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement); + + return ( + + + {open && ( +
+ {children} +
+ )} +

+ + ); + } +); +Select.displayName = 'Select'; + +export interface SelectOptionProps { + value: string; + children: React.ReactNode; + className?: string; + disabled?: boolean; +} + +const SelectOption = React.forwardRef( + ({ className, children, value: optionValue, disabled, ...props }, ref) => { + const { value, onChange } = useSelectContext(); + const isSelected = value === optionValue; + + return ( +
{ + if (!disabled) { + onChange?.(optionValue); + } + }} + {...props} + > + {children} + {isSelected && } +
+ ); + } +); +SelectOption.displayName = 'SelectOption'; + +export { Select, SelectOption }; + diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..668009a --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Table = React.forwardRef>( + ({ 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>( + ({ 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 }; diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..a8d8b74 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface TabsContextValue { + value: string; + onValueChange: (value: string) => void; +} + +const TabsContext = React.createContext(undefined); + +function useTabs() { + const context = React.useContext(TabsContext); + if (!context) { + throw new Error('useTabs must be used within a Tabs'); + } + return context; +} + +interface TabsProps { + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; + children: React.ReactNode; + className?: string; +} + +const Tabs: React.FC = ({ + defaultValue, + value: controlledValue, + onValueChange, + children, + className, +}) => { + const [internalValue, setInternalValue] = React.useState(defaultValue || ''); + const value = controlledValue !== undefined ? controlledValue : internalValue; + + const handleValueChange = (newValue: string) => { + if (controlledValue === undefined) { + setInternalValue(newValue); + } + onValueChange?.(newValue); + }; + + return ( + +
{children}
+
+ ); +}; + +const TabsList = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +TabsList.displayName = 'TabsList'; + +interface TabsTriggerProps extends React.ButtonHTMLAttributes { + value: string; +} + +const TabsTrigger = React.forwardRef( + ({ className, value: triggerValue, onClick, ...props }, ref) => { + const { value, onValueChange } = useTabs(); + const isActive = value === triggerValue; + + return ( + +
+ + {/* Keys Table */} +
+
+ + + + Name + Access Key ID + Status + Created + Last Used + Permissions + + + + + {isLoading ? ( + + +
+ + Loading API keys... +
+
+
+ ) : filteredKeys.length === 0 ? ( + + + {searchQuery ? 'No keys found matching your search' : 'No API keys yet'} + + + ) : ( + filteredKeys.map((key) => ( + + {key.name} + +
+ + {key.accessKeyId} + + +
+
+ + + {key.status} + + + {formatDate(key.createdAt)} + + {key.lastUsed ? formatDate(key.lastUsed) : 'Never'} + + +
+ {key.permissions.slice(0, 2).map((perm, idx) => ( + + {perm.bucketName}: {formatPermissions(perm)} + + ))} + {key.permissions.length > 2 && ( + + +{key.permissions.length - 2} more + + )} + {key.permissions.length === 0 && ( + No permissions + )} +
+
+ + + + + + + handleOpenEditPermissions(key)}> + + Edit Permissions + + handleOpenSettings(key)}> + {key.status === 'active' ? ( + <> + + Manage Status + + ) : ( + <> + + Manage Status + + )} + + + { + setSelectedKey(key); + setDeleteDialogOpen(true); + }} + > + + Delete + + + + +
+ )) + )} +
+
+
+
+ + + + + + Bucket Policies + + Manage resource-based policies for your buckets + + + +

+ Bucket policy editor coming soon... +

+
+
+
+ + + + {/* Create Key Dialog */} + + + + Create API Key + + Create a new API key with optional bucket permissions + + +
+
+ + setNewKeyName(e.target.value)} + /> +

+ A friendly name to identify this API key +

+
+ + {/* Optional: Grant permissions during creation */} +
+ +

+ You can also grant permissions later from the Edit Permissions menu +

+ + {createGrantPermissions && ( +
+ {/* Bucket Selection */} +
+ + +
+ + {/* Permissions */} + {createSelectedBucket && ( +
+ +
+ + + + + +
+
+ )} +
+ )} +
+
+ + + + +
+
+ + {/* Delete Key Dialog */} + + + + Delete API Key + + Are you sure you want to delete "{selectedKey?.name}"? Applications using this key + will lose access immediately. + + + + + + + + + + {/* Key Settings Dialog */} + + + + Key Settings - {settingsKey?.name} + + Manage activation status and expiration date for this API key + + +
+ {/* Status */} +
+ +
+ + +
+

+ Inactive keys cannot be used for authentication +

+
+ + {/* Expiration */} +
+ +
+ + + {!neverExpires && ( +
+ + setExpirationDate(e.target.value)} + className="w-full" + /> +

+ Key will automatically become inactive after this date +

+
+ )} +
+
+ + {/* Current Status Display */} +
+
+
+ Current Status: + + {settingsKey?.status} + +
+ {settingsKey?.expiration && ( +
+ Current Expiration: + + {formatDate(settingsKey.expiration)} + +
+ )} +
+
+
+ + + + +
+
+ + {/* Edit Permissions Dialog */} + + + + Edit Bucket Permissions - {editingKey?.name} + + Grant this access key permissions on buckets + + +
+ {/* Bucket Selection */} +
+ + +

+ Choose which bucket this key should have permissions on. Current permissions will be displayed when selected. +

+
+ + {/* Permissions */} +
+ +
+
+ setPermissionRead(checked as boolean)} + /> +
+ +

+ Allows reading objects from the bucket (GetObject, HeadObject, ListObjects) +

+
+
+ +
+ setPermissionWrite(checked as boolean)} + /> +
+ +

+ Allows writing and deleting objects in the bucket (PutObject, DeleteObject) +

+
+
+ +
+ setPermissionOwner(checked as boolean)} + /> +
+ +

+ Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy) +

+
+
+
+
+ + {/* Current Permissions Info */} + {selectedBucket && editingKey && ( +
+ +
+ {(() => { + const bucketPermission = editingKey.permissions.find( + perm => perm.bucketName === selectedBucket || perm.bucketId === selectedBucket + ); + + if (bucketPermission) { + const hasPermissions = bucketPermission.read || bucketPermission.write || bucketPermission.owner; + if (hasPermissions) { + return ( +
+

+ This key currently has the following permissions on this bucket: +

+
+ {bucketPermission.read && ( + Read + )} + {bucketPermission.write && ( + Write + )} + {bucketPermission.owner && ( + Owner + )} +
+

+ Modify the checkboxes above to update permissions +

+
+ ); + } + } + return ( +

+ This key has no permissions on this bucket yet. Select permissions above to grant access. +

+ ); + })()} +
+
+ )} + + {/* Current Bucket Permissions List */} + {editingKey && editingKey.permissions.length > 0 && ( +
+ +
+
+ {editingKey.permissions.map((perm, idx) => ( +
+ {perm.bucketName} +
+ {perm.read && R} + {perm.write && W} + {perm.owner && O} +
+
+ ))} +
+
+
+ )} +
+ + + + +
+
+ + ); +} diff --git a/frontend/src/pages/Buckets.tsx b/frontend/src/pages/Buckets.tsx new file mode 100644 index 0000000..41d7da2 --- /dev/null +++ b/frontend/src/pages/Buckets.tsx @@ -0,0 +1,220 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Header } from '@/components/layout/header'; +import { useBuckets } from '@/hooks/useBuckets'; +import { useBucketObjects } from '@/hooks/useBucketObjects'; +import { BucketListView } from '@/components/buckets/BucketListView'; +import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView'; +import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog'; +import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog'; +import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog'; +import type { Bucket } from '@/types'; +import { toast } from 'sonner'; + +export function Buckets() { + const [searchParams, setSearchParams] = useSearchParams(); + + // Bucket state + const [searchQuery, setSearchQuery] = useState(''); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false); + const [selectedBucket, setSelectedBucket] = useState(null); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [settingsBucket, setSettingsBucket] = useState(null); + + // Object browser state - initialize from URL params + const [viewingBucket, setViewingBucket] = useState(searchParams.get('bucket')); + const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') || ''); + const [objectSearchQuery, setObjectSearchQuery] = useState(''); + const [initialPageToken, setInitialPageToken] = useState( + searchParams.get('page') || undefined + ); + const [initialItemsPerPage, setInitialItemsPerPage] = useState( + parseInt(searchParams.get('limit') || '25', 10) + ); + + // Sync URL params with state on mount and when URL changes + useEffect(() => { + const bucketParam = searchParams.get('bucket'); + const prefixParam = searchParams.get('prefix') || ''; + const pageParam = searchParams.get('page') || undefined; + const limitParam = parseInt(searchParams.get('limit') || '25', 10); + + if (bucketParam !== viewingBucket) { + setViewingBucket(bucketParam); + } + if (prefixParam !== currentPath) { + setCurrentPath(prefixParam); + } + setInitialPageToken(pageParam); + setInitialItemsPerPage(limitParam); + }, [searchParams]); + + // Custom hooks + const { buckets, isLoading: bucketsLoading, createBucket, deleteBucket, grantPermission } = useBuckets(); + const { + objects, + isLoading: objectsLoading, + isRefreshing, + isNavigating, + isTruncated, + nextContinuationToken, + itemsPerPage, + setItemsPerPage, + uploadFiles, + deleteObject, + deleteMultipleObjects, + createDirectory, + fetchObjects + } = useBucketObjects( + viewingBucket, + currentPath + ); + + const handleViewBucket = (bucketName: string) => { + setViewingBucket(bucketName); + setCurrentPath(''); + setObjectSearchQuery(''); + setSearchParams({ bucket: bucketName }); + }; + + const handleBackToBuckets = () => { + setViewingBucket(null); + setCurrentPath(''); + setObjectSearchQuery(''); + setSearchParams({}); + }; + + const handleNavigateToFolder = (path: string) => { + setCurrentPath(path); + if (viewingBucket) { + const params: Record = { bucket: viewingBucket }; + if (path) { + params.prefix = path; + } + // Reset pagination when navigating to a new folder + setSearchParams(params); + } + }; + + const handlePageChange = (token?: string) => { + fetchObjects(token); + // Update URL with page token + if (viewingBucket) { + const params: Record = { bucket: viewingBucket }; + if (currentPath) { + params.prefix = currentPath; + } + if (token) { + params.page = token; + } + if (itemsPerPage !== 25) { + params.limit = itemsPerPage.toString(); + } + setSearchParams(params); + } + }; + + const handleItemsPerPageChange = (count: number) => { + setItemsPerPage(count); + // Update URL with new limit + if (viewingBucket) { + const params: Record = { bucket: viewingBucket }; + if (currentPath) { + params.prefix = currentPath; + } + if (count !== 25) { + params.limit = count.toString(); + } + setSearchParams(params); + } + }; + + const handleOpenSettings = (bucket: Bucket) => { + setSettingsBucket(bucket); + setSettingsDialogOpen(true); + }; + + const handleRefreshObjects = async () => { + if (isRefreshing) return; + try { + await fetchObjects(undefined, true); + toast.success('Objects refreshed successfully'); + } catch (error) { + console.error('Refresh error:', error); + } + }; + + // If viewing a bucket's objects, show the object browser view + if (viewingBucket) { + return ( + + ); + } + + // Default view: show buckets list + return ( +
+
+
+ setCreateDialogOpen(true)} + onDeleteBucket={(bucket) => { + setSelectedBucket(bucket); + setDeleteBucketDialogOpen(true); + }} + /> +
+ + {/* Dialogs */} + + + + + +
+ ); +} diff --git a/frontend/src/pages/Cluster.tsx b/frontend/src/pages/Cluster.tsx new file mode 100644 index 0000000..68359ef --- /dev/null +++ b/frontend/src/pages/Cluster.tsx @@ -0,0 +1,489 @@ +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; +import {Header} from '@/components/layout/header'; +import {formatBytes} from '@/lib/utils'; +import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react'; +import {useQuery} from '@tanstack/react-query'; +import {garageApi} from '@/lib/api'; +import {Badge} from '@/components/ui/badge'; +import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'; +import type {ClusterNode, LocalNodeInfo, NodeStatistics} from '@/types'; +import {useState} from 'react'; + +export function Cluster() { + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const { data: health, isLoading: healthLoading } = useQuery({ + queryKey: ['cluster-health'], + queryFn: () => garageApi.getClusterHealth(), + refetchInterval: 10000, + }); + + const { data: status, isLoading: statusLoading } = useQuery({ + queryKey: ['cluster-status'], + queryFn: () => garageApi.getClusterStatus(), + refetchInterval: 15000, + }); + + const { data: statistics, isLoading: statisticsLoading } = useQuery({ + queryKey: ['cluster-statistics'], + queryFn: () => garageApi.getClusterStatistics(), + refetchInterval: 30000, + }); + + const { data: nodeInfo, isLoading: nodeInfoLoading } = useQuery({ + queryKey: ['node-info', selectedNodeId || '*'], + queryFn: () => garageApi.getNodeInfo(selectedNodeId || '*'), + enabled: !!selectedNodeId || selectedNodeId === null, + }); + + const { data: nodeStats } = useQuery({ + queryKey: ['node-statistics', selectedNodeId || '*'], + queryFn: () => garageApi.getNodeStatistics(selectedNodeId || '*'), + enabled: !!selectedNodeId, + }); + + const isLoading = healthLoading || statusLoading || statisticsLoading; + + const getHealthStatus = () => { + if (!health) return { color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Unknown', icon: AlertCircle }; + if ( + health.storageNodesUp === health.storageNodes && + health.partitionsAllOk === health.partitions && + health.connectedNodes === health.knownNodes + ) { + return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Healthy', icon: CheckCircle2 }; + } + if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) { + return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Degraded', icon: AlertCircle }; + } + return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Unhealthy', icon: XCircle }; + }; + + const healthStatus = getHealthStatus(); + const HealthIcon = healthStatus.icon; + + const getNodeStatus = (node: ClusterNode) => { + if (!node.isUp) { + return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Down', icon: XCircle }; + } + if (node.draining) { + return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Draining', icon: AlertCircle }; + } + return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Up', icon: CheckCircle2 }; + }; + + const formatUptime = (seconds?: number) => { + if (!seconds) return 'N/A'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${days}d ${hours}h ${minutes}m`; + }; + + if (isLoading) { + return ( +
+
+
+
+
+

Loading cluster information...

+
+
+
+ ); + } + + return ( +
+
+
+ {/* Cluster Health Overview */} +
+ + + Cluster Status + + + +
{healthStatus.label}
+

+ Layout v{status?.layoutVersion || 0} +

+
+
+ + + + Connected Nodes + + + +
+ {health?.connectedNodes || 0}/{health?.knownNodes || 0} +
+

+ Nodes online +

+
+
+ + + + Storage Nodes + + + +
+ {health?.storageNodesUp || 0}/{health?.storageNodes || 0} +
+

+ Healthy storage nodes +

+
+
+ + + + Partitions + + + +
+ {health?.partitionsAllOk || 0}/{health?.partitions || 0} +
+

+ Healthy partitions +

+
+
+
+ + {/* Tabs for different views */} + + + Nodes + Statistics + Details + + + {/* Nodes Tab */} + + + + Cluster Nodes + + Overview of all nodes in the Garage cluster + + + +
+ {status?.nodes && status.nodes.length > 0 ? ( + status.nodes.map((node) => { + const nodeStatus = getNodeStatus(node); + const NodeIcon = nodeStatus.icon; + const dataUsage = node.dataPartition + ? ((node.dataPartition.total - node.dataPartition.available) / node.dataPartition.total) * 100 + : 0; + const metadataUsage = node.metadataPartition + ? ((node.metadataPartition.total - node.metadataPartition.available) / node.metadataPartition.total) * 100 + : 0; + + return ( + setSelectedNodeId(node.id)} + > + +
+
+
+ +
+
+ {node.id.substring(0, 16)}... +
+ {node.hostname && ( +
{node.hostname}
+ )} +
+
+ +
+
+
Status
+ + {nodeStatus.label} + +
+ + {node.addr && ( +
+
Address
+
{node.addr}
+
+ )} + + {node.garageVersion && ( +
+
Version
+
{node.garageVersion}
+
+ )} + + {node.role && ( +
+
Zone
+
{node.role.zone}
+
+ )} +
+ + {node.role?.capacity && ( +
+
+ Capacity: {formatBytes(node.role.capacity)} +
+
+ )} + + {(node.dataPartition || node.metadataPartition) && ( +
+ {node.dataPartition && ( +
+
+ Data Partition: {formatBytes(node.dataPartition.total - node.dataPartition.available)} / {formatBytes(node.dataPartition.total)} +
+
+
90 ? 'bg-red-500' : dataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${dataUsage}%` }} + /> +
+
+ )} + + {node.metadataPartition && ( +
+
+ Metadata Partition: {formatBytes(node.metadataPartition.total - node.metadataPartition.available)} / {formatBytes(node.metadataPartition.total)} +
+
+
90 ? 'bg-red-500' : metadataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500' + }`} + style={{ width: `${metadataUsage}%` }} + /> +
+
+ )} +
+ )} + + {!node.isUp && node.lastSeenSecsAgo !== undefined && ( +
+ + Last seen: {node.lastSeenSecsAgo === null ? 'Never' : formatUptime(node.lastSeenSecsAgo) + ' ago'} +
+ )} +
+
+ + + ); + }) + ) : ( +
+ +

No nodes found in the cluster

+
+ )} +
+ + + + + {/* Statistics Tab */} + + + + Cluster Statistics + + Detailed statistics and metrics from the Garage cluster + + + + {statistics ? ( +
+
+
+                        {statistics.freeform}
+                      
+
+
+ ) : ( +
+ +

No statistics available

+
+ )} +
+
+
+ + {/* Details Tab */} + + {selectedNodeId ? ( + <> + + + Node Information + + Detailed information for node: {selectedNodeId.substring(0, 16)}... + + + + {nodeInfoLoading ? ( +
+
+

Loading node info...

+
+ ) : nodeInfo ? ( +
+ {/* Success responses */} + {Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => ( +
+
+ +

+ Node: {nodeId.substring(0, 16)}... +

+
+ +
+
+
Node ID
+
{(info as LocalNodeInfo).nodeId}
+
+ +
+
Garage Version
+
{(info as LocalNodeInfo).garageVersion}
+
+ +
+
Rust Version
+
{(info as LocalNodeInfo).rustVersion}
+
+ +
+
Database Engine
+
{(info as LocalNodeInfo).dbEngine}
+
+
+ + {(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && ( +
+
Garage Features
+
+ {(info as LocalNodeInfo).garageFeatures!.map((feature) => ( + + {feature} + + ))} +
+
+ )} +
+ ))} + + {/* Error responses */} + {Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => ( +
+
+ +
Error for node {nodeId.substring(0, 16)}...
+
+
{error}
+
+ ))} +
+ ) : ( +
+ +

No node information available

+
+ )} +
+
+ + {nodeStats && ( + + + Node Statistics + + Performance metrics for the selected node + + + +
+ {/* Success responses */} + {Object.entries(nodeStats.success || {}).map(([nodeId, stats]) => ( +
+
+ +

+ Statistics for: {nodeId.substring(0, 16)}... +

+
+ +
+
+                                {(stats as NodeStatistics).freeform}
+                              
+
+
+ ))} + + {/* Error responses */} + {Object.entries(nodeStats.error || {}).map(([nodeId, error]) => ( +
+
+ +
Error for node {nodeId.substring(0, 16)}...
+
+
{error}
+
+ ))} +
+
+
+ )} + + ) : ( + + +
+ +

Select a Node

+

+ Click on a node in the Nodes tab to view detailed information and statistics +

+
+
+
+ )} +
+ +
+
+ ); +} + + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..0772591 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,244 @@ +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'; +import {Header} from '@/components/layout/header'; +import {formatBytes} from '@/lib/utils'; +import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react'; +import {BucketUsageChart} from '@/components/charts/BucketUsageChart'; +import {useDashboardData} from '@/hooks/useApi'; +import type {ClusterHealth} from '@/types'; + +export function Dashboard() { + const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData(); + + const metrics = metricsQuery.data; + const buckets = bucketsQuery.data || []; + const clusterHealth = healthQuery.data; + + const getHealthStatus = (health: ClusterHealth | null) => { + if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle }; + if ( + health.storageNodesUp === health.storageNodes && + health.partitionsAllOk === health.partitions && + health.connectedNodes === health.knownNodes + ) { + return { color: 'text-green-500', label: 'Healthy', icon: Zap }; + } + if ( + health.storageNodesUp > 0 && + health.partitionsQuorum > 0 + ) { + return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle }; + } + return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle }; + }; + + const healthStatus = getHealthStatus(clusterHealth ?? null); + + if (isLoading) { + return ( +
+
+
+
+
+

Loading dashboard...

+
+
+
+ ); + } + + return ( +
+
+
+ {/* Top Stats Grid */} +
+ + + Total Storage + + + +
+ {metrics ? formatBytes(metrics.totalSize) : '---'} +
+

+ Across {metrics?.bucketCount || 0} buckets +

+
+
+ + + + Total Objects + + + +
+ {metrics?.objectCount.toLocaleString() || '---'} +
+

+ Files and folders +

+
+
+ + + + Buckets + + + +
{metrics?.bucketCount || '---'}
+

+ Active storage buckets +

+
+
+
+ + {/* Cluster Status */} +
+ + + Cluster Status + + + +
+
+ {healthStatus.label} +
+
+

+ {clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected +

+
+
+ + + + Storage Nodes + + + +
+ {clusterHealth?.storageNodesUp || 0}/{clusterHealth?.storageNodes || 0} +
+

+ Healthy storage nodes +

+
+
+ + + + Partitions + + + +
+ {clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0} +
+

+ Healthy partitions +

+
+
+
+ + {/* Charts Row 1 */} +
+ {/* Bucket Usage Chart */} + + + Storage Usage by Bucket + Distribution of storage across buckets + + + {metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? ( + + ) : ( +
No data available
+ )} +
+
+ + {/* Bucket Details Table */} + + + Storage Usage by Bucket (Table) + Detailed breakdown of storage across all buckets + + +
+ {metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? ( + metrics.usageByBucket.map((bucket) => ( +
+
+ {bucket.bucketName} +
+ + {bucket.objectCount.toLocaleString()} objects + + {formatBytes(bucket.size)} + + {bucket.percentage.toFixed(1)}% + +
+
+
+
+
+
+ )) + ) : ( +
No buckets available
+ )} +
+ + +
+ + {/* Recent Buckets */} + + + Recent Buckets + Your most recently created buckets + + +
+ {buckets.slice(0, 5).map((bucket) => ( +
+
+
+ +
+
+

{bucket.name}

+

+ Created {new Date(bucket.creationDate).toLocaleDateString()} +

+
+
+
+

{bucket.objectCount?.toLocaleString()} objects

+

+ {bucket.size ? formatBytes(bucket.size) : '---'} +

+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..c03a67a --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,255 @@ +// Bucket types +export interface Bucket { + name: string; + creationDate: string; + objectCount?: number; + size?: number; + region?: string; +} + +export interface BucketDetails extends Bucket { + versioning?: boolean; + encryption?: boolean; + publicAccess?: boolean; + lifecycleRules?: LifecycleRule[]; +} + +export interface LifecycleRule { + id: string; + enabled: boolean; + prefix?: string; + expirationDays?: number; + transitions?: Transition[]; +} + +export interface Transition { + days: number; + storageClass: string; +} + +// Object types +export interface S3Object { + key: string; + size: number; + lastModified: string; + etag?: string; + storageClass?: string; + isFolder?: boolean; +} + +export interface ObjectListResponse { + bucket: string; + objects: S3Object[]; + prefixes: string[]; + count: number; + isTruncated: boolean; + nextContinuationToken?: string; +} + +export interface ObjectMetadata { + key: string; + size: number; + lastModified: string; + contentType: string; + etag: string; + metadata?: Record; + versionId?: string; +} + +// Access Control types +export interface AccessKey { + accessKeyId: string; + name: string; + createdAt: string; + lastUsed?: string; + status: 'active' | 'inactive'; + permissions: BucketPermission[]; + expiration?: string; +} + +export interface BucketPermission { + bucketId: string; + bucketName: string; + read: boolean; + write: boolean; + owner: boolean; +} + +export interface Permission { + resource: string; + actions: string[]; + effect: 'Allow' | 'Deny'; +} + +export interface BucketPolicy { + bucketName: string; + policy: PolicyStatement[]; +} + +export interface PolicyStatement { + sid?: string; + effect: 'Allow' | 'Deny'; + principal: string | string[]; + action: string | string[]; + resource: string | string[]; + condition?: Record; +} + +// User types +export interface User { + id: string; + username: string; + email: string; + role: 'admin' | 'user' | 'readonly'; + createdAt: string; +} + +// Storage Analytics types +export interface StorageMetrics { + totalSize: number; + objectCount: number; + bucketCount: number; + usageByBucket: BucketUsage[]; + requestMetrics: RequestMetrics; +} + +export interface BucketUsage { + bucketName: string; + size: number; + objectCount: number; + percentage: number; +} + +export interface RequestMetrics { + getRequests: number; + putRequests: number; + deleteRequests: number; + listRequests: number; + period: string; +} + +// Upload types +export interface UploadTask { + id: string; + file: File; + key: string; + bucket: string; + progress: number; + status: 'pending' | 'uploading' | 'completed' | 'error'; + error?: string; +} + +// API Response types +export interface ApiResponse { + data?: T; + error?: string; + message?: string; +} + +// Filter and Sort types +export interface TableFilter { + search?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + pageSize?: number; + page?: number; +} + +// Garage Cluster types +export interface ClusterHealth { + status: string; + connectedNodes: number; + knownNodes: number; + storageNodes: number; + storageNodesUp: number; + partitions: number; + partitionsQuorum: number; + partitionsAllOk: number; +} + +export interface ClusterStatistics { + timestamp: number; + uptime: number; + freeform: string; + [key: string]: any; +} + +export interface ClusterStatus { + layoutVersion: number; + nodes: ClusterNode[]; +} + +export interface ClusterNode { + id: string; + isUp: boolean; + lastSeenSecsAgo?: number; + hostname?: string; + addr?: string; + garageVersion?: string; + role?: NodeRole; + draining: boolean; + dataPartition?: FreeSpaceInfo; + metadataPartition?: FreeSpaceInfo; +} + +export interface NodeRole { + zone: string; + capacity?: number; + tags: string[]; +} + +export interface FreeSpaceInfo { + available: number; + total: number; +} + +export interface LocalNodeInfo { + nodeId: string; + garageVersion: string; + rustVersion: string; + dbEngine: string; + garageFeatures?: string[]; +} + +export interface MultiNodeResponse { + success: Record; + error: Record; +} + +export interface NodeStatistics { + freeform: string; +} + +export interface MultiNodeStatisticsResponse { + success: Record; + error: Record; +} + +export interface NodeInfo { + nodeId: string; + version: string; + rustVersion: string; + uptime: number; + dbSize: number; + blockReferenceTableSize: number; + blockMetricsTableSize: number; + objectTableSize: number; + objectVersionTableSize: number; + bucketTableSize: number; + bucketAliasTableSize: number; + [key: string]: any; +} + +export interface RequestTypeMetrics { + read: number; + write: number; + delete: number; + list: number; +} + +export interface GarageMetrics extends StorageMetrics { + clusterHealth?: ClusterHealth; + clusterStatistics?: ClusterStatistics; + nodeInfo?: NodeInfo; + requestTypeMetrics?: RequestTypeMetrics; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..0a5249d --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,53 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ['class'], + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + ], + theme: { + extend: { + colors: { + border: 'var(--border)', + input: 'var(--input)', + ring: 'var(--ring)', + background: 'var(--background)', + foreground: 'var(--foreground)', + primary: { + DEFAULT: 'var(--primary)', + foreground: 'var(--primary-foreground)', + }, + secondary: { + DEFAULT: 'var(--secondary)', + foreground: 'var(--secondary-foreground)', + }, + destructive: { + DEFAULT: 'var(--destructive)', + foreground: 'var(--destructive-foreground)', + }, + muted: { + DEFAULT: 'var(--muted)', + foreground: 'var(--muted-foreground)', + }, + accent: { + DEFAULT: 'var(--accent)', + foreground: 'var(--accent-foreground)', + }, + popover: { + DEFAULT: 'var(--popover)', + foreground: 'var(--popover-foreground)', + }, + card: { + DEFAULT: 'var(--card)', + foreground: 'var(--card-foreground)', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..59cb660 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f6899cb --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..19d763b --- /dev/null +++ b/go.mod @@ -0,0 +1,85 @@ +module Noooste/garage-ui + +go 1.25.3 + +require ( + github.com/Noooste/azuretls-client v1.12.9 + github.com/Noooste/swagger v1.2.0 + github.com/aws/aws-sdk-go-v2 v1.30.4 + github.com/aws/aws-sdk-go-v2/credentials v1.17.28 + github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/gofiber/fiber/v3 v3.0.0-rc.3 + github.com/spf13/viper v1.21.0 + github.com/swaggo/swag v1.16.6 + golang.org/x/oauth2 v0.33.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Noooste/fhttp v1.0.15 // indirect + github.com/Noooste/go-socks4 v0.0.2 // indirect + github.com/Noooste/uquic-go v1.0.1 // indirect + github.com/Noooste/utls v1.3.20 // indirect + github.com/Noooste/websocket v1.0.3 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect + github.com/aws/smithy-go v1.23.2 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gaukas/clienthellod v0.4.2 // indirect + github.com/gaukas/godicttls v0.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/spec v0.22.1 // indirect + github.com/go-openapi/swag/conv v0.25.3 // indirect + github.com/go-openapi/swag/jsonname v0.25.3 // indirect + github.com/go-openapi/swag/jsonutils v0.25.3 // indirect + github.com/go-openapi/swag/loading v0.25.3 // indirect + github.com/go-openapi/swag/stringutils v0.25.3 // indirect + github.com/go-openapi/swag/typeutils v0.25.3 // indirect + github.com/go-openapi/swag/yamlutils v0.25.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/utils/v2 v2.0.0-rc.2 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/refraction-networking/utls v1.8.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files/v2 v2.0.2 // indirect + github.com/tinylib/msgp v1.5.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.68.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a06d0f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,233 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Noooste/azuretls-client v1.12.9 h1:w6XVao95irzge8k+yF5ZovIKf7UBe0GVE3sTRKs/4wE= +github.com/Noooste/azuretls-client v1.12.9/go.mod h1:GHqLaS+vjBk+D3fMA0d+gNgDS2ahtic+6c+7JyMfrdU= +github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs= +github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE= +github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng= +github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE= +github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk= +github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM= +github.com/Noooste/uquic-go v1.0.1 h1:12ARejbnh0R5FLGoHhz4p+qT/8BKkRNTPsJlkX4/pg8= +github.com/Noooste/uquic-go v1.0.1/go.mod h1:6AUIck22N0wQ5+CY/5pLmW3IzdITlf7ABtjbRsaZq2A= +github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88= +github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg= +github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA= +github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= +github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= +github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 h1:Cso4Ev/XauMVsbwdhYEoxg8rxZWw43CFqqaPB5w3W2c= +github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE= +github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA= +github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= +github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k= +github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E= +github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU= +github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A= +github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw= +github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao= +github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y= +github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c= +github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w= +github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc= +github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg= +github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA= +github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo= +github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= +github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8= +github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE= +github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4= +github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= +github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc= +github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= +github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..f4a62eb --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,303 @@ +package auth + +import ( + "context" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "Noooste/garage-ui/internal/config" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// AuthService handles authentication operations +type AuthService struct { + config *config.AuthConfig + oidcProvider *oidc.Provider + oidcVerifier *oidc.IDTokenVerifier + oauth2Config *oauth2.Config +} + +// UserInfo represents authenticated user information +type UserInfo struct { + Username string + Email string + Name string + Roles []string +} + +// NewAuthService creates a new authentication service +func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) { + service := &AuthService{ + config: cfg, + } + + // Initialize OIDC if enabled + if cfg.Mode == "oidc" && cfg.OIDC.Enabled { + if err := service.initOIDC(); err != nil { + return nil, fmt.Errorf("failed to initialize OIDC: %w", err) + } + } + + return service, nil +} + +// initOIDC initializes the OIDC provider and configuration +func (a *AuthService) initOIDC() error { + ctx := context.Background() + + // Create OIDC provider + provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL) + if err != nil { + return fmt.Errorf("failed to create OIDC provider: %w", err) + } + + a.oidcProvider = provider + + // Create ID token verifier + verifierConfig := &oidc.Config{ + ClientID: a.config.OIDC.ClientID, + SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck, + SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck, + } + a.oidcVerifier = provider.Verifier(verifierConfig) + + // Create OAuth2 config + a.oauth2Config = &oauth2.Config{ + ClientID: a.config.OIDC.ClientID, + ClientSecret: a.config.OIDC.ClientSecret, + Endpoint: provider.Endpoint(), + Scopes: a.config.OIDC.Scopes, + } + + return nil +} + +// ValidateBasicAuth validates basic authentication credentials +func (a *AuthService) ValidateBasicAuth(username, password string) bool { + // Use constant-time comparison to prevent timing attacks + usernameMatch := subtle.ConstantTimeCompare( + []byte(username), + []byte(a.config.Basic.Username), + ) == 1 + + passwordMatch := subtle.ConstantTimeCompare( + []byte(password), + []byte(a.config.Basic.Password), + ) == 1 + + return usernameMatch && passwordMatch +} + +// ParseBasicAuth parses the Authorization header for basic auth +func ParseBasicAuth(authHeader string) (username, password string, ok bool) { + if authHeader == "" { + return "", "", false + } + + // Check if it's a Basic auth header + const prefix = "Basic " + if !strings.HasPrefix(authHeader, prefix) { + return "", "", false + } + + // Decode base64 credentials + encoded := authHeader[len(prefix):] + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", "", false + } + + // Split username:password + credentials := string(decoded) + parts := strings.SplitN(credentials, ":", 2) + if len(parts) != 2 { + return "", "", false + } + + return parts[0], parts[1], true +} + +// GetAuthorizationURL returns the OIDC authorization URL for login +func (a *AuthService) GetAuthorizationURL(state string) (string, error) { + if a.oauth2Config == nil { + return "", fmt.Errorf("OIDC not initialized") + } + + return a.oauth2Config.AuthCodeURL(state), nil +} + +// ExchangeCode exchanges an authorization code for tokens +func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) { + if a.oauth2Config == nil { + return nil, fmt.Errorf("OIDC not initialized") + } + + token, err := a.oauth2Config.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + + return token, nil +} + +// VerifyIDToken verifies an OIDC ID token and extracts user info +func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) { + if a.oidcVerifier == nil { + return nil, fmt.Errorf("OIDC not initialized") + } + + // Verify the ID token + idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + // Extract claims + var claims map[string]interface{} + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("failed to parse claims: %w", err) + } + + // Extract user information using configured attributes + userInfo := &UserInfo{ + Username: extractClaim(claims, a.config.OIDC.UsernameAttribute), + Email: extractClaim(claims, a.config.OIDC.EmailAttribute), + Name: extractClaim(claims, a.config.OIDC.NameAttribute), + } + + // Extract roles if configured + if a.config.OIDC.RoleAttributePath != "" { + userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath) + } + + return userInfo, nil +} + +// GetUserInfo retrieves user information from the OIDC provider +func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) { + if a.oidcProvider == nil { + return nil, fmt.Errorf("OIDC not initialized") + } + + // Create OAuth2 token source + tokenSource := a.oauth2Config.TokenSource(ctx, token) + + // Get user info from the provider + userInfoEndpoint, err := a.oidcProvider.UserInfo(ctx, tokenSource) + if err != nil { + return nil, fmt.Errorf("failed to get user info: %w", err) + } + + // Extract claims + var claims map[string]interface{} + if err := userInfoEndpoint.Claims(&claims); err != nil { + return nil, fmt.Errorf("failed to parse user info claims: %w", err) + } + + // Build user info + userInfo := &UserInfo{ + Username: extractClaim(claims, a.config.OIDC.UsernameAttribute), + Email: extractClaim(claims, a.config.OIDC.EmailAttribute), + Name: extractClaim(claims, a.config.OIDC.NameAttribute), + } + + // Extract roles if configured + if a.config.OIDC.RoleAttributePath != "" { + userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath) + } + + return userInfo, nil +} + +// IsAdmin checks if the user has admin role +func (a *AuthService) IsAdmin(userInfo *UserInfo) bool { + if a.config.OIDC.AdminRole == "" { + return false + } + + for _, role := range userInfo.Roles { + if role == a.config.OIDC.AdminRole { + return true + } + } + + return false +} + +// Helper functions + +// extractClaim extracts a string claim from the claims map +func extractClaim(claims map[string]interface{}, key string) string { + if key == "" { + return "" + } + + value, ok := claims[key] + if !ok { + return "" + } + + str, ok := value.(string) + if !ok { + return "" + } + + return str +} + +// extractRoles extracts roles from nested claim path (e.g., "resource_access.garage-ui.roles") +func extractRoles(claims map[string]interface{}, path string) []string { + if path == "" { + return nil + } + + // Split the path by dots to navigate nested claims + parts := strings.Split(path, ".") + + current := claims + for i, part := range parts { + value, ok := current[part] + if !ok { + return nil + } + + // If this is the last part, it should be the roles array + if i == len(parts)-1 { + return extractStringArray(value) + } + + // Navigate to next level + next, ok := value.(map[string]interface{}) + if !ok { + return nil + } + current = next + } + + return nil +} + +// extractStringArray converts an interface{} to []string if possible +func extractStringArray(value interface{}) []string { + // Try direct string array + if strArray, ok := value.([]string); ok { + return strArray + } + + // Try interface array and convert to strings + if array, ok := value.([]interface{}); ok { + result := make([]string, 0, len(array)) + for _, item := range array { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..bbe2c52 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,196 @@ +package config + +import ( + "fmt" + "os" + + "github.com/spf13/viper" +) + +// Config represents the application configuration +type Config struct { + Server ServerConfig `mapstructure:"server"` + Garage GarageConfig `mapstructure:"garage"` + Auth AuthConfig `mapstructure:"auth"` + CORS CORSConfig `mapstructure:"cors"` + Logging LoggingConfig `mapstructure:"logging"` +} + +// ServerConfig contains server-related configuration +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Environment string `mapstructure:"environment"` +} + +// GarageConfig contains Garage S3 connection settings +type GarageConfig struct { + Endpoint string `mapstructure:"endpoint"` + Region string `mapstructure:"region"` + AccessKey string `mapstructure:"access_key"` + SecretKey string `mapstructure:"secret_key"` + UseSSL bool `mapstructure:"use_ssl"` + ForcePathStyle bool `mapstructure:"force_path_style"` + AdminEndpoint string `mapstructure:"admin_endpoint"` + AdminToken string `mapstructure:"admin_token"` +} + +// AuthConfig contains authentication configuration +type AuthConfig struct { + Mode string `mapstructure:"mode"` // "none", "basic", or "oidc" + Basic BasicAuthConfig `mapstructure:"basic"` + OIDC OIDCConfig `mapstructure:"oidc"` +} + +// BasicAuthConfig contains basic authentication settings +type BasicAuthConfig struct { + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` +} + +// OIDCConfig contains OIDC authentication settings +type OIDCConfig struct { + Enabled bool `mapstructure:"enabled"` + ProviderName string `mapstructure:"provider_name"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + Scopes []string `mapstructure:"scopes"` + IssuerURL string `mapstructure:"issuer_url"` + AuthURL string `mapstructure:"auth_url"` + TokenURL string `mapstructure:"token_url"` + UserinfoURL string `mapstructure:"userinfo_url"` + SkipIssuerCheck bool `mapstructure:"skip_issuer_check"` + SkipExpiryCheck bool `mapstructure:"skip_expiry_check"` + EmailAttribute string `mapstructure:"email_attribute"` + UsernameAttribute string `mapstructure:"username_attribute"` + NameAttribute string `mapstructure:"name_attribute"` + RoleAttributePath string `mapstructure:"role_attribute_path"` + AdminRole string `mapstructure:"admin_role"` + TLSSkipVerify bool `mapstructure:"tls_skip_verify"` + SessionMaxAge int `mapstructure:"session_max_age"` + CookieName string `mapstructure:"cookie_name"` + CookieSecure bool `mapstructure:"cookie_secure"` + CookieHTTPOnly bool `mapstructure:"cookie_http_only"` + CookieSameSite string `mapstructure:"cookie_same_site"` +} + +// CORSConfig contains CORS settings for frontend communication +type CORSConfig struct { + Enabled bool `mapstructure:"enabled"` + AllowedOrigins []string `mapstructure:"allowed_origins"` + AllowedMethods []string `mapstructure:"allowed_methods"` + AllowedHeaders []string `mapstructure:"allowed_headers"` + AllowCredentials bool `mapstructure:"allow_credentials"` + MaxAge int `mapstructure:"max_age"` +} + +// LoggingConfig contains logging configuration +type LoggingConfig struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` +} + +// Load reads the configuration from the specified file +func Load(configPath string) (*Config, error) { + // Set default config file name if not specified + if configPath == "" { + configPath = "config.yaml" + } + + // Check if config file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil, fmt.Errorf("config file not found: %s", configPath) + } + + // Configure viper to read the config file + viper.SetConfigFile(configPath) + viper.SetConfigType("yaml") + + // Allow environment variables to override config values + viper.AutomaticEnv() + + // Read the configuration file + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // Unmarshal the configuration into our Config struct + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Validate the configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return &cfg, nil +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + // Validate server config + if c.Server.Port <= 0 || c.Server.Port > 65535 { + return fmt.Errorf("invalid server port: %d", c.Server.Port) + } + + // Validate Garage config + if c.Garage.Endpoint == "" { + return fmt.Errorf("garage endpoint is required") + } + if c.Garage.AccessKey == "" { + return fmt.Errorf("garage access_key is required") + } + if c.Garage.SecretKey == "" { + return fmt.Errorf("garage secret_key is required") + } + if c.Garage.AdminEndpoint == "" { + return fmt.Errorf("garage admin_endpoint is required") + } + if c.Garage.AdminToken == "" { + return fmt.Errorf("garage admin_token is required") + } + + // Validate auth mode + if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" { + return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode) + } + + // Validate basic auth if enabled + if c.Auth.Mode == "basic" { + if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" { + return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'") + } + } + + // Validate OIDC config if enabled + if c.Auth.Mode == "oidc" { + if c.Auth.OIDC.ClientID == "" { + return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'") + } + if c.Auth.OIDC.IssuerURL == "" { + return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'") + } + if len(c.Auth.OIDC.Scopes) == 0 { + return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'") + } + } + + return nil +} + +// GetAddress returns the full server address (host:port) +func (c *Config) GetAddress() string { + return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) +} + +// IsDevelopment returns true if running in development mode +func (c *Config) IsDevelopment() bool { + return c.Server.Environment == "development" +} + +// IsProduction returns true if running in production mode +func (c *Config) IsProduction() bool { + return c.Server.Environment == "production" +} diff --git a/internal/handlers/buckets.go b/internal/handlers/buckets.go new file mode 100644 index 0000000..8390a89 --- /dev/null +++ b/internal/handlers/buckets.go @@ -0,0 +1,316 @@ +package handlers + +import ( + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +// BucketHandler handles bucket-related operations +type BucketHandler struct { + adminService *services.GarageAdminService + s3Service *services.S3Service +} + +// NewBucketHandler creates a new bucket handler +func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler { + return &BucketHandler{ + adminService: adminService, + s3Service: s3Service, + } +} + +// ListBuckets lists all buckets +// +// @Summary List all buckets +// @Description Retrieves a list of all buckets in the Garage storage system with object count and size +// @Tags Buckets +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.BucketListResponse} "Successfully retrieved list of buckets" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list buckets" +// @Router /api/v1/buckets [get] +func (h *BucketHandler) ListBuckets(c fiber.Ctx) error { + ctx := c.Context() + + // List all buckets from Garage Admin API + adminBuckets, err := h.adminService.ListBuckets(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()), + ) + } + + // Convert admin bucket response to BucketInfo + buckets := make([]models.BucketInfo, 0, len(adminBuckets)) + for _, adminBucket := range adminBuckets { + // Get the bucket name from global aliases + var bucketName string + if len(adminBucket.GlobalAliases) > 0 { + bucketName = adminBucket.GlobalAliases[0] + } else { + // Skip buckets without global aliases + continue + } + + bucketInfo := models.BucketInfo{ + Name: bucketName, + CreationDate: adminBucket.Created, + Region: "", // Garage doesn't have regions + } + + // Try to get bucket statistics (object count and size) + // This is done asynchronously to avoid blocking the response + // If it fails, we still return the bucket info without stats + stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName) + if err == nil && stats != nil { + bucketInfo.ObjectCount = &stats.ObjectCount + bucketInfo.Size = &stats.TotalSize + } + + buckets = append(buckets, bucketInfo) + } + + response := models.BucketListResponse{ + Buckets: buckets, + Count: len(buckets), + } + + return c.JSON(models.SuccessResponse(response)) +} + +// CreateBucket creates a new bucket +// +// @Summary Create a new bucket +// @Description Creates a new bucket in the Garage storage system +// @Tags Buckets +// @Accept json +// @Produce json +// @Param payload body models.CreateBucketRequest true "Bucket creation payload" +// @Success 201 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket created successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body or bucket name is required" +// @Failure 409 {object} models.APIResponse{error=models.APIError} "Bucket already exists" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create bucket" +// @Router /api/v1/buckets [post] +func (h *BucketHandler) CreateBucket(c fiber.Ctx) error { + ctx := c.Context() + + // Parse request body + var req models.CreateBucketRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()), + ) + } + + // Validate bucket name + if req.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Check if bucket already exists + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), + ) + } + + if bucketInfo != nil { + return c.Status(fiber.StatusConflict).JSON( + models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"), + ) + } + + // Create the bucket + createBucketReq := models.CreateBucketAdminRequest{ + GlobalAlias: &req.Name, + } + if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()), + ) + } + + // Return success response + response := map[string]interface{}{ + "bucket": req.Name, + "message": "Bucket created successfully", + } + + return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(response)) +} + +// DeleteBucket deletes a bucket +// +// @Summary Delete a bucket +// @Description Deletes an existing bucket from the Garage storage system. The bucket must be empty before deletion. +// @Tags Buckets +// @Accept json +// @Produce json +// @Param name path string true "Name of the bucket to delete" +// @Success 200 {object} models.APIResponse{data=object{bucket=string,message=string}} "Bucket deleted successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete bucket" +// @Router /api/v1/buckets/{name} [delete] +func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("name") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Check if bucket already exists + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), + ) + } + + if bucketInfo == nil { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"), + ) + } + + // Delete the bucket + if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()), + ) + } + + // Return success response + response := map[string]interface{}{ + "bucket": bucketName, + "message": "Bucket deleted successfully", + } + + return c.JSON(models.SuccessResponse(response)) +} + +// GetBucketInfo returns information about a specific bucket +// +// @Summary Get bucket information +// @Description Retrieves detailed information about a specific bucket including creation date and region +// @Tags Buckets +// @Accept json +// @Produce json +// @Param name path string true "Name of the bucket to retrieve information for" +// @Success 200 {object} models.APIResponse{data=models.BucketInfo} "Successfully retrieved bucket information" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name is required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket does not exist" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve bucket information" +// @Router /api/v1/buckets/{name} [get] +func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("name") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Check if bucket already exists + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()), + ) + } + + if bucketInfo == nil { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"), + ) + } + + return c.JSON(models.SuccessResponse(bucketInfo)) +} + +// GrantBucketPermission grants permissions for an access key on a bucket +// +// @Summary Grant bucket permissions +// @Description Grants read/write/owner permissions for an access key on a specific bucket +// @Tags Buckets +// @Accept json +// @Produce json +// @Param name path string true "Name of the bucket" +// @Param request body models.GrantBucketPermissionRequest true "Permission grant request" +// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Permissions granted successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to grant permissions" +// @Router /api/v1/buckets/{name}/permissions [post] +func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("name") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Parse request body + var req models.GrantBucketPermissionRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()), + ) + } + + // Validate access key ID + if req.AccessKeyID == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Access key ID is required"), + ) + } + + // Get bucket info to retrieve bucket ID + bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()), + ) + } + + if bucketInfo == nil { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"), + ) + } + + // Build the permission request for Garage Admin API + permRequest := models.BucketKeyPermRequest{ + BucketID: bucketInfo.ID, + AccessKeyID: req.AccessKeyID, + Permissions: models.BucketKeyPermission{ + Read: req.Permissions.Read, + Write: req.Permissions.Write, + Owner: req.Permissions.Owner, + }, + } + + // Grant permissions using Garage Admin API + result, err := h.adminService.AllowBucketKey(ctx, permRequest) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(result)) +} diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go new file mode 100644 index 0000000..0bbede7 --- /dev/null +++ b/internal/handlers/cluster.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +// ClusterHandler handles cluster management operations +type ClusterHandler struct { + adminService *services.GarageAdminService +} + +// NewClusterHandler creates a new cluster handler +func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler { + return &ClusterHandler{ + adminService: adminService, + } +} + +// GetHealth returns the health status of the cluster +// +// @Summary Get cluster health +// @Description Retrieves the overall health status of the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster health" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster health" +// @Router /api/v1/cluster/health [get] +func (h *ClusterHandler) GetHealth(c fiber.Ctx) error { + ctx := c.Context() + + health, err := h.adminService.GetClusterHealth(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster health: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(health)) +} + +// GetStatus returns the status of the cluster +// +// @Summary Get cluster status +// @Description Retrieves the current status of the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved cluster status" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get cluster status" +// @Router /api/v1/cluster/status [get] +func (h *ClusterHandler) GetStatus(c fiber.Ctx) error { + ctx := c.Context() + + status, err := h.adminService.GetClusterStatus(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster status: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(status)) +} + +// GetStatistics returns global cluster statistics +// GET /api/v1/cluster/statistics +func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error { + ctx := c.Context() + + stats, err := h.adminService.GetClusterStatistics(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(stats)) +} + +// GetNodeInfo returns information about a specific node +// +// @Summary Get node information +// @Description Retrieves detailed information about a specific node in the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Param node_id path string true "ID of the node to retrieve information for" +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node information" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node information" +// @Router /api/v1/cluster/nodes/{node_id} [get] +func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error { + ctx := c.Context() + nodeID := c.Params("node_id") + + if nodeID == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"), + ) + } + + info, err := h.adminService.GetNodeInfo(ctx, nodeID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(info)) +} + +// GetNodeStatistics returns statistics for a specific node +// +// @Summary Get node statistics +// @Description Retrieves performance statistics and metrics for a specific node in the Garage storage cluster +// @Tags Cluster +// @Accept json +// @Produce json +// @Param node_id path string true "ID of the node to retrieve statistics for" +// @Success 200 {object} models.APIResponse{data=object} "Successfully retrieved node statistics" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Node ID is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get node statistics" +// @Router /api/v1/cluster/nodes/{node_id}/statistics [get] +func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error { + ctx := c.Context() + nodeID := c.Params("node_id") + + if nodeID == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"), + ) + } + + stats, err := h.adminService.GetNodeStatistics(ctx, nodeID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(stats)) +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..323ec08 --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "time" + + "Noooste/garage-ui/internal/models" + + "github.com/gofiber/fiber/v3" +) + +// HealthHandler handles health check requests +type HealthHandler struct { + version string +} + +// NewHealthHandler creates a new health check handler +func NewHealthHandler(version string) *HealthHandler { + return &HealthHandler{ + version: version, + } +} + +// Check returns the health status of the service +// +// @Summary Health check +// @Description Returns the health status of the API service along with version information +// @Tags Health +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.HealthResponse} "Service is healthy" +// @Router /api/v1/health [get] +func (h *HealthHandler) Check(c fiber.Ctx) error { + response := models.HealthResponse{ + Status: "healthy", + Timestamp: time.Now(), + Version: h.version, + } + + return c.JSON(models.SuccessResponse(response)) +} diff --git a/internal/handlers/monitoring.go b/internal/handlers/monitoring.go new file mode 100644 index 0000000..b1bfb00 --- /dev/null +++ b/internal/handlers/monitoring.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +// MonitoringHandler handles monitoring operations +type MonitoringHandler struct { + adminService *services.GarageAdminService + s3Service *services.S3Service +} + +// NewMonitoringHandler creates a new monitoring handler +func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler { + return &MonitoringHandler{ + adminService: adminService, + s3Service: s3Service, + } +} + +// GetMetrics retrieves system metrics from the Admin API +// +// @Summary Get system metrics +// @Description Retrieves system metrics from the Garage Admin API for monitoring purposes +// @Tags Monitoring +// @Accept json +// @Produce text/plain +// @Success 200 {string} string "System metrics in plain text format" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to retrieve metrics" +// @Router /api/v1/monitoring/metrics [get] +func (h *MonitoringHandler) GetMetrics(c fiber.Ctx) error { + ctx := c.Context() + + metrics, err := h.adminService.GetMetrics(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get metrics: "+err.Error()), + ) + } + + // Return metrics as plain text + c.Set("Content-Type", "text/plain; charset=utf-8") + return c.SendString(metrics) +} + +// CheckAdminHealth checks if the Admin API is reachable +// +// @Summary Check Admin API health +// @Description Performs a health check on the Garage Admin API to verify connectivity and availability +// @Tags Monitoring +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=object{status=string,message=string}} "Admin API is healthy" +// @Failure 503 {object} models.APIResponse{error=models.APIError} "Admin API health check failed" +// @Router /api/v1/monitoring/admin-health [get] +func (h *MonitoringHandler) CheckAdminHealth(c fiber.Ctx) error { + ctx := c.Context() + + err := h.adminService.HealthCheck(ctx) + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Admin API health check failed: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(map[string]interface{}{ + "status": "healthy", + "message": "Admin API is reachable", + })) +} + +// GetDashboardMetrics retrieves aggregated dashboard metrics +// +// @Summary Get dashboard metrics +// @Description Retrieves aggregated metrics for the dashboard including storage, buckets, and request metrics +// @Tags Monitoring +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.DashboardMetrics} "Successfully retrieved dashboard metrics" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get dashboard metrics" +// @Router /api/v1/monitoring/dashboard [get] +func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket list + buckets, err := h.adminService.ListBuckets(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get buckets: "+err.Error()), + ) + } + + // Calculate aggregated metrics + var totalSize int64 + var totalObjects int64 + usageByBucket := make([]models.BucketUsage, 0) + + for _, bucket := range buckets { + // Get bucket info to calculate size and object count + bucketInfo, err := h.adminService.GetBucketInfo(ctx, bucket.ID) + if err != nil { + continue // Skip buckets we can't access + } + + // Get size and object count from bucket info + bucketSize := bucketInfo.Bytes + objectCount := bucketInfo.Objects + + totalSize += bucketSize + totalObjects += objectCount + + // Get bucket name from aliases + bucketName := bucket.ID + if len(bucket.LocalAliases) > 0 { + bucketName = bucket.LocalAliases[0].Alias + } else if len(bucket.GlobalAliases) > 0 { + bucketName = bucket.GlobalAliases[0] + } + + usageByBucket = append(usageByBucket, models.BucketUsage{ + BucketName: bucketName, + Size: bucketSize, + ObjectCount: objectCount, + }) + } + + // Calculate percentages + for i := range usageByBucket { + if totalSize > 0 { + usageByBucket[i].Percentage = float64(usageByBucket[i].Size) / float64(totalSize) * 100 + } + } + + dashboardMetrics := models.DashboardMetrics{ + TotalSize: totalSize, + ObjectCount: totalObjects, + BucketCount: len(buckets), + UsageByBucket: usageByBucket, + } + + return c.JSON(models.SuccessResponse(dashboardMetrics)) +} diff --git a/internal/handlers/objects.go b/internal/handlers/objects.go new file mode 100644 index 0000000..25b3f72 --- /dev/null +++ b/internal/handlers/objects.go @@ -0,0 +1,588 @@ +package handlers + +import ( + "io" + "strconv" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +// ObjectHandler handles object-related operations +type ObjectHandler struct { + s3Service *services.S3Service +} + +// NewObjectHandler creates a new object handler +func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler { + return &ObjectHandler{ + s3Service: s3Service, + } +} + +// ListObjects lists objects in a bucket with optional filtering and pagination +// +// @Summary List objects in a bucket +// @Description Retrieves a list of objects and prefixes (folders) stored in the specified bucket, with optional filtering by prefix, pagination support, and max keys +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket to list objects from" +// @Param prefix query string false "Filter objects by prefix" +// @Param max_keys query int false "Maximum number of objects to return (default: 100)" +// @Param continuation_token query string false "Token for pagination to retrieve next page of results" +// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects" +// @Router /api/v1/buckets/{bucket}/objects [get] +func (h *ObjectHandler) ListObjects(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("bucket") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Get query parameters for filtering and pagination + prefix := c.Query("prefix", "") + continuationToken := c.Query("continuation_token", "") + + maxKeysStr := c.Query("max_keys", "100") + maxKeys, err := strconv.Atoi(maxKeysStr) + if err != nil || maxKeys <= 0 { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid max_keys parameter"), + ) + } + + // List objects in the bucket + objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(objects)) +} + +// UploadObject uploads an object to a bucket +// +// @Summary Upload object to bucket +// @Description Uploads an object to the specified bucket using multipart/form-data +// @Tags Objects +// @Accept multipart/form-data +// @Produce json +// @Param bucket path string true "Name of the bucket to upload the object to" +// @Param file formData file true "File to upload" +// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object" +// @Router /api/v1/buckets/{bucket}/objects [post] +func (h *ObjectHandler) UploadObject(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("bucket") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Get file from multipart form + file, err := c.FormFile("file") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "File is required: "+err.Error()), + ) + } + + // Get object key (path in bucket) + key := c.FormValue("key") + if key == "" { + // Use filename as key if not provided + key = file.Filename + } + + // Open the uploaded file + fileHandle, err := file.Open() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open uploaded file: "+err.Error()), + ) + } + defer fileHandle.Close() + + // Get content type + contentType := file.Header.Get("Content-Type") + + // Upload to Garage + uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()), + ) + } + + return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult)) +} + +// GetObject retrieves an object from a bucket +// +// @Summary Get object from bucket +// @Description Retrieves an object stored in the specified bucket +// @Tags Objects +// @Accept json +// @Produce application/octet-stream +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Param download query bool false "Set to true to download the object as an attachment" +// @Success 200 {file} binary "Successfully retrieved the object" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Router /api/v1/buckets/{bucket}/objects/{key} [get] +func (h *ObjectHandler) GetObject(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name and object key from URL parameters + bucketName := c.Params("bucket") + key := c.Params("key") + + if bucketName == "" || key == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"), + ) + } + + // Get object from Garage + body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()), + ) + } + defer body.Close() + + // Set response headers + c.Set("Content-Type", objectInfo.ContentType) + c.Set("Content-Length", string(rune(objectInfo.Size))) + c.Set("ETag", objectInfo.ETag) + c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123)) + + // Check if client wants to download or view inline + if c.Query("download") == "true" { + c.Set("Content-Disposition", "attachment; filename=\""+key+"\"") + } + + // Stream the object body to the client + return c.SendStream(body) +} + +// DeleteObject deletes an object from a bucket +// +// @Summary Delete object from bucket +// @Description Deletes an object stored in the specified bucket +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object" +// @Router /api/v1/buckets/{bucket}/objects/{key} [delete] +func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name and object key from URL parameters + bucketName := c.Params("bucket") + key := c.Params("key") + + if bucketName == "" || key == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"), + ) + } + + // Check if object exists + exists, err := h.s3Service.ObjectExists(ctx, bucketName, key) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()), + ) + } + + if !exists { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"), + ) + } + + // Delete the object + if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()), + ) + } + + // Return success response + response := models.ObjectDeleteResponse{ + Bucket: bucketName, + Key: key, + Deleted: true, + } + + return c.JSON(models.SuccessResponse(response)) +} + +// GetObjectMetadata returns metadata for an object without downloading it +// +// @Summary Get object metadata +// @Description Retrieves metadata information about an object without downloading the actual content +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Success 200 {object} models.APIResponse{data=models.ObjectInfo} "Successfully retrieved object metadata" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Router /api/v1/buckets/{bucket}/objects/{key}/metadata [get] +func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name and object key from URL parameters + bucketName := c.Params("bucket") + key := c.Params("key") + + if bucketName == "" || key == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"), + ) + } + + // Get object metadata + metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(metadata)) +} + +// GetPresignedURL generates a pre-signed URL for accessing an object +// +// @Summary Get pre-signed URL for object +// @Description Generates a pre-signed URL that allows temporary access to the specified object +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the object" +// @Param key path string true "Key (path) of the object" +// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)" +// @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to generate pre-signed URL" +// @Router /api/v1/buckets/{bucket}/objects/{key}/presigned-url [get] +func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name and object key from URL parameters + bucketName := c.Params("bucket") + key := c.Params("key") + + if bucketName == "" || key == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"), + ) + } + + // Get expiration time from query parameter (default: 1 hour) + expiresInStr := c.Query("expires_in", "3600") + expiresIn, err := strconv.ParseInt(expiresInStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time: "+err.Error()), + ) + } + + // Validate expiration time (1 second to 7 days) + if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"), + ) + } + + // Check if object exists + exists, err := h.s3Service.ObjectExists(ctx, bucketName, key) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()), + ) + } + + if !exists { + return c.Status(fiber.StatusNotFound).JSON( + models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"), + ) + } + + // Generate pre-signed URL + url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()), + ) + } + + response := models.PresignedURLResponse{ + URL: url, + ExpiresIn: expiresIn, + Bucket: bucketName, + Key: key, + } + + return c.JSON(models.SuccessResponse(response)) +} + +// DeleteMultipleObjects deletes multiple objects from a bucket +// +// @Summary Delete multiple objects from bucket +// @Description Deletes multiple objects stored in the specified bucket +// @Tags Objects +// @Accept json +// @Produce json +// @Param bucket path string true "Name of the bucket containing the objects" +// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context" +// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects" +// @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post] +func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("bucket") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Parse request body to get keys and optional prefix + var req struct { + Keys []string `json:"keys"` + Prefix string `json:"prefix,omitempty"` + } + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()), + ) + } + + if len(req.Keys) == 0 { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"), + ) + } + + // Delete multiple objects + if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()), + ) + } + + response := models.ObjectDeleteMultipleResponse{ + Bucket: bucketName, + Deleted: len(req.Keys), + Keys: req.Keys, + } + + return c.JSON(models.SuccessResponse(response)) +} + +// UploadObjectStream uploads an object from request body stream (for large files) +// +// @Summary Upload object via stream +// @Description Uploads an object directly from the request body stream, suitable for large files +// @Tags Objects +// @Accept application/octet-stream +// @Produce json +// @Param bucket path string true "Name of the bucket to upload the object to" +// @Param key path string true "Object key (path in bucket)" +// @Param Content-Type header string false "Content type of the object being uploaded" +// @Param body body string true "Raw binary data of the object" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object" +// @Router /api/v1/buckets/{bucket}/objects/{key} [put] +func (h *ObjectHandler) UploadObjectStream(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name and object key from URL parameters + bucketName := c.Params("bucket") + key := c.Params("key") + + if bucketName == "" || key == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"), + ) + } + + // Get content type from header + contentType := c.Get("Content-Type", "application/octet-stream") + + // Get request body as reader + bodyReader := c.Request().BodyStream() + + // Upload to Garage + uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, io.NopCloser(bodyReader), contentType) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()), + ) + } + + return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult)) +} + +// UploadMultipleObjects uploads multiple objects to a bucket +// +// @Summary Upload multiple objects to bucket +// @Description Uploads multiple objects to the specified bucket using multipart/form-data. Accepts unlimited number of files and handles them in a loop. +// @Tags Objects +// @Accept multipart/form-data +// @Produce json +// @Param bucket path string true "Name of the bucket to upload the objects to" +// @Param files formData file true "Files to upload (can be multiple)" +// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters" +// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects" +// @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post] +func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error { + ctx := c.Context() + + // Get bucket name from URL parameter + bucketName := c.Params("bucket") + if bucketName == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"), + ) + } + + // Parse multipart form to get all files + form, err := c.MultipartForm() + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Failed to parse multipart form: "+err.Error()), + ) + } + + // Get all files from the form (they should all be under "files" field) + files := form.File["files"] + if len(files) == 0 { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "At least one file is required"), + ) + } + + // Prepare upload data structure + uploadFiles := make([]struct { + Key string + Body io.Reader + ContentType string + }, len(files)) + + // Open all files and prepare for upload + for i, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open file "+fileHeader.Filename+": "+err.Error()), + ) + } + defer file.Close() + + // Use filename as the key + key := fileHeader.Filename + contentType := fileHeader.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + uploadFiles[i] = struct { + Key string + Body io.Reader + ContentType string + }{ + Key: key, + Body: file, + ContentType: contentType, + } + } + + // Upload all files using the service method + results := h.s3Service.UploadMultipleObjects(ctx, bucketName, uploadFiles) + + // Process results and categorize successes and failures + var successFiles []models.ObjectUploadResult + var failedFiles []models.ObjectUploadFailedResult + successCount := 0 + failureCount := 0 + + for _, result := range results { + if result.Success { + successCount++ + successFiles = append(successFiles, models.ObjectUploadResult{ + Key: result.Key, + ETag: result.ETag, + Size: result.Size, + ContentType: result.ContentType, + }) + } else { + failureCount++ + failedFiles = append(failedFiles, models.ObjectUploadFailedResult{ + Key: result.Key, + Error: result.Error.Error(), + ContentType: result.ContentType, + }) + } + } + + response := models.ObjectUploadMultipleResponse{ + Bucket: bucketName, + TotalFiles: len(files), + SuccessCount: successCount, + FailureCount: failureCount, + SuccessFiles: successFiles, + FailedFiles: failedFiles, + } + + // Return 201 if all succeeded, 207 (Multi-Status) if partial success, 500 if all failed + statusCode := fiber.StatusCreated + if failureCount > 0 && successCount > 0 { + statusCode = fiber.StatusMultiStatus // 207 + } else if failureCount > 0 && successCount == 0 { + statusCode = fiber.StatusInternalServerError + } + + return c.Status(statusCode).JSON(models.SuccessResponse(response)) +} diff --git a/internal/handlers/users.go b/internal/handlers/users.go new file mode 100644 index 0000000..3319fdf --- /dev/null +++ b/internal/handlers/users.go @@ -0,0 +1,341 @@ +package handlers + +import ( + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" +) + +// UserHandler handles user/key management operations using Garage Admin API +type UserHandler struct { + adminService *services.GarageAdminService +} + +// NewUserHandler creates a new user handler +func NewUserHandler(adminService *services.GarageAdminService) *UserHandler { + return &UserHandler{ + adminService: adminService, + } +} + +// ListUsers lists all users/access keys +// +// @Summary List all users +// @Description Retrieves a list of all users/access keys +// @Tags Users +// @Produce json +// @Success 200 {object} models.APIResponse{data=models.UserListResponse} "List of users retrieved successfully" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list users" +// @Router /api/v1/users [get] +func (h *UserHandler) ListUsers(c fiber.Ctx) error { + ctx := c.Context() + + keys, err := h.adminService.ListKeys(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()), + ) + } + + // Convert to UserInfo format + users := make([]models.UserInfo, 0, len(keys)) + for _, key := range keys { + // Get full key info to retrieve bucket permissions + keyInfo, err := h.adminService.GetKeyInfo(ctx, key.ID, false) + if err != nil { + // If we can't get full info, skip this key or use basic info + continue + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status based on expiration + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + users = append(users, models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + }) + } + + return c.JSON(models.SuccessResponse(models.UserListResponse{ + Users: users, + Count: len(users), + })) +} + +// convertBucketPermissionsToBucketPermissions converts Garage bucket permissions to frontend BucketPermission format +func convertBucketPermissionsToBucketPermissions(buckets []models.KeyBucketInfo) []models.BucketPermission { + permissions := make([]models.BucketPermission, 0, len(buckets)) + + for _, bucket := range buckets { + // Get bucket name from aliases + var bucketName string + if len(bucket.GlobalAliases) > 0 { + bucketName = bucket.GlobalAliases[0] + } else if len(bucket.LocalAliases) > 0 { + bucketName = bucket.LocalAliases[0] + } else { + bucketName = bucket.ID + } + + // Create bucket permission with simple read/write/owner flags + permissions = append(permissions, models.BucketPermission{ + BucketID: bucket.ID, + BucketName: bucketName, + Read: bucket.Permissions.Read, + Write: bucket.Permissions.Write, + Owner: bucket.Permissions.Owner, + }) + } + + return permissions +} + +// CreateUser creates a new user/access key +// +// @Summary Create a new user +// @Description Creates a new user/access key with optional name +// @Tags Users +// @Accept json +// @Produce json +// @Param request body models.CreateUserRequest true "User creation request" +// @Success 201 {object} models.APIResponse{data=models.UserInfo} "User created successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request body" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create user" +// @Router /api/v1/users [post] +func (h *UserHandler) CreateUser(c fiber.Ctx) error { + ctx := c.Context() + + var req models.CreateUserRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()), + ) + } + + // Prepare create key request + createReq := models.CreateKeyRequest{} + if req.Name != "" { + createReq.Name = &req.Name + } + + // Create the key + keyInfo, err := h.adminService.CreateKey(ctx, createReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()), + ) + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + // Convert to UserInfo format + userInfo := models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + SecretKey: keyInfo.SecretAccessKey, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + } + + return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(userInfo)) +} + +// DeleteUser deletes a user/access key +// +// @Summary Delete a user +// @Description Deletes a specific user/access key +// @Tags Users +// @Produce json +// @Param access_key path string true "Access key of the user to delete" +// @Success 200 {object} models.APIResponse{data=map[string]interface{}} "User deleted successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete user" +// @Router /api/v1/users/{access_key} [delete] +func (h *UserHandler) DeleteUser(c fiber.Ctx) error { + ctx := c.Context() + accessKey := c.Params("access_key") + + if accessKey == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"), + ) + } + + // Delete the key + err := h.adminService.DeleteKey(ctx, accessKey) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()), + ) + } + + return c.JSON(models.SuccessResponse(map[string]interface{}{ + "access_key": accessKey, + "deleted": true, + })) +} + +// GetUser retrieves information about a specific user/access key +// +// @Summary Get user information +// @Description Retrieves information about a specific user/access key +// @Tags Users +// @Produce json +// @Param access_key path string true "Access key of the user to retrieve" +// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User information retrieved successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get user info" +// @Router /api/v1/users/{access_key} [get] +func (h *UserHandler) GetUser(c fiber.Ctx) error { + ctx := c.Context() + accessKey := c.Params("access_key") + + if accessKey == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"), + ) + } + + // Get key information (without secret key) + keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()), + ) + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + // Convert to UserInfo format + userInfo := models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + } + + return c.JSON(models.SuccessResponse(userInfo)) +} + +// UpdateUserPermissions updates user permissions +// +// @Summary Update user permissions +// @Description Updates the permissions and settings for a specific user/access key +// @Tags Users +// @Accept json +// @Produce json +// @Param access_key path string true "Access key of the user to update" +// @Param request body models.UpdateUserRequest true "User update request with new permissions" +// @Success 200 {object} models.APIResponse{data=models.UserInfo} "User updated successfully" +// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required or invalid request body" +// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update user" +// @Router /api/v1/users/{access_key} [patch] +func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error { + ctx := c.Context() + accessKey := c.Params("access_key") + + if accessKey == "" { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"), + ) + } + + var req models.UpdateUserRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()), + ) + } + + // Prepare update request + updateReq := models.UpdateKeyRequest{} + + // Handle status change (activate/deactivate) + if req.Status != nil { + if *req.Status == "inactive" { + // Deactivate by setting expiration to the past + pastTime := time.Now().Add(-24 * time.Hour) + updateReq.Expiration = &pastTime + updateReq.NeverExpires = false + } else if *req.Status == "active" { + // Activate by removing expiration (set to never expire) + updateReq.NeverExpires = true + } + } + + // Handle explicit expiration date setting + if req.Expiration != nil && *req.Expiration != "" { + expirationTime, err := time.Parse(time.RFC3339, *req.Expiration) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON( + models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration date format: "+err.Error()), + ) + } + updateReq.Expiration = &expirationTime + updateReq.NeverExpires = false + } + + // Update the key + keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON( + models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()), + ) + } + + // Convert bucket permissions to frontend format + bucketPermissions := convertBucketPermissionsToBucketPermissions(keyInfo.Buckets) + + // Determine status + status := "active" + if keyInfo.Expired { + status = "inactive" + } + + // Convert to UserInfo format + userInfo := models.UserInfo{ + AccessKeyID: keyInfo.AccessKeyID, + Name: keyInfo.Name, + CreatedAt: keyInfo.Created, + Status: status, + BucketPermissions: bucketPermissions, + Expiration: keyInfo.Expiration, + Expired: keyInfo.Expired, + } + + return c.JSON(models.SuccessResponse(userInfo)) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..8411981 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,147 @@ +package middleware + +import ( + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + + "github.com/gofiber/fiber/v3" +) + +// AuthMiddleware returns a Fiber middleware for authentication +// It handles different auth modes: none, basic, and OIDC +func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler { + return func(c fiber.Ctx) error { + // If auth mode is "none", allow all requests + if cfg.Mode == "none" { + return c.Next() + } + + // Handle basic authentication + if cfg.Mode == "basic" { + return handleBasicAuth(c, authService) + } + + // Handle OIDC authentication + if cfg.Mode == "oidc" { + return handleOIDCAuth(c, authService, &cfg.OIDC) + } + + // Unknown auth mode - deny access + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"), + ) + } +} + +// handleBasicAuth validates basic authentication credentials +func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error { + // Get Authorization header + authHeader := c.Get("Authorization") + if authHeader == "" { + c.Set("WWW-Authenticate", `Basic realm="Restricted"`) + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"), + ) + } + + // Parse basic auth credentials + username, password, ok := auth.ParseBasicAuth(authHeader) + if !ok { + c.Set("WWW-Authenticate", `Basic realm="Restricted"`) + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"), + ) + } + + // Validate credentials + if !authService.ValidateBasicAuth(username, password) { + c.Set("WWW-Authenticate", `Basic realm="Restricted"`) + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"), + ) + } + + // Store username in context for later use + c.Locals("username", username) + + return c.Next() +} + +// handleOIDCAuth validates OIDC session/token +func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error { + // Get session cookie + sessionCookie := c.Cookies(oidcCfg.CookieName) + if sessionCookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"), + ) + } + + // In a production implementation, you would: + // 1. Validate the session token from the cookie + // 2. Look up the session in a session store (Redis, memory, etc.) + // 3. Verify the session hasn't expired + // 4. Extract user information from the session + // + // For now, we'll implement a basic token verification + // You should extend this based on your session management strategy + + // Verify ID token if it's stored in the session + ctx := c.Context() + userInfo, err := authService.VerifyIDToken(ctx, sessionCookie) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON( + models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"), + ) + } + + // Store user info in context for handlers to use + c.Locals("userInfo", userInfo) + c.Locals("username", userInfo.Username) + c.Locals("email", userInfo.Email) + + return c.Next() +} + +// RequireAuth is a simpler middleware that just checks if auth is enabled +// Use this for routes that should only be accessible when any auth is active +func RequireAuth(cfg *config.AuthConfig) fiber.Handler { + return func(c fiber.Ctx) error { + if cfg.Mode == "none" { + return c.Status(fiber.StatusForbidden).JSON( + models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"), + ) + } + return c.Next() + } +} + +// RequireAdmin is middleware that checks if the user has admin role (OIDC only) +func RequireAdmin(authService *auth.AuthService) fiber.Handler { + return func(c fiber.Ctx) error { + // Get user info from context (set by AuthMiddleware) + userInfoInterface := c.Locals("userInfo") + if userInfoInterface == nil { + return c.Status(fiber.StatusForbidden).JSON( + models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"), + ) + } + + userInfo, ok := userInfoInterface.(*auth.UserInfo) + if !ok { + return c.Status(fiber.StatusForbidden).JSON( + models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"), + ) + } + + // Check if user has admin role + if !authService.IsAdmin(userInfo) { + return c.Status(fiber.StatusForbidden).JSON( + models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"), + ) + } + + return c.Next() + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..bac15f2 --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,65 @@ +package middleware + +import ( + "strings" + + "Noooste/garage-ui/internal/config" + + "github.com/gofiber/fiber/v3" +) + +// CORSMiddleware creates a CORS middleware from configuration +func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler { + // If CORS is disabled, return a no-op middleware + if !cfg.Enabled { + return func(c fiber.Ctx) error { + return c.Next() + } + } + + return func(c fiber.Ctx) error { + origin := c.Get("Origin") + + // Check if origin is allowed + if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) { + // Set CORS headers + c.Set("Access-Control-Allow-Origin", origin) + + if cfg.AllowCredentials { + c.Set("Access-Control-Allow-Credentials", "true") + } + + // Set allowed methods + if len(cfg.AllowedMethods) > 0 { + c.Set("Access-Control-Allow-Methods", strings.Join(cfg.AllowedMethods, ", ")) + } + + // Set allowed headers + if len(cfg.AllowedHeaders) > 0 { + c.Set("Access-Control-Allow-Headers", strings.Join(cfg.AllowedHeaders, ", ")) + } + + // Set max age for preflight cache + if cfg.MaxAge > 0 { + c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge))) + } + } + + // Handle preflight requests + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusNoContent) + } + + return c.Next() + } +} + +// isAllowedOrigin checks if an origin is in the allowed list +func isAllowedOrigin(origin string, allowedOrigins []string) bool { + for _, allowed := range allowedOrigins { + if allowed == "*" || allowed == origin { + return true + } + } + return false +} diff --git a/internal/models/garage_admin.go b/internal/models/garage_admin.go new file mode 100644 index 0000000..7ca63fd --- /dev/null +++ b/internal/models/garage_admin.go @@ -0,0 +1,263 @@ +package models + +import "time" + +// ==================================== +// Access Key Models +// ==================================== + +// GarageKeyInfo represents detailed information about a Garage access key +type GarageKeyInfo struct { + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + Expired bool `json:"expired"` + SecretAccessKey *string `json:"secretAccessKey,omitempty"` + Permissions KeyPermissions `json:"permissions"` + Buckets []KeyBucketInfo `json:"buckets"` + Created *time.Time `json:"created,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` +} + +// KeyPermissions represents permissions for an access key +type KeyPermissions struct { + CreateBucket bool `json:"createBucket"` +} + +// KeyBucketInfo represents bucket information associated with a key +type KeyBucketInfo struct { + ID string `json:"id"` + GlobalAliases []string `json:"globalAliases"` + LocalAliases []string `json:"localAliases"` + Permissions BucketKeyPermission `json:"permissions"` +} + +// BucketKeyPermission represents permissions a key has on a specific bucket +type BucketKeyPermission struct { + Read bool `json:"read"` + Write bool `json:"write"` + Owner bool `json:"owner"` +} + +// CreateKeyRequest represents the request to create a new access key +type CreateKeyRequest struct { + Name *string `json:"name,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` + NeverExpires bool `json:"neverExpires,omitempty"` + Allow *KeyPermissions `json:"allow,omitempty"` + Deny *KeyPermissions `json:"deny,omitempty"` +} + +// UpdateKeyRequest represents the request to update an access key +type UpdateKeyRequest struct { + Name *string `json:"name,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` + NeverExpires bool `json:"neverExpires,omitempty"` + Allow *KeyPermissions `json:"allow,omitempty"` + Deny *KeyPermissions `json:"deny,omitempty"` +} + +// ImportKeyRequest represents the request to import an existing key +type ImportKeyRequest struct { + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + Name *string `json:"name,omitempty"` +} + +// ListKeysResponseItem represents a single key in the list response +type ListKeysResponseItem struct { + ID string `json:"id"` + Name string `json:"name"` + Expired bool `json:"expired"` + Created *time.Time `json:"created,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` +} + +// ==================================== +// Bucket Models (Admin API) +// ==================================== + +// GarageBucketInfo represents detailed information about a bucket from Admin API +type GarageBucketInfo struct { + ID string `json:"id"` + Created time.Time `json:"created"` + GlobalAliases []string `json:"globalAliases"` + WebsiteAccess bool `json:"websiteAccess"` + WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"` + Keys []BucketKeyInfo `json:"keys"` + Objects int64 `json:"objects"` + Bytes int64 `json:"bytes"` + UnfinishedUploads int64 `json:"unfinishedUploads"` + UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"` + UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"` + UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"` + Quotas *BucketQuotas `json:"quotas,omitempty"` +} + +// BucketWebsiteConfig represents website configuration for a bucket +type BucketWebsiteConfig struct { + IndexDocument string `json:"indexDocument"` + ErrorDocument *string `json:"errorDocument,omitempty"` +} + +// BucketQuotas represents quota settings for a bucket +type BucketQuotas struct { + MaxSize *int64 `json:"maxSize,omitempty"` + MaxObjects *int64 `json:"maxObjects,omitempty"` +} + +// BucketKeyInfo represents key information associated with a bucket +type BucketKeyInfo struct { + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + Permissions BucketKeyPermission `json:"permissions"` + BucketLocalAliases []string `json:"bucketLocalAliases"` +} + +// CreateBucketAdminRequest represents the request to create a bucket via Admin API +type CreateBucketAdminRequest struct { + GlobalAlias *string `json:"globalAlias,omitempty"` + LocalAlias *CreateBucketLocalAlias `json:"localAlias,omitempty"` +} + +// CreateBucketLocalAlias represents local alias configuration when creating a bucket +type CreateBucketLocalAlias struct { + AccessKeyID string `json:"accessKeyId"` + Alias string `json:"alias"` + Allow *BucketKeyPermission `json:"allow,omitempty"` +} + +// UpdateBucketRequest represents the request to update bucket settings +type UpdateBucketRequest struct { + WebsiteAccess *UpdateBucketWebsiteAccess `json:"websiteAccess,omitempty"` + Quotas *BucketQuotas `json:"quotas,omitempty"` +} + +// UpdateBucketWebsiteAccess represents website access settings update +type UpdateBucketWebsiteAccess struct { + Enabled bool `json:"enabled"` + IndexDocument *string `json:"indexDocument,omitempty"` + ErrorDocument *string `json:"errorDocument,omitempty"` +} + +// ListBucketsResponseItem represents a single bucket in the list response +type ListBucketsResponseItem struct { + ID string `json:"id"` + Created time.Time `json:"created"` + GlobalAliases []string `json:"globalAliases"` + LocalAliases []BucketLocalAlias `json:"localAliases"` +} + +// BucketLocalAlias represents a local alias for a bucket +type BucketLocalAlias struct { + AccessKeyID string `json:"accessKeyId"` + Alias string `json:"alias"` +} + +// ==================================== +// Bucket Alias Models +// ==================================== + +// AddBucketAliasRequest represents the request to add a bucket alias +type AddBucketAliasRequest struct { + BucketID string `json:"bucketId"` + GlobalAlias *string `json:"globalAlias,omitempty"` + LocalAlias *string `json:"localAlias,omitempty"` + AccessKeyID *string `json:"accessKeyId,omitempty"` +} + +// RemoveBucketAliasRequest represents the request to remove a bucket alias +type RemoveBucketAliasRequest struct { + BucketID string `json:"bucketId"` + GlobalAlias *string `json:"globalAlias,omitempty"` + LocalAlias *string `json:"localAlias,omitempty"` + AccessKeyID *string `json:"accessKeyId,omitempty"` +} + +// ==================================== +// Permission Models +// ==================================== + +// BucketKeyPermRequest represents a request to change bucket-key permissions +type BucketKeyPermRequest struct { + BucketID string `json:"bucketId"` + AccessKeyID string `json:"accessKeyId"` + Permissions BucketKeyPermission `json:"permissions"` +} + +// ==================================== +// Cluster Models +// ==================================== + +// ClusterHealth represents the health status of the cluster +type ClusterHealth struct { + Status string `json:"status"` + KnownNodes int `json:"knownNodes"` + ConnectedNodes int `json:"connectedNodes"` + StorageNodes int `json:"storageNodes"` + StorageNodesUp int `json:"storageNodesUp"` + Partitions int `json:"partitions"` + PartitionsQuorum int `json:"partitionsQuorum"` + PartitionsAllOk int `json:"partitionsAllOk"` +} + +// ClusterStatus represents the current status of the cluster +type ClusterStatus struct { + LayoutVersion int `json:"layoutVersion"` + Nodes []NodeInfo `json:"nodes"` +} + +// ClusterStatistics represents global cluster statistics +type ClusterStatistics struct { + Freeform string `json:"freeform"` +} + +// ==================================== +// Node Models +// ==================================== + +// NodeInfo represents information about a cluster node +type NodeInfo struct { + ID string `json:"id"` + IsUp bool `json:"isUp"` + LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo,omitempty"` + Hostname *string `json:"hostname,omitempty"` + Addr *string `json:"addr,omitempty"` + GarageVersion *string `json:"garageVersion,omitempty"` + Role *NodeRole `json:"role,omitempty"` + Draining bool `json:"draining"` + DataPartition *FreeSpaceInfo `json:"dataPartition,omitempty"` + MetadataPartition *FreeSpaceInfo `json:"metadataPartition,omitempty"` +} + +// NodeRole represents the role assigned to a node +type NodeRole struct { + Zone string `json:"zone"` + Capacity *int64 `json:"capacity,omitempty"` + Tags []string `json:"tags"` +} + +// FreeSpaceInfo represents disk space information +type FreeSpaceInfo struct { + Available int64 `json:"available"` + Total int64 `json:"total"` +} + +// NodeInfoResponse represents the response for GetNodeInfo +type NodeInfoResponse struct { + NodeID string `json:"nodeId"` + GarageVersion string `json:"garageVersion"` + RustVersion string `json:"rustVersion"` + DBEngine string `json:"dbEngine"` + GarageFeatures []string `json:"garageFeatures,omitempty"` +} + +// NodeStatisticsResponse represents the response for GetNodeStatistics +type NodeStatisticsResponse struct { + Freeform string `json:"freeform"` +} + +// MultiNodeResponse represents responses from multiple nodes +type MultiNodeResponse struct { + Success map[string]interface{} `json:"success"` + Error map[string]string `json:"error"` +} diff --git a/internal/models/requests.go b/internal/models/requests.go new file mode 100644 index 0000000..34f05a0 --- /dev/null +++ b/internal/models/requests.go @@ -0,0 +1,62 @@ +package models + +// CreateBucketRequest represents a request to create a new bucket +type CreateBucketRequest struct { + Name string `json:"name" validate:"required"` + Region string `json:"region,omitempty"` +} + +// GrantBucketPermissionRequest represents a request to grant permissions on a bucket +type GrantBucketPermissionRequest struct { + AccessKeyID string `json:"accessKeyId" validate:"required"` + Permissions BucketKeyPermission `json:"permissions" validate:"required"` +} + +// DeleteBucketRequest represents a request to delete a bucket +type DeleteBucketRequest struct { + Name string `json:"name" validate:"required"` +} + +// ListObjectsRequest represents a request to list objects in a bucket +type ListObjectsRequest struct { + Bucket string `json:"bucket" validate:"required"` + Prefix string `json:"prefix,omitempty"` + MaxKeys int `json:"max_keys,omitempty"` + Marker string `json:"marker,omitempty"` +} + +// UploadObjectRequest represents metadata for an object upload +// Note: The actual file data comes from multipart form or request body +type UploadObjectRequest struct { + Bucket string `json:"bucket" validate:"required"` + Key string `json:"key" validate:"required"` + ContentType string `json:"content_type,omitempty"` +} + +// DeleteObjectRequest represents a request to delete an object +type DeleteObjectRequest struct { + Bucket string `json:"bucket" validate:"required"` + Key string `json:"key" validate:"required"` +} + +// GetObjectRequest represents a request to get/download an object +type GetObjectRequest struct { + Bucket string `json:"bucket" validate:"required"` + Key string `json:"key" validate:"required"` +} + +// CreateUserRequest represents a request to create a new user/key +type CreateUserRequest struct { + Name string `json:"name,omitempty"` +} + +// DeleteUserRequest represents a request to delete a user/key +type DeleteUserRequest struct { + AccessKey string `json:"access_key" validate:"required"` +} + +// UpdateUserRequest represents a request to update user permissions +type UpdateUserRequest struct { + Status *string `json:"status,omitempty"` // "active" or "inactive" + Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string +} diff --git a/internal/models/responses.go b/internal/models/responses.go new file mode 100644 index 0000000..bf9f513 --- /dev/null +++ b/internal/models/responses.go @@ -0,0 +1,204 @@ +package models + +import "time" + +// DashboardMetrics represents aggregated metrics for the dashboard +type DashboardMetrics struct { + TotalSize int64 `json:"totalSize"` + ObjectCount int64 `json:"objectCount"` + BucketCount int `json:"bucketCount"` + UsageByBucket []BucketUsage `json:"usageByBucket"` +} + +// BucketUsage represents storage usage for a single bucket +type BucketUsage struct { + BucketName string `json:"bucketName"` + Size int64 `json:"size"` + ObjectCount int64 `json:"objectCount"` + Percentage float64 `json:"percentage"` +} + +// APIResponse is the standard response structure for all API endpoints +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *APIError `json:"error,omitempty"` +} + +// APIError represents an error in the API response +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// HealthResponse represents the health check response +type HealthResponse struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Version string `json:"version"` +} + +// BucketInfo represents information about a bucket +type BucketInfo struct { + Name string `json:"name"` + CreationDate time.Time `json:"creation_date"` + ObjectCount *int64 `json:"object_count,omitempty"` + Size *int64 `json:"size,omitempty"` + Region string `json:"region,omitempty"` +} + +// BucketListResponse represents a list of buckets +type BucketListResponse struct { + Buckets []BucketInfo `json:"buckets"` + Count int `json:"count"` +} + +// ObjectInfo represents information about an object +type ObjectInfo struct { + Key string `json:"key"` + Size int64 `json:"size"` + LastModified time.Time `json:"last_modified"` + ETag string `json:"etag"` + ContentType string `json:"content_type,omitempty"` + StorageClass string `json:"storage_class,omitempty"` +} + +// ObjectListResponse represents a list of objects in a bucket +type ObjectListResponse struct { + Bucket string `json:"bucket"` + Objects []ObjectInfo `json:"objects"` + Prefixes []string `json:"prefixes"` + Count int `json:"count"` + IsTruncated bool `json:"is_truncated"` + NextContinuationToken string `json:"next_continuation_token,omitempty"` +} + +// ObjectUploadResponse represents the response after uploading an object +type ObjectUploadResponse struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + ETag string `json:"etag"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` +} + +// ObjectUploadMultipleResponse represents the response after uploading multiple objects +type ObjectUploadMultipleResponse struct { + Bucket string `json:"bucket"` + TotalFiles int `json:"total_files"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + SuccessFiles []ObjectUploadResult `json:"success_files"` + FailedFiles []ObjectUploadFailedResult `json:"failed_files,omitempty"` +} + +// ObjectUploadResult represents a successful upload result +type ObjectUploadResult struct { + Key string `json:"key"` + ETag string `json:"etag"` + Size int64 `json:"size"` + ContentType string `json:"content_type,omitempty"` +} + +// ObjectUploadFailedResult represents a failed upload result +type ObjectUploadFailedResult struct { + Key string `json:"key"` + Error string `json:"error"` + ContentType string `json:"content_type,omitempty"` +} + +// ObjectDeleteResponse represents the response after deleting an object +type ObjectDeleteResponse struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Deleted bool `json:"deleted"` +} + +// UserInfo represents information about a Garage user (key pair) +type UserInfo struct { + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + SecretKey *string `json:"secretKey,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + LastUsed *time.Time `json:"lastUsed,omitempty"` + Status string `json:"status"` // "active" or "inactive" + BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions + Expiration *time.Time `json:"expiration,omitempty"` + Expired bool `json:"expired"` +} + +// BucketPermission represents permissions for a specific bucket +type BucketPermission struct { + BucketID string `json:"bucketId"` + BucketName string `json:"bucketName"` + Read bool `json:"read"` + Write bool `json:"write"` + Owner bool `json:"owner"` +} + +// Permission represents a permission entry for access control (legacy/deprecated) +type Permission struct { + Resource string `json:"resource"` + Actions []string `json:"actions"` + Effect string `json:"effect"` // "Allow" or "Deny" +} + +type PresignedURLResponse struct { + URL string `json:"url"` + ExpiresIn int64 `json:"expires_in"` // in seconds + Bucket string `json:"bucket"` + Key string `json:"key"` +} + +type ObjectDeleteMultipleResponse struct { + Bucket string `json:"bucket"` + Deleted int `json:"deleted"` + Keys []string `json:"keys"` +} + +// UserListResponse represents a list of users/keys +type UserListResponse struct { + Users []UserInfo `json:"users"` + Count int `json:"count"` +} + +// Helper functions to create standard responses + +// SuccessResponse creates a successful API response +func SuccessResponse(data interface{}) APIResponse { + return APIResponse{ + Success: true, + Data: data, + Error: nil, + } +} + +// ErrorResponse creates an error API response +func ErrorResponse(code, message string) APIResponse { + return APIResponse{ + Success: false, + Data: nil, + Error: &APIError{ + Code: code, + Message: message, + }, + } +} + +// Common error codes +const ( + ErrCodeBadRequest = "BAD_REQUEST" + ErrCodeUnauthorized = "UNAUTHORIZED" + ErrCodeForbidden = "FORBIDDEN" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeConflict = "CONFLICT" + ErrCodeInternalError = "INTERNAL_ERROR" + ErrCodeBucketExists = "BUCKET_ALREADY_EXISTS" + ErrCodeBucketNotFound = "BUCKET_NOT_FOUND" + ErrCodeObjectNotFound = "OBJECT_NOT_FOUND" + ErrCodeInvalidBucketName = "INVALID_BUCKET_NAME" + ErrCodeInvalidObjectKey = "INVALID_OBJECT_KEY" + ErrCodeUploadFailed = "UPLOAD_FAILED" + ErrCodeDeleteFailed = "DELETE_FAILED" + ErrCodeListFailed = "LIST_FAILED" +) diff --git a/internal/routes/routes.go b/internal/routes/routes.go new file mode 100644 index 0000000..e47d980 --- /dev/null +++ b/internal/routes/routes.go @@ -0,0 +1,201 @@ +package routes + +import ( + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/handlers" + "Noooste/garage-ui/internal/middleware" + + "github.com/gofiber/fiber/v3" + + // Swagger imports + _ "Noooste/garage-ui/docs" + + "github.com/Noooste/swagger" +) + +// SetupRoutes configures all API routes +func SetupRoutes( + app *fiber.App, + cfg *config.Config, + authService *auth.AuthService, + healthHandler *handlers.HealthHandler, + bucketHandler *handlers.BucketHandler, + objectHandler *handlers.ObjectHandler, + userHandler *handlers.UserHandler, + clusterHandler *handlers.ClusterHandler, + monitoringHandler *handlers.MonitoringHandler, +) { + // Apply CORS middleware globally + app.Use(middleware.CORSMiddleware(&cfg.CORS)) + + // Health check endpoint (no auth required) + app.Get("/health", healthHandler.Check) + app.Get("/api/v1/health", healthHandler.Check) + + // Swagger documentation endpoint (no auth required) + app.Get("/docs/*", swagger.HandlerDefault) + + // API v1 group + api := app.Group("/api/v1") + + // Apply authentication middleware to all API routes + api.Use(middleware.AuthMiddleware(&cfg.Auth, authService)) + + // Bucket routes + buckets := api.Group("/buckets") + { + buckets.Get("/", bucketHandler.ListBuckets) // List all buckets + buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket + buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info + buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket + buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions + } + + // Object routes + objects := api.Group("/buckets/:bucket/objects") + { + objects.Get("/", objectHandler.ListObjects) // List objects in bucket + objects.Post("/", objectHandler.UploadObject) // Upload object (multipart) + objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects + objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects + objects.Get("/:key", objectHandler.GetObject) // Download object + objects.Put("/:key", objectHandler.UploadObjectStream) // Upload object (stream) + objects.Delete("/:key", objectHandler.DeleteObject) // Delete object + objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata + objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL + } + + // User/Key management routes + users := api.Group("/users") + { + users.Get("/", userHandler.ListUsers) // List all users/keys + users.Post("/", userHandler.CreateUser) // Create new user/key + users.Get("/:access_key", userHandler.GetUser) // Get user info + users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key + users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions + } + + // Cluster management routes + cluster := api.Group("/cluster") + { + cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health + cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status + cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics + cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info + cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics + } + + // Monitoring routes + monitoring := api.Group("/monitoring") + { + monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics + monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health + monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics + } + + // OIDC authentication routes (only if OIDC is enabled) + if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled { + authRoutes := app.Group("/auth") + { + // Login endpoint - redirects to OIDC provider + authRoutes.Get("/login", func(c fiber.Ctx) error { + // Generate state token for CSRF protection + state := "random-state-token" // In production, use a secure random token + authURL, err := authService.GetAuthorizationURL(state) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to generate login URL", + }) + } + return c.Redirect().To(authURL) + }) + + // Callback endpoint - handles OIDC redirect after login + authRoutes.Get("/callback", func(c fiber.Ctx) error { + // Get authorization code from query + code := c.Query("code") + if code == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Authorization code is required", + }) + } + + // Exchange code for tokens + ctx := c.Context() + token, err := authService.ExchangeCode(ctx, code) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Failed to exchange authorization code", + }) + } + + // Extract ID token from OAuth2 token + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "No ID token in response", + }) + } + + // Verify ID token and get user info + userInfo, err := authService.VerifyIDToken(ctx, rawIDToken) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid ID token", + }) + } + + // In production, you should: + // 1. Create a session and store it in Redis/memory + // 2. Set a secure session cookie + // 3. Redirect to the frontend with the session + + // For now, just set the ID token as a cookie (not recommended for production) + c.Cookie(&fiber.Cookie{ + Name: cfg.Auth.OIDC.CookieName, + Value: rawIDToken, + MaxAge: cfg.Auth.OIDC.SessionMaxAge, + Secure: cfg.Auth.OIDC.CookieSecure, + HTTPOnly: cfg.Auth.OIDC.CookieHTTPOnly, + SameSite: cfg.Auth.OIDC.CookieSameSite, + }) + + return c.JSON(fiber.Map{ + "success": true, + "user": userInfo, + }) + }) + + // Logout endpoint + authRoutes.Post("/logout", func(c fiber.Ctx) error { + // Clear session cookie + c.Cookie(&fiber.Cookie{ + Name: cfg.Auth.OIDC.CookieName, + Value: "", + MaxAge: -1, + }) + + return c.JSON(fiber.Map{ + "success": true, + "message": "Logged out successfully", + }) + }) + + // User info endpoint + authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error { + userInfo := c.Locals("userInfo") + if userInfo == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Not authenticated", + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "user": userInfo, + }) + }) + } + } +} diff --git a/internal/services/garage_admin.go b/internal/services/garage_admin.go new file mode 100644 index 0000000..1e8f44b --- /dev/null +++ b/internal/services/garage_admin.go @@ -0,0 +1,460 @@ +package services + +import ( + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/Noooste/azuretls-client" +) + +// GarageAdminService handles interactions with the Garage Admin API +type GarageAdminService struct { + baseURL string + token string + httpClient *azuretls.Session +} + +// NewGarageAdminService creates a new Garage Admin API service +func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService { + session := azuretls.NewSession() + session.Log() + + return &GarageAdminService{ + baseURL: cfg.AdminEndpoint, + token: cfg.AdminToken, + httpClient: session, + } +} + +// doRequest performs an HTTP request to the Admin API +func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) { + return s.httpClient.Do(&azuretls.Request{ + Method: method, + Url: s.baseURL + path, + Body: body, + IgnoreBody: true, // decodeResponse will handle body reading + OrderedHeaders: azuretls.OrderedHeaders{ + {"Authorization", fmt.Sprintf("Bearer %s", s.token)}, + }, + }, ctx) +} + +// decodeResponse decodes a JSON response into the target structure +func decodeResponse(resp *azuretls.Response, target interface{}) error { + defer resp.RawBody.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.RawBody) + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + if target != nil { + if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + +// ==================================== +// Access Key Operations +// ==================================== + +// ListKeys returns all access keys in the cluster +func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result []models.ListKeysResponseItem + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return result, nil +} + +// CreateKey creates a new API access key +func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetKeyInfo returns information about a specific access key +func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) { + path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID) + if showSecret { + path += "&showSecretKey=true" + } + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// UpdateKey updates information about an access key +func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) { + path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// DeleteKey deletes an access key from the cluster +func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error { + path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("failed to process response: %w", err) + } + + return nil +} + +// ImportKey imports an existing API access key +func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageKeyInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Bucket Operations (Admin API) +// ==================================== + +// ListBuckets returns all buckets in the cluster +func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result []models.ListBucketsResponseItem + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return result, nil +} + +// GetBucketInfo returns detailed information about a bucket by ID +func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) { + path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetBucketInfoByAlias returns detailed information about a bucket by its global alias +func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) { + path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// CreateBucket creates a new bucket via the Admin API +func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// UpdateBucket updates bucket settings +func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { + path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// DeleteBucket deletes a bucket +func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error { + path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID) + + resp, err := s.doRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("failed to process response: %w", err) + } + + return nil +} + +// ==================================== +// Bucket Alias Operations +// ==================================== + +// AddBucketAlias adds an alias to a bucket +func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// RemoveBucketAlias removes an alias from a bucket +func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Permission Operations +// ==================================== + +// AllowBucketKey grants permissions for a key on a bucket +func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// DenyBucketKey revokes permissions for a key on a bucket +func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.GarageBucketInfo + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Cluster Operations +// ==================================== + +// GetClusterHealth returns the health status of the cluster +func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.ClusterHealth + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetClusterStatus returns the current status of the cluster +func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.ClusterStatus + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetClusterStatistics returns global cluster statistics +func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.ClusterStatistics + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Node Operations +// ==================================== + +// GetNodeInfo returns information about a specific node +func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.MultiNodeResponse + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// GetNodeStatistics returns statistics for a specific node +func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) { + path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID) + + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var result models.MultiNodeResponse + if err := decodeResponse(resp, &result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// ==================================== +// Monitoring Operations +// ==================================== + +// HealthCheck checks if the Admin API is reachable +func (s *GarageAdminService) HealthCheck(ctx context.Context) error { + resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + if err := decodeResponse(resp, nil); err != nil { + return fmt.Errorf("health check returned error: %w", err) + } + + return nil +} + +// GetMetrics returns Prometheus metrics from the Admin API +func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) { + resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.RawBody.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.RawBody) + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + bodyBytes, err := io.ReadAll(resp.RawBody) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + return string(bodyBytes), nil +} diff --git a/internal/services/s3.go b/internal/services/s3.go new file mode 100644 index 0000000..756e7dd --- /dev/null +++ b/internal/services/s3.go @@ -0,0 +1,543 @@ +package services + +import ( + "context" + "fmt" + "io" + "time" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/pkg/utils" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// S3Service handles all S3 operations with Garage +type S3Service struct { + client *s3.Client + config *config.GarageConfig + adminService *GarageAdminService +} + +// NewS3Service creates a new S3 service instance +func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service { + // Create AWS credentials from Garage config (default/fallback credentials) + creds := credentials.NewStaticCredentialsProvider( + cfg.AccessKey, + cfg.SecretKey, + "", // session token (not used for Garage) + ) + + // Configure S3 client for Garage + s3Config := aws.Config{ + Region: cfg.Region, + Credentials: creds, + } + + // Create S3 client with custom endpoint resolver for Garage + client := s3.NewFromConfig(s3Config, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.Endpoint) + o.UsePathStyle = cfg.ForcePathStyle + }) + + return &S3Service{ + client: client, + config: cfg, + adminService: adminService, + } +} + +// getBucketCredentials retrieves credentials for a specific bucket +// It checks the cache first, then queries the Garage Admin API +func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (aws.CredentialsProvider, error) { + cacheKey := fmt.Sprintf("key:%s", bucketName) + cacheData := utils.GlobalCache.Get(cacheKey) + + if cacheData != nil { + return cacheData.(aws.CredentialsProvider), nil + } + + // Get bucket info from Garage Admin API + bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get bucket info: %w", err) + } + + // Find a key with read and write permissions + var accessKeyID, secretAccessKey string + for _, keyInfo := range bucketInfo.Keys { + if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write { + continue + } + + // Get key details with secret + keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true) + if err != nil { + return nil, fmt.Errorf("failed to get key info: %w", err) + } + + if keyDetails.SecretAccessKey != nil { + accessKeyID = keyDetails.AccessKeyID + secretAccessKey = *keyDetails.SecretAccessKey + break + } + } + + if accessKeyID == "" || secretAccessKey == "" { + return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName) + } + + // Create credentials provider + credential := credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "") + + // Cache credentials for 1 hour + utils.GlobalCache.Set(cacheKey, credential, time.Hour) + + return credential, nil +} + +// getS3Client creates an S3 client for a specific bucket with dynamic credentials +func (s *S3Service) getS3Client(ctx context.Context, bucketName string) (*s3.Client, error) { + creds, err := s.getBucketCredentials(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err) + } + + // AWS config + awsConfig := aws.Config{ + Credentials: creds, + Region: s.config.Region, + } + + // Build S3 client with BaseEndpoint for Garage + client := s3.NewFromConfig(awsConfig, func(o *s3.Options) { + o.BaseEndpoint = aws.String(s.config.Endpoint) + o.UsePathStyle = s.config.ForcePathStyle + o.EndpointResolver = s3.EndpointResolverFunc(func(region string, opts s3.EndpointResolverOptions) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: s.config.Endpoint, + SigningRegion: s.config.Region, + }, nil + }) + }) + + return client, nil +} + +// ListBuckets retrieves all buckets from Garage +func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) { + // Call S3 ListBuckets API + result, err := s.client.ListBuckets(ctx, &s3.ListBucketsInput{}) + if err != nil { + return nil, fmt.Errorf("failed to list buckets: %w", err) + } + + // Convert S3 buckets to our model + buckets := make([]models.BucketInfo, 0, len(result.Buckets)) + for _, bucket := range result.Buckets { + buckets = append(buckets, models.BucketInfo{ + Name: aws.ToString(bucket.Name), + CreationDate: aws.ToTime(bucket.CreationDate), + }) + } + + return &models.BucketListResponse{ + Buckets: buckets, + Count: len(buckets), + }, nil +} + +// CreateBucket creates a new bucket in Garage +func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error { + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + input := &s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + } + + // Call S3 CreateBucket API + _, err = client.CreateBucket(ctx, input) + if err != nil { + return fmt.Errorf("failed to create bucket %s: %w", bucketName, err) + } + + return nil +} + +// DeleteBucket deletes a bucket from Garage +func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error { + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + // Call S3 DeleteBucket API + _, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{ + Bucket: aws.String(bucketName), + }) + if err != nil { + return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err) + } + + return nil +} + +// ListObjects lists objects in a bucket with optional prefix filter and pagination +func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + // Set default max keys if not specified + if maxKeys <= 0 { + maxKeys = 100 + } + + // Create list objects input + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + Delimiter: aws.String("/"), + MaxKeys: aws.Int32(int32(maxKeys)), + } + + if prefix != "" { + input.Prefix = aws.String(prefix) + } + + if continuationToken != "" { + input.ContinuationToken = aws.String(continuationToken) + } + + // Call S3 ListObjectsV2 API + result, err := client.ListObjectsV2(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err) + } + + // Convert S3 objects to our model + objects := make([]models.ObjectInfo, 0, len(result.Contents)) + for _, obj := range result.Contents { + objects = append(objects, models.ObjectInfo{ + Key: aws.ToString(obj.Key), + Size: aws.ToInt64(obj.Size), + LastModified: aws.ToTime(obj.LastModified), + ETag: aws.ToString(obj.ETag), + StorageClass: string(obj.StorageClass), + }) + } + + // Extract common prefixes (folders/directories) + prefixes := make([]string, 0, len(result.CommonPrefixes)) + for _, p := range result.CommonPrefixes { + prefixes = append(prefixes, aws.ToString(p.Prefix)) + } + + return &models.ObjectListResponse{ + Bucket: bucketName, + Objects: objects, + Prefixes: prefixes, + Count: len(objects), + IsTruncated: aws.ToBool(result.IsTruncated), + NextContinuationToken: aws.ToString(result.NextContinuationToken), + }, nil +} + +// UploadObject uploads an object to a bucket +func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + // Create put object input + input := &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: body, + } + + if contentType != "" { + input.ContentType = aws.String(contentType) + } + + // Call S3 PutObject API + result, err := client.PutObject(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err) + } + + // Get object metadata to return size + headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + + var size int64 + if err == nil { + size = aws.ToInt64(headResult.ContentLength) + } + + return &models.ObjectUploadResponse{ + Bucket: bucketName, + Key: key, + ETag: aws.ToString(result.ETag), + Size: size, + ContentType: contentType, + }, nil +} + +// GetObject retrieves an object from a bucket +func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) { + // Call S3 GetObject API + result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err) + } + + // Create object info + objectInfo := &models.ObjectInfo{ + Key: key, + Size: aws.ToInt64(result.ContentLength), + LastModified: aws.ToTime(result.LastModified), + ETag: aws.ToString(result.ETag), + ContentType: aws.ToString(result.ContentType), + } + + return result.Body, objectInfo, nil +} + +// DeleteObject deletes an object from a bucket +func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error { + // Call S3 DeleteObject API + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err) + } + + return nil +} + +// ObjectExists checks if an object exists in a bucket +func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return false, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + _, err = client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + return false, nil + } + return true, nil +} + +// GetObjectMetadata retrieves metadata for an object without downloading it +func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + result, err := client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err) + } + + return &models.ObjectInfo{ + Key: key, + Size: aws.ToInt64(result.ContentLength), + LastModified: aws.ToTime(result.LastModified), + ETag: aws.ToString(result.ETag), + ContentType: aws.ToString(result.ContentType), + }, nil +} + +// DeleteMultipleObjects deletes multiple objects from a bucket +func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error { + if len(keys) == 0 { + return nil + } + + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + // Create delete objects for batch deletion + objects := make([]types.ObjectIdentifier, len(keys)) + for i, key := range keys { + objects[i] = types.ObjectIdentifier{ + Key: aws.String(key), + } + } + + // Call S3 DeleteObjects API (batch delete) + _, err = client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: aws.String(bucketName), + Delete: &types.Delete{ + Objects: objects, + Quiet: aws.Bool(false), + }, + }) + if err != nil { + return fmt.Errorf("failed to delete multiple objects from bucket %s: %w", bucketName, err) + } + + return nil +} + +// GetPresignedURL generates a pre-signed URL for temporary access to an object +// This is useful for sharing files without exposing credentials +func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) { + // Get bucket-specific S3 client + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + return "", fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err) + } + + // Create presign client + presignClient := s3.NewPresignClient(client) + + // Generate presigned GET request + presignResult, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }, func(opts *s3.PresignOptions) { + opts.Expires = expiresIn + }) + if err != nil { + return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err) + } + + return presignResult.URL, nil +} + +// UploadResult represents the result of a single file upload +type UploadResult struct { + Key string + Success bool + Error error + ETag string + Size int64 + ContentType string +} + +// UploadMultipleObjects uploads multiple objects to a bucket +// It handles uploads in batches to respect any S3/Garage limits +// Returns results for each file, including both successes and failures +func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct { + Key string + Body io.Reader + ContentType string +}) []UploadResult { + results := make([]UploadResult, len(files)) + + // Get bucket-specific S3 client once for all uploads + client, err := s.getS3Client(ctx, bucketName) + if err != nil { + // If we can't get the client, all uploads fail + for i := range files { + results[i] = UploadResult{ + Key: files[i].Key, + Success: false, + Error: fmt.Errorf("failed to get S3 client for bucket %s: %w", bucketName, err), + } + } + return results + } + + // Upload each file + for i, file := range files { + // Create put object input + input := &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(file.Key), + Body: file.Body, + } + + if file.ContentType != "" { + input.ContentType = aws.String(file.ContentType) + } + + // Attempt upload + result, err := client.PutObject(ctx, input) + if err != nil { + results[i] = UploadResult{ + Key: file.Key, + Success: false, + Error: fmt.Errorf("failed to upload object %s: %w", file.Key, err), + ContentType: file.ContentType, + } + continue + } + + // Get object metadata to return size + headResult, err := client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(file.Key), + }) + + var size int64 + if err == nil { + size = aws.ToInt64(headResult.ContentLength) + } + + results[i] = UploadResult{ + Key: file.Key, + Success: true, + Error: nil, + ETag: aws.ToString(result.ETag), + Size: size, + ContentType: file.ContentType, + } + } + + return results +} + +// BucketStatistics holds statistical information about a bucket +type BucketStatistics struct { + ObjectCount int64 + TotalSize int64 +} + +// GetBucketStatistics retrieves bucket statistics from Garage Admin API +// This is much more efficient than iterating through all objects +func (s *S3Service) GetBucketStatistics(ctx context.Context, bucketName string) (*BucketStatistics, error) { + // Get bucket info from Garage Admin API which includes object count and size + bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get bucket info for %s: %w", bucketName, err) + } + + // Return statistics from Admin API + return &BucketStatistics{ + ObjectCount: bucketInfo.Objects, + TotalSize: bucketInfo.Bytes, + }, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cb2352f --- /dev/null +++ b/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/handlers" + "Noooste/garage-ui/internal/routes" + "Noooste/garage-ui/internal/services" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/gofiber/fiber/v3/middleware/recover" +) + +// @title Garage UI API +// @version 0.1.0 +// @description REST API for managing Garage distributed object storage system +// @description This API provides endpoints for managing buckets, objects, users, and cluster operations. +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.email support@garage-ui.io + +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT + +// @host localhost:8080 +// @BasePath / +// @schemes http https + +// @tag.name Health +// @tag.description Health check endpoints + +// @tag.name Buckets +// @tag.description Bucket management operations + +// @tag.name Objects +// @tag.description Object storage and retrieval operations + +// @tag.name Users +// @tag.description User and access key management + +// @tag.name Cluster +// @tag.description Cluster status and node management + +// @tag.name Monitoring +// @tag.description Monitoring and metrics endpoints + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token. + +const version = "0.1.0" + +func main() { + // Parse command-line flags + configPath := flag.String("config", "config.yaml", "Path to configuration file") + flag.Parse() + + // Load configuration + log.Printf("Loading configuration from: %s", *configPath) + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment) + + // Initialize services + log.Println("Initializing Garage Admin service...") + adminService := services.NewGarageAdminService(&cfg.Garage) + + log.Println("Initializing S3 service...") + s3Service := services.NewS3Service(&cfg.Garage, adminService) + + log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode) + authService, err := auth.NewAuthService(&cfg.Auth) + if err != nil { + log.Fatalf("Failed to initialize auth service: %v", err) + } + + // Initialize handlers + healthHandler := handlers.NewHealthHandler(version) + bucketHandler := handlers.NewBucketHandler(adminService, s3Service) + objectHandler := handlers.NewObjectHandler(s3Service) + userHandler := handlers.NewUserHandler(adminService) + clusterHandler := handlers.NewClusterHandler(adminService) + monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service) + + // Create Fiber app with configuration + app := fiber.New(fiber.Config{ + AppName: "Garage UI Backend v" + version, + //DisableStartupMessage: false, + //EnablePrintRoutes: cfg.IsDevelopment(), + ErrorHandler: customErrorHandler, + }) + + // Apply global middleware + app.Use(recover.New()) // Panic recovery + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} (${latency})\n", + })) + + // Setup routes + log.Println("Setting up routes...") + routes.SetupRoutes( + app, + cfg, + authService, + healthHandler, + bucketHandler, + objectHandler, + userHandler, + clusterHandler, + monitoringHandler, + ) + + // Start server in a goroutine + go func() { + addr := cfg.GetAddress() + log.Printf("Server listening on %s", addr) + log.Printf("Health check available at: http://%s/health", addr) + log.Printf("API documentation: http://%s/api/v1/", addr) + + if err := app.Listen(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // Wait for interrupt signal to gracefully shutdown the server + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + if err := app.Shutdown(); err != nil { + log.Fatalf("Server shutdown failed: %v", err) + } + + log.Println("Server stopped gracefully") +} + +// customErrorHandler handles errors globally +func customErrorHandler(c fiber.Ctx, err error) error { + // Default to 500 Internal Server Error + code := fiber.StatusInternalServerError + + // Check if it's a Fiber error + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + + // Log the error + log.Printf("Error: %v", err) + + // Return JSON error response + return c.Status(code).JSON(fiber.Map{ + "success": false, + "error": fiber.Map{ + "code": fmt.Sprintf("ERROR_%d", code), + "message": err.Error(), + }, + }) +} diff --git a/pkg/utils/cache.go b/pkg/utils/cache.go new file mode 100644 index 0000000..5413ea0 --- /dev/null +++ b/pkg/utils/cache.go @@ -0,0 +1,95 @@ +package utils + +import ( + "sync" + "time" +) + +// CacheItem represents a cached item with expiration +type CacheItem struct { + Value interface{} + Expiration time.Time +} + +// Cache represents a simple in-memory cache with expiration +type Cache struct { + mu sync.RWMutex + items map[string]CacheItem +} + +// NewCache creates a new cache instance +func NewCache() *Cache { + c := &Cache{ + items: make(map[string]CacheItem), + } + + // Start cleanup goroutine + go c.cleanupExpired() + + return c +} + +// Get retrieves a value from the cache +func (c *Cache) Get(key string) interface{} { + c.mu.RLock() + defer c.mu.RUnlock() + + item, exists := c.items[key] + if !exists { + return nil + } + + // Check if item has expired + if time.Now().After(item.Expiration) { + return nil + } + + return item.Value +} + +// Set stores a value in the cache with an expiration duration +func (c *Cache) Set(key string, value interface{}, duration time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.items[key] = CacheItem{ + Value: value, + Expiration: time.Now().Add(duration), + } +} + +// Delete removes a value from the cache +func (c *Cache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.items, key) +} + +// Clear removes all items from the cache +func (c *Cache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + c.items = make(map[string]CacheItem) +} + +// cleanupExpired periodically removes expired items +func (c *Cache) cleanupExpired() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + c.mu.Lock() + now := time.Now() + for key, item := range c.items { + if now.After(item.Expiration) { + delete(c.items, key) + } + } + c.mu.Unlock() + } +} + +// Global cache instance +var GlobalCache = NewCache()