feat(dashboard): add analytics APIs (summary/file-types/activity/tags), ES aggregation helpers; dashboard page with KPIs, Recharts (file types, activity), top-tags, and vector panel with drill-through; Qdrant SDK fallback/compat tweaks
This commit is contained in:
parent
9be3320e5b
commit
eba3fe7bf2
275
package-lock.json
generated
275
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
36
src/app/api/analytics/activity/route.ts
Normal file
36
src/app/api/analytics/activity/route.ts
Normal file
@ -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<T>(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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/analytics/file-types/route.ts
Normal file
34
src/app/api/analytics/file-types/route.ts
Normal file
@ -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<T>(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 });
|
||||
}
|
||||
}
|
||||
32
src/app/api/analytics/summary/route.ts
Normal file
32
src/app/api/analytics/summary/route.ts
Normal file
@ -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<T>(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 });
|
||||
}
|
||||
}
|
||||
32
src/app/api/analytics/tags/route.ts
Normal file
32
src/app/api/analytics/tags/route.ts
Normal file
@ -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<T>(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 });
|
||||
}
|
||||
}
|
||||
51
src/app/dashboard/page.tsx
Normal file
51
src/app/dashboard/page.tsx
Normal file
@ -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 (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="border-b p-3 flex items-center gap-3">
|
||||
<div className="text-lg font-semibold">Dashboard</div>
|
||||
<div className="ml-auto">
|
||||
<Button variant="outline" onClick={() => router.push("/")}>Open Explorer</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-3 flex-1 overflow-auto">
|
||||
<div className="space-y-3">
|
||||
{/* KPIs */}
|
||||
<KPICards />
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<FileTypesChart />
|
||||
<ActivityChart />
|
||||
</div>
|
||||
|
||||
{/* Tags + Vector Panel */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<TopTags onDrillSearch={handleDrillSearch} />
|
||||
<VectorPanel />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/dashboard/charts/activity-chart.tsx
Normal file
93
src/components/dashboard/charts/activity-chart.tsx
Normal file
@ -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<ActivitySeries> {
|
||||
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 (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm font-medium mb-2">Activity</div>
|
||||
<div className="h-64">
|
||||
{q.isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading…</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No data</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="uploaded"
|
||||
name="Uploaded"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.25}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="modified"
|
||||
name="Modified"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
fill="hsl(var(--chart-1))"
|
||||
fillOpacity={0.25}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/dashboard/charts/file-types-chart.tsx
Normal file
68
src/components/dashboard/charts/file-types-chart.tsx
Normal file
@ -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<FileTypesResponse> {
|
||||
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 (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm font-medium mb-2">File Types</div>
|
||||
<div className="h-64">
|
||||
{q.isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading…</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No data</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/dashboard/charts/top-tags.tsx
Normal file
64
src/components/dashboard/charts/top-tags.tsx
Normal file
@ -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<TagsResponse> {
|
||||
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 (
|
||||
<div className="rounded-md border p-3 flex flex-col">
|
||||
<div className="text-sm font-medium mb-2">Top Tags</div>
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-1">
|
||||
{q.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
||||
{!q.isLoading && data.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">No tags</div>
|
||||
)}
|
||||
{data.map((t) => (
|
||||
<div
|
||||
key={t.tag}
|
||||
className="flex items-center justify-between rounded border px-2 py-1"
|
||||
title={t.tag}
|
||||
>
|
||||
<div className="truncate">{t.tag || "(empty)"}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground">{t.count.toLocaleString()}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onDrillSearch?.(t.tag)}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/dashboard/kpis.tsx
Normal file
77
src/components/dashboard/kpis.tsx
Normal file
@ -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<AnalyticsSummary> {
|
||||
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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<KPI title="Total Files" value={q.isLoading ? "…" : (s?.totalFiles ?? 0).toLocaleString()} />
|
||||
<KPI title="Total Size" value={q.isLoading ? "…" : formatBytes(s?.totalSizeBytes)} />
|
||||
<KPI title="Unique Tags" value={q.isLoading ? "…" : (s?.uniqueTags ?? 0).toLocaleString()} />
|
||||
<KPI
|
||||
title="Last Modified"
|
||||
value={q.isLoading ? "…" : (s?.lastModifiedAt ? new Date(s.lastModifiedAt).toLocaleString() : "—")}
|
||||
/>
|
||||
{/* Owners list */}
|
||||
<div className="rounded-md border p-3 col-span-1 md:col-span-2">
|
||||
<div className="text-sm font-medium mb-1">Top Owners</div>
|
||||
{q.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
||||
{!q.isLoading && (s?.owners?.length ?? 0) === 0 && (
|
||||
<div className="text-xs text-muted-foreground">No owner data</div>
|
||||
)}
|
||||
<ul className="text-sm space-y-1">
|
||||
{(s?.owners ?? []).map((o) => (
|
||||
<li key={o.owner} className="flex items-center justify-between">
|
||||
<span className="truncate">{o.owner || "unknown"}</span>
|
||||
<span className="text-muted-foreground">{o.count.toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm font-medium mb-1">Last Uploaded</div>
|
||||
<div className="text-lg">
|
||||
{q.isLoading ? "…" : (s?.lastUploadedAt ? new Date(s.lastUploadedAt).toLocaleString() : "—")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KPI({ title, value }: { title: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="text-sm text-muted-foreground">{title}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
src/components/dashboard/vector-panel.tsx
Normal file
221
src/components/dashboard/vector-panel.tsx
Normal file
@ -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<string, unknown>; vector?: number[] | Record<string, number> }>; next_page_offset?: string | number | null };
|
||||
|
||||
async function fetchCollections(): Promise<CollectionsResp> {
|
||||
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<PointsResp> {
|
||||
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<string>("");
|
||||
const [limit, setLimit] = React.useState(200);
|
||||
const [withVector, setWithVector] = React.useState(true);
|
||||
const [offset, setOffset] = React.useState<string | number | null>(null);
|
||||
const [selection, setSelection] = React.useState<EmbeddingPoint[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="rounded-md border p-3 flex flex-col">
|
||||
<div className="text-sm font-medium mb-2">Vector Exploration</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Collection</span>
|
||||
<Input
|
||||
value={selectedCollection}
|
||||
onChange={(e) => {
|
||||
setSelectedCollection(e.target.value);
|
||||
setOffset(null);
|
||||
}}
|
||||
className="w-56"
|
||||
placeholder="collection name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Limit</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))}
|
||||
/>
|
||||
</div>
|
||||
<Button variant={withVector ? "default" : "outline"} onClick={() => setWithVector((v) => !v)}>
|
||||
{withVector ? "Vectors: on" : "Vectors: off"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOffset(null);
|
||||
pointsQuery.refetch();
|
||||
}}
|
||||
disabled={!selectedCollection || pointsQuery.isFetching}
|
||||
>
|
||||
{pointsQuery.isFetching ? "Loading…" : "Refresh"}
|
||||
</Button>
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
Selected: {selection.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[280px_1fr] gap-3 min-h-[380px]">
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-3 py-2 text-sm font-medium">Collections</div>
|
||||
<ScrollArea className="h-[340px]">
|
||||
<div className="p-2 space-y-1">
|
||||
{collectionsQuery.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
||||
{collectionsQuery.error && (
|
||||
<div className="text-xs text-destructive">{(collectionsQuery.error as Error).message}</div>
|
||||
)}
|
||||
{(collectionsQuery.data?.collections ?? []).map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
className={`w-full text-left px-2 py-1 rounded hover:bg-accent ${
|
||||
selectedCollection === c.name ? "bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedCollection(c.name);
|
||||
setOffset(null);
|
||||
}}
|
||||
title={`${c.points_count ?? 0} pts`}
|
||||
>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
pts: {c.points_count ?? "—"} vec: {c.vectors_count ?? "—"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{(collectionsQuery.data?.collections ?? []).length === 0 && !collectionsQuery.isLoading && (
|
||||
<div className="text-xs text-muted-foreground px-2">No collections</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium">Embedding Scatter</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setOffset(null)} disabled={pointsQuery.isFetching}>
|
||||
Reset Offset
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setOffset(pointsQuery.data?.next_page_offset ?? null)}
|
||||
disabled={pointsQuery.isFetching || !pointsQuery.data?.next_page_offset}
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-[360px]">
|
||||
{pointsQuery.isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<EmbeddingScatter
|
||||
points={points}
|
||||
onSelect={(sel) => 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");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button size="sm" onClick={doDrillOpenExplorer} disabled={selection.length === 0}>
|
||||
Drill: Open Explorer
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={doDrillSearchES} disabled={selection.length === 0}>
|
||||
Drill: Search in ES
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
src/lib/analytics.ts
Normal file
290
src/lib/analytics.ts
Normal file
@ -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<AnalyticsSummary> {
|
||||
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<FileTypesResponse> {
|
||||
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<string, estypes.AggregationsAggregationContainer> = {
|
||||
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<ActivitySeries> {
|
||||
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<TagsResponse> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@ -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<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const base = env.QDRANT_URL!;
|
||||
const headers: Record<string, string> = {
|
||||
"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<string, string> | 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;
|
||||
}
|
||||
}
|
||||
|
||||
49
src/types/analytics.ts
Normal file
49
src/types/analytics.ts
Normal file
@ -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<string | number>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DrillTarget = {
|
||||
path: string;
|
||||
id?: string;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user