From 4ceb2709bec5d5346d9d35f72f460028a024d60b Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Thu, 23 Jul 2026 23:11:36 +0100 Subject: [PATCH 1/2] feat: Replace CLI admin panel with web-based one --- admin/README.md | 55 - admin/package-lock.json | 2782 ----------------- admin/package.json | 37 - admin/src/billing/pricing.ts | 169 - .../src/commands/InactiveProjectsCommand.tsx | 188 -- admin/src/commands/LimitsCommand.tsx | 223 -- admin/src/commands/OrgsCommand.tsx | 388 --- admin/src/commands/ProjectsCommand.tsx | 350 --- admin/src/commands/RecentActivityCommand.tsx | 270 -- admin/src/commands/SearchCommand.tsx | 323 -- admin/src/commands/StatsCommand.tsx | 127 - admin/src/commands/TopProjectsCommand.tsx | 201 -- admin/src/commands/UsersCommand.tsx | 564 ---- admin/src/components/App.tsx | 166 - admin/src/components/Spinner.tsx | 18 - admin/src/components/Table.tsx | 79 - admin/src/db/clickhouse.ts | 215 -- admin/src/db/mysql.ts | 58 - .../entities/organisation-member.entity.ts | 45 - admin/src/entities/organisation.entity.ts | 30 - admin/src/entities/project.entity.ts | 59 - admin/src/entities/user.entity.ts | 122 - admin/src/index.tsx | 33 - admin/tsconfig.json | 22 - backend/.env.example | 5 + .../apps/cloud/src/admin/admin.controller.ts | 215 ++ backend/apps/cloud/src/admin/admin.module.ts | 18 + backend/apps/cloud/src/admin/admin.service.ts | 1153 +++++++ .../admin/decorators/admin-auth.decorator.ts | 10 + .../src/admin/guards/admin-access.guard.ts | 38 + backend/apps/cloud/src/app.module.ts | 2 + web/app/lib/constants/index.ts | 1 + web/app/pages/Admin/DatabaseTab.tsx | 131 + web/app/pages/Admin/OrganisationsTab.tsx | 68 + web/app/pages/Admin/OverviewTab.tsx | 313 ++ web/app/pages/Admin/ProjectsTab.tsx | 254 ++ web/app/pages/Admin/UsersTab.tsx | 455 +++ web/app/pages/Admin/components.tsx | 143 + web/app/pages/Admin/index.tsx | 265 ++ web/app/pages/Admin/types.ts | 207 ++ web/app/routes/admin.tsx | 288 ++ 41 files changed, 3566 insertions(+), 6524 deletions(-) delete mode 100644 admin/README.md delete mode 100644 admin/package-lock.json delete mode 100644 admin/package.json delete mode 100644 admin/src/billing/pricing.ts delete mode 100644 admin/src/commands/InactiveProjectsCommand.tsx delete mode 100644 admin/src/commands/LimitsCommand.tsx delete mode 100644 admin/src/commands/OrgsCommand.tsx delete mode 100644 admin/src/commands/ProjectsCommand.tsx delete mode 100644 admin/src/commands/RecentActivityCommand.tsx delete mode 100644 admin/src/commands/SearchCommand.tsx delete mode 100644 admin/src/commands/StatsCommand.tsx delete mode 100644 admin/src/commands/TopProjectsCommand.tsx delete mode 100644 admin/src/commands/UsersCommand.tsx delete mode 100644 admin/src/components/App.tsx delete mode 100644 admin/src/components/Spinner.tsx delete mode 100644 admin/src/components/Table.tsx delete mode 100644 admin/src/db/clickhouse.ts delete mode 100644 admin/src/db/mysql.ts delete mode 100644 admin/src/entities/organisation-member.entity.ts delete mode 100644 admin/src/entities/organisation.entity.ts delete mode 100644 admin/src/entities/project.entity.ts delete mode 100644 admin/src/entities/user.entity.ts delete mode 100644 admin/src/index.tsx delete mode 100644 admin/tsconfig.json create mode 100644 backend/apps/cloud/src/admin/admin.controller.ts create mode 100644 backend/apps/cloud/src/admin/admin.module.ts create mode 100644 backend/apps/cloud/src/admin/admin.service.ts create mode 100644 backend/apps/cloud/src/admin/decorators/admin-auth.decorator.ts create mode 100644 backend/apps/cloud/src/admin/guards/admin-access.guard.ts create mode 100644 web/app/pages/Admin/DatabaseTab.tsx create mode 100644 web/app/pages/Admin/OrganisationsTab.tsx create mode 100644 web/app/pages/Admin/OverviewTab.tsx create mode 100644 web/app/pages/Admin/ProjectsTab.tsx create mode 100644 web/app/pages/Admin/UsersTab.tsx create mode 100644 web/app/pages/Admin/components.tsx create mode 100644 web/app/pages/Admin/index.tsx create mode 100644 web/app/pages/Admin/types.ts create mode 100644 web/app/routes/admin.tsx diff --git a/admin/README.md b/admin/README.md deleted file mode 100644 index a89b85309..000000000 --- a/admin/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Swetrix Admin CLI - -An interactive CLI tool for managing Swetrix. - -## Prerequisites - -- Node.js >= 22 -- Access to the Swetrix MySQL and ClickHouse databases -- Environment variables configured (either in `admin/.env` or `backend/.env`) - -## Installation - -```bash -cd admin -npm install -``` - -## Configuration - -The CLI reads database configuration from environment variables. You can either: - -1. Create an `.env` file in the `admin/` directory -2. Or rely on the existing `backend/.env` file (it will be auto-loaded) - -Required environment variables: - -```env -# MySQL -MYSQL_HOST=localhost -MYSQL_USER=root -MYSQL_ROOT_PASSWORD=your_password -MYSQL_DATABASE=analytics - -# ClickHouse -CLICKHOUSE_HOST=http://localhost -CLICKHOUSE_PORT=8123 -CLICKHOUSE_USER=default -CLICKHOUSE_PASSWORD= -CLICKHOUSE_DATABASE=analytics -``` - -## Usage - -Run the CLI using: - -```bash -npm run dev -``` - -Or build and run: - -```bash -npm run build -node dist/index.js -``` diff --git a/admin/package-lock.json b/admin/package-lock.json deleted file mode 100644 index 308391bea..000000000 --- a/admin/package-lock.json +++ /dev/null @@ -1,2782 +0,0 @@ -{ - "name": "swetrix-admin-cli", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "swetrix-admin-cli", - "version": "1.0.0", - "dependencies": { - "@clickhouse/client": "^1.14.0", - "dotenv": "^17.2.3", - "ink": "^5.2.0", - "ink-select-input": "^6.0.0", - "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", - "mysql2": "^3.15.3", - "react": "^18.3.1", - "reflect-metadata": "^0.2.2", - "typeorm": "^0.3.27" - }, - "bin": { - "swetrix-admin": "dist/index.js" - }, - "devDependencies": { - "@types/node": "^24.10.1", - "@types/react": "^18.3.18", - "tsx": "^4.19.4", - "typescript": "^5.9.3" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", - "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=14.13.1" - } - }, - "node_modules/@clickhouse/client": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.15.0.tgz", - "integrity": "sha512-QmW+p4c/r0oa3X6Un6lcBs4GZtJEQUdvf//x8GeqM5ru6m4oIUg3WwvermP3HE31kpEGoFOQfKbMN5ooR5gvNw==", - "license": "Apache-2.0", - "dependencies": { - "@clickhouse/client-common": "1.15.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@clickhouse/client-common": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-1.15.0.tgz", - "integrity": "sha512-/1BXaNNsBzH2w5ALWiH6M9zS+cJwlM9uMnMj9e8ETwvDjJzO+nIYlyxzp8Prc+9pEDZ5iAilZ4F8c0YVCzzNaA==", - "license": "Apache-2.0" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@sqltools/formatter": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", - "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", - "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ansis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", - "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", - "license": "ISC", - "engines": { - "node": ">=14" - } - }, - "node_modules/app-root-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", - "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "license": "MIT", - "engines": { - "node": ">= 6.0.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/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "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/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "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": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/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/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "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/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "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/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, - "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/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "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/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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-toolkit": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", - "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "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": "BSD-3-Clause" - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ink": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", - "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", - "license": "MIT", - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.1.3", - "ansi-escapes": "^7.0.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.22.0", - "indent-string": "^5.0.0", - "is-in-ci": "^1.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.29.0", - "scheduler": "^0.23.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^7.2.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-devtools-core": "^4.19.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } - } - }, - "node_modules/ink-select-input": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", - "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", - "license": "MIT", - "dependencies": { - "figures": "^6.1.0", - "to-rotated": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5.0.0", - "react": ">=18.0.0" - } - }, - "node_modules/ink-spinner": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", - "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", - "license": "MIT", - "dependencies": { - "cli-spinners": "^2.7.0" - }, - "engines": { - "node": ">=14.16" - }, - "peerDependencies": { - "ink": ">=4.0.0", - "react": ">=18.0.0" - } - }, - "node_modules/ink-text-input": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", - "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5", - "react": ">=18" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "license": "MIT" - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, - "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/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "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/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "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": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/lru.min": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", - "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, - "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/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "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/mysql2": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.0.tgz", - "integrity": "sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA==", - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "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/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-reconciler": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", - "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sql-highlight": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", - "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", - "funding": [ - "https://github.com/scriptcoded/sql-highlight?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/scriptcoded" - } - ], - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/to-rotated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", - "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typeorm": { - "version": "0.3.28", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", - "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", - "license": "MIT", - "dependencies": { - "@sqltools/formatter": "^1.2.5", - "ansis": "^4.2.0", - "app-root-path": "^3.1.0", - "buffer": "^6.0.3", - "dayjs": "^1.11.19", - "debug": "^4.4.3", - "dedent": "^1.7.0", - "dotenv": "^16.6.1", - "glob": "^10.5.0", - "reflect-metadata": "^0.2.2", - "sha.js": "^2.4.12", - "sql-highlight": "^6.1.0", - "tslib": "^2.8.1", - "uuid": "^11.1.0", - "yargs": "^17.7.2" - }, - "bin": { - "typeorm": "cli.js", - "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", - "typeorm-ts-node-esm": "cli-ts-node-esm.js" - }, - "engines": { - "node": ">=16.13.0" - }, - "funding": { - "url": "https://opencollective.com/typeorm" - }, - "peerDependencies": { - "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@sap/hana-client": "^2.14.22", - "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", - "ioredis": "^5.0.4", - "mongodb": "^5.8.0 || ^6.0.0", - "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", - "mysql2": "^2.2.5 || ^3.0.1", - "oracledb": "^6.3.0", - "pg": "^8.5.1", - "pg-native": "^3.0.0", - "pg-query-stream": "^4.0.0", - "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", - "sql.js": "^1.4.0", - "sqlite3": "^5.0.3", - "ts-node": "^10.7.0", - "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" - }, - "peerDependenciesMeta": { - "@google-cloud/spanner": { - "optional": true - }, - "@sap/hana-client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "mongodb": { - "optional": true - }, - "mssql": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "oracledb": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-native": { - "optional": true - }, - "pg-query-stream": { - "optional": true - }, - "redis": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "ts-node": { - "optional": true - }, - "typeorm-aurora-data-api-driver": { - "optional": true - } - } - }, - "node_modules/typeorm/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "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/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/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "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/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yoga-layout": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", - "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "license": "MIT" - } - } -} diff --git a/admin/package.json b/admin/package.json deleted file mode 100644 index 79ce2be4e..000000000 --- a/admin/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "swetrix-admin-cli", - "private": true, - "version": "1.0.0", - "description": "CLI admin tool for managing Swetrix organizations, projects, and users", - "type": "module", - "main": "dist/index.js", - "bin": { - "swetrix-admin": "./dist/index.js" - }, - "scripts": { - "build": "tsc", - "start": "node --loader ts-node/esm src/index.tsx", - "dev": "tsx src/index.tsx" - }, - "dependencies": { - "@clickhouse/client": "^1.14.0", - "dotenv": "^17.2.3", - "ink": "^5.2.0", - "ink-select-input": "^6.0.0", - "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", - "mysql2": "^3.15.3", - "react": "^18.3.1", - "reflect-metadata": "^0.2.2", - "typeorm": "^0.3.27" - }, - "devDependencies": { - "@types/node": "^24.10.1", - "@types/react": "^18.3.18", - "tsx": "^4.19.4", - "typescript": "^5.9.3" - }, - "engines": { - "node": ">=22" - } -} diff --git a/admin/src/billing/pricing.ts b/admin/src/billing/pricing.ts deleted file mode 100644 index bd48c5bf0..000000000 --- a/admin/src/billing/pricing.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { PlanCode, PlanType, User } from '../entities/user.entity.js' - -const paidPlanCodes = new Set([ - PlanCode.hobby, - PlanCode.freelancer, - PlanCode['50k'], - PlanCode['100k'], - PlanCode['200k'], - PlanCode['500k'], - PlanCode['1m'], - PlanCode['2m'], - PlanCode['5m'], - PlanCode['10m'], - PlanCode['15m'], - PlanCode['20m'], - PlanCode['30m'], - PlanCode['40m'], - PlanCode['50m'], -]) - -export const eventBuckets: Record = { - [PlanCode.none]: 0, - [PlanCode.free]: 5000, - [PlanCode.trial]: 10000000, - [PlanCode.hobby]: 10000, - [PlanCode['50k']]: 50000, - [PlanCode.freelancer]: 100000, - [PlanCode['100k']]: 100000, - [PlanCode['200k']]: 200000, - [PlanCode['500k']]: 500000, - [PlanCode['1m']]: 1000000, - [PlanCode['2m']]: 2000000, - [PlanCode['5m']]: 5000000, - [PlanCode['10m']]: 10000000, - [PlanCode['15m']]: 15000000, - [PlanCode['20m']]: 20000000, - [PlanCode['30m']]: 30000000, - [PlanCode['40m']]: 40000000, - [PlanCode['50m']]: 50000000, -} - -export const planEntitlements = { - [PlanType.standard]: { - websites: 10, - teamMembers: 10, - organisations: 3, - apiRateLimitPerHour: 300, - sessionReplaysIncluded: 0, - }, - [PlanType.plus]: { - websites: 100, - teamMembers: 25, - organisations: 10, - apiRateLimitPerHour: 6000, - sessionReplaysIncluded: 'byEventTier', - }, - [PlanType.enterprise]: { - websites: 'custom', - teamMembers: 'custom', - organisations: 'custom', - apiRateLimitPerHour: 'custom', - sessionReplaysIncluded: 'custom', - }, -} as const - -const sessionReplayQuotas: Partial> = { - [PlanCode.freelancer]: 5000, - [PlanCode['100k']]: 5000, - [PlanCode['200k']]: 10000, - [PlanCode['500k']]: 25000, - [PlanCode['1m']]: 50000, - [PlanCode['2m']]: 100000, - [PlanCode['5m']]: 250000, - [PlanCode['10m']]: 500000, - [PlanCode['15m']]: 750000, - [PlanCode['20m']]: 1000000, - [PlanCode['30m']]: 1500000, - [PlanCode['40m']]: 2000000, - [PlanCode['50m']]: 2500000, -} - -const numberValue = (source: Record | null, key: string) => { - const value = source?.[key] - - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -export const getEffectivePlanType = (user: User): PlanType | null => { - if (user.planType) return user.planType - if (paidPlanCodes.has(user.planCode)) return PlanType.standard - return null -} - -export const getEffectiveLimits = (user: User) => { - const planType = getEffectivePlanType(user) || PlanType.standard - const entitlements = planEntitlements[planType] - const addonWebsites = - numberValue(user.addonOverrides, 'websites') ?? - numberValue(user.addonOverrides, 'additionalWebsites') ?? - 0 - const addonSessionReplays = - numberValue(user.addonOverrides, 'sessionReplays') ?? 0 - const websiteOverride = numberValue(user.entitlementOverrides, 'websites') - const apiOverride = numberValue( - user.entitlementOverrides, - 'apiRateLimitPerHour', - ) - const replayOverride = numberValue( - user.entitlementOverrides, - 'sessionReplaysIncluded', - ) - const teamMembersOverride = numberValue( - user.entitlementOverrides, - 'teamMembers', - ) - const organisationsOverride = numberValue( - user.entitlementOverrides, - 'organisations', - ) - const replayQuota = - replayOverride ?? - (entitlements.sessionReplaysIncluded === 'byEventTier' - ? sessionReplayQuotas[user.planCode] || 0 - : entitlements.sessionReplaysIncluded) - const effectiveReplayQuota = - typeof replayQuota === 'number' - ? replayQuota + addonSessionReplays - : replayQuota - const includedSessionReplays = replayQuota - const websites = - websiteOverride ?? - (typeof entitlements.websites === 'number' - ? entitlements.websites - : user.maxProjects) - const apiRateLimitPerHour = - apiOverride ?? - (typeof entitlements.apiRateLimitPerHour === 'number' - ? entitlements.apiRateLimitPerHour - : user.maxApiKeyRequestsPerHour) - const teamMembers = - teamMembersOverride ?? - (typeof entitlements.teamMembers === 'number' - ? entitlements.teamMembers - : 'custom') - const organisations = - organisationsOverride ?? - (typeof entitlements.organisations === 'number' - ? entitlements.organisations - : 'custom') - - return { - planType, - eventBucket: eventBuckets[user.planCode] || 0, - includedWebsites: websites, - websitesIncluded: - typeof websites === 'number' ? websites + addonWebsites : websites, - effectiveProjectLimit: - typeof websites === 'number' ? websites + addonWebsites : websites, - maxProjects: - typeof websites === 'number' ? websites + addonWebsites : websites, - purchasedWebsiteAddons: addonWebsites, - includedSessionReplays, - replayQuota: effectiveReplayQuota, - purchasedSessionReplayAddons: addonSessionReplays, - apiRateLimitPerHour, - teamMembers, - organisations, - } -} diff --git a/admin/src/commands/InactiveProjectsCommand.tsx b/admin/src/commands/InactiveProjectsCommand.tsx deleted file mode 100644 index 4c41f1c13..000000000 --- a/admin/src/commands/InactiveProjectsCommand.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text, useInput } from 'ink' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { getProjectsWithRecentEvents, getProjectEventCount } from '../db/clickhouse.js' -import { AppDataSource, Project } from '../db/mysql.js' - -interface InactiveProjectsCommandProps { - onBack: () => void -} - -type Period = '30d' | '60d' | '90d' -type Mode = 'list' | 'detail' - -const periodDays: Record = { - '30d': 30, - '60d': 60, - '90d': 90, -} - -interface InactiveProject extends Project { - adminEmail?: string - lastEventDays?: number -} - -export function InactiveProjectsCommand({ onBack }: InactiveProjectsCommandProps) { - const [mode, setMode] = useState('list') - const [loading, setLoading] = useState(true) - const [period, setPeriod] = useState('30d') - const [projects, setProjects] = useState([]) - const [selectedProject, setSelectedProject] = useState(null) - const [eventCount, setEventCount] = useState(0) - const [error, setError] = useState(null) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'detail') { - setMode('list') - setSelectedProject(null) - } else { - onBack() - } - } - if (mode === 'list') { - if (input === '1') setPeriod('30d') - if (input === '2') setPeriod('60d') - if (input === '3') setPeriod('90d') - } - }) - - useEffect(() => { - loadInactiveProjects() - }, [period]) - - async function loadInactiveProjects() { - setLoading(true) - setError(null) - try { - const activeProjectPids = await getProjectsWithRecentEvents(periodDays[period]) - - const projectRepo = AppDataSource.getRepository(Project) - const allProjects = await projectRepo - .createQueryBuilder('project') - .leftJoinAndSelect('project.admin', 'admin') - .where('project.isArchived = :archived', { archived: false }) - .orderBy('project.created', 'DESC') - .getMany() - - const inactiveProjects: InactiveProject[] = allProjects - .filter(p => !activeProjectPids.has(p.id)) - .map(p => ({ - ...p, - adminEmail: p.admin?.email || 'N/A', - })) - .slice(0, 50) - - setProjects(inactiveProjects) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load inactive projects') - } finally { - setLoading(false) - } - } - - async function loadProjectDetails(project: Project) { - setLoading(true) - try { - const count = await getProjectEventCount(project.id) - setEventCount(count) - setSelectedProject(project) - setMode('detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project details') - } finally { - setLoading(false) - } - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'detail' && selectedProject) { - return ( - - 📁 Project Details - - - ID: {selectedProject.id} - Name: {selectedProject.name} - - Status:{' '} - - {selectedProject.active ? 'Active' : 'Inactive'} - - - Website URL: {selectedProject.origins?.[0] || 'Not set'} - Created: {new Date(selectedProject.created).toLocaleString()} - Owner: {selectedProject.admin?.email || 'N/A'} - - Total Events (all time):{' '} - {eventCount.toLocaleString()} - - - ⚠️ No events in the last {periodDays[period]} days - - - - - Press ESC to go back - - - ) - } - - const projectItems = projects.map(p => ({ - label: `[${p.id}] ${p.name} - ${p.adminEmail}`, - value: p.id, - })) - - return ( - - 💀 Inactive/Zombie Projects - - - - No events in:{' '} - [1] 30 days - {' | '} - [2] 60 days - {' | '} - [3] 90 days - - - - - Found {projects.length} inactive projects (showing first 50) - - - {projects.length === 0 ? ( - No inactive projects found! - ) : ( - - { - const project = projects.find(p => p.id === item.value) - if (project) loadProjectDetails(project) - }} - /> - - )} - - - Press ESC to go back - - - ) -} diff --git a/admin/src/commands/LimitsCommand.tsx b/admin/src/commands/LimitsCommand.tsx deleted file mode 100644 index b8645f4a6..000000000 --- a/admin/src/commands/LimitsCommand.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text, useInput } from 'ink' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { AppDataSource, User, Project, OrganisationMember } from '../db/mysql.js' -import { getEffectiveLimits } from '../billing/pricing.js' - -interface LimitsCommandProps { - onBack: () => void -} - -type Mode = 'list' | 'detail' - -interface UserWithLimits extends User { - projectCount: number - usagePercent: number - effectiveProjectLimit: number | 'custom' -} - -export function LimitsCommand({ onBack }: LimitsCommandProps) { - const [mode, setMode] = useState('list') - const [loading, setLoading] = useState(true) - const [users, setUsers] = useState([]) - const [selectedUser, setSelectedUser] = useState(null) - const [userProjects, setUserProjects] = useState([]) - const [userOrgs, setUserOrgs] = useState([]) - const [error, setError] = useState(null) - const [threshold, setThreshold] = useState(80) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'detail') { - setMode('list') - setSelectedUser(null) - } else { - onBack() - } - } - if (mode === 'list') { - if (input === '1') setThreshold(50) - if (input === '2') setThreshold(80) - if (input === '3') setThreshold(90) - } - }) - - useEffect(() => { - loadUsersApproachingLimits() - }, [threshold]) - - async function loadUsersApproachingLimits() { - setLoading(true) - setError(null) - try { - const userRepo = AppDataSource.getRepository(User) - - const usersWithCounts = await userRepo - .createQueryBuilder('user') - .loadRelationCountAndMap('user.projectCount', 'user.projects') - .where('user.isActive = :active', { active: true }) - .orderBy('user.created', 'DESC') - .getMany() - - const usersApproaching: UserWithLimits[] = (usersWithCounts as (User & { projectCount: number })[]) - .map(user => { - const effectiveLimits = getEffectiveLimits(user) - const effectiveProjectLimit = effectiveLimits.effectiveProjectLimit - - return { - ...user, - projectCount: user.projectCount || 0, - effectiveProjectLimit, - usagePercent: - typeof effectiveProjectLimit === 'number' && - effectiveProjectLimit > 0 - ? Math.round( - (user.projectCount / effectiveProjectLimit) * 100, - ) - : 0, - } - }) - .filter(user => user.usagePercent >= threshold) - .sort((a, b) => b.usagePercent - a.usagePercent) - .slice(0, 50) - - setUsers(usersApproaching) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load users') - } finally { - setLoading(false) - } - } - - async function loadUserDetails(user: User) { - setLoading(true) - try { - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo.find({ - where: { admin: { id: user.id } }, - order: { created: 'DESC' }, - take: 10, - }) - setUserProjects(projects) - - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const orgs = await memberRepo.find({ - where: { user: { id: user.id } }, - relations: ['organisation'], - }) - setUserOrgs(orgs) - - setSelectedUser(user) - setMode('detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load user details') - } finally { - setLoading(false) - } - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'detail' && selectedUser) { - const userWithLimits = users.find(u => u.id === selectedUser.id) - - return ( - - 👤 User Details - - - ID: {selectedUser.id} - Email: {selectedUser.email} - Nickname: {selectedUser.nickname || 'N/A'} - Plan: {selectedUser.planCode} - - Projects:{' '} - = 90 ? 'red' : 'yellow'} bold> - {userWithLimits?.projectCount || 0} / {userWithLimits?.effectiveProjectLimit ?? selectedUser.maxProjects} - - ({userWithLimits?.usagePercent || 0}%) - - Created: {new Date(selectedUser.created).toLocaleString()} - {selectedUser.trialEndDate && ( - - Trial Ends:{' '} - {new Date(selectedUser.trialEndDate).toLocaleString()} - - )} - - - {userProjects.length > 0 && ( - - Recent Projects: - {userProjects.slice(0, 5).map(p => ( - • [{p.id}] {p.name} - ))} - - )} - - - Press ESC to go back - - - ) - } - - const userItems = users.map(user => { - const color = user.usagePercent >= 100 ? 'red' : user.usagePercent >= 90 ? 'yellow' : 'white' - return { - label: `${user.email} (${user.planCode}) - ${user.projectCount}/${user.effectiveProjectLimit} projects (${user.usagePercent}%)`, - value: user.id, - } - }) - - return ( - - ⚠️ Users Approaching Limits - - - - Threshold:{' '} - [1] 50%+ - {' | '} - [2] 80%+ - {' | '} - [3] 90%+ - - - - - Found {users.length} users at {threshold}%+ capacity - - - {users.length === 0 ? ( - No users approaching this limit - ) : ( - - { - const user = users.find(u => u.id === item.value) - if (user) loadUserDetails(user) - }} - /> - - )} - - - Press ESC to go back - - - ) -} diff --git a/admin/src/commands/OrgsCommand.tsx b/admin/src/commands/OrgsCommand.tsx deleted file mode 100644 index 94b416000..000000000 --- a/admin/src/commands/OrgsCommand.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text, useInput } from 'ink' -import TextInput from 'ink-text-input' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { Table } from '../components/Table.js' -import { AppDataSource, Organisation, OrganisationMember, User, Project } from '../db/mysql.js' -import { getProjectEventCount } from '../db/clickhouse.js' - -interface OrgsCommandProps { - onBack: () => void -} - -type Mode = 'list' | 'search' | 'detail' | 'user-detail' | 'project-detail' - -export function OrgsCommand({ onBack }: OrgsCommandProps) { - const [mode, setMode] = useState('list') - const [loading, setLoading] = useState(false) - const [orgs, setOrgs] = useState([]) - const [selectedOrg, setSelectedOrg] = useState(null) - const [members, setMembers] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - const [error, setError] = useState(null) - const [page, setPage] = useState(0) - const pageSize = 10 - - const [selectedUser, setSelectedUser] = useState(null) - const [userProjects, setUserProjects] = useState([]) - const [userOrgs, setUserOrgs] = useState([]) - - const [selectedProject, setSelectedProject] = useState(null) - const [projectEventCount, setProjectEventCount] = useState(0) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'user-detail' || mode === 'project-detail') { - setMode('detail') - setSelectedUser(null) - setSelectedProject(null) - } else if (mode === 'detail') { - setMode('list') - setSelectedOrg(null) - } else { - onBack() - } - } - if (input === 's' && mode === 'list') { - setMode('search') - } - if (key.leftArrow && page > 0) { - setPage(p => p - 1) - } - if (key.rightArrow && orgs.length === pageSize) { - setPage(p => p + 1) - } - }) - - useEffect(() => { - if (mode === 'list') { - loadOrgs() - } - }, [mode, page]) - - async function loadOrgs(search?: string) { - setLoading(true) - setError(null) - try { - const repo = AppDataSource.getRepository(Organisation) - let query = repo.createQueryBuilder('org') - .leftJoinAndSelect('org.members', 'members') - .leftJoinAndSelect('org.projects', 'projects') - .orderBy('org.created', 'DESC') - .skip(page * pageSize) - .take(pageSize) - - if (search) { - query = query.where('org.name LIKE :search OR org.id LIKE :search', { - search: `%${search}%` - }) - } - - const result = await query.getMany() - setOrgs(result) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load organizations') - } finally { - setLoading(false) - } - } - - async function loadOrgDetails(org: Organisation) { - setLoading(true) - try { - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const orgMembers = await memberRepo.find({ - where: { organisation: { id: org.id } }, - relations: ['user'], - }) - setMembers(orgMembers) - setSelectedOrg(org) - setMode('detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load organization details') - } finally { - setLoading(false) - } - } - - async function loadUserDetails(user: User) { - setLoading(true) - try { - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo.find({ - where: { admin: { id: user.id } }, - relations: ['organisation'], - order: { created: 'DESC' }, - take: 10, - }) - setUserProjects(projects) - - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const orgs = await memberRepo.find({ - where: { user: { id: user.id } }, - relations: ['organisation'], - }) - setUserOrgs(orgs) - - setSelectedUser(user) - setMode('user-detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load user details') - } finally { - setLoading(false) - } - } - - async function loadProjectDetails(project: Project) { - setLoading(true) - try { - const count = await getProjectEventCount(project.id) - setProjectEventCount(count) - setSelectedProject(project) - setMode('project-detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project details') - } finally { - setLoading(false) - } - } - - const handleSearch = () => { - if (searchQuery.trim()) { - loadOrgs(searchQuery.trim()) - setMode('list') - } - } - - const handleSelectOrg = (item: { value: string }) => { - const org = orgs.find(o => o.id === item.value) - if (org) { - loadOrgDetails(org) - } - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'search') { - return ( - - 🔍 Search Organizations - - Search: - - - - Press Enter to search, ESC to cancel - - - ) - } - - if (mode === 'user-detail' && selectedUser) { - const projectItems = userProjects.map(project => ({ - label: `📁 [${project.id}] ${project.name} ${project.isArchived ? '(archived)' : ''}`, - value: project.id, - })) - - return ( - - 👤 User Details - - - ID: {selectedUser.id} - Email: {selectedUser.email} - Nickname: {selectedUser.nickname || 'N/A'} - - Status:{' '} - - {selectedUser.isActive ? 'Active' : 'Inactive'} - - - Plan: {selectedUser.planCode} - Created: {new Date(selectedUser.created).toLocaleString()} - - - {projectItems.length > 0 && ( - - - Projects ({projectItems.length}): - - p.active ? 'Active' : 'Inactive' - }, - ]} - /> - - )} - - {userOrgs.length > 0 && ( - - - Organizations ({userOrgs.length}): - -
m.organisation?.name || 'N/A' - }, - { key: 'role', header: 'Role', width: 10 }, - ]} - /> - - )} - - - Press ESC to go back to organization - - - ) - } - - if (mode === 'project-detail' && selectedProject) { - return ( - - 📁 Project Details - - - ID: {selectedProject.id} - Name: {selectedProject.name} - - Status:{' '} - - {selectedProject.active ? 'Active' : 'Inactive'} - - {selectedProject.isArchived && (Archived)} - - Public: {selectedProject.public ? 'Yes' : 'No'} - Created: {new Date(selectedProject.created).toLocaleString()} - Website URL: {selectedProject.origins?.[0] || 'Not set'} - All Origins: {selectedProject.origins?.join(', ') || 'None'} - Bots Protection: {selectedProject.botsProtectionLevel} - - Total Events:{' '} - {projectEventCount.toLocaleString()} - - - - - Press ESC to go back to organization - - - ) - } - - if (mode === 'detail' && selectedOrg) { - const memberItems = members.filter(m => m.user).map(member => ({ - label: `👤 ${member.user?.email} (${member.role}) ${member.confirmed ? '' : '[pending]'}`, - value: member.user?.id || '', - })) - - const projectItems = (selectedOrg.projects || []).map(project => ({ - label: `📁 [${project.id}] ${project.name} ${project.isArchived ? '(archived)' : ''}`, - value: project.id, - })) - - const allItems = [ - ...memberItems, - ...projectItems, - ] - - return ( - - 🏢 Organization Details - - - ID: {selectedOrg.id} - Name: {selectedOrg.name} - Created: {new Date(selectedOrg.created).toLocaleString()} - Projects: {selectedOrg.projects?.length || 0} - Members: {members.length} - - - {allItems.length > 0 && ( - - - Members & Projects - Select to view: - - { - const member = members.find(m => m.user?.id === item.value) - if (member && member.user) { - loadUserDetails(member.user) - return - } - const project = selectedOrg.projects?.find(p => p.id === item.value) - if (project) { - loadProjectDetails(project) - } - }} - /> - - )} - - - Press ESC to go back to list - - - ) - } - - // List mode - const orgItems = orgs.map(org => ({ - label: `${org.name} (${org.members?.length || 0} members, ${org.projects?.length || 0} projects)`, - value: org.id, - })) - - return ( - - 🏢 Organizations - - - - Page {page + 1} | Press S to search | ←/→ to navigate pages - - - - {orgs.length === 0 ? ( - No organizations found - ) : ( - - - Select an organization to view details: - - - - )} - - ) -} diff --git a/admin/src/commands/ProjectsCommand.tsx b/admin/src/commands/ProjectsCommand.tsx deleted file mode 100644 index a0b7cb5aa..000000000 --- a/admin/src/commands/ProjectsCommand.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text, useInput } from 'ink' -import TextInput from 'ink-text-input' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { Table } from '../components/Table.js' -import { AppDataSource, Project, User, OrganisationMember } from '../db/mysql.js' -import { getProjectEventCount } from '../db/clickhouse.js' - -interface ProjectsCommandProps { - onBack: () => void - initialProject?: Project - showListOnBack?: boolean -} - -type Mode = 'list' | 'search' | 'detail' | 'admin-detail' - -export function ProjectsCommand({ onBack, initialProject, showListOnBack = true }: ProjectsCommandProps) { - const [mode, setMode] = useState(initialProject ? 'detail' : 'list') - const [loading, setLoading] = useState(false) - const [projects, setProjects] = useState([]) - const [selectedProject, setSelectedProject] = useState(initialProject || null) - const [eventCount, setEventCount] = useState(0) - const [searchQuery, setSearchQuery] = useState('') - const [error, setError] = useState(null) - const [page, setPage] = useState(0) - const [filter, setFilter] = useState<'all' | 'active' | 'archived'>('all') - const [selectedAdmin, setSelectedAdmin] = useState(null) - const [adminProjects, setAdminProjects] = useState([]) - const [adminOrgs, setAdminOrgs] = useState([]) - const pageSize = 10 - - useEffect(() => { - if (initialProject && !eventCount) { - loadProjectDetails(initialProject) - } - }, [initialProject]) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'admin-detail') { - setMode('detail') - setSelectedAdmin(null) - } else if (mode === 'detail') { - if (showListOnBack) { - setMode('list') - setSelectedProject(null) - } else { - onBack() - } - } else { - onBack() - } - } - if (input === 's' && mode === 'list') { - setMode('search') - } - if (input === 'f' && mode === 'list') { - setFilter(f => f === 'all' ? 'active' : f === 'active' ? 'archived' : 'all') - } - if (key.leftArrow && page > 0) { - setPage(p => p - 1) - } - if (key.rightArrow && projects.length === pageSize) { - setPage(p => p + 1) - } - }) - - useEffect(() => { - if (mode === 'list') { - loadProjects() - } - }, [mode, page, filter]) - - async function loadProjects(search?: string) { - setLoading(true) - setError(null) - try { - const repo = AppDataSource.getRepository(Project) - let query = repo.createQueryBuilder('project') - .leftJoinAndSelect('project.admin', 'admin') - .leftJoinAndSelect('project.organisation', 'organisation') - .orderBy('project.created', 'DESC') - .skip(page * pageSize) - .take(pageSize) - - if (filter === 'active') { - query = query.andWhere('project.isArchived = :archived', { archived: false }) - } else if (filter === 'archived') { - query = query.andWhere('project.isArchived = :archived', { archived: true }) - } - - if (search) { - const searchLower = search.toLowerCase(); - query = query.andWhere( - '(LOWER(project.name) LIKE :search OR LOWER(project.id) LIKE :search)', - { search: `%${searchLower}%` } - ); - } - - const result = await query.getMany() - setProjects(result) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load projects') - } finally { - setLoading(false) - } - } - - async function loadProjectDetails(project: Project) { - setLoading(true) - try { - const count = await getProjectEventCount(project.id) - setEventCount(count) - setSelectedProject(project) - setMode('detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project details') - } finally { - setLoading(false) - } - } - - async function loadAdminDetails(user: User) { - setLoading(true) - try { - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo.find({ - where: { admin: { id: user.id } }, - order: { created: 'DESC' }, - take: 10, - }) - setAdminProjects(projects) - - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const orgs = await memberRepo.find({ - where: { user: { id: user.id } }, - relations: ['organisation'], - }) - setAdminOrgs(orgs) - - setSelectedAdmin(user) - setMode('admin-detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load admin details') - } finally { - setLoading(false) - } - } - - const handleSearch = () => { - if (searchQuery.trim()) { - loadProjects(searchQuery.trim()) - setMode('list') - } - } - - const handleSelectProject = (item: { value: string }) => { - const project = projects.find(p => p.id === item.value) - if (project) { - loadProjectDetails(project) - } - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'search') { - return ( - - 🔍 Search Projects - - Search: - - - - Press Enter to search, ESC to cancel - - - ) - } - - if (mode === 'admin-detail' && selectedAdmin) { - return ( - - 👤 Admin Details - - - ID: {selectedAdmin.id} - Email: {selectedAdmin.email} - Nickname: {selectedAdmin.nickname || 'N/A'} - - Status:{' '} - - {selectedAdmin.isActive ? 'Active' : 'Inactive'} - - - Plan: {selectedAdmin.planCode} - Created: {new Date(selectedAdmin.created).toLocaleString()} - 2FA Enabled: {selectedAdmin.isTwoFactorAuthenticationEnabled ? 'Yes' : 'No'} - Legacy Max Projects: {selectedAdmin.maxProjects} - - - {adminProjects.length > 0 && ( - - - Projects ({adminProjects.length}): - -
p.active ? 'Active' : 'Inactive' - }, - ]} - /> - - )} - - {adminOrgs.length > 0 && ( - - - Organizations ({adminOrgs.length}): - -
m.organisation?.name || 'N/A' - }, - { key: 'role', header: 'Role', width: 10 }, - ]} - /> - - )} - - - Press ESC to go back to project - - - ) - } - - if (mode === 'detail' && selectedProject) { - const adminItem = selectedProject.admin ? [{ - label: `👤 View Admin: ${selectedProject.admin.email}`, - value: 'view-admin', - }] : [] - - return ( - - 📁 Project Details - - - ID: {selectedProject.id} - Name: {selectedProject.name} - - Status:{' '} - - {selectedProject.active ? 'Active' : 'Inactive'} - - {selectedProject.isArchived && (Archived)} - - Public: {selectedProject.public ? 'Yes' : 'No'} - Created: {new Date(selectedProject.created).toLocaleString()} - Website URL: {selectedProject.origins?.[0] || 'Not set'} - All Origins: {selectedProject.origins?.join(', ') || 'None'} - Bots Protection: {selectedProject.botsProtectionLevel} - - Organization:{' '} - {selectedProject.organisation?.name || 'None'} - - - Total Events:{' '} - {eventCount.toLocaleString()} - - - - {adminItem.length > 0 && ( - - { - if (selectedProject.admin) { - loadAdminDetails(selectedProject.admin) - } - }} - /> - - )} - - - Press ESC to go back - - - ) - } - - // List mode - const projectItems = projects.map(project => ({ - label: `[${project.id}] ${project.name} ${project.isArchived ? '(archived)' : ''}`, - value: project.id, - })) - - return ( - - 📁 Projects - - - - Page {page + 1} | Filter: {filter} | Press S to search | F to filter | ←/→ to navigate - - - - {projects.length === 0 ? ( - No projects found - ) : ( - - - Select a project to view details: - - - - )} - - ) -} diff --git a/admin/src/commands/RecentActivityCommand.tsx b/admin/src/commands/RecentActivityCommand.tsx deleted file mode 100644 index d268581f2..000000000 --- a/admin/src/commands/RecentActivityCommand.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text, useInput } from 'ink' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { AppDataSource, User, Project, OrganisationMember } from '../db/mysql.js' -import { getProjectEventCount } from '../db/clickhouse.js' - -interface RecentActivityCommandProps { - onBack: () => void -} - -type Tab = 'users' | 'projects' -type Mode = 'list' | 'user-detail' | 'project-detail' - -export function RecentActivityCommand({ onBack }: RecentActivityCommandProps) { - const [mode, setMode] = useState('list') - const [tab, setTab] = useState('users') - const [loading, setLoading] = useState(true) - const [recentUsers, setRecentUsers] = useState([]) - const [recentProjects, setRecentProjects] = useState([]) - const [error, setError] = useState(null) - - const [selectedUser, setSelectedUser] = useState(null) - const [userProjects, setUserProjects] = useState([]) - const [userOrgs, setUserOrgs] = useState([]) - - const [selectedProject, setSelectedProject] = useState(null) - const [projectEventCount, setProjectEventCount] = useState(0) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'user-detail' || mode === 'project-detail') { - setMode('list') - setSelectedUser(null) - setSelectedProject(null) - } else { - onBack() - } - } - if (mode === 'list') { - if (input === '1') setTab('users') - if (input === '2') setTab('projects') - } - }) - - useEffect(() => { - loadRecentActivity() - }, []) - - async function loadRecentActivity() { - setLoading(true) - setError(null) - try { - const userRepo = AppDataSource.getRepository(User) - const users = await userRepo.find({ - order: { created: 'DESC' }, - take: 20, - }) - setRecentUsers(users) - - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo.find({ - relations: ['admin'], - order: { created: 'DESC' }, - take: 20, - }) - setRecentProjects(projects) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load recent activity') - } finally { - setLoading(false) - } - } - - async function loadUserDetails(user: User) { - setLoading(true) - try { - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo.find({ - where: { admin: { id: user.id } }, - order: { created: 'DESC' }, - take: 10, - }) - setUserProjects(projects) - - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const orgs = await memberRepo.find({ - where: { user: { id: user.id } }, - relations: ['organisation'], - }) - setUserOrgs(orgs) - - setSelectedUser(user) - setMode('user-detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load user details') - } finally { - setLoading(false) - } - } - - async function loadProjectDetails(project: Project) { - setLoading(true) - try { - const count = await getProjectEventCount(project.id) - setProjectEventCount(count) - setSelectedProject(project) - setMode('project-detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project details') - } finally { - setLoading(false) - } - } - - function formatTimeAgo(date: Date): string { - const now = new Date() - const diffMs = now.getTime() - new Date(date).getTime() - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMs / 3600000) - const diffDays = Math.floor(diffMs / 86400000) - - if (diffMins < 60) return `${diffMins}m ago` - if (diffHours < 24) return `${diffHours}h ago` - if (diffDays < 7) return `${diffDays}d ago` - return new Date(date).toLocaleDateString() - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'user-detail' && selectedUser) { - return ( - - 👤 User Details - - - ID: {selectedUser.id} - Email: {selectedUser.email} - Nickname: {selectedUser.nickname || 'N/A'} - - Status:{' '} - - {selectedUser.isActive ? 'Active' : 'Inactive'} - - - Plan: {selectedUser.planCode} - Created: {new Date(selectedUser.created).toLocaleString()} - Projects: {userProjects.length} - {selectedUser.trialEndDate && ( - - Trial Ends:{' '} - {new Date(selectedUser.trialEndDate).toLocaleString()} - - )} - - - {userProjects.length > 0 && ( - - Projects: - {userProjects.slice(0, 5).map(p => ( - • [{p.id}] {p.name} - ))} - - )} - - - Press ESC to go back - - - ) - } - - if (mode === 'project-detail' && selectedProject) { - return ( - - 📁 Project Details - - - ID: {selectedProject.id} - Name: {selectedProject.name} - - Status:{' '} - - {selectedProject.active ? 'Active' : 'Inactive'} - - - Website URL: {selectedProject.origins?.[0] || 'Not set'} - Created: {new Date(selectedProject.created).toLocaleString()} - Owner: {selectedProject.admin?.email || 'N/A'} - - Total Events:{' '} - {projectEventCount.toLocaleString()} - - - - - Press ESC to go back - - - ) - } - - const userItems = recentUsers.map(user => ({ - label: `${formatTimeAgo(user.created)} - ${user.email} (${user.planCode}) ${user.isActive ? '' : '[inactive]'}`, - value: user.id, - })) - - const projectItems = recentProjects.map(project => ({ - label: `${formatTimeAgo(project.created)} - [${project.id}] ${project.name} (${project.admin?.email || 'N/A'})`, - value: project.id, - })) - - return ( - - 📈 Recent Activity - - - - View:{' '} - [1] Recent Signups - {' | '} - [2] New Projects - - - - {tab === 'users' ? ( - - - Last 20 signups: - - { - const user = recentUsers.find(u => u.id === item.value) - if (user) loadUserDetails(user) - }} - /> - - ) : ( - - - Last 20 projects created: - - { - const project = recentProjects.find(p => p.id === item.value) - if (project) loadProjectDetails(project) - }} - /> - - )} - - - Press ESC to go back - - - ) -} diff --git a/admin/src/commands/SearchCommand.tsx b/admin/src/commands/SearchCommand.tsx deleted file mode 100644 index f4421e683..000000000 --- a/admin/src/commands/SearchCommand.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import React, { useState } from 'react' -import { Box, Text, useInput } from 'ink' -import TextInput from 'ink-text-input' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { AppDataSource, User, Project, Organisation, OrganisationMember } from '../db/mysql.js' -import { getProjectEventCount } from '../db/clickhouse.js' - -interface SearchCommandProps { - onBack: () => void -} - -type Mode = 'input' | 'results' | 'user-detail' | 'project-detail' | 'org-detail' - -interface SearchResult { - type: 'user' | 'project' | 'org' - id: string - label: string - data: User | Project | Organisation -} - -export function SearchCommand({ onBack }: SearchCommandProps) { - const [mode, setMode] = useState('input') - const [loading, setLoading] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [results, setResults] = useState([]) - const [error, setError] = useState(null) - - const [selectedUser, setSelectedUser] = useState(null) - const [userProjects, setUserProjects] = useState([]) - const [userOrgs, setUserOrgs] = useState([]) - - const [selectedProject, setSelectedProject] = useState(null) - const [projectEventCount, setProjectEventCount] = useState(0) - - const [selectedOrg, setSelectedOrg] = useState(null) - const [orgMembers, setOrgMembers] = useState([]) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'user-detail' || mode === 'project-detail' || mode === 'org-detail') { - setMode('results') - } else if (mode === 'results') { - setMode('input') - setResults([]) - } else { - onBack() - } - } - }) - - async function performSearch() { - if (!searchQuery.trim()) return - - setLoading(true) - setError(null) - setResults([]) - - try { - const query = searchQuery.trim().toLowerCase() - const searchResults: SearchResult[] = [] - - const userRepo = AppDataSource.getRepository(User) - const users = await userRepo - .createQueryBuilder('user') - .where('LOWER(user.email) LIKE :query', { query: `%${query}%` }) - .orWhere('LOWER(user.id) LIKE :query', { query: `%${query}%` }) - .orWhere('LOWER(user.nickname) LIKE :query', { query: `%${query}%` }) - .take(10) - .getMany() - - users.forEach(user => { - searchResults.push({ - type: 'user', - id: user.id, - label: `👤 User: ${user.email} (${user.planCode})`, - data: user, - }) - }) - - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo - .createQueryBuilder('project') - .leftJoinAndSelect('project.admin', 'admin') - .where('LOWER(project.name) LIKE :query', { query: `%${query}%` }) - .orWhere('LOWER(project.id) LIKE :query', { query: `%${query}%` }) - .take(10) - .getMany() - - projects.forEach(project => { - searchResults.push({ - type: 'project', - id: project.id, - label: `📁 Project: [${project.id}] ${project.name}`, - data: project, - }) - }) - - const orgRepo = AppDataSource.getRepository(Organisation) - const orgs = await orgRepo - .createQueryBuilder('org') - .leftJoinAndSelect('org.members', 'members') - .leftJoinAndSelect('org.projects', 'projects') - .where('LOWER(org.name) LIKE :query', { query: `%${query}%` }) - .orWhere('LOWER(org.id) LIKE :query', { query: `%${query}%` }) - .take(10) - .getMany() - - orgs.forEach(org => { - searchResults.push({ - type: 'org', - id: org.id, - label: `🏢 Org: ${org.name} (${org.members?.length || 0} members)`, - data: org, - }) - }) - - setResults(searchResults) - setMode('results') - } catch (err) { - setError(err instanceof Error ? err.message : 'Search failed') - } finally { - setLoading(false) - } - } - - async function handleSelectResult(item: { value: string }) { - const result = results.find(r => `${r.type}-${r.id}` === item.value) - if (!result) return - - setLoading(true) - try { - if (result.type === 'user') { - const user = result.data as User - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo.find({ - where: { admin: { id: user.id } }, - order: { created: 'DESC' }, - take: 10, - }) - setUserProjects(projects) - - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const orgs = await memberRepo.find({ - where: { user: { id: user.id } }, - relations: ['organisation'], - }) - setUserOrgs(orgs) - - setSelectedUser(user) - setMode('user-detail') - } else if (result.type === 'project') { - const project = result.data as Project - const count = await getProjectEventCount(project.id) - setProjectEventCount(count) - setSelectedProject(project) - setMode('project-detail') - } else if (result.type === 'org') { - const org = result.data as Organisation - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const members = await memberRepo.find({ - where: { organisation: { id: org.id } }, - relations: ['user'], - }) - setOrgMembers(members) - setSelectedOrg(org) - setMode('org-detail') - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load details') - } finally { - setLoading(false) - } - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'user-detail' && selectedUser) { - return ( - - 👤 User Details - - - ID: {selectedUser.id} - Email: {selectedUser.email} - Nickname: {selectedUser.nickname || 'N/A'} - - Status:{' '} - - {selectedUser.isActive ? 'Active' : 'Inactive'} - - - Plan: {selectedUser.planCode} - Projects: {userProjects.length} - Organizations: {userOrgs.length} - - - - Press ESC to go back to results - - - ) - } - - if (mode === 'project-detail' && selectedProject) { - return ( - - 📁 Project Details - - - ID: {selectedProject.id} - Name: {selectedProject.name} - - Status:{' '} - - {selectedProject.active ? 'Active' : 'Inactive'} - - - Website URL: {selectedProject.origins?.[0] || 'Not set'} - Owner: {selectedProject.admin?.email || 'N/A'} - - Total Events:{' '} - {projectEventCount.toLocaleString()} - - - - - Press ESC to go back to results - - - ) - } - - if (mode === 'org-detail' && selectedOrg) { - return ( - - 🏢 Organization Details - - - ID: {selectedOrg.id} - Name: {selectedOrg.name} - Created: {new Date(selectedOrg.created).toLocaleString()} - Members: {orgMembers.length} - Projects: {selectedOrg.projects?.length || 0} - - - {orgMembers.length > 0 && ( - - Members: - {orgMembers.slice(0, 5).map(m => ( - • {m.user?.email} ({m.role}) - ))} - - )} - - - Press ESC to go back to results - - - ) - } - - if (mode === 'results') { - const resultItems = results.map(r => ({ - label: r.label, - value: `${r.type}-${r.id}`, - })) - - return ( - - 🔍 Search Results for "{searchQuery}" - - - Found {results.length} results - - - {results.length === 0 ? ( - No results found - ) : ( - - )} - - - Press ESC to search again - - - ) - } - - return ( - - 🔍 Global Search - - - Search: - - - - - Searches across users, projects, and organizations - - - Press Enter to search, ESC to go back - - - ) -} diff --git a/admin/src/commands/StatsCommand.tsx b/admin/src/commands/StatsCommand.tsx deleted file mode 100644 index 6d0f01c00..000000000 --- a/admin/src/commands/StatsCommand.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text } from 'ink' -import { Spinner } from '../components/Spinner.js' -import { Table } from '../components/Table.js' -import { getClickHouseStats, ClickHouseStats, TableStats } from '../db/clickhouse.js' - -interface StatsCommandProps { - onBack: () => void -} - -function formatNumber(num: number): string { - if (num >= 1_000_000_000) { - return `${(num / 1_000_000_000).toFixed(2)}B` - } - if (num >= 1_000_000) { - return `${(num / 1_000_000).toFixed(2)}M` - } - if (num >= 1_000) { - return `${(num / 1_000).toFixed(2)}K` - } - return num.toString() -} - -export function StatsCommand({ onBack }: StatsCommandProps) { - const [loading, setLoading] = useState(true) - const [stats, setStats] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - async function load() { - try { - const result = await getClickHouseStats() - setStats(result) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load ClickHouse stats') - } finally { - setLoading(false) - } - } - load() - }, []) - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (!stats) { - return No statistics available - } - - const totalBytes = stats.tables.reduce((sum, t) => sum + t.bytes, 0) - const formatBytes = (bytes: number): string => { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` - } - - const filteredTables = stats.tables.filter(t => t.rows > 0 || t.bytes > 0) - const emptyTables = stats.tables.filter(t => t.rows === 0 && t.bytes === 0) - - return ( - - 📊 ClickHouse Statistics - - {/* Summary */} - - - Total Events:{' '} - {stats.totalEvents.toLocaleString()} - ({formatNumber(stats.totalEvents)}) - - - Total Size:{' '} - {formatBytes(totalBytes)} - - - - {/* Per-table breakdown */} - - Table Breakdown: - - - - data={filteredTables} - columns={[ - { - key: 'table', - header: 'Table', - width: 28, - }, - { - key: 'rows', - header: 'Rows', - width: 15, - render: (t) => t.rows.toLocaleString() - }, - { - key: 'size', - header: 'Size', - width: 12, - render: (t) => t.bytesFormatted - }, - ]} - /> - - {/* Tables with no data */} - {emptyTables.length > 0 && ( - - - Empty tables: {emptyTables.map(t => t.table).join(', ')} - - - )} - - ) -} diff --git a/admin/src/commands/TopProjectsCommand.tsx b/admin/src/commands/TopProjectsCommand.tsx deleted file mode 100644 index 97f071a4b..000000000 --- a/admin/src/commands/TopProjectsCommand.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text, useInput } from 'ink' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { Table } from '../components/Table.js' -import { getTopProjectsByEvents, TopProject, getProjectEventCount } from '../db/clickhouse.js' -import { AppDataSource, Project } from '../db/mysql.js' - -interface TopProjectsCommandProps { - onBack: () => void -} - -type Period = '24h' | '7d' | '30d' -type Mode = 'list' | 'detail' - -const periodDays: Record = { - '24h': 1, - '7d': 7, - '30d': 30, -} - -interface ProjectWithEvents extends TopProject { - name?: string - adminEmail?: string -} - -export function TopProjectsCommand({ onBack }: TopProjectsCommandProps) { - const [mode, setMode] = useState('list') - const [loading, setLoading] = useState(true) - const [period, setPeriod] = useState('7d') - const [projects, setProjects] = useState([]) - const [selectedProject, setSelectedProject] = useState(null) - const [eventCount, setEventCount] = useState(0) - const [error, setError] = useState(null) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'detail') { - setMode('list') - setSelectedProject(null) - } else { - onBack() - } - } - if (mode === 'list') { - if (input === '1') setPeriod('24h') - if (input === '2') setPeriod('7d') - if (input === '3') setPeriod('30d') - } - }) - - useEffect(() => { - loadTopProjects() - }, [period]) - - async function loadTopProjects() { - setLoading(true) - setError(null) - try { - const topProjects = await getTopProjectsByEvents(periodDays[period], 20) - - if (topProjects.length === 0) { - setProjects([]) - setLoading(false) - return - } - - const projectRepo = AppDataSource.getRepository(Project) - const pids = topProjects.map(p => p.pid) - - const dbProjects = await projectRepo - .createQueryBuilder('project') - .leftJoinAndSelect('project.admin', 'admin') - .where('project.id IN (:...pids)', { pids }) - .getMany() - - const projectMap = new Map(dbProjects.map(p => [p.id, p])) - - const enrichedProjects: ProjectWithEvents[] = topProjects.map(tp => ({ - ...tp, - name: projectMap.get(tp.pid)?.name || 'Unknown', - adminEmail: projectMap.get(tp.pid)?.admin?.email || 'N/A', - })) - - setProjects(enrichedProjects) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load top projects') - } finally { - setLoading(false) - } - } - - async function loadProjectDetails(pid: string) { - setLoading(true) - try { - const projectRepo = AppDataSource.getRepository(Project) - const project = await projectRepo.findOne({ - where: { id: pid }, - relations: ['admin', 'organisation'], - }) - - if (project) { - const count = await getProjectEventCount(pid) - setEventCount(count) - setSelectedProject(project) - setMode('detail') - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project details') - } finally { - setLoading(false) - } - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'detail' && selectedProject) { - return ( - - 📁 Project Details - - - ID: {selectedProject.id} - Name: {selectedProject.name} - - Status:{' '} - - {selectedProject.active ? 'Active' : 'Inactive'} - - {selectedProject.isArchived && (Archived)} - - Website URL: {selectedProject.origins?.[0] || 'Not set'} - Owner: {selectedProject.admin?.email || 'N/A'} - - Organization:{' '} - {selectedProject.organisation?.name || 'None'} - - - Total Events:{' '} - {eventCount.toLocaleString()} - - - - - Press ESC to go back - - - ) - } - - const projectItems = projects.map((p, index) => ({ - label: `${index + 1}. [${p.pid}] ${p.name} - ${p.eventCount.toLocaleString()} events (${p.adminEmail})`, - value: p.pid, - })) - - return ( - - 🔥 Top Projects by Events - - - - Period:{' '} - [1] 24h - {' | '} - [2] 7 days - {' | '} - [3] 30 days - - - - {projects.length === 0 ? ( - No projects with events in this period - ) : ( - - - Select a project to view details: - - loadProjectDetails(item.value)} - /> - - )} - - - Press ESC to go back - - - ) -} diff --git a/admin/src/commands/UsersCommand.tsx b/admin/src/commands/UsersCommand.tsx deleted file mode 100644 index 02db8984a..000000000 --- a/admin/src/commands/UsersCommand.tsx +++ /dev/null @@ -1,564 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { Box, Text, useInput } from 'ink' -import TextInput from 'ink-text-input' -import SelectInput from 'ink-select-input' -import { Spinner } from '../components/Spinner.js' -import { Table } from '../components/Table.js' -import { AppDataSource, User, Project, OrganisationMember } from '../db/mysql.js' -import { getProjectEventCount } from '../db/clickhouse.js' -import { PlanType } from '../entities/user.entity.js' -import { - getEffectiveLimits, - getEffectivePlanType, - planEntitlements, -} from '../billing/pricing.js' - -interface UsersCommandProps { - onBack: () => void - initialUser?: User - showListOnBack?: boolean -} - -type Mode = - | 'list' - | 'search' - | 'detail' - | 'project-detail' - | 'edit-plan-type' - | 'edit-addon-overrides' - | 'edit-entitlement-overrides' - -interface UserWithProjectCount extends User { - projectCount?: number -} - -const numberValue = (source: Record | null, key: string) => - source && typeof source[key] === 'number' ? (source[key] as number) : null - -export function UsersCommand({ onBack, initialUser, showListOnBack = true }: UsersCommandProps) { - const [mode, setMode] = useState(initialUser ? 'detail' : 'list') - const [loading, setLoading] = useState(false) - const [users, setUsers] = useState([]) - const [selectedUser, setSelectedUser] = useState(initialUser || null) - const [userProjects, setUserProjects] = useState([]) - const [userOrgs, setUserOrgs] = useState([]) - const [searchQuery, setSearchQuery] = useState('') - const [error, setError] = useState(null) - const [page, setPage] = useState(0) - const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all') - const [selectedProject, setSelectedProject] = useState(null) - const [projectEventCount, setProjectEventCount] = useState(0) - const [jsonInput, setJsonInput] = useState('') - const pageSize = 10 - - useEffect(() => { - if (initialUser) { - loadUserDetails(initialUser) - } - }, [initialUser]) - - useInput((input, key) => { - if (key.escape) { - if (mode === 'project-detail') { - setMode('detail') - setSelectedProject(null) - } else if ( - mode === 'edit-plan-type' || - mode === 'edit-addon-overrides' || - mode === 'edit-entitlement-overrides' - ) { - setMode('detail') - } else if (mode === 'detail') { - if (showListOnBack) { - setMode('list') - setSelectedUser(null) - } else { - onBack() - } - } else { - onBack() - } - } - if (input === 's' && mode === 'list') { - setMode('search') - } - if (input === 'f' && mode === 'list') { - setFilter(f => f === 'all' ? 'active' : f === 'active' ? 'inactive' : 'all') - } - if (input === 'p' && mode === 'detail') { - setMode('edit-plan-type') - } - if (input === 'a' && mode === 'detail' && selectedUser) { - setJsonInput(JSON.stringify(selectedUser.addonOverrides || {})) - setMode('edit-addon-overrides') - } - if (input === 'e' && mode === 'detail' && selectedUser) { - setJsonInput(JSON.stringify(selectedUser.entitlementOverrides || {})) - setMode('edit-entitlement-overrides') - } - if (key.leftArrow && page > 0) { - setPage(p => p - 1) - } - if (key.rightArrow && users.length === pageSize) { - setPage(p => p + 1) - } - }) - - useEffect(() => { - if (mode === 'list') { - loadUsers() - } - }, [mode, page, filter]) - - async function loadUsers(search?: string) { - setLoading(true) - setError(null) - try { - const repo = AppDataSource.getRepository(User) - let query = repo.createQueryBuilder('user') - .loadRelationCountAndMap('user.projectCount', 'user.projects') - .orderBy('user.created', 'DESC') - .skip(page * pageSize) - .take(pageSize) - - if (filter === 'active') { - query = query.andWhere('user.isActive = :active', { active: true }) - } else if (filter === 'inactive') { - query = query.andWhere('user.isActive = :active', { active: false }) - } - - if (search) { - const searchLower = search.toLowerCase(); - query = query.andWhere( - '(LOWER(user.email) LIKE :search OR LOWER(user.id) LIKE :search OR LOWER(user.nickname) LIKE :search)', - { search: `%${searchLower}%` } - ); - } - - const result = await query.getMany() - setUsers(result as UserWithProjectCount[]) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load users') - } finally { - setLoading(false) - } - } - - async function loadUserDetails(user: User) { - setLoading(true) - try { - const projectRepo = AppDataSource.getRepository(Project) - const projects = await projectRepo.find({ - where: { admin: { id: user.id } }, - relations: ['organisation'], - order: { created: 'DESC' }, - take: 10, - }) - setUserProjects(projects) - - const memberRepo = AppDataSource.getRepository(OrganisationMember) - const orgs = await memberRepo.find({ - where: { user: { id: user.id } }, - relations: ['organisation'], - }) - setUserOrgs(orgs) - - setSelectedUser(user) - setMode('detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load user details') - } finally { - setLoading(false) - } - } - - async function loadProjectDetails(project: Project) { - setLoading(true) - try { - const count = await getProjectEventCount(project.id) - setProjectEventCount(count) - setSelectedProject(project) - setMode('project-detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project details') - } finally { - setLoading(false) - } - } - - const handleSearch = () => { - if (searchQuery.trim()) { - loadUsers(searchQuery.trim()) - setMode('list') - } - } - - const handleSelectUser = (item: { value: string }) => { - const user = users.find(u => u.id === item.value) - if (user) { - loadUserDetails(user) - } - } - - async function updateSelectedUser(update: Partial) { - if (!selectedUser) return - - setLoading(true) - setError(null) - try { - const repo = AppDataSource.getRepository(User) - await repo.update({ id: selectedUser.id }, update as any) - setSelectedUser({ ...selectedUser, ...update }) - setMode('detail') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update user') - } finally { - setLoading(false) - } - } - - async function handlePlanTypeSelect(item: { value: string }) { - if (!selectedUser) return - - const nextPlanType = - item.value === 'clear' ? null : (item.value as PlanType) - const update: Partial = { planType: nextPlanType } - - if (nextPlanType) { - const entitlements = planEntitlements[nextPlanType] - if (typeof entitlements.websites === 'number') { - const purchasedWebsiteAddons = - (selectedUser as User & { purchasedWebsiteAddons?: number }) - .purchasedWebsiteAddons ?? - numberValue(selectedUser.addonOverrides, 'websites') ?? - numberValue(selectedUser.addonOverrides, 'additionalWebsites') ?? - 0 - - update.maxProjects = Math.max( - entitlements.websites + purchasedWebsiteAddons, - selectedUser.maxProjects || 0, - ) - } - if (typeof entitlements.apiRateLimitPerHour === 'number') { - update.maxApiKeyRequestsPerHour = entitlements.apiRateLimitPerHour - } - } - - await updateSelectedUser(update) - } - - async function handleJsonSubmit(target: 'addonOverrides' | 'entitlementOverrides') { - const trimmed = jsonInput.trim() - let parsed: Record | null = null - - try { - if (trimmed && trimmed !== 'null') { - const value = JSON.parse(trimmed) - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Overrides must be a JSON object') - } - parsed = value - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Invalid JSON') - return - } - - const update = { [target]: parsed } as Partial - - if (target === 'entitlementOverrides' && selectedUser) { - const websiteOverride = numberValue(parsed, 'websites') - const apiOverride = numberValue(parsed, 'apiRateLimitPerHour') - const entitlements = - planEntitlements[getEffectivePlanType(selectedUser) || PlanType.standard] - const purchasedWebsiteAddons = - (selectedUser as User & { purchasedWebsiteAddons?: number }) - .purchasedWebsiteAddons ?? - numberValue(selectedUser.addonOverrides, 'websites') ?? - numberValue(selectedUser.addonOverrides, 'additionalWebsites') ?? - 0 - const includedWebsites = - websiteOverride ?? - (typeof entitlements.websites === 'number' - ? entitlements.websites - : Math.max( - planEntitlements[PlanType.standard].websites, - (selectedUser.maxProjects || 0) - purchasedWebsiteAddons, - )) - const apiRateLimitPerHour = - apiOverride ?? - (typeof entitlements.apiRateLimitPerHour === 'number' - ? entitlements.apiRateLimitPerHour - : selectedUser.maxApiKeyRequestsPerHour) - - if (typeof includedWebsites === 'number') { - update.maxProjects = includedWebsites + purchasedWebsiteAddons - } - - if (typeof apiRateLimitPerHour === 'number') { - update.maxApiKeyRequestsPerHour = apiRateLimitPerHour - } - } - - await updateSelectedUser(update) - } - - if (loading) { - return - } - - if (error) { - return ( - - Error: {error} - Press ESC to go back - - ) - } - - if (mode === 'search') { - return ( - - 🔍 Search Users - - Search: - - - - Press Enter to search, ESC to cancel - - - ) - } - - if (mode === 'project-detail' && selectedProject) { - return ( - - 📁 Project Details - - - ID: {selectedProject.id} - Name: {selectedProject.name} - - Status:{' '} - - {selectedProject.active ? 'Active' : 'Inactive'} - - {selectedProject.isArchived && (Archived)} - - Public: {selectedProject.public ? 'Yes' : 'No'} - Created: {new Date(selectedProject.created).toLocaleString()} - Website URL: {selectedProject.origins?.[0] || 'Not set'} - All Origins: {selectedProject.origins?.join(', ') || 'None'} - Bots Protection: {selectedProject.botsProtectionLevel} - - Organization:{' '} - {selectedProject.organisation?.name || 'None'} - - - Total Events:{' '} - {projectEventCount.toLocaleString()} - - - - - Press ESC to go back to user - - - ) - } - - if (mode === 'edit-plan-type' && selectedUser) { - const items = [ - { label: 'Clear plan type (legacy fallback)', value: 'clear' }, - { label: 'Standard', value: PlanType.standard }, - { label: 'Plus', value: PlanType.plus }, - { label: 'Enterprise (manual)', value: PlanType.enterprise }, - ] - - return ( - - Edit Plan Type - - User: {selectedUser.email} - - - Current:{' '} - {selectedUser.planType || `${getEffectivePlanType(selectedUser) || 'none'} fallback`} - - - - - - Press ESC to cancel - - - ) - } - - if ( - (mode === 'edit-addon-overrides' || - mode === 'edit-entitlement-overrides') && - selectedUser - ) { - const target = - mode === 'edit-addon-overrides' - ? 'addonOverrides' - : 'entitlementOverrides' - - return ( - - - Edit {target === 'addonOverrides' ? 'Add-on' : 'Entitlement'} Overrides - - - User: {selectedUser.email} - - - JSON: - handleJsonSubmit(target)} - placeholder='{"websites":50}' - /> - - - Submit an empty value to clear. Press ESC to cancel - - - ) - } - - if (mode === 'detail' && selectedUser) { - const effectiveLimits = getEffectiveLimits(selectedUser) - const projectItems = userProjects.map(project => ({ - label: `📁 [${project.id}] ${project.name} ${project.isArchived ? '(archived)' : ''}`, - value: project.id, - })) - - return ( - - 👤 User Details - - - ID: {selectedUser.id} - Email: {selectedUser.email} - Nickname: {selectedUser.nickname || 'N/A'} - - Status:{' '} - - {selectedUser.isActive ? 'Active' : 'Inactive'} - - - Event Bucket: {effectiveLimits.eventBucket} - Plan Type: {selectedUser.planType || `${effectiveLimits.planType} fallback`} - Websites Included: {effectiveLimits.includedWebsites} - Purchased Website Add-ons: {effectiveLimits.purchasedWebsiteAddons} - Effective Project Limit: {effectiveLimits.effectiveProjectLimit} - Replay Quota: {effectiveLimits.replayQuota} - API Rate Limit: {effectiveLimits.apiRateLimitPerHour}/hour - Team Members: {effectiveLimits.teamMembers} - Organisations: {effectiveLimits.organisations} - Created: {new Date(selectedUser.created).toLocaleString()} - 2FA Enabled: {selectedUser.isTwoFactorAuthenticationEnabled ? 'Yes' : 'No'} - Legacy Max Projects: {selectedUser.maxProjects} - API Key: {selectedUser.apiKey ? 'Set' : 'Not set'} - {selectedUser.dashboardBlockReason && ( - - Block Reason:{' '} - {selectedUser.dashboardBlockReason} - - )} - {selectedUser.trialEndDate && ( - - Trial Ends:{' '} - {new Date(selectedUser.trialEndDate).toLocaleString()} - - )} - - - {projectItems.length > 0 && ( - - - Projects ({projectItems.length}) - Select to view: - - { - const project = userProjects.find(p => p.id === item.value) - if (project) { - loadProjectDetails(project) - } - }} - /> - - )} - - {userOrgs.length > 0 && ( - - - Organizations ({userOrgs.length}): - -
m.organisation?.name || 'N/A' - }, - { key: 'role', header: 'Role', width: 10 }, - { - key: 'confirmed', - header: 'Confirmed', - width: 12, - render: (m) => m.confirmed ? 'Yes' : 'No' - }, - ]} - /> - - )} - - - Press P to edit plan type | A to edit add-on overrides | E to edit entitlement overrides | ESC to go back - - - ) - } - - // List mode - const userItems = users.map(user => ({ - label: `${user.email} (${user.planType || getEffectivePlanType(user) || 'none'} / ${user.planCode}, ${user.projectCount || 0} projects) ${user.isActive ? '' : '[inactive]'}`, - value: user.id, - })) - - return ( - - 👥 Users - - - - Page {page + 1} | Filter: {filter} | Press S to search | F to filter | ←/→ to navigate - - - - {users.length === 0 ? ( - No users found - ) : ( - - - Select a user to view details: - - - - )} - - ) -} diff --git a/admin/src/components/App.tsx b/admin/src/components/App.tsx deleted file mode 100644 index d63fead2a..000000000 --- a/admin/src/components/App.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Box, Text, useApp } from "ink"; -import SelectInput from "ink-select-input"; -import { Spinner } from "./Spinner.js"; -import { OrgsCommand } from "../commands/OrgsCommand.js"; -import { ProjectsCommand } from "../commands/ProjectsCommand.js"; -import { UsersCommand } from "../commands/UsersCommand.js"; -import { StatsCommand } from "../commands/StatsCommand.js"; -import { TopProjectsCommand } from "../commands/TopProjectsCommand.js"; -import { LimitsCommand } from "../commands/LimitsCommand.js"; -import { InactiveProjectsCommand } from "../commands/InactiveProjectsCommand.js"; -import { SearchCommand } from "../commands/SearchCommand.js"; -import { RecentActivityCommand } from "../commands/RecentActivityCommand.js"; -import { initializeDatabase, testMySQLConnection } from "../db/mysql.js"; -import { testClickHouseConnection } from "../db/clickhouse.js"; - -type View = "menu" | "orgs" | "projects" | "users" | "stats" | "top" | "limits" | "inactive" | "search" | "recent"; - -const menuItems = [ - { label: "🔍 Global Search", value: "search" as const }, - { label: "📈 Recent Activity", value: "recent" as const }, - { label: "🔥 Top Projects", value: "top" as const }, - { label: "⚠️ Users Near Limits", value: "limits" as const }, - { label: "💀 Inactive Projects", value: "inactive" as const }, - { label: "📊 ClickHouse Stats", value: "stats" as const }, - { label: "🏢 Organizations", value: "orgs" as const }, - { label: "📁 Projects", value: "projects" as const }, - { label: "👥 Users", value: "users" as const }, - { label: "❌ Exit", value: "exit" as const }, -]; - -export function App() { - const { exit } = useApp(); - const [view, setView] = useState("menu"); - const [loading, setLoading] = useState(true); - const [mysqlConnected, setMysqlConnected] = useState(false); - const [clickhouseConnected, setClickhouseConnected] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - async function init() { - try { - // Test connections in parallel - const [mysqlOk, chOk] = await Promise.all([ - testMySQLConnection(), - testClickHouseConnection(), - ]); - - setMysqlConnected(mysqlOk); - setClickhouseConnected(chOk); - - if (mysqlOk) { - await initializeDatabase(); - } - - if (!mysqlOk && !chOk) { - setError( - "Could not connect to any database. Check your .env configuration." - ); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Connection failed"); - } finally { - setLoading(false); - } - } - init(); - }, []); - - const handleExit = () => { - exit(); - setTimeout(() => process.exit(0), 100); - }; - - const handleSelect = (item: { value: string }) => { - if (item.value === "exit") { - handleExit(); - return; - } - setView(item.value as View); - }; - - if (loading) { - return ( - - - Swetrix Admin CLI - - - - - - ); - } - - if (error) { - return ( - - - Error - - {error} - - - Make sure you have a .env file in the backend directory with the - correct database credentials. - - - - ); - } - - return ( - - - - 🔧 Swetrix Admin CLI - - - - {/* Connection status */} - - - - {mysqlConnected ? "●" : "○"} - - {" MySQL: "} - - {mysqlConnected ? "Connected" : "Disconnected"} - - - - - {clickhouseConnected ? "●" : "○"} - - {" ClickHouse: "} - - {clickhouseConnected ? "Connected" : "Disconnected"} - - - - - {view === "menu" && ( - - - Select an option: - - - - )} - - {view === "orgs" && setView("menu")} />} - {view === "projects" && ( - setView("menu")} /> - )} - {view === "users" && setView("menu")} />} - {view === "stats" && setView("menu")} />} - {view === "top" && setView("menu")} />} - {view === "limits" && setView("menu")} />} - {view === "inactive" && setView("menu")} />} - {view === "search" && setView("menu")} />} - {view === "recent" && setView("menu")} />} - - - ); -} diff --git a/admin/src/components/Spinner.tsx b/admin/src/components/Spinner.tsx deleted file mode 100644 index fc418672b..000000000 --- a/admin/src/components/Spinner.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import { Text } from 'ink' -import InkSpinner from 'ink-spinner' - -interface SpinnerProps { - text?: string -} - -export function Spinner({ text = 'Loading...' }: SpinnerProps) { - return ( - - - - - {' '}{text} - - ) -} diff --git a/admin/src/components/Table.tsx b/admin/src/components/Table.tsx deleted file mode 100644 index de7ac1cc2..000000000 --- a/admin/src/components/Table.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react' -import { Text, Box } from 'ink' - -interface Column { - key: string - header: string - width?: number - render?: (row: T) => string -} - -interface TableProps { - data: T[] - columns: Column[] -} - -export function Table({ data, columns }: TableProps) { - // Calculate column widths - const columnWidths = columns.map(col => { - if (col.width) return col.width - const headerLen = col.header.length - const maxDataLen = data.reduce((max, row) => { - const value = col.render - ? col.render(row) - : String((row as Record)[col.key] ?? '') - return Math.max(max, value.length) - }, 0) - return Math.max(headerLen, maxDataLen) + 2 - }) - - const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + columns.length + 1 - - const renderSeparator = (char: string, leftCorner: string, midCorner: string, rightCorner: string) => { - const line = columnWidths.map(w => char.repeat(w)).join(midCorner) - return `${leftCorner}${line}${rightCorner}` - } - - const renderRow = (cells: string[]) => { - const paddedCells = cells.map((cell, i) => { - const width = columnWidths[i] - return ` ${cell.padEnd(width - 1)}` - }) - return `│${paddedCells.join('│')}│` - } - - return ( - - {/* Top border */} - {renderSeparator('─', '┌', '┬', '┐')} - - {/* Header */} - - {renderRow(columns.map(c => c.header))} - - - {/* Header separator */} - {renderSeparator('─', '├', '┼', '┤')} - - {/* Data rows */} - {data.length === 0 ? ( - - {'│'}{' No data found'.padEnd(totalWidth - 2)}{'│'} - - ) : ( - data.map((row, i) => ( - - {renderRow(columns.map(col => { - if (col.render) return col.render(row) - const value = (row as Record)[col.key] - return String(value ?? '') - }))} - - )) - )} - - {/* Bottom border */} - {renderSeparator('─', '└', '┴', '┘')} - - ) -} diff --git a/admin/src/db/clickhouse.ts b/admin/src/db/clickhouse.ts deleted file mode 100644 index 3b76d08eb..000000000 --- a/admin/src/db/clickhouse.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { createClient, ClickHouseClient } from "@clickhouse/client"; - -let clickhouse: ClickHouseClient | null = null; - -function getClient(): ClickHouseClient { - if (!clickhouse) { - clickhouse = createClient({ - url: `${process.env.CLICKHOUSE_HOST}:${process.env.CLICKHOUSE_PORT}`, - username: process.env.CLICKHOUSE_USER, - password: process.env.CLICKHOUSE_PASSWORD, - database: process.env.CLICKHOUSE_DATABASE, - request_timeout: 60000, - clickhouse_settings: { - connect_timeout: 60000, - date_time_output_format: "iso", - output_format_json_quote_64bit_integers: 0, - }, - }); - } - return clickhouse; -} - -export const dbName = process.env.CLICKHOUSE_DATABASE || "analytics"; - -export interface TableStats { - table: string; - rows: number; - bytes: number; - bytesFormatted: string; -} - -export interface ClickHouseStats { - totalEvents: number; - tables: TableStats[]; -} - -const EVENT_TABLE = "events"; -const PROJECT_ACTIVITY_EVENT_TYPES = [ - "pageview", - "custom_event", - "error", - "captcha", - "performance", -]; -const PROJECT_ACTIVITY_TYPE_LIST = PROJECT_ACTIVITY_EVENT_TYPES.map( - (type) => `'${type}'` -).join(", "); - -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; -} - -export async function getClickHouseStats(): Promise { - const client = getClient(); - const database = process.env.CLICKHOUSE_DATABASE || "analytics"; - - const tables = [ - EVENT_TABLE, - "sessions", - "revenue", - "feature_flag_evaluations", - "experiment_exposures", - "error_statuses", - "bot_blocks", - ]; - - const tableStats: TableStats[] = []; - - for (const table of tables) { - try { - // Get row count - const countResult = await client.query({ - query: `SELECT count() as count FROM ${database}.${table}`, - format: "JSONEachRow", - }); - const countData = await countResult.json<{ count: number }>(); - const countArray = countData as unknown as { count: number }[]; - const rows = countArray[0]?.count || 0; - - // Get table size - const sizeResult = await client.query({ - query: ` - SELECT - sum(bytes) as bytes - FROM system.parts - WHERE database = '${database}' AND table = '${table}' AND active = 1 - `, - format: "JSONEachRow", - }); - const sizeData = await sizeResult.json<{ bytes: number }>(); - const sizeArray = sizeData as unknown as { bytes: number }[]; - const bytes = sizeArray[0]?.bytes || 0; - - tableStats.push({ - table, - rows, - bytes, - bytesFormatted: formatBytes(bytes), - }); - } catch { - // Table might not exist, skip it - tableStats.push({ - table, - rows: 0, - bytes: 0, - bytesFormatted: "0 B", - }); - } - } - - const totalEvents = - tableStats.find((table) => table.table === EVENT_TABLE)?.rows || 0; - - return { - totalEvents, - tables: tableStats, - }; -} - -export async function getProjectEventCount(pid: string): Promise { - const client = getClient(); - const database = process.env.CLICKHOUSE_DATABASE || "analytics"; - - try { - const result = await client.query({ - query: ` - SELECT count() as count - FROM ${database}.${EVENT_TABLE} - WHERE pid = {pid:FixedString(12)} - AND type IN (${PROJECT_ACTIVITY_TYPE_LIST}) - `, - query_params: { pid }, - format: "JSONEachRow", - }); - const data = await result.json<{ count: number }>(); - const dataArray = data as unknown as { count: number }[]; - return dataArray[0]?.count || 0; - } catch { - return 0; - } -} - -export async function testClickHouseConnection(): Promise { - try { - const client = getClient(); - const result = await client.ping(); - return result.success; - } catch { - return false; - } -} - -export interface TopProject { - pid: string; - eventCount: number; -} - -export async function getTopProjectsByEvents( - days: number, - limit: number = 20 -): Promise { - const client = getClient(); - const database = process.env.CLICKHOUSE_DATABASE || "analytics"; - - try { - const result = await client.query({ - query: ` - SELECT - pid, - count() as eventCount - FROM ${database}.${EVENT_TABLE} - WHERE created >= now() - INTERVAL ${days} DAY - AND type IN (${PROJECT_ACTIVITY_TYPE_LIST}) - GROUP BY pid - ORDER BY eventCount DESC - LIMIT ${limit} - `, - format: "JSONEachRow", - }); - const data = await result.json(); - return data as unknown as TopProject[]; - } catch { - return []; - } -} - -export async function getProjectsWithRecentEvents( - days: number -): Promise> { - const client = getClient(); - const database = process.env.CLICKHOUSE_DATABASE || "analytics"; - - try { - const result = await client.query({ - query: ` - SELECT DISTINCT pid - FROM ${database}.${EVENT_TABLE} - WHERE created >= now() - INTERVAL ${days} DAY - AND type IN (${PROJECT_ACTIVITY_TYPE_LIST}) - `, - format: "JSONEachRow", - }); - const data = await result.json<{ pid: string }>(); - const dataArray = data as unknown as { pid: string }[]; - return new Set(dataArray.map((row) => row.pid)); - } catch { - return new Set(); - } -} - -export { getClient as clickhouse }; diff --git a/admin/src/db/mysql.ts b/admin/src/db/mysql.ts deleted file mode 100644 index bbc6004b7..000000000 --- a/admin/src/db/mysql.ts +++ /dev/null @@ -1,58 +0,0 @@ -import "reflect-metadata"; -import { DataSource } from "typeorm"; -import { User } from "../entities/user.entity.js"; -import { Organisation } from "../entities/organisation.entity.js"; -import { OrganisationMember } from "../entities/organisation-member.entity.js"; -import { Project } from "../entities/project.entity.js"; - -let dataSource: DataSource | null = null; - -function getDataSource(): DataSource { - if (!dataSource) { - dataSource = new DataSource({ - type: "mysql", - host: process.env.MYSQL_HOST, - port: 3306, - username: process.env.MYSQL_USER, - password: process.env.MYSQL_ROOT_PASSWORD, - database: process.env.MYSQL_DATABASE, - synchronize: false, - entities: [User, Organisation, OrganisationMember, Project], - }); - } - return dataSource; -} - -let initialized = false; - -export async function initializeDatabase(): Promise { - const ds = getDataSource(); - if (!initialized) { - await ds.initialize(); - initialized = true; - } - return ds; -} - -export async function testMySQLConnection(): Promise { - try { - await initializeDatabase(); - return getDataSource().isInitialized; - } catch { - return false; - } -} - -// Proxy object that lazily gets the DataSource -export const AppDataSource = new Proxy({} as DataSource, { - get(_target, prop) { - const ds = getDataSource(); - const value = ds[prop as keyof DataSource]; - if (typeof value === "function") { - return value.bind(ds); - } - return value; - }, -}); - -export { User, Organisation, OrganisationMember, Project }; diff --git a/admin/src/entities/organisation-member.entity.ts b/admin/src/entities/organisation-member.entity.ts deleted file mode 100644 index 98589a8b2..000000000 --- a/admin/src/entities/organisation-member.entity.ts +++ /dev/null @@ -1,45 +0,0 @@ -import "reflect-metadata"; -import { - Entity, - Column, - ManyToOne, - PrimaryGeneratedColumn, -} from "typeorm"; -import { User } from './user.entity.js' -import { Organisation } from './organisation.entity.js' - -export enum OrganisationRole { - owner = 'owner', - admin = 'admin', - viewer = 'viewer', -} - -@Entity() -export class OrganisationMember { - @PrimaryGeneratedColumn('uuid') - id: string - - @ManyToOne(() => User, user => user.organisationMemberships) - user: User - - @ManyToOne(() => Organisation, org => org.members) - organisation: Organisation - - @Column({ - type: 'enum', - enum: OrganisationRole, - }) - role: OrganisationRole - - @Column({ - type: "boolean", - default: false, - }) - confirmed: boolean - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - created: Date - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - updated: Date -} diff --git a/admin/src/entities/organisation.entity.ts b/admin/src/entities/organisation.entity.ts deleted file mode 100644 index 18d028549..000000000 --- a/admin/src/entities/organisation.entity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import "reflect-metadata"; -import { - Entity, - Column, - PrimaryGeneratedColumn, - OneToMany, -} from "typeorm"; -import { Project } from './project.entity.js' -import { OrganisationMember } from './organisation-member.entity.js' - -@Entity() -export class Organisation { - @PrimaryGeneratedColumn('uuid') - id: string - - @Column('varchar', { length: 50 }) - name: string - - @OneToMany(() => OrganisationMember, member => member.organisation) - members: OrganisationMember[] - - @OneToMany(() => Project, project => project.organisation) - projects: Project[] - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - created: Date - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - updated: Date -} diff --git a/admin/src/entities/project.entity.ts b/admin/src/entities/project.entity.ts deleted file mode 100644 index c48f9a7e1..000000000 --- a/admin/src/entities/project.entity.ts +++ /dev/null @@ -1,59 +0,0 @@ -import "reflect-metadata"; -import { Entity, Column, PrimaryColumn, ManyToOne } from "typeorm"; -import { User } from './user.entity.js' -import { Organisation } from './organisation.entity.js' - -export enum BotsProtectionLevel { - OFF = 'off', - BASIC = 'basic', - STRICT = 'strict', -} - -@Entity() -export class Project { - @PrimaryColumn('varchar', { - unique: true, - length: 12, - }) - id: string - - @Column('varchar', { length: 50 }) - name: string - - @Column('simple-array') - origins: string[] - - @Column({ - type: "boolean", - default: true, - }) - active: boolean - - @Column({ - type: "boolean", - default: false, - }) - public: boolean - - @Column('boolean', { default: false }) - isTransferring: boolean - - @Column({ - type: 'enum', - enum: BotsProtectionLevel, - default: BotsProtectionLevel.BASIC, - }) - botsProtectionLevel: BotsProtectionLevel - - @ManyToOne(() => User, user => user.projects) - admin: User - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - created: Date - - @Column('boolean', { default: false }) - isArchived: boolean - - @ManyToOne(() => Organisation, org => org.projects, { nullable: true }) - organisation: Organisation | null -} diff --git a/admin/src/entities/user.entity.ts b/admin/src/entities/user.entity.ts deleted file mode 100644 index 1c7b4124a..000000000 --- a/admin/src/entities/user.entity.ts +++ /dev/null @@ -1,122 +0,0 @@ -import "reflect-metadata"; -import { - Entity, - Column, - PrimaryGeneratedColumn, - OneToMany, -} from "typeorm"; -import { Project } from './project.entity.js' -import { OrganisationMember } from './organisation-member.entity.js' - -export enum PlanCode { - none = 'none', - free = 'free', - trial = 'trial', - hobby = 'hobby', - freelancer = 'freelancer', - '50k' = '50k', - '100k' = '100k', - '200k' = '200k', - '500k' = '500k', - '1m' = '1m', - '2m' = '2m', - '5m' = '5m', - '10m' = '10m', - '15m' = '15m', - '20m' = '20m', - '30m' = '30m', - '40m' = '40m', - '50m' = '50m', -} - -export enum PlanType { - standard = 'standard', - plus = 'plus', - enterprise = 'enterprise', -} - -export enum DashboardBlockReason { - 'exceeding_plan_limits' = 'exceeding_plan_limits', - 'trial_ended' = 'trial_ended', - 'payment_failed' = 'payment_failed', - 'subscription_cancelled' = 'subscription_cancelled', -} - -@Entity() -export class User { - @PrimaryGeneratedColumn('uuid') - id: string - - @Column({ - type: 'enum', - enum: PlanCode, - default: PlanCode.none, - }) - planCode: PlanCode - - @Column({ - type: 'enum', - enum: PlanType, - nullable: true, - default: null, - }) - planType: PlanType | null - - @Column({ type: 'json', nullable: true }) - addonOverrides: Record | null - - @Column({ type: 'json', nullable: true }) - entitlementOverrides: Record | null - - @Column('varchar', { length: 100, nullable: true, default: null }) - nickname: string | null - - @Column('varchar', { length: 254, unique: true }) - email: string - - @Column({ type: "boolean", default: false }) - isActive: boolean - - @Column({ type: 'timestamp', nullable: true }) - trialEndDate: Date | null - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - created: Date - - @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) - updated: Date - - @Column('int', { default: 10 }) - maxProjects: number - - @Column('int', { default: 300 }) - maxApiKeyRequestsPerHour: number - - @Column({ - type: 'enum', - enum: DashboardBlockReason, - nullable: true, - }) - dashboardBlockReason: DashboardBlockReason | null - - @Column({ type: "boolean", default: false }) - isAccountBillingSuspended: boolean - - @Column({ - type: 'varchar', - length: 36, - unique: true, - nullable: true, - default: null, - }) - apiKey: string | null - - @Column({ type: "boolean", default: false }) - isTwoFactorAuthenticationEnabled: boolean - - @OneToMany(() => Project, project => project.admin) - projects: Project[] - - @OneToMany(() => OrganisationMember, membership => membership.user) - organisationMemberships: OrganisationMember[] -} diff --git a/admin/src/index.tsx b/admin/src/index.tsx deleted file mode 100644 index 53a5736fc..000000000 --- a/admin/src/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import path from "path"; -import { fileURLToPath } from "url"; -import fs from "fs"; -import { config } from "dotenv"; - -// Suppress dotenv log messages -process.env.DOTENV_CONFIG_QUIET = "true"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -// Load .env from backend directory if not already loaded (quiet mode) -const backendEnvPath = path.resolve(__dirname, "../../backend/.env"); -if (fs.existsSync(backendEnvPath)) { - config({ path: backendEnvPath, override: false, quiet: true }); -} - -// Also try local .env -const localEnvPath = path.resolve(__dirname, "../.env"); -if (fs.existsSync(localEnvPath)) { - config({ path: localEnvPath, override: false, quiet: true }); -} - -import "reflect-metadata"; - -const { React, render, App } = await (async () => { - const React = (await import("react")).default; - const { render } = await import("ink"); - const { App } = await import("./components/App.js"); - return { React, render, App }; -})(); - -render(); diff --git a/admin/tsconfig.json b/admin/tsconfig.json deleted file mode 100644 index db3b7420e..000000000 --- a/admin/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "jsx": "react-jsx", - "strict": true, - "strictPropertyInitialization": false, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "resolveJsonModule": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/backend/.env.example b/backend/.env.example index a50c10c95..7f4ac1736 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -106,6 +106,11 @@ IS_PRIMARY_NODE=true # Generic EMAIL_ACTION_ENCRYPTION_KEY= +# Admin panel (Cloud edition only) +# Comma-separated list of user emails allowed to access the /admin web panel. +# If unset, the admin panel is disabled entirely. +ADMIN_EMAILS= + # Swetrix Enterprise (Cloud) SWETRIX_LICENSE_KEY= diff --git a/backend/apps/cloud/src/admin/admin.controller.ts b/backend/apps/cloud/src/admin/admin.controller.ts new file mode 100644 index 000000000..c9a38dc0b --- /dev/null +++ b/backend/apps/cloud/src/admin/admin.controller.ts @@ -0,0 +1,215 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Patch, + Query, +} from '@nestjs/common' +import { ApiExcludeController } from '@nestjs/swagger' + +import { PlanType } from '../user/entities/user.entity' +import { AdminService, ProjectsFilter, UsersFilter } from './admin.service' +import { AdminAuth } from './decorators/admin-auth.decorator' + +const USERS_FILTERS: UsersFilter[] = [ + 'all', + 'active', + 'inactive', + 'paid', + 'trial', + 'free', + 'blocked', +] + +const PROJECTS_FILTERS: ProjectsFilter[] = [ + 'all', + 'active', + 'archived', + 'inactive-30', + 'inactive-60', + 'inactive-90', +] + +const CHART_DAYS = [30, 90, 180, 365] +const TOP_PROJECTS_DAYS = [1, 7, 30] + +const parsePage = (page?: string): number => { + const parsed = Number(page) + + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100000) { + return 0 + } + + return parsed +} + +const parseSearch = (search?: string): string => + (search || '').trim().slice(0, 100) + +const parseOneOf = ( + value: string | undefined, + allowed: readonly T[], + fallback: T, +): T => (allowed.includes(value as T) ? (value as T) : fallback) + +const parseDays = ( + value: string | undefined, + allowed: readonly number[], + fallback: number, +): number => { + const parsed = Number(value) + + return allowed.includes(parsed) ? parsed : fallback +} + +const parseOverrides = ( + value: unknown, + field: string, +): Record | null => { + if (value === null) { + return null + } + + if ( + typeof value !== 'object' || + Array.isArray(value) || + JSON.stringify(value).length > 5000 + ) { + throw new BadRequestException( + `${field} must be a small JSON object or null`, + ) + } + + return value as Record +} + +@ApiExcludeController() +@AdminAuth() +@Controller('admin') +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + // Used by the frontend loader to decide whether /admin exists for this user + @Get('access') + async getAccess() { + return { granted: true } + } + + @Get('overview') + async getOverview() { + return this.adminService.getOverview() + } + + @Get('charts') + async getCharts(@Query('days') days?: string) { + return this.adminService.getCharts(parseDays(days, CHART_DAYS, 30)) + } + + @Get('users') + async getUsers( + @Query('page') page?: string, + @Query('search') search?: string, + @Query('filter') filter?: string, + @Query('sortBy') sortBy?: string, + @Query('order') order?: string, + ) { + return this.adminService.getUsers( + parsePage(page), + parseSearch(search), + parseOneOf(filter, USERS_FILTERS, 'all'), + parseOneOf(sortBy, ['created', 'email', 'planCode'] as const, 'created'), + parseOneOf(order, ['ASC', 'DESC'] as const, 'DESC'), + ) + } + + @Get('users/:id') + async getUserDetails(@Param('id') id: string) { + return this.adminService.getUserDetails(id) + } + + @Patch('users/:id') + async updateUser( + @Param('id') id: string, + @Body() + body: { + planType?: PlanType | null + addonOverrides?: Record | null + entitlementOverrides?: Record | null + }, + ) { + const updates: Parameters[1] = {} + + if ('planType' in body) { + if ( + body.planType !== null && + !Object.values(PlanType).includes(body.planType) + ) { + throw new BadRequestException('Invalid planType') + } + + updates.planType = body.planType + } + + if ('addonOverrides' in body) { + updates.addonOverrides = parseOverrides( + body.addonOverrides, + 'addonOverrides', + ) + } + + if ('entitlementOverrides' in body) { + updates.entitlementOverrides = parseOverrides( + body.entitlementOverrides, + 'entitlementOverrides', + ) + } + + if (Object.keys(updates).length === 0) { + throw new BadRequestException('Nothing to update') + } + + return this.adminService.updateUser(id, updates) + } + + @Get('projects') + async getProjects( + @Query('page') page?: string, + @Query('search') search?: string, + @Query('filter') filter?: string, + @Query('sortBy') sortBy?: string, + @Query('order') order?: string, + ) { + return this.adminService.getProjects( + parsePage(page), + parseSearch(search), + parseOneOf(filter, PROJECTS_FILTERS, 'all'), + parseOneOf(sortBy, ['created', 'name'] as const, 'created'), + parseOneOf(order, ['ASC', 'DESC'] as const, 'DESC'), + ) + } + + @Get('projects/top') + async getTopProjects(@Query('days') days?: string) { + return this.adminService.getTopProjects( + parseDays(days, TOP_PROJECTS_DAYS, 7), + ) + } + + @Get('organisations') + async getOrganisations( + @Query('page') page?: string, + @Query('search') search?: string, + ) { + return this.adminService.getOrganisations( + parsePage(page), + parseSearch(search), + ) + } + + @Get('database') + async getDatabaseInfo() { + return this.adminService.getDatabaseInfo() + } +} diff --git a/backend/apps/cloud/src/admin/admin.module.ts b/backend/apps/cloud/src/admin/admin.module.ts new file mode 100644 index 000000000..62a249eb8 --- /dev/null +++ b/backend/apps/cloud/src/admin/admin.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { Organisation } from '../organisation/entity/organisation.entity' +import { OrganisationMember } from '../organisation/entity/organisation-member.entity' +import { Project } from '../project/entity/project.entity' +import { User } from '../user/entities/user.entity' +import { AdminController } from './admin.controller' +import { AdminService } from './admin.service' + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Project, Organisation, OrganisationMember]), + ], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/backend/apps/cloud/src/admin/admin.service.ts b/backend/apps/cloud/src/admin/admin.service.ts new file mode 100644 index 000000000..56045d383 --- /dev/null +++ b/backend/apps/cloud/src/admin/admin.service.ts @@ -0,0 +1,1153 @@ +import { Injectable, NotFoundException } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { In, IsNull, Not, Repository } from 'typeorm' + +import { clickhouse } from '../common/integrations/clickhouse' +import { Organisation } from '../organisation/entity/organisation.entity' +import { + OrganisationMember, + OrganisationRole, +} from '../organisation/entity/organisation-member.entity' +import { Project } from '../project/entity/project.entity' +import { + ACCOUNT_PLANS, + BillingFrequency, + getEffectiveAccountLimits, + getEffectivePlanType, + PlanCode, + PlanType, + User, +} from '../user/entities/user.entity' + +// Event types that count towards project activity (parity with the analytics views) +const ACTIVITY_EVENT_TYPES = [ + 'pageview', + 'custom_event', + 'error', + 'captcha', + 'performance', +] + +// Billable usage condition - keep in sync with task-manager's plan usage query +const CAPTCHA_PASS_CONDITION = + "type = 'captcha' AND if(indexOf(`meta.key`, 'captcha_event') = 0, 'pass', arrayElement(`meta.value`, indexOf(`meta.key`, 'captcha_event'))) = 'pass'" + +const BILLABLE_COUNT_IF = `countIf(type IN ('pageview', 'custom_event', 'error') OR (${CAPTCHA_PASS_CONDITION}))` + +const FREE_PLAN_CODES = [PlanCode.none, PlanCode.free, PlanCode.trial] + +const USERS_PAGE_SIZE = 25 +const PROJECTS_PAGE_SIZE = 25 +const ORGANISATIONS_PAGE_SIZE = 25 + +interface CurrencyAmounts { + USD: number + EUR: number + GBP: number +} + +interface PlanPricing { + monthly: CurrencyAmounts + yearly: CurrencyAmounts +} + +// List prices per plan (mirrors web/app/lib/pricing/catalog.ts) - used for MRR estimates +const STANDARD_PLAN_PRICES: Partial> = { + [PlanCode.hobby]: { + monthly: { USD: 5, EUR: 5, GBP: 4 }, + yearly: { USD: 50, EUR: 50, GBP: 40 }, + }, + [PlanCode['50k']]: { + monthly: { USD: 12, EUR: 10, GBP: 10 }, + yearly: { USD: 120, EUR: 100, GBP: 100 }, + }, + [PlanCode.freelancer]: { + monthly: { USD: 15, EUR: 15, GBP: 14 }, + yearly: { USD: 150, EUR: 150, GBP: 140 }, + }, + [PlanCode['100k']]: { + monthly: { USD: 19, EUR: 17, GBP: 15 }, + yearly: { USD: 190, EUR: 170, GBP: 150 }, + }, + [PlanCode['200k']]: { + monthly: { USD: 29, EUR: 25, GBP: 23 }, + yearly: { USD: 290, EUR: 250, GBP: 230 }, + }, + [PlanCode['500k']]: { + monthly: { USD: 49, EUR: 42, GBP: 39 }, + yearly: { USD: 490, EUR: 420, GBP: 390 }, + }, + [PlanCode['1m']]: { + monthly: { USD: 79, EUR: 69, GBP: 59 }, + yearly: { USD: 790, EUR: 690, GBP: 590 }, + }, + [PlanCode['2m']]: { + monthly: { USD: 119, EUR: 99, GBP: 89 }, + yearly: { USD: 1190, EUR: 990, GBP: 890 }, + }, + [PlanCode['5m']]: { + monthly: { USD: 179, EUR: 149, GBP: 139 }, + yearly: { USD: 1790, EUR: 1490, GBP: 1390 }, + }, + [PlanCode['10m']]: { + monthly: { USD: 249, EUR: 209, GBP: 189 }, + yearly: { USD: 2490, EUR: 2090, GBP: 1890 }, + }, + [PlanCode['15m']]: { + monthly: { USD: 349, EUR: 299, GBP: 259 }, + yearly: { USD: 3490, EUR: 2990, GBP: 2590 }, + }, + [PlanCode['20m']]: { + monthly: { USD: 419, EUR: 359, GBP: 319 }, + yearly: { USD: 4190, EUR: 3590, GBP: 3190 }, + }, + [PlanCode['30m']]: { + monthly: { USD: 519, EUR: 449, GBP: 389 }, + yearly: { USD: 5190, EUR: 4490, GBP: 3890 }, + }, + [PlanCode['40m']]: { + monthly: { USD: 619, EUR: 529, GBP: 459 }, + yearly: { USD: 6190, EUR: 5290, GBP: 4590 }, + }, + [PlanCode['50m']]: { + monthly: { USD: 719, EUR: 619, GBP: 539 }, + yearly: { USD: 7190, EUR: 6190, GBP: 5390 }, + }, +} + +const PLUS_PLAN_PRICES: Partial> = { + [PlanCode['100k']]: { + monthly: { USD: 39, EUR: 35, GBP: 29 }, + yearly: { USD: 390, EUR: 350, GBP: 290 }, + }, + [PlanCode['200k']]: { + monthly: { USD: 59, EUR: 49, GBP: 45 }, + yearly: { USD: 590, EUR: 490, GBP: 450 }, + }, + [PlanCode['500k']]: { + monthly: { USD: 109, EUR: 95, GBP: 79 }, + yearly: { USD: 1090, EUR: 950, GBP: 790 }, + }, + [PlanCode['1m']]: { + monthly: { USD: 179, EUR: 159, GBP: 135 }, + yearly: { USD: 1790, EUR: 1590, GBP: 1350 }, + }, + [PlanCode['2m']]: { + monthly: { USD: 279, EUR: 239, GBP: 209 }, + yearly: { USD: 2790, EUR: 2390, GBP: 2090 }, + }, + [PlanCode['5m']]: { + monthly: { USD: 439, EUR: 379, GBP: 329 }, + yearly: { USD: 4390, EUR: 3790, GBP: 3290 }, + }, + [PlanCode['10m']]: { + monthly: { USD: 629, EUR: 539, GBP: 469 }, + yearly: { USD: 6290, EUR: 5390, GBP: 4690 }, + }, + [PlanCode['15m']]: { + monthly: { USD: 919, EUR: 799, GBP: 699 }, + yearly: { USD: 9190, EUR: 7990, GBP: 6990 }, + }, + [PlanCode['20m']]: { + monthly: { USD: 1139, EUR: 979, GBP: 849 }, + yearly: { USD: 11390, EUR: 9790, GBP: 8490 }, + }, + [PlanCode['30m']]: { + monthly: { USD: 1459, EUR: 1249, GBP: 1079 }, + yearly: { USD: 14590, EUR: 12490, GBP: 10790 }, + }, + [PlanCode['40m']]: { + monthly: { USD: 1799, EUR: 1549, GBP: 1329 }, + yearly: { USD: 17990, EUR: 15490, GBP: 13290 }, + }, + [PlanCode['50m']]: { + monthly: { USD: 2159, EUR: 1859, GBP: 1599 }, + yearly: { USD: 21590, EUR: 18590, GBP: 15990 }, + }, +} + +type SupportedCurrency = keyof CurrencyAmounts + +interface MrrSubscription { + planCode: PlanCode + planType: PlanType | null + billingFrequency: BillingFrequency | null + tierCurrency: string | null +} + +export type UsersFilter = + | 'all' + | 'active' + | 'inactive' + | 'paid' + | 'trial' + | 'free' + | 'blocked' + +export type ProjectsFilter = + | 'all' + | 'active' + | 'archived' + | 'inactive-30' + | 'inactive-60' + | 'inactive-90' + +const dateToChDateTime = (date: Date): string => + date.toISOString().slice(0, 19).replace('T', ' ') + +@Injectable() +export class AdminService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Project) + private readonly projectRepository: Repository, + @InjectRepository(Organisation) + private readonly organisationRepository: Repository, + @InjectRepository(OrganisationMember) + private readonly organisationMemberRepository: Repository, + ) {} + + // -------------------- MRR -------------------- + + private getMonthlyRevenue( + subscription: MrrSubscription, + ): { currency: SupportedCurrency; amount: number; usdAmount: number } | null { + const { planCode, billingFrequency, tierCurrency } = subscription + + if (FREE_PLAN_CODES.includes(planCode)) { + return null + } + + const planType = getEffectivePlanType(subscription) + const priceTable = + planType === PlanType.plus ? PLUS_PLAN_PRICES : STANDARD_PLAN_PRICES + const pricing = priceTable[planCode] + + if (!pricing) { + return null + } + + const currency: SupportedCurrency = ['USD', 'EUR', 'GBP'].includes( + tierCurrency as string, + ) + ? (tierCurrency as SupportedCurrency) + : 'USD' + + const isYearly = billingFrequency === BillingFrequency.Yearly + const amount = isYearly + ? pricing.yearly[currency] / 12 + : pricing.monthly[currency] + const usdAmount = isYearly ? pricing.yearly.USD / 12 : pricing.monthly.USD + + return { currency, amount, usdAmount } + } + + private async calculateMrr() { + const paidUsers = await this.userRepository.find({ + where: { planCode: Not(In(FREE_PLAN_CODES)) }, + select: [ + 'id', + 'planCode', + 'planType', + 'billingFrequency', + 'tierCurrency', + ], + }) + + const byCurrency: Record = { + USD: 0, + EUR: 0, + GBP: 0, + } + let usdEquivalent = 0 + let unpricedSubscriptions = 0 + + for (const user of paidUsers) { + const revenue = this.getMonthlyRevenue(user) + + if (!revenue) { + unpricedSubscriptions += 1 + continue + } + + byCurrency[revenue.currency] += revenue.amount + usdEquivalent += revenue.usdAmount + } + + return { + byCurrency: { + USD: Math.round(byCurrency.USD * 100) / 100, + EUR: Math.round(byCurrency.EUR * 100) / 100, + GBP: Math.round(byCurrency.GBP * 100) / 100, + }, + // Every subscription valued at its USD list price; an estimate, not accounting data + usdEquivalent: Math.round(usdEquivalent * 100) / 100, + payingUsers: paidUsers.length, + unpricedSubscriptions, + } + } + + // -------------------- Overview -------------------- + + async getOverview() { + const now = new Date() + const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + + const [ + totalUsers, + activeUsers, + paidUsers, + trialUsers, + cancellingUsers, + suspendedUsers, + totalProjects, + liveProjects, + totalOrganisations, + planDistributionRaw, + signups24h, + signups7d, + signups30d, + mrr, + ] = await Promise.all([ + this.userRepository.count(), + this.userRepository.count({ where: { isActive: true } }), + this.userRepository.count({ + where: { planCode: Not(In(FREE_PLAN_CODES)) }, + }), + this.userRepository.count({ where: { planCode: PlanCode.trial } }), + this.userRepository.count({ + where: { cancellationEffectiveDate: Not(IsNull()) }, + }), + this.userRepository.count({ + where: { isAccountBillingSuspended: true }, + }), + this.projectRepository.count(), + this.projectRepository.count({ + where: { active: true, isArchived: false }, + }), + this.organisationRepository.count(), + this.userRepository + .createQueryBuilder('user') + .select('user.planCode', 'planCode') + .addSelect('COUNT(*)', 'count') + .groupBy('user.planCode') + .getRawMany<{ planCode: PlanCode; count: string }>(), + this.countUsersCreatedSince(dayAgo), + this.countUsersCreatedSince(weekAgo), + this.countUsersCreatedSince(monthAgo), + this.calculateMrr(), + ]) + + const eventStats = await this.getEventTotals() + + const planDistribution = planDistributionRaw + .map(({ planCode, count }) => ({ planCode, count: Number(count) })) + .sort((a, b) => b.count - a.count) + + return { + users: { + total: totalUsers, + active: activeUsers, + paid: paidUsers, + trial: trialUsers, + cancelling: cancellingUsers, + suspended: suspendedUsers, + signups24h, + signups7d, + signups30d, + }, + projects: { + total: totalProjects, + live: liveProjects, + }, + organisations: { + total: totalOrganisations, + }, + events: eventStats, + mrr, + planDistribution, + } + } + + private async countUsersCreatedSince(since: Date): Promise { + return this.userRepository + .createQueryBuilder('user') + .where('user.created >= :since', { since }) + .getCount() + } + + private async getEventTotals() { + const query = ` + SELECT + count() AS total, + countIf(created >= now() - INTERVAL 1 DAY) AS last24h, + countIf(created >= now() - INTERVAL 7 DAY) AS last7d, + countIf(created >= now() - INTERVAL 30 DAY) AS last30d + FROM events + WHERE type IN ({types:Array(String)}) + ` + + const { data } = await clickhouse + .query({ query, query_params: { types: ACTIVITY_EVENT_TYPES } }) + .then((resultSet) => + resultSet.json<{ + total: number + last24h: number + last7d: number + last30d: number + }>(), + ) + + return ( + data[0] || { + total: 0, + last24h: 0, + last7d: 0, + last30d: 0, + } + ) + } + + // -------------------- Charts -------------------- + + async getCharts(days: number) { + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + + const [signups, projects, organisations, events] = await Promise.all([ + this.getMysqlCreationSeries('user', since), + this.getMysqlCreationSeries('project', since), + this.getMysqlCreationSeries('organisation', since), + this.getEventsSeries(days), + ]) + + return { signups, projects, organisations, events } + } + + private async getMysqlCreationSeries( + table: 'user' | 'project' | 'organisation', + since: Date, + ): Promise<{ date: string; count: number }[]> { + // table name comes from a hardcoded whitelist, never from user input + const rows: { date: string; count: string }[] = + await this.userRepository.query( + ` + SELECT DATE_FORMAT(created, '%Y-%m-%d') AS date, COUNT(*) AS count + FROM \`${table}\` + WHERE created >= ? + GROUP BY date + ORDER BY date ASC + `, + [since], + ) + + return rows.map(({ date, count }) => ({ date, count: Number(count) })) + } + + private async getEventsSeries( + days: number, + ): Promise<{ date: string; count: number }[]> { + const query = ` + SELECT + toString(toDate(created)) AS date, + count() AS count + FROM events + WHERE created >= now() - INTERVAL {days:UInt16} DAY + AND type IN ({types:Array(String)}) + GROUP BY date + ORDER BY date ASC + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { days, types: ACTIVITY_EVENT_TYPES }, + }) + .then((resultSet) => resultSet.json<{ date: string; count: number }>()) + + return data.map(({ date, count }) => ({ date, count: Number(count) })) + } + + // -------------------- Users -------------------- + + async getUsers( + page: number, + search: string, + filter: UsersFilter, + sortBy: 'created' | 'email' | 'planCode', + order: 'ASC' | 'DESC', + ) { + let query = this.userRepository + .createQueryBuilder('user') + .loadRelationCountAndMap('user.projectCount', 'user.projects') + .orderBy(`user.${sortBy}`, order) + .skip(page * USERS_PAGE_SIZE) + .take(USERS_PAGE_SIZE) + + if (filter === 'active') { + query = query.andWhere('user.isActive = true') + } else if (filter === 'inactive') { + query = query.andWhere('user.isActive = false') + } else if (filter === 'paid') { + query = query.andWhere('user.planCode NOT IN (:...freePlans)', { + freePlans: FREE_PLAN_CODES, + }) + } else if (filter === 'trial') { + query = query.andWhere('user.planCode = :planCode', { + planCode: PlanCode.trial, + }) + } else if (filter === 'free') { + query = query.andWhere('user.planCode IN (:...freePlans)', { + freePlans: [PlanCode.free, PlanCode.none], + }) + } else if (filter === 'blocked') { + query = query.andWhere( + '(user.dashboardBlockReason IS NOT NULL OR user.isAccountBillingSuspended = true)', + ) + } + + if (search) { + query = query.andWhere( + '(LOWER(user.email) LIKE :search OR LOWER(user.id) LIKE :search OR LOWER(user.nickname) LIKE :search)', + { search: `%${search.toLowerCase()}%` }, + ) + } + + const [users, total] = await query.getManyAndCount() + + const monthlyEvents = await this.getMonthlyEventsForUsers( + users.map((user) => user.id), + ) + + return { + total, + pageSize: USERS_PAGE_SIZE, + users: users.map((user) => this.formatUserForList(user, monthlyEvents)), + } + } + + private formatUserForList( + user: User & { projectCount?: number }, + monthlyEvents: Record, + ) { + const limits = getEffectiveAccountLimits(user) + + return { + id: user.id, + email: user.email, + nickname: user.nickname, + planCode: user.planCode, + planType: getEffectivePlanType(user), + billingFrequency: user.billingFrequency, + tierCurrency: user.tierCurrency, + isActive: user.isActive, + created: user.created, + trialEndDate: user.trialEndDate, + nextBillDate: user.nextBillDate, + cancellationEffectiveDate: user.cancellationEffectiveDate, + dashboardBlockReason: user.dashboardBlockReason, + isAccountBillingSuspended: user.isAccountBillingSuspended, + addonOverrides: user.addonOverrides, + entitlementOverrides: user.entitlementOverrides, + // The raw column value (null = legacy), as opposed to the effective planType + storedPlanType: user.planType, + projectCount: user.projectCount || 0, + maxProjects: limits.maxProjects, + monthlyEvents: monthlyEvents[user.id] || 0, + monthlyUsageLimit: + ACCOUNT_PLANS[user.planCode]?.monthlyUsageLimit ?? null, + } + } + + // Billable events in the current calendar month, grouped by user + private async getMonthlyEventsForUsers( + userIds: string[], + ): Promise> { + if (userIds.length === 0) { + return {} + } + + const projects: { pid: string; userId: string }[] = + await this.projectRepository + .createQueryBuilder('project') + .select('project.id', 'pid') + .addSelect('admin.id', 'userId') + .leftJoin('project.admin', 'admin') + .where('admin.id IN (:...userIds)', { userIds }) + .getRawMany() + + if (projects.length === 0) { + return {} + } + + const monthStart = new Date() + monthStart.setUTCDate(1) + monthStart.setUTCHours(0, 0, 0, 0) + + const query = ` + SELECT + pid, + ${BILLABLE_COUNT_IF} AS count + FROM events + WHERE pid IN ({pids:Array(FixedString(12))}) + AND type IN ('pageview', 'custom_event', 'error', 'captcha') + AND created >= {from:String} + GROUP BY pid + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { + pids: projects.map(({ pid }) => pid), + from: dateToChDateTime(monthStart), + }, + }) + .then((resultSet) => resultSet.json<{ pid: string; count: number }>()) + + const countByPid: Record = {} + + for (const { pid, count } of data) { + countByPid[pid] = Number(count) + } + + const countByUser: Record = {} + + for (const { pid, userId } of projects) { + countByUser[userId] = (countByUser[userId] || 0) + (countByPid[pid] || 0) + } + + return countByUser + } + + async getUserDetails(id: string) { + const user = await this.userRepository.findOne({ where: { id } }) + + if (!user) { + throw new NotFoundException() + } + + const [projects, memberships, monthlyEvents] = await Promise.all([ + this.projectRepository.find({ + where: { admin: { id } }, + relations: ['organisation'], + order: { created: 'DESC' }, + }), + this.organisationMemberRepository.find({ + where: { user: { id } }, + relations: ['organisation'], + }), + this.getMonthlyEventsForUsers([id]), + ]) + + const eventCounts = await this.getEventCountsForProjects( + projects.map((project) => project.id), + ) + + return { + user: this.formatUserForList( + Object.assign(user, { projectCount: projects.length }), + monthlyEvents, + ), + effectiveLimits: getEffectiveAccountLimits(user), + projects: projects.map((project) => ({ + id: project.id, + name: project.name, + active: project.active, + isArchived: project.isArchived, + created: project.created, + organisation: project.organisation + ? { id: project.organisation.id, name: project.organisation.name } + : null, + ...(eventCounts[project.id] || { + events24h: 0, + events30d: 0, + totalEvents: 0, + }), + })), + memberships: memberships.map((membership) => ({ + id: membership.id, + role: membership.role, + confirmed: membership.confirmed, + created: membership.created, + organisation: membership.organisation + ? { + id: membership.organisation.id, + name: membership.organisation.name, + } + : null, + })), + } + } + + async updateUser( + id: string, + updates: { + planType?: PlanType | null + addonOverrides?: Record | null + entitlementOverrides?: Record | null + }, + ) { + const user = await this.userRepository.findOne({ where: { id } }) + + if (!user) { + throw new NotFoundException() + } + + const update: Partial = {} + + if ('planType' in updates) { + update.planType = updates.planType + } + + if ('addonOverrides' in updates) { + update.addonOverrides = updates.addonOverrides + } + + if ('entitlementOverrides' in updates) { + update.entitlementOverrides = updates.entitlementOverrides + } + + // Keep the legacy per-user limit columns in sync with the new effective + // limits (parity with what the admin CLI used to do) + const limits = getEffectiveAccountLimits({ ...user, ...update }) + + if (typeof limits.maxProjects === 'number') { + update.maxProjects = limits.maxProjects + } + + if (typeof limits.maxApiKeyRequestsPerHour === 'number') { + update.maxApiKeyRequestsPerHour = limits.maxApiKeyRequestsPerHour + } + + await this.userRepository.update({ id }, update) + + return this.getUserDetails(id) + } + + // -------------------- Projects -------------------- + + async getProjects( + page: number, + search: string, + filter: ProjectsFilter, + sortBy: 'created' | 'name', + order: 'ASC' | 'DESC', + ) { + let query = this.projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect('project.admin', 'admin') + .leftJoinAndSelect('project.organisation', 'organisation') + .orderBy(`project.${sortBy}`, order) + .skip(page * PROJECTS_PAGE_SIZE) + .take(PROJECTS_PAGE_SIZE) + + if (filter === 'active') { + query = query.andWhere( + 'project.active = true AND project.isArchived = false', + ) + } else if (filter === 'archived') { + query = query.andWhere('project.isArchived = true') + } else if (filter.startsWith('inactive-')) { + const days = Number(filter.split('-')[1]) + const activePids = await this.getProjectsWithRecentEvents(days) + + query = query.andWhere('project.isArchived = false') + + if (activePids.length > 0) { + query = query.andWhere('project.id NOT IN (:...activePids)', { + activePids, + }) + } + } + + if (search) { + query = query.andWhere( + '(LOWER(project.name) LIKE :search OR LOWER(project.id) LIKE :search OR LOWER(admin.email) LIKE :search)', + { search: `%${search.toLowerCase()}%` }, + ) + } + + const [projects, total] = await query.getManyAndCount() + + const eventCounts = await this.getEventCountsForProjects( + projects.map((project) => project.id), + ) + + return { + total, + pageSize: PROJECTS_PAGE_SIZE, + projects: projects.map((project) => + this.formatProject(project, eventCounts), + ), + } + } + + private formatProject( + project: Project, + eventCounts: Record< + string, + { events24h: number; events30d: number; totalEvents: number } + >, + ) { + return { + id: project.id, + name: project.name, + active: project.active, + public: project.public, + isArchived: project.isArchived, + isPasswordProtected: project.isPasswordProtected, + botsProtectionLevel: project.botsProtectionLevel, + created: project.created, + admin: project.admin + ? { + id: project.admin.id, + email: project.admin.email, + planCode: project.admin.planCode, + } + : null, + organisation: project.organisation + ? { id: project.organisation.id, name: project.organisation.name } + : null, + ...(eventCounts[project.id] || { + events24h: 0, + events30d: 0, + totalEvents: 0, + }), + } + } + + private async getProjectsWithRecentEvents(days: number): Promise { + const query = ` + SELECT DISTINCT pid + FROM events + WHERE created >= now() - INTERVAL {days:UInt16} DAY + AND type IN ({types:Array(String)}) + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { days, types: ACTIVITY_EVENT_TYPES }, + }) + .then((resultSet) => resultSet.json<{ pid: string }>()) + + return data.map(({ pid }) => pid) + } + + private async getEventCountsForProjects( + pids: string[], + ): Promise< + Record< + string, + { events24h: number; events30d: number; totalEvents: number } + > + > { + if (pids.length === 0) { + return {} + } + + const query = ` + SELECT + pid, + countIf(created >= now() - INTERVAL 1 DAY) AS events24h, + countIf(created >= now() - INTERVAL 30 DAY) AS events30d, + count() AS totalEvents + FROM events + WHERE pid IN ({pids:Array(FixedString(12))}) + AND type IN ({types:Array(String)}) + GROUP BY pid + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { pids, types: ACTIVITY_EVENT_TYPES }, + }) + .then((resultSet) => + resultSet.json<{ + pid: string + events24h: number + events30d: number + totalEvents: number + }>(), + ) + + const result: Record< + string, + { events24h: number; events30d: number; totalEvents: number } + > = {} + + for (const { pid, events24h, events30d, totalEvents } of data) { + result[pid] = { + events24h: Number(events24h), + events30d: Number(events30d), + totalEvents: Number(totalEvents), + } + } + + return result + } + + async getTopProjects(days: number, limit = 50) { + const query = ` + SELECT + pid, + count() AS eventCount + FROM events + WHERE created >= now() - INTERVAL {days:UInt16} DAY + AND type IN ({types:Array(String)}) + GROUP BY pid + ORDER BY eventCount DESC + LIMIT {limit:UInt16} + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { days, limit, types: ACTIVITY_EVENT_TYPES }, + }) + .then((resultSet) => + resultSet.json<{ pid: string; eventCount: number }>(), + ) + + if (data.length === 0) { + return { projects: [] } + } + + const projects = await this.projectRepository.find({ + where: { id: In(data.map(({ pid }) => pid)) }, + relations: ['admin', 'organisation'], + }) + + const projectsByPid: Record = {} + + for (const project of projects) { + projectsByPid[project.id] = project + } + + return { + projects: data.map(({ pid, eventCount }) => { + const project = projectsByPid[pid] + + return { + id: pid, + eventCount: Number(eventCount), + name: project?.name || null, + created: project?.created || null, + admin: project?.admin + ? { + id: project.admin.id, + email: project.admin.email, + planCode: project.admin.planCode, + } + : null, + organisation: project?.organisation + ? { id: project.organisation.id, name: project.organisation.name } + : null, + } + }), + } + } + + // -------------------- Organisations -------------------- + + async getOrganisations(page: number, search: string) { + let query = this.organisationRepository + .createQueryBuilder('org') + .loadRelationCountAndMap('org.memberCount', 'org.members') + .loadRelationCountAndMap('org.projectCount', 'org.projects') + .orderBy('org.created', 'DESC') + .skip(page * ORGANISATIONS_PAGE_SIZE) + .take(ORGANISATIONS_PAGE_SIZE) + + if (search) { + query = query.where( + '(LOWER(org.name) LIKE :search OR LOWER(org.id) LIKE :search)', + { search: `%${search.toLowerCase()}%` }, + ) + } + + const [organisations, total] = await query.getManyAndCount() + + const owners = + organisations.length === 0 + ? [] + : await this.organisationMemberRepository.find({ + where: { + organisation: { id: In(organisations.map((org) => org.id)) }, + role: OrganisationRole.owner, + }, + relations: ['organisation', 'user'], + }) + + const ownersByOrgId: Record = {} + + for (const owner of owners) { + if (owner.organisation) { + ownersByOrgId[owner.organisation.id] = owner + } + } + + return { + total, + pageSize: ORGANISATIONS_PAGE_SIZE, + organisations: organisations.map( + ( + org: Organisation & { memberCount?: number; projectCount?: number }, + ) => ({ + id: org.id, + name: org.name, + created: org.created, + memberCount: org.memberCount || 0, + projectCount: org.projectCount || 0, + owner: ownersByOrgId[org.id]?.user + ? { + id: ownersByOrgId[org.id].user.id, + email: ownersByOrgId[org.id].user.email, + } + : null, + }), + ), + } + } + + // -------------------- Database -------------------- + + async getDatabaseInfo() { + const [clickhouseInfo, mysqlInfo] = await Promise.all([ + this.getClickhouseInfo(), + this.getMysqlInfo(), + ]) + + return { clickhouse: clickhouseInfo, mysql: mysqlInfo } + } + + private async getClickhouseInfo() { + const disksQuery = ` + SELECT + name, + path, + free_space AS freeSpace, + total_space AS totalSpace + FROM system.disks + ` + + const tablesQuery = ` + SELECT + table, + sum(rows) AS rows, + sum(data_compressed_bytes) AS compressedBytes, + sum(data_uncompressed_bytes) AS uncompressedBytes, + count() AS parts + FROM system.parts + WHERE database = currentDatabase() AND active + GROUP BY table + ORDER BY compressedBytes DESC + ` + + const [disks, tables, version] = await Promise.all([ + clickhouse + .query({ query: disksQuery }) + .then((resultSet) => + resultSet.json<{ + name: string + path: string + freeSpace: number + totalSpace: number + }>(), + ) + .then(({ data }) => + data.map((disk) => ({ + ...disk, + freeSpace: Number(disk.freeSpace), + totalSpace: Number(disk.totalSpace), + })), + ), + clickhouse + .query({ query: tablesQuery }) + .then((resultSet) => + resultSet.json<{ + table: string + rows: number + compressedBytes: number + uncompressedBytes: number + parts: number + }>(), + ) + .then(({ data }) => + data.map((table) => ({ + ...table, + rows: Number(table.rows), + compressedBytes: Number(table.compressedBytes), + uncompressedBytes: Number(table.uncompressedBytes), + parts: Number(table.parts), + })), + ), + clickhouse + .query({ query: 'SELECT version() AS version' }) + .then((resultSet) => resultSet.json<{ version: string }>()) + .then(({ data }) => data[0]?.version || null), + ]) + + return { + version, + disks, + tables, + totalCompressedBytes: tables.reduce( + (acc, table) => acc + table.compressedBytes, + 0, + ), + totalUncompressedBytes: tables.reduce( + (acc, table) => acc + table.uncompressedBytes, + 0, + ), + totalRows: tables.reduce((acc, table) => acc + table.rows, 0), + } + } + + private async getMysqlInfo() { + const [tables, versionRow] = await Promise.all([ + this.userRepository.query( + ` + SELECT + table_name AS tableName, + table_rows AS estimatedRows, + data_length AS dataBytes, + index_length AS indexBytes + FROM information_schema.tables + WHERE table_schema = DATABASE() + ORDER BY data_length + index_length DESC + `, + ) as Promise< + { + tableName: string + estimatedRows: string + dataBytes: string + indexBytes: string + }[] + >, + this.userRepository.query('SELECT VERSION() AS version') as Promise< + { version: string }[] + >, + ]) + + const formattedTables = tables.map((table) => ({ + tableName: table.tableName, + estimatedRows: Number(table.estimatedRows), + dataBytes: Number(table.dataBytes), + indexBytes: Number(table.indexBytes), + })) + + return { + version: versionRow[0]?.version || null, + tables: formattedTables, + totalBytes: formattedTables.reduce( + (acc, table) => acc + table.dataBytes + table.indexBytes, + 0, + ), + } + } +} diff --git a/backend/apps/cloud/src/admin/decorators/admin-auth.decorator.ts b/backend/apps/cloud/src/admin/decorators/admin-auth.decorator.ts new file mode 100644 index 000000000..9a5fb3328 --- /dev/null +++ b/backend/apps/cloud/src/admin/decorators/admin-auth.decorator.ts @@ -0,0 +1,10 @@ +import { applyDecorators, UseGuards } from '@nestjs/common' + +import { Auth } from '../../auth/decorators' +import { AdminAccessGuard } from '../guards/admin-access.guard' + +// Standard JWT auth (401 on expired tokens so the refresh flow works), +// then a 404 for anyone not on the ADMIN_EMAILS allowlist +export function AdminAuth() { + return applyDecorators(Auth(), UseGuards(AdminAccessGuard)) +} diff --git a/backend/apps/cloud/src/admin/guards/admin-access.guard.ts b/backend/apps/cloud/src/admin/guards/admin-access.guard.ts new file mode 100644 index 000000000..f186355be --- /dev/null +++ b/backend/apps/cloud/src/admin/guards/admin-access.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + NotFoundException, +} from '@nestjs/common' + +// Comma-separated list of emails allowed to access the admin panel. +// If unset, the admin panel is disabled entirely. +const getAdminEmails = (): string[] => { + const raw = process.env.ADMIN_EMAILS || '' + + return raw + .split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean) +} + +@Injectable() +export class AdminAccessGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() + const user = request.user + + const adminEmails = getAdminEmails() + + if ( + !user?.email || + !user?.isActive || + !adminEmails.includes(user.email.toLowerCase()) + ) { + // 404 on purpose - the admin panel should not reveal its existence + throw new NotFoundException() + } + + return true + } +} diff --git a/backend/apps/cloud/src/app.module.ts b/backend/apps/cloud/src/app.module.ts index 6cdd3bd40..23616e218 100644 --- a/backend/apps/cloud/src/app.module.ts +++ b/backend/apps/cloud/src/app.module.ts @@ -36,6 +36,7 @@ import { ToolsModule } from './tools/tools.module' import { PendingInvitationModule } from './pending-invitation/pending-invitation.module' import { DataImportModule } from './data-import/data-import.module' import { NotificationChannelModule } from './notification-channel/notification-channel.module' +import { AdminModule } from './admin/admin.module' const modules = [ SentryModule.forRoot(), @@ -103,6 +104,7 @@ const modules = [ PendingInvitationModule, DataImportModule, NotificationChannelModule, + AdminModule, ] @Module({ diff --git a/web/app/lib/constants/index.ts b/web/app/lib/constants/index.ts index 7bd4ec015..99519279f 100644 --- a/web/app/lib/constants/index.ts +++ b/web/app/lib/constants/index.ts @@ -437,6 +437,7 @@ export const localisedLanguages = whitelist.filter( // Blog and blog-style content (rendered through routes/$.tsx) stays unlocalised, // as do API routes and other internal endpoints. const UNLOCALISED_PATH_PATTERNS: RegExp[] = [ + /^\/admin(\/|$)/, /^\/blog(\/|$)/, /^\/glossary(\/|$)/, /^\/api(\/|$)/, diff --git a/web/app/pages/Admin/DatabaseTab.tsx b/web/app/pages/Admin/DatabaseTab.tsx new file mode 100644 index 000000000..0f07171a4 --- /dev/null +++ b/web/app/pages/Admin/DatabaseTab.tsx @@ -0,0 +1,131 @@ +import { Text } from '~/ui/Text' +import { nFormatter, nLocaleFormatter } from '~/utils/generic' + +import { AdminTable, formatBytes, StatCard, Td, UsageBar } from './components' +import type { AdminDatabaseInfo } from './types' + +interface DatabaseTabProps { + database: AdminDatabaseInfo +} + +export const DatabaseTab = ({ database }: DatabaseTabProps) => { + const { clickhouse, mysql } = database + + const compressionRatio = + clickhouse.totalCompressedBytes > 0 + ? ( + clickhouse.totalUncompressedBytes / clickhouse.totalCompressedBytes + ).toFixed(1) + : null + + return ( +
+
+
+ + ClickHouse + + {clickhouse.version ? ( + + v{clickhouse.version} + + ) : null} +
+ +
+ + + {clickhouse.disks.map((disk) => { + const used = disk.totalSpace - disk.freeSpace + + return ( + + + + ) + })} +
+ + + {clickhouse.tables.map((table) => ( +
+ + + + + + + + ))} + + + +
+
+ + MySQL + + {mysql.version ? ( + + v{mysql.version} + + ) : null} + + {formatBytes(mysql.totalBytes)} total + +
+ + + {mysql.tables.map((table) => ( +
+ + + + + + ))} + + + + ) +} diff --git a/web/app/pages/Admin/OrganisationsTab.tsx b/web/app/pages/Admin/OrganisationsTab.tsx new file mode 100644 index 000000000..84a4f3ca8 --- /dev/null +++ b/web/app/pages/Admin/OrganisationsTab.tsx @@ -0,0 +1,68 @@ +import { MagnifyingGlassIcon } from '@phosphor-icons/react' + +import Input from '~/ui/Input' +import Pagination from '~/ui/Pagination' + +import { AdminTable, EmptyState, formatDate, Td } from './components' +import type { AdminOrganisationsList } from './types' + +interface OrganisationsTabProps { + organisations: AdminOrganisationsList + page: number + search: string + onPageChange: (page: number) => void + onSearchChange: (search: string) => void +} + +export const OrganisationsTab = ({ + organisations, + page, + search, + onPageChange, + onSearchChange, +}: OrganisationsTabProps) => ( +
+ } + placeholder='Search by name or ID' + defaultValue={search} + onChange={(event) => onSearchChange(event.target.value)} + /> + + {organisations.organisations.length === 0 ? ( + + ) : ( + <> + + {organisations.organisations.map((organisation) => ( +
+ + + + + + + ))} + + {organisations.total > organisations.pageSize ? ( + onPageChange(newPage - 1)} + pageAmount={Math.ceil(organisations.total / organisations.pageSize)} + total={organisations.total} + pageSize={organisations.pageSize} + /> + ) : null} + + )} + +) diff --git a/web/app/pages/Admin/OverviewTab.tsx b/web/app/pages/Admin/OverviewTab.tsx new file mode 100644 index 000000000..8f58338ab --- /dev/null +++ b/web/app/pages/Admin/OverviewTab.tsx @@ -0,0 +1,313 @@ +import type { ChartOptions } from 'billboard.js' +import { area } from 'billboard.js' +import dayjs from 'dayjs' +import { useMemo } from 'react' + +import BillboardChart from '~/ui/BillboardChart' +import { Badge } from '~/ui/Badge' +import Select from '~/ui/Select' +import { Text } from '~/ui/Text' +import { nFormatter, nLocaleFormatter } from '~/utils/generic' + +import { StatCard } from './components' +import type { AdminCharts, AdminOverview, SeriesPoint } from './types' + +const CHART_DAYS_OPTIONS = [30, 90, 180, 365] + +const buildTimeseriesOptions = ( + series: { id: string; name: string; color: string; data: SeriesPoint[] }[], +): ChartOptions => { + const dates = Array.from( + new Set(series.flatMap(({ data }) => data.map(({ date }) => date))), + ).sort() + + const columns: [string, ...(Date | number)[]][] = [ + ['x', ...dates.map((date) => dayjs(date).toDate())], + ] + + for (const { id, data } of series) { + const countByDate = new Map(data.map(({ date, count }) => [date, count])) + columns.push([id, ...dates.map((date) => countByDate.get(date) || 0)]) + } + + return { + data: { + x: 'x', + columns, + types: Object.fromEntries(series.map(({ id }) => [id, area()])), + colors: Object.fromEntries(series.map(({ id, color }) => [id, color])), + names: Object.fromEntries(series.map(({ id, name }) => [id, name])), + }, + grid: { + y: { + show: true, + }, + }, + transition: { + duration: 200, + }, + resize: { + auto: true, + timer: true, + }, + axis: { + x: { + type: 'timeseries', + tick: { + fit: false, + format: '%b %d', + }, + }, + y: { + tick: { + // Counts are integers - hide the fractional gridline labels + format: (d: number) => (Number.isInteger(d) ? nFormatter(d, 1) : ''), + }, + show: true, + inner: true, + }, + }, + point: { + focus: { + only: true, + }, + pattern: ['circle'], + r: 2, + }, + legend: { + show: true, + }, + } +} + +const MRR_CURRENCY_SYMBOLS = { USD: '$', EUR: '€', GBP: '£' } as const + +interface OverviewTabProps { + overview: AdminOverview + charts: AdminCharts + chartDays: number + onChartDaysChange: (days: number) => void +} + +export const OverviewTab = ({ + overview, + charts, + chartDays, + onChartDaysChange, +}: OverviewTabProps) => { + const growthOptions = useMemo( + () => + buildTimeseriesOptions([ + { + id: 'signups', + name: 'Signups', + color: '#2563eb', + data: charts.signups, + }, + { + id: 'projects', + name: 'Projects created', + color: '#16a34a', + data: charts.projects, + }, + { + id: 'organisations', + name: 'Organisations created', + color: '#9333ea', + data: charts.organisations, + }, + ]), + [charts], + ) + + const eventsOptions = useMemo( + () => + buildTimeseriesOptions([ + { + id: 'events', + name: 'Events', + color: '#f59e0b', + data: charts.events, + }, + ]), + [charts], + ) + + const mrrBreakdown = ( + Object.entries(overview.mrr.byCurrency) as [ + keyof typeof MRR_CURRENCY_SYMBOLS, + number, + ][] + ) + .filter(([, amount]) => amount > 0) + .map( + ([currency, amount]) => + `${MRR_CURRENCY_SYMBOLS[currency]}${nLocaleFormatter(Math.round(amount))}`, + ) + .join(' + ') + + const maxPlanCount = Math.max( + ...overview.planDistribution.map(({ count }) => count), + 1, + ) + + return ( +
+
+ 0 + ? `${mrrBreakdown} · ${overview.mrr.unpricedSubscriptions} unpriced` + : mrrBreakdown + } + /> + + + +
+ +
+
+ + Growth + + + label='Period' + fieldLabelClassName='sr-only' + title={`Last ${chartDays} days`} + items={CHART_DAYS_OPTIONS} + labelExtractor={(days) => `Last ${days} days`} + keyExtractor={(days) => days.toString()} + selectedItem={chartDays} + onSelect={onChartDaysChange} + /> +
+ +
+ +
+ + Events processed + + +
+ +
+
+ + Plan distribution + +
+ {overview.planDistribution.map(({ planCode, count }) => ( +
+ + {planCode} + +
+
+
+ + {nLocaleFormatter(count)} + +
+ ))} +
+
+ +
+ + At a glance + +
+ {[ + { + label: 'Verified users', + value: nLocaleFormatter(overview.users.active), + }, + { + label: 'Projects', + value: nLocaleFormatter(overview.projects.total), + }, + { + label: 'Live projects', + value: nLocaleFormatter(overview.projects.live), + }, + { + label: 'Organisations', + value: nLocaleFormatter(overview.organisations.total), + }, + { + label: 'Events (7d)', + value: nFormatter(overview.events.last7d, 1), + }, + { + label: 'Billing suspended', + value: ( + + {nLocaleFormatter(overview.users.suspended)} + {overview.users.suspended > 0 ? ( + + ) : null} + + ), + }, + ].map(({ label, value }) => ( +
+
+ + {label} + +
+
+ + {value} + +
+
+ ))} +
+
+
+
+ ) +} diff --git a/web/app/pages/Admin/ProjectsTab.tsx b/web/app/pages/Admin/ProjectsTab.tsx new file mode 100644 index 000000000..25c98fe2d --- /dev/null +++ b/web/app/pages/Admin/ProjectsTab.tsx @@ -0,0 +1,254 @@ +import { MagnifyingGlassIcon } from '@phosphor-icons/react' + +import { Badge } from '~/ui/Badge' +import Input from '~/ui/Input' +import Pagination from '~/ui/Pagination' +import Select from '~/ui/Select' +import { Text } from '~/ui/Text' +import { cn, nLocaleFormatter } from '~/utils/generic' + +import { AdminTable, EmptyState, formatDate, Td } from './components' +import type { AdminProject, AdminProjectsList, AdminTopProjects } from './types' + +const PROJECT_FILTERS = [ + { key: 'all', label: 'All projects' }, + { key: 'active', label: 'Active' }, + { key: 'archived', label: 'Archived' }, + { key: 'inactive-30', label: 'No events in 30 days' }, + { key: 'inactive-60', label: 'No events in 60 days' }, + { key: 'inactive-90', label: 'No events in 90 days' }, +] + +const TOP_PERIODS = [ + { key: 1, label: 'Last 24 hours' }, + { key: 7, label: 'Last 7 days' }, + { key: 30, label: 'Last 30 days' }, +] + +const ProjectBadges = ({ project }: { project: AdminProject }) => ( + + {project.isArchived ? ( + + ) : null} + {!project.active ? ( + + ) : null} + {project.public ? : null} + {project.isPasswordProtected ? ( + + ) : null} + +) + +interface ProjectsTabProps { + projects?: AdminProjectsList + topProjects?: AdminTopProjects + view: 'list' | 'top' + page: number + search: string + filter: string + topDays: number + onViewChange: (view: 'list' | 'top') => void + onPageChange: (page: number) => void + onSearchChange: (search: string) => void + onFilterChange: (filter: string) => void + onTopDaysChange: (days: number) => void +} + +export const ProjectsTab = ({ + projects, + topProjects, + view, + page, + search, + filter, + topDays, + onViewChange, + onPageChange, + onSearchChange, + onFilterChange, + onTopDaysChange, +}: ProjectsTabProps) => { + const selectedFilter = + PROJECT_FILTERS.find(({ key }) => key === filter) || PROJECT_FILTERS[0] + const selectedPeriod = + TOP_PERIODS.find(({ key }) => key === topDays) || TOP_PERIODS[1] + + return ( +
+
+ {( + [ + { key: 'list', label: 'All projects' }, + { key: 'top', label: 'Top by traffic' }, + ] as const + ).map(({ key, label }) => ( + + ))} +
+ + {view === 'list' && projects ? ( + <> +
+ + } + placeholder='Search by name, ID or owner email' + defaultValue={search} + onChange={(event) => onSearchChange(event.target.value)} + /> +
+ + + + + + + + + ))} + + {projects.total > projects.pageSize ? ( + onPageChange(newPage - 1)} + pageAmount={Math.ceil(projects.total / projects.pageSize)} + total={projects.total} + pageSize={projects.pageSize} + /> + ) : null} + + )} + + ) : null} + + {view === 'top' && topProjects ? ( + <> +
+ + Top {topProjects.projects.length} projects by activity events + +
+ + + + + + + ))} + + )} + + ) : null} + + ) +} diff --git a/web/app/pages/Admin/UsersTab.tsx b/web/app/pages/Admin/UsersTab.tsx new file mode 100644 index 000000000..89d65333d --- /dev/null +++ b/web/app/pages/Admin/UsersTab.tsx @@ -0,0 +1,455 @@ +import { ArrowLeftIcon, MagnifyingGlassIcon } from '@phosphor-icons/react' +import { useEffect, useState } from 'react' +import { useFetcher } from 'react-router' +import { toast } from 'sonner' + +import { Badge } from '~/ui/Badge' +import Button from '~/ui/Button' +import Input from '~/ui/Input' +import Pagination from '~/ui/Pagination' +import Select from '~/ui/Select' +import { Text } from '~/ui/Text' +import Textarea from '~/ui/Textarea' +import { nFormatter, nLocaleFormatter } from '~/utils/generic' + +import { + AdminTable, + EmptyState, + formatDate, + formatDateTime, + Td, + UsageBar, +} from './components' +import type { + AdminActionData, + AdminUser, + AdminUserDetails, + AdminUsersList, +} from './types' + +const USER_FILTERS = [ + { key: 'all', label: 'All users' }, + { key: 'active', label: 'Verified' }, + { key: 'inactive', label: 'Unverified' }, + { key: 'paid', label: 'Paying' }, + { key: 'trial', label: 'Trial' }, + { key: 'free', label: 'Free / none' }, + { key: 'blocked', label: 'Blocked / suspended' }, +] + +const PlanCell = ({ user }: { user: AdminUser }) => ( +
+ {user.planCode} + {user.planType === 'plus' ? ( + + ) : null} + {user.planType === 'enterprise' ? ( + + ) : null} + {user.billingFrequency === 'yearly' ? ( + + ) : null} +
+) + +const UserStatusBadges = ({ user }: { user: AdminUser }) => ( + + {!user.isActive ? ( + + ) : null} + {user.isAccountBillingSuspended ? ( + + ) : null} + {user.dashboardBlockReason ? ( + + ) : null} + {user.cancellationEffectiveDate ? ( + + ) : null} + +) + +const MonthlyEventsCell = ({ user }: { user: AdminUser }) => ( +
+
+ + {nFormatter(user.monthlyEvents, 1)} + + {user.monthlyUsageLimit ? ( + + / {nFormatter(user.monthlyUsageLimit, 1)} + + ) : null} +
+ {user.monthlyUsageLimit ? ( + + ) : null} +
+) + +const PLAN_TYPE_OPTIONS = [ + { key: '', label: 'Legacy default (unset)' }, + { key: 'standard', label: 'Standard' }, + { key: 'plus', label: 'Plus' }, + { key: 'enterprise', label: 'Enterprise' }, +] + +const formatOverrides = (value: unknown): string => + value ? JSON.stringify(value, null, 2) : '' + +// Edits planType / addonOverrides / entitlementOverrides - the write +// operations the old admin CLI supported +const BillingControls = ({ user }: { user: AdminUser }) => { + const fetcher = useFetcher() + + const [planType, setPlanType] = useState(user.storedPlanType || '') + const [addonOverrides, setAddonOverrides] = useState( + formatOverrides(user.addonOverrides), + ) + const [entitlementOverrides, setEntitlementOverrides] = useState( + formatOverrides(user.entitlementOverrides), + ) + + useEffect(() => { + if (fetcher.state !== 'idle' || !fetcher.data) { + return + } + + if (fetcher.data.success) { + toast.success('User updated') + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + }, [fetcher.state, fetcher.data]) + + return ( +
+ + Billing controls + + + Changes apply immediately. Overrides are JSON objects, e.g.{' '} + {'{"websites": 50}'} + + +
+
{table.table}{nLocaleFormatter(table.rows)} + {formatBytes(table.compressedBytes)} + + {formatBytes(table.uncompressedBytes)} + + {table.compressedBytes > 0 + ? `${(table.uncompressedBytes / table.compressedBytes).toFixed(1)}x` + : '—'} + {table.parts}
{table.tableName} + {nLocaleFormatter(table.estimatedRows)} + {formatBytes(table.dataBytes)}{formatBytes(table.indexBytes)}
+ {organisation.name} +
+ {organisation.id} +
+
{organisation.owner?.email || '—'}{organisation.memberCount}{organisation.projectCount}{formatDate(organisation.created)}
+
+ {project.name} + +
+ + {project.id} + +
+ {project.admin ? ( +
+ {project.admin.email} + + {project.admin.planCode} + +
+ ) : ( + '—' + )} +
{project.organisation?.name || '—'} + {nLocaleFormatter(project.events24h)} + + {nLocaleFormatter(project.events30d)} + + {nLocaleFormatter(project.totalEvents)} + {formatDate(project.created)}
+ {index + 1} + + + {project.name || 'Unknown (deleted?)'} + + + {project.id} + + + {project.admin ? ( +
+ {project.admin.email} + + {project.admin.planCode} + +
+ ) : ( + '—' + )} +
+ {nLocaleFormatter(project.eventCount)} + {formatDate(project.created)}