diff --git a/package-lock.json b/package-lock.json index fabadc2..f1c9736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-loading-skeleton": "^3.5.0", + "recharts": "^3.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "umap-js": "^1.4.0", @@ -4639,6 +4640,32 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", @@ -5532,6 +5559,18 @@ "integrity": "sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -6240,6 +6279,27 @@ "integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==", "license": "MIT" }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz", @@ -6249,12 +6309,27 @@ "@types/d3-time": "^2" } }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, "node_modules/@types/d3-time": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz", "integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==", "license": "MIT" }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -6407,6 +6482,12 @@ "license": "MIT", "peer": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.43.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", @@ -8645,6 +8726,15 @@ "node": ">=12" } }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -8672,6 +8762,15 @@ "node": ">=12" } }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -8688,6 +8787,18 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -8712,6 +8823,15 @@ "node": ">=12" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -8874,6 +8994,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deck.gl": { "version": "9.1.14", "resolved": "https://registry.npmjs.org/deck.gl/-/deck.gl-9.1.14.tgz", @@ -9276,6 +9402,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.39.10", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", + "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -9803,6 +9939,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -10661,6 +10803,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -13215,6 +13367,29 @@ "react": ">=16.8.0" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", @@ -13323,6 +13498,48 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.0.tgz", + "integrity": "sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==", + "license": "MIT", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13397,6 +13614,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -14628,6 +14851,12 @@ "node": ">=18.12.0" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -15155,6 +15384,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -15174,6 +15412,43 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.20", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", diff --git a/package.json b/package.json index cd99ac1..2ae4bf7 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-loading-skeleton": "^3.5.0", + "recharts": "^3.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "umap-js": "^1.4.0", @@ -46,11 +47,11 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.47.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@playwright/test": "^1.47.2", "@vitest/coverage-v8": "^2.1.3", "dotenv": "^17.2.2", "eslint": "^9", diff --git a/src/app/api/analytics/activity/route.ts b/src/app/api/analytics/activity/route.ts new file mode 100644 index 0000000..2399b48 --- /dev/null +++ b/src/app/api/analytics/activity/route.ts @@ -0,0 +1,36 @@ +import * as Sentry from "@sentry/nextjs"; +import { NextResponse } from "next/server"; +import { getActivity } from "@/lib/analytics"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(data: T, init?: { status?: number } & ResponseInit) { + return NextResponse.json(data, init); +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const from = searchParams.get("from") || undefined; + const to = searchParams.get("to") || undefined; + const interval = (searchParams.get("interval") as "day" | "week" | "month") || "day"; + + const result = await Sentry.startSpan( + { op: "db.query", name: "analytics.activity" }, + async (span) => { + span.setAttribute("interval", interval); + if (from) span.setAttribute("from", from); + if (to) span.setAttribute("to", to); + const data = await getActivity({ from, to, interval }); + return data; + }, + ); + + return json(result); + } catch (error: unknown) { + Sentry.captureException(error); + const message = error instanceof Error ? error.message : String(error); + return json({ error: "Failed to fetch activity", message }, { status: 500 }); + } +} diff --git a/src/app/api/analytics/file-types/route.ts b/src/app/api/analytics/file-types/route.ts new file mode 100644 index 0000000..f7604b0 --- /dev/null +++ b/src/app/api/analytics/file-types/route.ts @@ -0,0 +1,34 @@ +import * as Sentry from "@sentry/nextjs"; +import { NextResponse } from "next/server"; +import { getFileTypes } from "@/lib/analytics"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(data: T, init?: { status?: number } & ResponseInit) { + return NextResponse.json(data, init); +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const limit = Number(searchParams.get("limit") || "20") || 20; + const withSize = searchParams.get("withSize") === "true"; + + const result = await Sentry.startSpan( + { op: "db.query", name: "analytics.file-types" }, + async (span) => { + span.setAttribute("limit", limit); + span.setAttribute("withSize", withSize); + const data = await getFileTypes({ limit, withSize }); + return data; + }, + ); + + return json(result); + } catch (error: unknown) { + Sentry.captureException(error); + const message = error instanceof Error ? error.message : String(error); + return json({ error: "Failed to fetch file types", message }, { status: 500 }); + } +} diff --git a/src/app/api/analytics/summary/route.ts b/src/app/api/analytics/summary/route.ts new file mode 100644 index 0000000..9b9a127 --- /dev/null +++ b/src/app/api/analytics/summary/route.ts @@ -0,0 +1,32 @@ +import * as Sentry from "@sentry/nextjs"; +import { NextResponse } from "next/server"; +import { getSummary } from "@/lib/analytics"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(data: T, init?: { status?: number } & ResponseInit) { + return NextResponse.json(data, init); +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const ownersLimit = Number(searchParams.get("ownersLimit") || "10") || 10; + + const result = await Sentry.startSpan( + { op: "db.query", name: "analytics.summary" }, + async (span) => { + span.setAttribute("ownersLimit", ownersLimit); + const summary = await getSummary({ ownersLimit }); + return summary; + }, + ); + + return json(result); + } catch (error: unknown) { + Sentry.captureException(error); + const message = error instanceof Error ? error.message : String(error); + return json({ error: "Failed to fetch analytics summary", message }, { status: 500 }); + } +} diff --git a/src/app/api/analytics/tags/route.ts b/src/app/api/analytics/tags/route.ts new file mode 100644 index 0000000..d81042f --- /dev/null +++ b/src/app/api/analytics/tags/route.ts @@ -0,0 +1,32 @@ +import * as Sentry from "@sentry/nextjs"; +import { NextResponse } from "next/server"; +import { getTopTags } from "@/lib/analytics"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(data: T, init?: { status?: number } & ResponseInit) { + return NextResponse.json(data, init); +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const limit = Number(searchParams.get("limit") || "20") || 20; + + const result = await Sentry.startSpan( + { op: "db.query", name: "analytics.tags" }, + async (span) => { + span.setAttribute("limit", limit); + const data = await getTopTags({ limit }); + return data; + }, + ); + + return json(result); + } catch (error: unknown) { + Sentry.captureException(error); + const message = error instanceof Error ? error.message : String(error); + return json({ error: "Failed to fetch top tags", message }, { status: 500 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..843190a --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import * as React from "react"; +import { KPICards } from "@/components/dashboard/kpis"; +import { FileTypesChart } from "@/components/dashboard/charts/file-types-chart"; +import { ActivityChart } from "@/components/dashboard/charts/activity-chart"; +import { TopTags } from "@/components/dashboard/charts/top-tags"; +import { VectorPanel } from "@/components/dashboard/vector-panel"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +export default function DashboardPage() { + const router = useRouter(); + + function handleDrillSearch(tag: string) { + // For now, surface a hint; in a future PR we can wire a tag filter to the search API directly from the dashboard + // Here we just navigate to the home page; the user can use the global search input + alert(`Search for tag: ${tag}. Use the global search box on the home page.`); + router.push("/"); + } + + return ( +
+
+
Dashboard
+
+ +
+
+ +
+
+ {/* KPIs */} + + + {/* Charts row */} +
+ + +
+ + {/* Tags + Vector Panel */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/dashboard/charts/activity-chart.tsx b/src/components/dashboard/charts/activity-chart.tsx new file mode 100644 index 0000000..c6e4b13 --- /dev/null +++ b/src/components/dashboard/charts/activity-chart.tsx @@ -0,0 +1,93 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { ActivitySeries } from "@/types/analytics"; +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Legend, +} from "recharts"; + +async function fetchActivity( + from?: string, + to?: string, + interval: "day" | "week" | "month" = "day", +): Promise { + const url = new URL("/api/analytics/activity", window.location.origin); + if (from) url.searchParams.set("from", from); + if (to) url.searchParams.set("to", to); + url.searchParams.set("interval", interval); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load activity (${res.status})`); + } + return (await res.json()) as ActivitySeries; +} + +export function ActivityChart() { + const q = useQuery({ + queryKey: ["analytics", "activity", "day"], + queryFn: () => fetchActivity(undefined, undefined, "day"), + }); + + const data = + q.data?.points.map((p) => ({ + date: p.date.slice(0, 10), + uploaded: p.uploaded ?? 0, + modified: p.modified ?? 0, + })) ?? []; + + return ( +
+
Activity
+
+ {q.isLoading ? ( +
Loading…
+ ) : data.length === 0 ? ( +
No data
+ ) : ( + + + + + + + + + + + + )} +
+
+ ); +} diff --git a/src/components/dashboard/charts/file-types-chart.tsx b/src/components/dashboard/charts/file-types-chart.tsx new file mode 100644 index 0000000..11d4550 --- /dev/null +++ b/src/components/dashboard/charts/file-types-chart.tsx @@ -0,0 +1,68 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { FileTypesResponse } from "@/types/analytics"; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, +} from "recharts"; + +async function fetchFileTypes(limit = 15, withSize = false): Promise { + const url = new URL("/api/analytics/file-types", window.location.origin); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("withSize", String(withSize)); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load file types (${res.status})`); + } + return (await res.json()) as FileTypesResponse; +} + +export function FileTypesChart() { + const q = useQuery({ + queryKey: ["analytics", "file-types"], + queryFn: () => fetchFileTypes(15, false), + }); + + const data = + q.data?.buckets.map((b) => ({ + name: b.mimeType || "unknown", + count: b.count, + })) ?? []; + + return ( +
+
File Types
+
+ {q.isLoading ? ( +
Loading…
+ ) : data.length === 0 ? ( +
No data
+ ) : ( + + + + + + + + + + )} +
+
+ ); +} diff --git a/src/components/dashboard/charts/top-tags.tsx b/src/components/dashboard/charts/top-tags.tsx new file mode 100644 index 0000000..0b9a95c --- /dev/null +++ b/src/components/dashboard/charts/top-tags.tsx @@ -0,0 +1,64 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { TagsResponse } from "@/types/analytics"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; + +async function fetchTopTags(limit = 20): Promise { + const url = new URL("/api/analytics/tags", window.location.origin); + url.searchParams.set("limit", String(limit)); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load tags (${res.status})`); + } + return (await res.json()) as TagsResponse; +} + +export function TopTags({ + onDrillSearch, +}: { + onDrillSearch?: (tag: string) => void; +}) { + const q = useQuery({ + queryKey: ["analytics", "top-tags"], + queryFn: () => fetchTopTags(20), + }); + + const data = q.data?.buckets ?? []; + + return ( +
+
Top Tags
+ +
+ {q.isLoading &&
Loading…
} + {!q.isLoading && data.length === 0 && ( +
No tags
+ )} + {data.map((t) => ( +
+
{t.tag || "(empty)"}
+
+
{t.count.toLocaleString()}
+ +
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/dashboard/kpis.tsx b/src/components/dashboard/kpis.tsx new file mode 100644 index 0000000..a932931 --- /dev/null +++ b/src/components/dashboard/kpis.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { AnalyticsSummary } from "@/types/analytics"; + +async function fetchSummary(ownersLimit = 8): Promise { + const url = new URL("/api/analytics/summary", window.location.origin); + url.searchParams.set("ownersLimit", String(ownersLimit)); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load summary (${res.status})`); + } + return (await res.json()) as AnalyticsSummary; +} + +function formatBytes(bytes?: number) { + const b = Number(bytes || 0); + if (!b) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(b) / Math.log(k)); + return `${parseFloat((b / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +export function KPICards() { + const q = useQuery({ + queryKey: ["analytics", "summary"], + queryFn: () => fetchSummary(8), + }); + + const s = q.data; + + return ( +
+ + + + + {/* Owners list */} +
+
Top Owners
+ {q.isLoading &&
Loading…
} + {!q.isLoading && (s?.owners?.length ?? 0) === 0 && ( +
No owner data
+ )} +
    + {(s?.owners ?? []).map((o) => ( +
  • + {o.owner || "unknown"} + {o.count.toLocaleString()} +
  • + ))} +
+
+
+
Last Uploaded
+
+ {q.isLoading ? "…" : (s?.lastUploadedAt ? new Date(s.lastUploadedAt).toLocaleString() : "—")} +
+
+
+ ); +} + +function KPI({ title, value }: { title: string; value: React.ReactNode }) { + return ( +
+
{title}
+
{value}
+
+ ); +} diff --git a/src/components/dashboard/vector-panel.tsx b/src/components/dashboard/vector-panel.tsx new file mode 100644 index 0000000..79b70d0 --- /dev/null +++ b/src/components/dashboard/vector-panel.tsx @@ -0,0 +1,221 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter"; + +type CollectionsResp = { collections: Array<{ name: string; points_count?: number; vectors_count?: number }> }; +type PointsResp = { points: Array<{ id: string | number; payload?: Record; vector?: number[] | Record }>; next_page_offset?: string | number | null }; + +async function fetchCollections(): Promise { + const res = await fetch("/api/qdrant/collections", { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load collections (${res.status})`); + } + return (await res.json()) as CollectionsResp; +} + +async function fetchPoints(collection: string, limit = 100, offset?: string | number | null, withVector = true): Promise { + const url = new URL("/api/qdrant/points", window.location.origin); + url.searchParams.set("collection", collection); + url.searchParams.set("limit", String(limit)); + if (offset != null) url.searchParams.set("offset", String(offset)); + url.searchParams.set("withVector", String(withVector)); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load points (${res.status})`); + } + return (await res.json()) as PointsResp; +} + +export function VectorPanel() { + const [selectedCollection, setSelectedCollection] = React.useState(""); + const [limit, setLimit] = React.useState(200); + const [withVector, setWithVector] = React.useState(true); + const [offset, setOffset] = React.useState(null); + const [selection, setSelection] = React.useState([]); + + const collectionsQuery = useQuery({ + queryKey: ["qdrant", "collections"], + queryFn: fetchCollections, + }); + + React.useEffect(() => { + const names = collectionsQuery.data?.collections?.map((c) => c.name) ?? []; + if (!selectedCollection && names.length > 0) { + const preferred = + names.find((n) => n === "fortura-db") || + names.find((n) => n === "miguel_responses") || + names[0]; + setSelectedCollection(preferred); + } + }, [collectionsQuery.data, selectedCollection]); + + const pointsQuery = useQuery({ + queryKey: ["qdrant", "points", selectedCollection, limit, offset, withVector], + queryFn: () => fetchPoints(selectedCollection!, limit, offset, withVector), + enabled: !!selectedCollection, + }); + + const points = (pointsQuery.data?.points ?? []) as unknown as EmbeddingPoint[]; + + function doDrillOpenExplorer() { + // Prefer path from payload, otherwise noop + const paths = + selection + .map((p) => (p.payload?.path as string | undefined)) + .filter(Boolean) + .slice(0, 1) as string[]; + + if (paths.length > 0) { + // Navigate to app root; this app does not yet parse query params for path navigation. + // We open a download for now as a practical drill-through. + const path = paths[0]!; + const url = new URL("/api/files/download", window.location.origin); + url.searchParams.set("path", path); + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } else { + alert("No path available in selected points. Please select points with payload.path."); + } + } + + function doDrillSearchES() { + // Make a simple search from first payload.name or id + const first = selection[0]; + const name = (first?.payload?.name as string | undefined) || String(first?.id ?? ""); + const q = name ?? ""; + if (!q) return; + // Navigate to root with a hint; the app currently doesn't read URL q param, so we just show info. + alert(`Search hint (manual): ${q}`); + } + + return ( +
+
Vector Exploration
+ +
+
+ Collection + { + setSelectedCollection(e.target.value); + setOffset(null); + }} + className="w-56" + placeholder="collection name" + /> +
+
+ Limit + setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))} + /> +
+ + +
+ Selected: {selection.length} +
+
+ +
+
+
Collections
+ +
+ {collectionsQuery.isLoading &&
Loading…
} + {collectionsQuery.error && ( +
{(collectionsQuery.error as Error).message}
+ )} + {(collectionsQuery.data?.collections ?? []).map((c) => ( + + ))} + {(collectionsQuery.data?.collections ?? []).length === 0 && !collectionsQuery.isLoading && ( +
No collections
+ )} +
+
+
+ +
+
+
Embedding Scatter
+
+ + +
+
+
+ {pointsQuery.isLoading ? ( +
Loading…
+ ) : ( + setSelection(sel)} + onOpenPoint={(p) => { + const path = (p.payload?.path as string | undefined) ?? ""; + if (path) { + const url = new URL("/api/files/download", window.location.origin); + url.searchParams.set("path", path); + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + }} + /> + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..f254f50 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,290 @@ +import * as Sentry from "@sentry/nextjs"; +import type { estypes } from "@elastic/elasticsearch"; +import { getEsClient } from "@/lib/elasticsearch"; +import { env } from "@/lib/env"; +import type { + AnalyticsSummary, + FileTypesResponse, + ActivitySeries, + TagsResponse, +} from "@/types/analytics"; + +/** + * Elasticsearch aggregations for dashboard analytics. + * Uses the alias if configured, otherwise the base index name. + */ +function indexName() { + return env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX; +} + +/** + * Summary: + * - totalFiles: value_count over id or doc_count if strict index + * - totalSizeBytes: sum(sizeBytes) + * - uniqueTags: cardinality(tags) + * - lastModifiedAt: max(modifiedAt) + * - lastUploadedAt: max(uploadedAt) + * - owners: top owners (terms) + */ +export async function getSummary(opts?: { ownersLimit?: number }): Promise { + return Sentry.startSpan({ op: "db.query", name: "ES analytics.getSummary" }, async (span) => { + const client = getEsClient(); + const ownersLimit = Math.min(50, Math.max(1, opts?.ownersLimit ?? 10)); + + const res = await client.search({ + index: indexName(), + size: 0, + aggs: { + totalFiles: { value_count: { field: "id" } }, + totalSizeBytes: { sum: { field: "sizeBytes" } }, + uniqueTags: { cardinality: { field: "tags" } }, + lastModifiedAt: { max: { field: "modifiedAt" } }, + lastUploadedAt: { max: { field: "uploadedAt" } }, + owners: { + terms: { + field: "owner", + size: ownersLimit, + order: { _count: "desc" }, + }, + }, + }, + }); + + type OwnersBucket = { key: string; doc_count: number }; + type SummaryAggs = { + totalFiles?: { value?: number }; + totalSizeBytes?: { value?: number }; + uniqueTags?: { value?: number }; + lastModifiedAt?: { value_as_string?: string }; + lastUploadedAt?: { value_as_string?: string }; + owners?: { buckets?: OwnersBucket[] }; + }; + + const aggs = (res.aggregations as unknown) as SummaryAggs; + + const ownersBuckets: OwnersBucket[] = aggs?.owners?.buckets ?? []; + + const summary: AnalyticsSummary = { + totalFiles: + typeof aggs?.totalFiles?.value === "number" + ? aggs.totalFiles.value + : (res.hits.total as any)?.value ?? 0, + totalSizeBytes: Math.round(Number(aggs?.totalSizeBytes?.value || 0)), + uniqueTags: Number(aggs?.uniqueTags?.value || 0), + lastModifiedAt: aggs?.lastModifiedAt?.value_as_string, + lastUploadedAt: aggs?.lastUploadedAt?.value_as_string, + owners: ownersBuckets.map((b) => ({ owner: String(b.key), count: b.doc_count })), + }; + + span.setAttribute("owners.count", summary.owners?.length ?? 0); + return summary; + }); +} + +/** + * File Types: + * - terms aggregation on mimeType + * - optional sum of sizeBytes can be included if needed later + */ +export async function getFileTypes(opts?: { + limit?: number; + withSize?: boolean; +}): Promise { + return Sentry.startSpan({ op: "db.query", name: "ES analytics.getFileTypes" }, async (span) => { + const client = getEsClient(); + const limit = Math.min(100, Math.max(1, opts?.limit ?? 20)); + const withSize = !!opts?.withSize; + + const aggs: Record = { + fileTypes: { + terms: { + field: "mimeType", + size: limit, + order: { _count: "desc" }, + }, + aggs: withSize + ? { + totalSize: { sum: { field: "sizeBytes" } }, + } + : undefined, + }, + }; + + const res = await client.search({ + index: indexName(), + size: 0, + aggs, + }); + + type FileTypesAggs = { + fileTypes?: { + buckets?: Array<{ key: string; doc_count: number; totalSize?: { value?: number } }>; + }; + }; + + const agg = (res.aggregations as unknown) as FileTypesAggs; + const buckets = + agg.fileTypes?.buckets ?? []; + + const data = { + buckets: buckets.map((b) => ({ + mimeType: b.key, + count: b.doc_count, + sizeBytes: withSize ? Math.round(Number(b.totalSize?.value || 0)) : undefined, + })), + total: + typeof res.hits.total === "number" + ? (res.hits.total as number) + : (res.hits.total as any)?.value ?? 0, + }; + + span.setAttribute("filetypes.count", data.buckets.length); + return data; + }); +} + +/** + * Activity: + * - date histogram on uploadedAt and modifiedAt + * - returns daily series + */ +export async function getActivity(opts?: { + from?: string; // ISO + to?: string; // ISO + interval?: "day" | "week" | "month"; +}): Promise { + return Sentry.startSpan({ op: "db.query", name: "ES analytics.getActivity" }, async (span) => { + const client = getEsClient(); + const interval = opts?.interval ?? "day"; + + const makeDateHist = (field: string) => ({ + date_histogram: { + field, + fixed_interval: + interval === "month" ? "30d" : interval === "week" ? "7d" : "1d", + min_doc_count: 0, + ...(opts?.from || opts?.to + ? { + extended_bounds: { + min: opts.from, + max: opts.to, + }, + } + : {}), + }, + }); + + const res = await client.search({ + index: indexName(), + size: 0, + query: + opts?.from || opts?.to + ? { + bool: { + should: [ + { + range: { + uploadedAt: { + gte: opts.from, + lte: opts.to, + }, + }, + }, + { + range: { + modifiedAt: { + gte: opts.from, + lte: opts.to, + }, + }, + }, + ], + minimum_should_match: 1, + }, + } + : { match_all: {} }, + aggs: { + uploaded: makeDateHist("uploadedAt"), + modified: makeDateHist("modifiedAt"), + }, + }); + + type HistBucket = { key_as_string: string; doc_count: number }; + type ActivityAggs = { + uploaded?: { buckets?: HistBucket[] }; + modified?: { buckets?: HistBucket[] }; + }; + const a = (res.aggregations as unknown) as ActivityAggs; + + const upBuckets: HistBucket[] = a.uploaded?.buckets ?? []; + const modBuckets: HistBucket[] = a.modified?.buckets ?? []; + + // Merge series by date string + const map = new Map< + string, + { + date: string; + uploaded?: number; + modified?: number; + } + >(); + + for (const b of upBuckets) { + map.set(b.key_as_string, { date: b.key_as_string, uploaded: b.doc_count }); + } + for (const b of modBuckets) { + const prev = map.get(b.key_as_string) || { date: b.key_as_string }; + prev.modified = b.doc_count; + map.set(b.key_as_string, prev); + } + + const points = Array.from(map.values()).sort((a, b) => + a.date.localeCompare(b.date), + ); + + const series: ActivitySeries = { points }; + span.setAttribute("activity.points", points.length); + return series; + }); +} + +/** + * Top Tags: + * - terms aggregation on tags + */ +export async function getTopTags(opts?: { limit?: number }): Promise { + return Sentry.startSpan({ op: "db.query", name: "ES analytics.getTopTags" }, async (span) => { + const client = getEsClient(); + const limit = Math.min(200, Math.max(1, opts?.limit ?? 20)); + + const res = await client.search({ + index: indexName(), + size: 0, + aggs: { + tags: { + terms: { + field: "tags", + size: limit, + order: { _count: "desc" }, + }, + }, + }, + }); + + type TagsAggs = { tags?: { buckets?: Array<{ key: string; doc_count: number }> } }; + const t = (res.aggregations as unknown) as TagsAggs; + + const buckets = t.tags?.buckets ?? []; + + const data: TagsResponse = { + buckets: buckets.map((b) => ({ tag: b.key, count: b.doc_count })), + total: + typeof res.hits.total === "number" + ? (res.hits.total as number) + : (res.hits.total as any)?.value ?? 0, + }; + + span.setAttribute("tags.count", data.buckets.length); + return data; + }); +} diff --git a/src/lib/qdrant.ts b/src/lib/qdrant.ts index 68ff662..03bef7d 100644 --- a/src/lib/qdrant.ts +++ b/src/lib/qdrant.ts @@ -3,23 +3,65 @@ import { env } from "@/lib/env"; let _client: QdrantClient | null = null; +/** + * In development, avoid caching the client so new options (like checkCompatibility) + * take effect without a full server restart. In production, cache for performance. + */ export function getQdrantClient() { - if (_client) return _client; + const isProd = process.env.NODE_ENV === "production"; + if (isProd && _client) return _client; + if (!env.QDRANT_URL) { throw new Error("QDRANT_URL not configured"); } - _client = new QdrantClient({ + + const instance = new QdrantClient({ url: env.QDRANT_URL, apiKey: env.QDRANT_API_KEY, + // Some managed endpoints block version discovery; skip compat check to prevent throw. + checkCompatibility: false, }); - return _client; + + if (isProd) { + _client = instance; + } + return instance; +} + +/** + * Low-level REST fallback to avoid SDK compatibility checks (some managed + * gateways block version discovery). Uses Qdrant REST API directly. + */ +async function rest(path: string, init?: RequestInit): Promise { + const base = env.QDRANT_URL!; + const headers: Record = { + "content-type": "application/json", + ...(env.QDRANT_API_KEY ? { "api-key": env.QDRANT_API_KEY } : {}), + }; + const res = await fetch(`${base}${path}`, { + ...init, + headers: { ...headers, ...(init?.headers as Record | undefined) }, + // give remote gateways some headroom + cache: "no-store", + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Qdrant REST ${path} failed (${res.status}): ${text || res.statusText}`); + } + return (await res.json()) as T; } export async function listCollections() { - const client = getQdrantClient(); - const res = await client.getCollections(); - // Shape: { collections: [{ name, vectors_count, points_count, config: { params }, ... }] } - return res.collections ?? []; + // Try SDK first + try { + const client = getQdrantClient(); + const res = await client.getCollections(); + return (res as any).collections ?? []; + } catch (e) { + // Fallback to REST + const r = await rest<{ result?: { collections?: any[] }; collections?: any[] }>("/collections"); + return r.result?.collections ?? r.collections ?? []; + } } export type ListPointsOpts = { @@ -30,13 +72,33 @@ export type ListPointsOpts = { }; export async function listPoints(opts: ListPointsOpts) { - const client = getQdrantClient(); const limit = opts.limit ?? 100; - const res = await client.scroll(opts.collection, { - limit, - with_payload: true, - with_vector: !!opts.withVector, - offset: opts.offset ?? undefined, - }); - return res; // { points: [{ id, payload, vector }], next_page_offset? } + // Try SDK first + try { + const client = getQdrantClient(); + const res = await client.scroll(opts.collection, { + limit, + with_payload: true, + with_vector: !!opts.withVector, + offset: opts.offset ?? undefined, + }); + return res; // { points, next_page_offset? } + } catch (e) { + // Fallback to REST: POST /collections/{collection}/points/scroll + const body = { + limit, + with_payload: true, + with_vector: !!opts.withVector, + offset: opts.offset ?? undefined, + }; + const r = await rest<{ result?: { points: any[]; next_page_offset?: string | number | null } }>( + `/collections/${encodeURIComponent(opts.collection)}/points/scroll`, + { method: "POST", body: JSON.stringify(body) }, + ); + const result = r.result ?? { points: [] }; + return { + points: result.points ?? [], + next_page_offset: result.next_page_offset ?? null, + } as any; + } } diff --git a/src/types/analytics.ts b/src/types/analytics.ts new file mode 100644 index 0000000..855ecd6 --- /dev/null +++ b/src/types/analytics.ts @@ -0,0 +1,49 @@ +export type AnalyticsSummary = { + totalFiles: number; + totalSizeBytes: number; + uniqueTags: number; + lastModifiedAt?: string; + lastUploadedAt?: string; + owners?: Array<{ owner: string; count: number }>; +}; + +export type FileTypeBucket = { + mimeType: string; + count: number; + sizeBytes?: number; +}; + +export type FileTypesResponse = { + buckets: FileTypeBucket[]; + total: number; +}; + +export type ActivityPoint = { + date: string; // ISO date (day resolution) + uploaded?: number; + modified?: number; +}; + +export type ActivitySeries = { + points: ActivityPoint[]; +}; + +export type TagBucket = { + tag: string; + count: number; +}; + +export type TagsResponse = { + buckets: TagBucket[]; + total: number; +}; + +export type VectorSelection = { + ids: Array; + count: number; +}; + +export type DrillTarget = { + path: string; + id?: string; +};