diff --git a/.env.example b/.env.example index c32fde50..d2535787 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,10 @@ NEXT_PUBLIC_S3_ENDPOINT=https://data.source.coop DYNAMODB_ENDPOINT=http://localhost:8000 STAGE=local +# Cloudflare Analytics Engine (request analytics written by the data proxy). +# Token needs Account Analytics:Read. Dataset is source_data_proxy_production +# or source_data_proxy_staging. Optional — analytics UI hides itself when unset. +# CF_ANALYTICS_ACCOUNT_ID= +# CF_ANALYTICS_API_TOKEN= +# CF_ANALYTICS_DATASET=source_data_proxy_staging + diff --git a/jest.setup.ts b/jest.setup.ts index ffd2b1d5..697b7682 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -45,5 +45,8 @@ jest.mock("@/lib/config", () => ({ }, // 32 zero bytes, base64 — a valid key for the encrypted-cookie helpers. proxyCredentialsCookieKey: Buffer.alloc(32).toString("base64"), + // Unconfigured by default so analytics components no-op in unrelated + // tests; the analytics client's own tests mock this with real values. + analytics: { accountId: "", apiToken: "", dataset: "" }, }, })); diff --git a/package-lock.json b/package-lock.json index 454d35c2..722469da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "react-dropzone": "^14.3.8", "react-globe.gl": "^2.37.0", "react-markdown": "^9.0.1", + "recharts": "^3.9.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.0", @@ -8839,6 +8840,32 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -9641,6 +9668,18 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "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", @@ -9953,6 +9992,69 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -10410,6 +10512,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "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/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -12527,6 +12635,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.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -12581,6 +12698,15 @@ "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", "license": "MIT" }, + "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", @@ -12619,6 +12745,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", @@ -12643,6 +12781,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/d3-tricontour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", @@ -12765,6 +12912,12 @@ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "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/decode-named-character-reference": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", @@ -13272,6 +13425,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -13969,6 +14132,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -15021,12 +15190,10 @@ } }, "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", "license": "MIT", - "optional": true, - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -20956,6 +21123,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/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.6.3", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", @@ -21039,6 +21229,36 @@ "node": ">= 6" } }, + "node_modules/recharts": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "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/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -21053,6 +21273,21 @@ "node": ">=8" } }, + "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", @@ -22058,6 +22293,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -23205,6 +23446,12 @@ "three": ">=0.154" } }, + "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/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -23913,8 +24160,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "optional": true, - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -23962,6 +24207,28 @@ "node": ">=10.12.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index eaea4b83..c6fcad3d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "react-dropzone": "^14.3.8", "react-globe.gl": "^2.37.0", "react-markdown": "^9.0.1", + "recharts": "^3.9.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.0", diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/loading.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/loading.tsx new file mode 100644 index 00000000..4c5a2653 --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/loading.tsx @@ -0,0 +1,25 @@ +import { Box, Card, Flex } from "@radix-ui/themes"; +import { Skeleton } from "@/components/core"; + +/** + * Without this, clicking the ANALYTICS tab freezes on the product view + * until the analytics queries resolve. + */ +export default function ProductAnalyticsLoading() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/page.tsx new file mode 100644 index 00000000..edff95b4 --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/page.tsx @@ -0,0 +1,121 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { Box, Button, Card, Flex, Heading, Text } from "@radix-ui/themes"; +import { getPageSession } from "@/lib"; +import { isAuthorized } from "@/lib/api/authz"; +import { Actions } from "@/types/shared"; +import { + USAGE_DAYS, + USAGE_WINDOWS, + getProductBreakdowns, + getUsage, + type UsageWindow, +} from "@/lib/clients/analytics"; +import { + ProductAnalyticsView, + ProductTabs, +} from "@/components/features/analytics"; +import { SectionHeader } from "@/components/core/SectionHeader"; +import { productAnalyticsUrl } from "@/lib/urls"; +import { getAuthorizedProduct } from "../../[[...path]]/data"; + +interface PageProps { + params: Promise<{ account_id: string; product_id: string }>; + searchParams: Promise<{ window?: string | string[] }>; +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { account_id, product_id } = await params; + // Same full gate as the page body — generateMetadata streams + // independently, and unauthorized must look exactly like nonexistent + // (no "— Analytics" title on the 404). + const product = await getAuthorizedProduct(account_id, product_id); + const session = await getPageSession(); + if (!isAuthorized(session, product, Actions.PutRepository)) { + notFound(); + } + return { title: `${product.title || product_id} — Analytics` }; +} + +function parseWindow(value: string | string[] | undefined): UsageWindow { + const days = Number(Array.isArray(value) ? value[0] : value); + return (USAGE_WINDOWS as readonly number[]).includes(days) + ? (days as UsageWindow) + : USAGE_DAYS; +} + +export default async function ProductAnalyticsPage({ + params, + searchParams, +}: PageProps) { + const { account_id, product_id } = await params; + const product = await getAuthorizedProduct(account_id, product_id); + + // Analytics are for the people who run the product (same gate as the Edit + // button); everyone else gets the same 404 an unknown path would. + const session = await getPageSession(); + if (!isAuthorized(session, product, Actions.PutRepository)) { + notFound(); + } + + const windowDays = parseWindow((await searchParams).window); + const [usage, breakdowns] = await Promise.all([ + getUsage(account_id, product_id, undefined, windowDays), + getProductBreakdowns(account_id, product_id, windowDays), + ]); + + return ( + + + + {product.title || product_id} + + + + + {USAGE_WINDOWS.map((days) => ( + + ))} + + } + > + {usage ? ( + + ) : ( + + Analytics are unavailable right now. Try again in a few minutes. + + )} + + + + ); +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/loading.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/loading.tsx new file mode 100644 index 00000000..bffa02d5 --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/loading.tsx @@ -0,0 +1,3 @@ +export default function PreviewLoading() { + return null; +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/page.tsx new file mode 100644 index 00000000..52117a4e --- /dev/null +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/page.tsx @@ -0,0 +1,112 @@ +import { Suspense } from "react"; +import { Card, Flex, Link } from "@radix-ui/themes"; +import { SectionHeader } from "@/components/core/SectionHeader"; +import { getStorageClient } from "@/lib/clients/storage"; +import { readProxyCredentials } from "@/lib/services/proxy-credentials-read"; +import { ObjectPreview } from "@/components/features/products/object-browser/ObjectPreview"; +import { getAuthorizedProduct } from "../data"; +import { fileSourceUrl } from "@/lib/urls"; +import { ExternalLinkIcon } from "@radix-ui/react-icons"; +import { getExtension } from "@/lib/files"; +import { getIframeSrc } from "@/components/features/products/object-browser/ObjectPreviewExternal"; + +interface PageProps { + params: Promise<{ + account_id: string; + product_id: string; + path?: string[]; + }>; +} + +async function OpenInNewTabLink({ + account_id, + product_id, + object_path, +}: { + account_id: string; + product_id: string; + object_path: string; +}) { + const cloudUri = fileSourceUrl({ + account_id, + product_id, + object_path, + }); + const extension = getExtension(object_path); + const iframeSrc = await (extension + ? getIframeSrc(cloudUri, extension) + : null); + if (!iframeSrc) return null; + return ( + + + Open in new tab + + + + ); +} + +async function isFile( + account_id: string, + product_id: string, + object_path: string, +): Promise { + // Same gates as the main slot: authorize the viewer (notFound for those + // who may not read the product), and for a restricted/disabled product + // with no fresh proxy credentials render nothing while the main slot + // shows the credentials gate. + const product = await getAuthorizedProduct(account_id, product_id); + const creds = await readProxyCredentials(); + if (!creds && (product.visibility === "restricted" || product.disabled)) { + return false; + } + + try { + const s3 = await getStorageClient(creds ?? null); + const info = await s3.getObjectInfo({ + account_id, + product_id, + object_path, + }); + return info?.type === "file"; + } catch { + return false; + } +} + +/** + * Full-width object preview below the product grid (mirrors the @readme + * slot). Renders only when the path is a file; directories, the product + * root, credential gating, and backend failures are the main slot's story. + */ +export default async function ObjectPreviewSlot({ params }: PageProps) { + const { account_id, product_id, path = [] } = await params; + const object_path = path.map((p) => decodeURIComponent(p)).join("/"); + if (!object_path || !(await isFile(account_id, product_id, object_path))) { + return null; + } + + return ( + + + } + > + + + + + + ); +} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx index 20be24a3..1b245665 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx @@ -8,18 +8,26 @@ * we need to use a parent route-group to handle these cases. */ +import { Suspense } from "react"; import { BreadcrumbNav } from "@/components/display/BreadcrumbNav"; import { FetchCredentialsButton } from "@/components/features/uploader/FetchCredentialsButton"; import { PendingInvitationBanner } from "@/components/features/memberships/PendingInvitationBanner"; -import { ProductHeader } from "@/components/features/products/ProductHeader"; +import { ProductSummaryCard } from "@/components/features/products/ProductSummaryCard"; +import { ProductMetaCard } from "@/components/features/products/ProductMetaCard"; +import { + ProductTabs, + UsageCard, + UsageCardSkeleton, +} from "@/components/features/analytics"; +import { isAnalyticsConfigured } from "@/lib/clients/analytics"; import { SectionHeader } from "@/components/core/SectionHeader"; import { Dropzone } from "@/components/features/uploader/Dropzone"; import { getPageSession } from "@/lib"; import { isAuthorized } from "@/lib/api/authz"; -import { dataConnectionsTable, productsTable } from "@/lib/clients/database"; +import { dataConnectionsTable } from "@/lib/clients/database"; import { productUrl } from "@/lib/urls"; import { Actions } from "@/types/shared"; -import { Box, Callout, Card, Flex } from "@radix-ui/themes"; +import { Box, Callout, Card, Flex, Grid } from "@radix-ui/themes"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { getPendingInvitation } from "@/lib/actions/memberships"; import { ProductSchemaMetadata } from "@/components/features/products/ProductSchemaMetadata"; @@ -28,6 +36,7 @@ import { getAuthorizedProduct } from "./data"; interface ProductLayoutProps { children: React.ReactNode; readme: React.ReactNode; + preview: React.ReactNode; params: Promise<{ account_id: string; product_id: string; path?: string[] }>; } @@ -35,6 +44,7 @@ export default async function ProductLayout({ params, children, readme, + preview, }: ProductLayoutProps) { // Fetch + authorize in one place. Throws a 404 for missing products or // unauthorized viewers. The session is also needed below for the write check. @@ -54,6 +64,14 @@ export default async function ProductLayout({ !dataConnection.read_only && isAuthorized(session, product, Actions.WriteRepositoryData); + // Same gate as the Edit button and the /-/analytics route: the analytics + // tab and "view all" link only exist for people who run the product. + const canViewAnalytics = isAuthorized( + session, + product, + Actions.PutRepository, + ); + // Check for pending invitation const pendingInvitation = await getPendingInvitation(account_id, product_id); @@ -85,43 +103,83 @@ export default async function ProductLayout({ /> )} - - - - - - - - ) - } - > - + + + )} + + {/* Two columns: summary + contents (+ readme) on the left; details + and analytics stacked on the right. */} + + + {/* Mobile edge padding applies to the summary text only — cards + stay full-bleed like they were before the two-column rework */} + + + + + + + ) + } > - - decodeURIComponent(p)) || []} - baseUrl={productUrl(account_id, product_id)} - /> - - - - {children} - - - + + + decodeURIComponent(p)) || []} + baseUrl={productUrl(account_id, product_id)} + /> + + + + {children} + + + + + + + {/* Public downloads summary, the same for every viewer (managers + reach /-/analytics via the ANALYTICS tab). Streams in after + the page shell; hidden when analytics is off. The skeleton + reserves the card's space so warm-cache data fills in instead + of reflowing the column. */} + : null} + > + + + + + {/* Full-width rows below both columns: the object preview (file + views) and the README/viewer. Each slot renders its own Card with + mt, or null when it doesn't apply. */} + {preview} {readme} ); diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx index 8e43a962..0f1bb28b 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx @@ -1,4 +1,3 @@ -import { Suspense } from "react"; import { Metadata } from "next"; import { LOGGER, dataConnectionsTable, getPageSession } from "@/lib"; @@ -12,7 +11,6 @@ import { ProxyCredentialsGate } from "@/components/features/products/ProxyCreden import { ProductDataUnavailable } from "@/components/features/products/ProductDataUnavailable"; import { DirectoryList } from "@/components/features/products/object-browser/DirectoryList"; import { ObjectSummary } from "@/components/features/products/object-browser/ObjectSummary"; -import { ObjectPreview } from "@/components/features/products/object-browser/ObjectPreview"; import { generateProductMetadata } from "@/components/features/metadata/ProductMetadata"; export async function generateMetadata({ @@ -135,21 +133,13 @@ export default async function ProductPathPage({ params }: PageProps) { connectionDetails = { primaryMirror, dataConnection }; } + // The preview itself renders full-width via the @preview slot. return ( - <> - - - - - + ); } } diff --git a/src/app/(app)/[account_id]/[product_id]/loading.tsx b/src/app/(app)/[account_id]/[product_id]/loading.tsx index d5881ef4..fc56bd4b 100644 --- a/src/app/(app)/[account_id]/[product_id]/loading.tsx +++ b/src/app/(app)/[account_id]/[product_id]/loading.tsx @@ -1,119 +1,113 @@ import { Box, Card, Grid, Flex, DataList } from "@radix-ui/themes"; import { SectionHeader, Skeleton } from "@/components/core"; +import { UsageCardSkeleton } from "@/components/features/analytics"; +import { isAnalyticsConfigured } from "@/lib/clients/analytics"; import DirectoryListLoading from "@/components/features/products/object-browser/DirectoryListLoading"; export default function ProductDetailsLoading() { return ( - <> - + {/* Left column: summary + contents */} + - {/* Product Summary Card Skeleton - spans 2 columns */} - - - {/* Title Skeleton */} - + + {/* Title Skeleton */} + - {/* Description Skeleton */} - - + {/* Description Skeleton */} + + - {/* Tags Skeleton */} - - - - - - + {/* Tags Skeleton */} + + + + + - {/* Product Meta Card Skeleton - 1 column */} - - - - - {/* Visibility Badge Skeleton */} - - - - - - - - + {/* Product Contents Skeleton */} + + + {/* Breadcrumb Skeleton */} + + + + + + + - {/* Owner Skeleton */} - - - - - - - - - - - + {/* Directory List Skeleton */} + + + + - {/* Created Date Skeleton */} - - - - - - - - + {/* Right column: details + analytics stacked */} + + + + + {/* Visibility Badge Skeleton */} + + + + + + + + - {/* Last Updated Skeleton */} - - + {/* Owner Skeleton */} + + + + + + + - - - - - - - - - - + + + - {/* Product Contents Skeleton */} - - - {/* Breadcrumb Skeleton */} - - - - - - + {/* Created Date Skeleton */} + + + + + + + + + + {/* Last Updated Skeleton */} + + + + + + + + + + - {/* Directory List Skeleton */} - - - - + {isAnalyticsConfigured() && } + + ); } diff --git a/src/app/(app)/admin/analytics/page.tsx b/src/app/(app)/admin/analytics/page.tsx new file mode 100644 index 00000000..24d1241f --- /dev/null +++ b/src/app/(app)/admin/analytics/page.tsx @@ -0,0 +1,582 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { getPageSession } from "@/lib"; +import { isAdmin } from "@/lib/api/authz"; +import { + Box, + Button, + Callout, + Card, + Flex, + Heading, + Table, + Text, + Tooltip, +} from "@radix-ui/themes"; +import { + ExclamationTriangleIcon, + InfoCircledIcon, +} from "@radix-ui/react-icons"; +import { + ADMIN_DIMENSIONS, + BUCKET_INTERVALS, + MAX_CHART_BUCKETS, + OTHER_KEY, + RETENTION_DAYS, + getAdminBreakdown, + isAnalyticsConfigured, + type AdminBreakdown, + type AdminDimension, +} from "@/lib/clients/analytics"; +import { + AdminBreakdownChart, + seriesColor, +} from "@/components/features/analytics"; +// Components come from the client module; HELP/mono must come from the +// plain style module — client-module exports can't be called on the server. +import { MonoLabel } from "@/components/features/analytics/panels"; +import { HELP, mono } from "@/components/features/analytics/style"; +import { AdminFiltersForm } from "@/components/features/analytics/AdminFiltersForm"; +import { GroupByChips } from "@/components/features/analytics/GroupByChips"; +import { adminAnalyticsUrl, formatBytes } from "@/lib"; +import { accountUrl } from "@/lib/urls"; + +export const metadata: Metadata = { title: "Admin — Analytics" }; + +interface PageState { + /** + * UTC day "YYYY-MM-DD" (inclusive) or UTC instant "YYYY-MM-DDTHH:MM" + * (as `to`: exclusive) from the datetime filters and chart drill-downs; + * empty string = default + */ + from: string; + to: string; + /** Sum interval in minutes (a BUCKET_INTERVALS value); undefined = auto */ + bucketMinutes?: number; + /** Chart/ranking metric; "requests" is the default and stays out of URLs */ + metric: "bytes" | "requests"; + groupBy: AdminDimension[]; + /** Per-dimension value filters, one URL param per dimension key */ + filters: Partial>; +} + +const first = (v: string | string[] | undefined) => + Array.isArray(v) ? v[0] : v; + +const numberFormat = new Intl.NumberFormat("en-US"); + +const DAY_MS = 86_400_000; +const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); +const todayUtc = () => new Date().setUTCHours(0, 0, 0, 0); + +const dateParam = (v: string | string[] | undefined): string => { + const value = first(v) ?? ""; + return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/.test(value) ? value : ""; +}; + +/** Parse a range param (day or instant) to ms; day-only = UTC day start. */ +const paramMs = (value: string): number => + Date.parse(value.length === 10 ? `${value}T00:00:00Z` : `${value}:00Z`); + +/** "15-minute" / "6-hour" / "3-day" for any ladder value. */ +const bucketName = (minutes: number): string => + minutes < 60 + ? `${minutes}-minute` + : minutes < 1440 + ? `${minutes / 60}-hour` + : `${minutes / 1440}-day`; + +/** "6 hours" / "16 days" — the longest range an interval can draw. */ +const spanName = (minutes: number): string => + minutes < 1440 + ? `${Math.floor(minutes / 60)} hours` + : `${Math.floor(minutes / 1440)} days`; + +// Whole weeks (plus "Today"), to avoid aliasing day-of-week patterns. +const PRESETS = [ + { label: "Today", days: 1 }, + { label: "7d", days: 7 }, + { label: "28d", days: 28 }, + { label: "91d", days: 91 }, +]; + +function parseState(params: Record): PageState { + const groupByParam = first(params.groupBy); + const interval = Number(first(params.interval)); + return { + from: dateParam(params.from), + to: dateParam(params.to), + bucketMinutes: BUCKET_INTERVALS.some((b) => b.minutes === interval) + ? interval + : undefined, + metric: first(params.metric) === "bytes" ? "bytes" : "requests", + // Absent → the default grouping; present but empty → no grouping at all. + groupBy: + groupByParam === undefined + ? ["product"] + : [ + ...new Set( + groupByParam + .split(",") + // Object.hasOwn, not `in`: ?groupBy=constructor must not + // match prototype keys. + .filter((d): d is AdminDimension => + Object.hasOwn(ADMIN_DIMENSIONS, d), + ), + ), + ], + filters: Object.fromEntries( + (Object.keys(ADMIN_DIMENSIONS) as AdminDimension[]) + .map((dim) => [dim, first(params[dim])?.trim()]) + .filter(([, value]) => value), + ), + }; +} + +function pageUrl(state: PageState): string { + const params = new URLSearchParams({ groupBy: state.groupBy.join(",") }); + if (state.from) params.set("from", state.from); + if (state.to) params.set("to", state.to); + if (state.bucketMinutes) params.set("interval", String(state.bucketMinutes)); + if (state.metric === "bytes") params.set("metric", state.metric); + for (const [dim, value] of Object.entries(state.filters)) { + params.set(dim, value); + } + return `${adminAnalyticsUrl()}?${params}`; +} + +/** Range-shift arrow: a tooltipped link button, inert when at a boundary. */ +function ShiftButton({ + label, + help, + href, + disabled, +}: { + label: string; + help: string; + href: string; + disabled: boolean; +}) { + const button = disabled ? ( + + ) : ( + + ); + return {button}; +} + +/** Product/account group keys double as site paths ("acct" or "acct/prod"). */ +function groupHref(key: string, groupBy: AdminDimension[]): string | null { + if (key === OTHER_KEY || groupBy.length !== 1) return null; + if (groupBy[0] === "account" || groupBy[0] === "product") { + return accountUrl(key); // "/{key}" — product keys already include the slash + } + return null; +} + +interface PageProps { + searchParams: Promise>; +} + +export default async function AdminAnalyticsPage({ searchParams }: PageProps) { + // The admin layout renders NotAuthorizedPage, but layouts aren't an auth + // boundary (they render in parallel with pages and don't re-render on + // soft navigation) — gate the data work here too. + if (!isAdmin(await getPageSession())) { + notFound(); + } + + const state = parseState(await searchParams); + + if (!isAnalyticsConfigured()) { + return ( + + Analytics + + + + + + Analytics is not configured. Set CF_ANALYTICS_ACCOUNT_ID, + CF_ANALYTICS_API_TOKEN, and CF_ANALYTICS_DATASET. + + + + ); + } + + let breakdown: AdminBreakdown | null = null; + let queryError: string | null = null; + try { + breakdown = await getAdminBreakdown(state); + } catch (error) { + queryError = error instanceof Error ? error.message : String(error); + } + + const seriesColors = new Map( + breakdown?.series.map((key, i) => [key, seriesColor(key, i, OTHER_KEY)]), + ); + + // The resolved (clamped) range drives the presets, shift arrows, and the + // date inputs' defaults; fall back to the same default the client uses. + // A drilled range may be time-grained ("…THH:MM", exclusive `to`); the + // day-oriented controls operate on the days it touches. + const today = todayUtc(); + const range = breakdown?.range ?? { from: isoDay(today - 6 * DAY_MS), to: isoDay(today) }; + const fromMs = paramMs(range.from); + const endMs = + range.to.length === 10 ? paramMs(range.to) + DAY_MS : paramMs(range.to); + const fromDayMs = Math.floor(fromMs / DAY_MS) * DAY_MS; + const lastDayMs = Math.floor((endMs - 1) / DAY_MS) * DAY_MS; + const retentionEdge = today - RETENTION_DAYS * DAY_MS; + const rangeDays = Math.round((lastDayMs - fromDayMs) / DAY_MS) + 1; + const rangeMinutes = (endMs - fromMs) / 60_000; + const rangeLabel = `${rangeDays} day${rangeDays === 1 ? "" : "s"}`; + // Shift the whole range by N days, clamped so its length is preserved at + // the edges (today forward, ~retention backward). Shifting a drilled + // sub-day range deliberately widens it back to whole days. + const shiftUrl = (days: number) => { + const deltaMs = + days > 0 + ? Math.min(days * DAY_MS, today - lastDayMs) + : Math.max(days * DAY_MS, retentionEdge - fromDayMs); + return pageUrl({ + ...state, + from: isoDay(fromDayMs + deltaMs), + to: isoDay(lastDayMs + deltaMs), + }); + }; + const atToday = lastDayMs >= today; + const atRetention = fromDayMs <= retentionEdge; + // Bandwidth denominator: elapsed wall-clock within the range — a range + // that includes today only counts the part that has happened. + const elapsedSeconds = Math.max( + 1, + (Math.min(Date.now(), endMs) - fromMs) / 1000, + ); + + return ( + + Analytics + + {/* Two zones: what data (dates + entity filters) | how it's drawn + (group by + interval), split by the stats-row hairline. */} + + + {/* flexBasis 0: zones split the row by ratio instead of claiming + their content width, so GROUP BY/INTERVAL stay to the right + (inner chip rows wrap within the zone); minWidth only forces + stacking on truly narrow screens. */} + + + + Date range (UTC) + + + + + {PRESETS.map((preset) => { + const from = isoDay(today - (preset.days - 1) * DAY_MS); + const to = isoDay(today); + const active = range.from === from && range.to === to; + return ( + + ); + })} + + + + + ({ + key: dim, + label: ADMIN_DIMENSIONS[dim].label, + }))} + defaults={{ + // datetime-local values over the resolved [from, end) — + // midnight-aligned submissions collapse back to day grain + // in the data layer. + from: new Date(fromMs).toISOString().slice(0, 16), + to: new Date(endMs).toISOString().slice(0, 16), + filters: state.filters, + }} + hidden={{ + groupBy: state.groupBy.join(","), + ...(state.bucketMinutes && { + interval: String(state.bucketMinutes), + }), + ...(state.metric === "bytes" && { metric: state.metric }), + }} + /> + + + + + + + + Group by + + ({ + key: dim, + label: ADMIN_DIMENSIONS[dim].label, + }))} + selected={state.groupBy} + /> + + + + Interval + + + + {BUCKET_INTERVALS.map((bucket) => { + // An interval that would draw more bars than the chart can + // hold is disabled rather than silently coarsened. + const fits = + rangeMinutes <= MAX_CHART_BUCKETS * bucket.minutes; + if (!fits) { + return ( + + + + ); + } + return ( + + ); + })} + + + + + + + {queryError ? ( + + + + + {queryError} + + ) : !breakdown || + (breakdown.totals.bytes === 0 && breakdown.totals.requests === 0) ? ( + + + + + + No traffic recorded for this selection between {range.from} and{" "} + {range.to}. + + + ) : ( + <> + + {state.bucketMinutes !== undefined && + breakdown.bucketMinutes !== state.bucketMinutes && ( + + Showing {bucketName(breakdown.bucketMinutes)} buckets — the + requested interval would draw more than {MAX_CHART_BUCKETS}{" "} + bars over this range. + + )} + + + + + + + + # + + + + {state.groupBy.length + ? state.groupBy + .map((d) => ADMIN_DIMENSIONS[d].label) + .join(" · ") + : "Scope"} + + + + Data served + + + Requests + + + Share + + + + + {breakdown.groups.map((group, index) => { + // Share follows the active metric, like the row order. + const share = breakdown.totals[state.metric] + ? (group[state.metric] / breakdown.totals[state.metric]) * 100 + : 0; + const color = seriesColors.get(group.key); + const href = groupHref(group.key, state.groupBy); + return ( + // align="center": the swatch/meter cells hold block-level + // Flexes that otherwise top-align against the text cells. + + + + {/* The Other remainder isn't a ranked group */} + {group.key === OTHER_KEY ? "" : index + 1} + + + + + + + {href ? ( + {group.key} + ) : ( + group.key + )} + + + + + + {formatBytes(group.bytes)} + + + + + {numberFormat.format(Math.round(group.requests))} + + + + + + + + + {share.toFixed(1)}% + + + + + ); + })} + + + + )} + + ); +} diff --git a/src/components/features/admin/tools.ts b/src/components/features/admin/tools.ts index 23280d4b..016164d0 100644 --- a/src/components/features/admin/tools.ts +++ b/src/components/features/admin/tools.ts @@ -1,6 +1,14 @@ import type { ComponentType, ComponentProps } from "react"; -import { MagnifyingGlassIcon, Link1Icon } from "@radix-ui/react-icons"; -import { adminUserLookupUrl, adminDataConnectionsUrl } from "@/lib"; +import { + MagnifyingGlassIcon, + Link1Icon, + BarChartIcon, +} from "@radix-ui/react-icons"; +import { + adminAnalyticsUrl, + adminUserLookupUrl, + adminDataConnectionsUrl, +} from "@/lib"; type IconProps = ComponentProps; @@ -17,6 +25,12 @@ export interface AdminTool { } export const ADMIN_TOOLS: AdminTool[] = [ + { + name: "Analytics", + description: "Explore which accounts and products serve the most traffic.", + href: adminAnalyticsUrl(), + Icon: BarChartIcon, + }, { name: "User Lookup", description: "Find a user by email and open their profile.", diff --git a/src/components/features/analytics/AdminBreakdownChart.tsx b/src/components/features/analytics/AdminBreakdownChart.tsx new file mode 100644 index 00000000..7ab3a6b6 --- /dev/null +++ b/src/components/features/analytics/AdminBreakdownChart.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Box, + Dialog, + Flex, + IconButton, + SegmentedControl, + Text, +} from "@radix-ui/themes"; +import { + CodeIcon, + EnterFullScreenIcon, + ExitFullScreenIcon, +} from "@radix-ui/react-icons"; +import { + Bar, + BarChart, + CartesianGrid, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { formatBytes } from "@/lib/format"; +import { parseActiveIndex, Stat } from "./panels"; +import { HELP, mono } from "./style"; +import { seriesColor } from "./palette"; + +type Metric = "bytes" | "requests"; + +interface AdminBreakdownChartProps { + buckets: string[]; + bucketMinutes: number; + series: string[]; + points: Record[]; + totals: { + bytes: number; + requests: number; + uniqueIps: number; + countries: number; + }; + /** Elapsed wall-clock seconds within the range, for the bandwidth stat */ + elapsedSeconds: number; + otherKey: string; + /** From ?metric= so shared URLs reproduce the toggle state */ + initialMetric: Metric; + /** SQL executed for this view, shown in the "view SQL" dialog */ + queries?: string[]; +} + +const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "); +const DAY_MIN = 1440; +const DAY_MS = 86_400_000; + +const hhmm = (d: Date) => + `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; + +function tickLabel(iso: string, bucketMinutes: number): string { + const d = new Date(iso); + const day = `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; + // Sub-daily buckets need the time too; daily+ buckets always start at + // 00:00 UTC, so the time would be noise. + return bucketMinutes < DAY_MIN ? `${day} ${hhmm(d)}` : day; +} + +function bucketLabel(iso: string, bucketMinutes: number): string { + const d = new Date(iso); + const day = `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; + return bucketMinutes < DAY_MIN + ? `${day} ${hhmm(d)} UTC` + : bucketMinutes > DAY_MIN + ? `${day} + ${bucketMinutes / DAY_MIN - 1}d` + : day; +} + +/** + * Zooming in: the interval shown after drilling into one bucket of a given + * size — weeks break into days, days into hours, hours into minutes. + */ +const DRILL_INTERVAL: Record = { + 10080: 1440, + 4320: 1440, + 1440: 60, + 360: 60, + 180: 60, + 60: 1, + 15: 1, + 1: 1, +}; + +// en-US explicitly: this renders in SSR HTML, and locale-following +// toLocaleString() would hydrate differently for non-en visitors. +const compact = new Intl.NumberFormat("en-US", { notation: "compact" }); +const plain = new Intl.NumberFormat("en-US"); + +function formatMetric(value: number, metric: Metric): string { + return metric === "bytes" ? formatBytes(value) : compact.format(value); +} + +/** formatBytes chokes on sub-1 values (negative log); pin those to bytes. */ +const byteRate = (perSec: number) => + `${perSec > 0 && perSec < 1 ? `${perSec.toFixed(2)} B` : formatBytes(perSec, 1)}/s`; + +/** Average rate over one bucket: "0.43/s" requests, "12.3 MB/s" bytes. */ +function rate(value: number, bucketMinutes: number, metric: Metric): string { + const perSec = value / (bucketMinutes * 60); + return metric === "bytes" + ? byteRate(perSec) + : `${perSec >= 10 ? compact.format(perSec) : perSec.toFixed(2)}/s`; +} + +/** + * Stacked bar timeseries of traffic, one bar per time bucket, stacked by the + * page's group-by series. Bytes/requests toggle is client-side — both metrics + * ride in `points`, so switching never refetches. + */ +export function AdminBreakdownChart({ + buckets, + bucketMinutes, + series, + points, + totals, + elapsedSeconds, + otherKey, + initialMetric, + queries = [], +}: AdminBreakdownChartProps) { + const [metric, setMetric] = useState(initialMetric); + const router = useRouter(); + + // History back/forward re-renders this mounted component with a different + // ?metric= — resync the local (instant-toggle) copy. + useEffect(() => setMetric(initialMetric), [initialMetric]); + + // Click/drag drill-down: a click narrows the range to that bucket, a drag + // to the spanned buckets, both at the next finer interval. router.push + // adds a history entry, so the browser back button zooms out. + const [drag, setDrag] = useState<{ start: number; end: number } | null>(null); + const drillTo = (i0: number, i1: number) => { + const lo = Math.min(i0, i1); + const hi = Math.max(i0, i1); + // A single minute bucket has nothing finer to show. + if (bucketMinutes === 1 && lo === hi) return; + const startMs = Date.parse(buckets[lo]); + const endMs = Date.parse(buckets[hi]) + bucketMinutes * 60_000; + const url = new URL(window.location.href); + if (startMs % DAY_MS === 0 && endMs % DAY_MS === 0) { + // Whole days: keep the friendlier inclusive-day param form. + url.searchParams.set("from", buckets[lo].slice(0, 10)); + url.searchParams.set( + "to", + new Date(endMs - DAY_MS).toISOString().slice(0, 10), + ); + } else { + url.searchParams.set("from", buckets[lo].slice(0, 16)); + url.searchParams.set("to", new Date(endMs).toISOString().slice(0, 16)); + } + url.searchParams.set( + "interval", + String(DRILL_INTERVAL[bucketMinutes] ?? bucketMinutes), + ); + router.push(url.pathname + url.search); + }; + const activeAt = (state: { activeTooltipIndex?: unknown } | null) => + parseActiveIndex(state?.activeTooltipIndex, buckets.length); + + // Native Fullscreen API on the chart block. Hidden where unsupported + // (e.g. iPhone Safari). + const rootRef = useRef(null); + const [fullscreen, setFullscreen] = useState(false); + const [canFullscreen, setCanFullscreen] = useState(false); + useEffect(() => { + setCanFullscreen(Boolean(document.fullscreenEnabled)); + const onChange = () => + setFullscreen(document.fullscreenElement === rootRef.current); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, []); + const toggleFullscreen = () => { + if (document.fullscreenElement) void document.exitFullscreen(); + else void rootRef.current?.requestFullscreen(); + }; + + // Both metrics ride in `points`, so the chart flips instantly from local + // state — but the ranking (table order, charted top slice) is computed + // server-side per metric, so the toggle also soft-navigates. replace, not + // push: the back button is reserved for drill-down zoom-out. + const changeMetric = (value: Metric) => { + setMetric(value); + const url = new URL(window.location.href); + if (value === "bytes") url.searchParams.set("metric", value); + else url.searchParams.delete("metric"); + router.replace(url.pathname + url.search); + }; + + // Series keys are arbitrary strings (account/product names, hashes), so + // rows use positional dataKeys that can't collide with "date". + const rows = buckets.map((date, i) => ({ + date, + ...Object.fromEntries( + series.map((key, s) => [`s${s}`, points[i][key]?.[metric] ?? 0]), + ), + })); + const colorAt = (s: number) => seriesColor(series[s], s, otherKey); + // The 1px panel-colored segment separator erases bars once they get down + // to a few px wide (the stroke covers the entire fill) — dense charts + // drop it and let color changes separate the stack instead. + const separators = buckets.length <= 150; + + return ( + + + + + + + + + + + changeMetric(value as Metric)} + > + Bytes + + Requests + + + {queries.length > 0 && ( + + + + + + + + Analytics Engine SQL + + The statements that produced this view, in execution order. + + {queries.map((sql, i) => ( + + + {sql} + + + ))} + + + )} + {canFullscreen && ( + + {fullscreen ? : } + + )} + + + + event.preventDefault()} + style={{ + userSelect: "none", + WebkitUserSelect: "none", + cursor: "crosshair", + ...(fullscreen && { flexGrow: 1, minHeight: 0 }), + }} + > + + { + const i = activeAt(state); + if (i !== null) setDrag({ start: i, end: i }); + }} + onMouseMove={(state) => { + if (!drag) return; + const i = activeAt(state); + if (i !== null && i !== drag.end) setDrag({ ...drag, end: i }); + }} + onMouseUp={() => { + if (drag) drillTo(drag.start, drag.end); + setDrag(null); + }} + onMouseLeave={() => setDrag(null)} + > + + tickLabel(iso, bucketMinutes)} + tick={{ fill: "var(--gray-11)", fontSize: 11, fontFamily: "var(--code-font-family)" }} + tickLine={false} + axisLine={{ stroke: "var(--gray-a6)" }} + minTickGap={24} + /> + formatMetric(value, metric)} + tick={{ fill: "var(--gray-11)", fontSize: 11, fontFamily: "var(--code-font-family)" }} + tickLine={false} + axisLine={false} + width={64} + /> + { + if (!active || !payload?.length) return null; + const entries = payload + .map((entry) => ({ + index: Number(String(entry.dataKey).slice(1)), + value: Number(entry.value) || 0, + })) + .filter((entry) => entry.value > 0) + .sort((a, b) => b.value - a.value); + const total = entries.reduce((sum, e) => sum + e.value, 0); + return ( + + + {bucketLabel(String(label), bucketMinutes)} + + {entries.map((entry) => ( + + + + {series[entry.index]} + + + {formatMetric(entry.value, metric)} + + {" "} + {rate(entry.value, bucketMinutes, metric)} + + + + ))} + {entries.length > 1 && ( + + + + Total + + + {formatMetric(total, metric)} + + {" "} + {rate(total, bucketMinutes, metric)} + + + + )} + + ); + }} + /> + {series.map((key, s) => ( + + ))} + {drag && drag.start !== drag.end && ( + + )} + + + + + + {series.map((key, s) => ( + + + + {key} + + + ))} + + + Times are UTC. Values are estimates — Analytics Engine samples + high-volume traffic. Click a bar (or drag across several) to zoom in; + the browser back button zooms out. + + + ); +} diff --git a/src/components/features/analytics/AdminFiltersForm.tsx b/src/components/features/analytics/AdminFiltersForm.tsx new file mode 100644 index 00000000..9ed1359e --- /dev/null +++ b/src/components/features/analytics/AdminFiltersForm.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useRef, useState } from "react"; +import { + Box, + Button, + Flex, + IconButton, + Select, + Text, + TextField, +} from "@radix-ui/themes"; +import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { MonoLabel } from "./panels"; + +const DEBOUNCE_MS = 500; + +/** Value-input hints per dimension key (labels come from the page). */ +const PLACEHOLDER: Record = { + account: "account id", + product: "product id", + country: "country code (US)", + client: "ip hash prefix", +}; + +interface AdminFiltersFormProps { + action: string; + /** Filterable dimensions, same set as the group-by chips */ + dimensions: { key: string; label: string }[]; + defaults: { from: string; to: string; filters: Record }; + /** Params to carry through unchanged (groupBy, interval, metric) */ + hidden: Record; +} + +/** + * The admin explorer's filter form. Edits auto-apply after a brief quiet + * period — the debounce lets several edits (both dates, a couple of filters) + * land in one reload — while Enter or Apply still submits immediately. The + * Apply button spins from the first detected edit until the reload lands. + * + * Filters are dynamic rows: a dimension dropdown naming the GET param plus + * a value input, one row per dimension at most. + */ +export function AdminFiltersForm({ + action, + dimensions, + defaults, + hidden, +}: AdminFiltersFormProps) { + const form = useRef(null); + const timer = useRef | undefined>(undefined); + const [pending, setPending] = useState(false); + const [rows, setRows] = useState<{ dim: string; value: string }[]>( + Object.entries(defaults.filters).map(([dim, value]) => ({ dim, value })), + ); + + const submitSoon = () => { + setPending(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => form.current?.requestSubmit(), DEBOUNCE_MS); + }; + + const setRow = (index: number, row: { dim: string; value: string }) => { + setRows(rows.map((r, i) => (i === index ? row : r))); + // Re-dimensioning an empty row changes nothing until a value is typed. + if (row.value) submitSoon(); + }; + const removeRow = (index: number) => { + const removed = rows[index]; + setRows(rows.filter((_, i) => i !== index)); + if (removed.value) submitSoon(); + }; + const addRow = () => { + const free = dimensions.find((d) => !rows.some((r) => r.dim === d.key)); + if (free) setRows([...rows, { dim: free.key, value: "" }]); + }; + + return ( + // No form-level onChange: Radix Select keeps a hidden native + ))} + {/* align start: stretched children would pull the datetime inputs + to the zone's full width. */} + + + + + + UTC · end exclusive + + + + + Filter by + + + {rows.map((row, index) => ( + + setRow(index, { ...row, dim })} + > + + + {dimensions.map((d) => ( + i !== index && r.dim === d.key, + )} + > + {d.label} + + ))} + + + { + setRows( + rows.map((r, i) => + i === index ? { ...r, value: event.target.value } : r, + ), + ); + submitSoon(); + }} + placeholder={PLACEHOLDER[row.dim] ?? "value"} + /> + removeRow(index)} + > + + + + ))} + + + + + + + + + ); +} diff --git a/src/components/features/analytics/GroupByChips.tsx b/src/components/features/analytics/GroupByChips.tsx new file mode 100644 index 00000000..8f1b2628 --- /dev/null +++ b/src/components/features/analytics/GroupByChips.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Flex, Spinner } from "@radix-ui/themes"; + +const DEBOUNCE_MS = 500; + +interface GroupByChipsProps { + dimensions: { key: string; label: string }[]; + selected: string[]; +} + +/** + * Multi-select group-by toggles. Chips flip instantly (local state) and the + * navigation is debounced, so composing several dimensions costs one reload + * instead of one per click. Other params are read from the URL at fire time + * so nothing else (range, interval, metric, filters) is lost. + */ +export function GroupByChips({ dimensions, selected }: GroupByChipsProps) { + const router = useRouter(); + const [active, setActive] = useState(selected); + const [waiting, setWaiting] = useState(false); + const [navigating, startTransition] = useTransition(); + const timer = useRef | undefined>(undefined); + + // A pending push must die with the page: it reads window.location when it + // fires, so after navigating away it would yank the user back here. + useEffect(() => () => clearTimeout(timer.current), []); + + const toggle = (key: string) => { + const next = active.includes(key) + ? active.filter((k) => k !== key) + : [...active, key]; + setActive(next); + setWaiting(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => { + setWaiting(false); + const url = new URL(window.location.href); + // Present-but-empty means "no grouping", distinct from the default. + url.searchParams.set("groupBy", next.join(",")); + startTransition(() => router.push(url.pathname + url.search)); + }, DEBOUNCE_MS); + }; + + return ( + + {dimensions.map((dim) => ( + + ))} + {(waiting || navigating) && } + + ); +} diff --git a/src/components/features/analytics/ProductAnalyticsView.test.tsx b/src/components/features/analytics/ProductAnalyticsView.test.tsx new file mode 100644 index 00000000..59eb5b6d --- /dev/null +++ b/src/components/features/analytics/ProductAnalyticsView.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Theme } from "@radix-ui/themes"; +import { ProductAnalyticsView } from "./ProductAnalyticsView"; +import type { UsagePoint } from "@/lib/clients/analytics"; + +beforeAll(() => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + +const days: UsagePoint[] = Array.from({ length: 30 }, (_, i) => ({ + date: new Date(Date.UTC(2026, 5, 7 + i)).toISOString(), + bytes: i, + requests: i, + countries: 1, +})); + +it("renders stats, country ranking, and top files", () => { + render( + + + , + ); + + expect(screen.getByText("12,847")).toBeInTheDocument(); + // Daily avg = round(12847 / 30) + expect(screen.getByText("428")).toBeInTheDocument(); + expect(screen.getByText("By country")).toBeInTheDocument(); + expect(screen.getByText("United States")).toBeInTheDocument(); + expect(screen.getByText("37 others")).toBeInTheDocument(); + const fileLink = screen.getByRole("link", { + name: "global-land-cover-2023.tif", + }); + expect(fileLink).toHaveAttribute( + "href", + "/acct/prod/global-land-cover-2023.tif", + ); + expect(screen.getByText("9,214")).toBeInTheDocument(); +}); + +it("renders the Pareto chart on the Users tab", async () => { + render( + + + , + ); + await userEvent.click(screen.getByRole("tab", { name: /users/i })); + expect( + screen.getByRole("img", { name: /pareto chart of unique ips/i }), + ).toBeInTheDocument(); + expect(screen.getByText("cumulative")).toBeInTheDocument(); +}); diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx new file mode 100644 index 00000000..4b64a4e7 --- /dev/null +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState } from "react"; +import { Box, Flex, Grid, Table, Tabs, Text } from "@radix-ui/themes"; +import Link from "next/link"; +import type { + ProductBreakdowns, + UsagePoint, + UsageTotals, + UsageUsers, +} from "@/lib/clients/analytics"; +import { formatBytes } from "@/lib/format"; +import { objectUrl } from "@/lib/urls"; +import { + DownloadsChart, + HELP, + HoverCaption, + MonoLabel, + mono, + numberFormat, + Stat, + UsersContent, +} from "./panels"; + +interface ProductAnalyticsViewProps { + accountId: string; + productId: string; + days: UsagePoint[]; + totals: UsageTotals; + users: UsageUsers; + breakdowns: ProductBreakdowns | null; +} + +/** + * Full product analytics page body (issue #257 mock): stats row with daily + * average, downloads chart beside a by-country ranking, and a top-files + * table. Same DOWNLOADS/USERS tab pair as the compact card. + */ +export function ProductAnalyticsView({ + accountId, + productId, + days, + totals, + users, + breakdowns, +}: ProductAnalyticsViewProps) { + const [hovered, setHovered] = useState(null); + const shown = hovered === null ? totals : days[hovered]; + const maxCountry = Math.max( + 1, + ...(breakdowns?.countries.map((c) => c.requests) ?? []), + breakdowns?.otherCountries?.requests ?? 0, + ); + + return ( + + + + + DOWNLOADS + + + + + USERS + + + + + + + + + + + + + + + + + + + + By country + {!breakdowns ? ( + + Country breakdown unavailable. + + ) : ( + + {[ + ...breakdowns.countries.map((c) => ({ + code: c.code, + label: c.name, + requests: c.requests, + })), + ...(breakdowns.otherCountries + ? [ + { + code: "·", + label: `${breakdowns.otherCountries.count} others`, + requests: breakdowns.otherCountries.requests, + }, + ] + : []), + ].map((row) => ( + + + {row.code} + + + + + {row.label} + + + {numberFormat.format(Math.round(row.requests))} + + + + + + + + ))} + + )} + + + + + Top files + {!breakdowns || breakdowns.files.length === 0 ? ( + + No file downloads in this period. + + ) : ( + + + + + File + + + Downloads + + + Data served + + + + + {breakdowns.files.map((file) => ( + + + + + {file.path} + + + + + + {numberFormat.format(Math.round(file.requests))} + + + + + {formatBytes(file.bytes, 1)} + + + + ))} + + + )} + + + + + + + + ); +} diff --git a/src/components/features/analytics/ProductTabs.tsx b/src/components/features/analytics/ProductTabs.tsx new file mode 100644 index 00000000..c3d4db43 --- /dev/null +++ b/src/components/features/analytics/ProductTabs.tsx @@ -0,0 +1,74 @@ +import Link from "next/link"; +import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { LockClosedIcon } from "@radix-ui/react-icons"; +import { productAnalyticsUrl, productUrl } from "@/lib/urls"; + +interface ProductTabsProps { + accountId: string; + productId: string; + active: "product" | "analytics"; +} + +const label = (active: boolean): React.CSSProperties => ({ + fontFamily: "var(--code-font-family)", + letterSpacing: "0.03em", + color: active ? "var(--gray-12)" : "var(--gray-10)", +}); + +/** + * PRODUCT | ANALYTICS strip shown at the product root to viewers who can + * manage the product (the analytics route 404s everyone else). + */ +export function ProductTabs({ accountId, productId, active }: ProductTabsProps) { + const tabs = [ + { key: "product", text: "PRODUCT", href: productUrl(accountId, productId) }, + { + key: "analytics", + text: "ANALYTICS", + href: productAnalyticsUrl(accountId, productId), + }, + ] as const; + + return ( + + {tabs.map((tab) => ( + + + + {tab.key === "analytics" && ( + + + + )} + + {tab.text} + + + + + ))} + + ); +} diff --git a/src/components/features/analytics/UsageCard.tsx b/src/components/features/analytics/UsageCard.tsx new file mode 100644 index 00000000..41fbe2f0 --- /dev/null +++ b/src/components/features/analytics/UsageCard.tsx @@ -0,0 +1,39 @@ +import { Card } from "@radix-ui/themes"; +import { SectionHeader } from "@/components/core/SectionHeader"; +import { getUsage } from "@/lib/clients/analytics"; +import { MonoLabel } from "./panels"; +import { HELP } from "./style"; +import { UsagePanel } from "./UsagePanel"; + +interface UsageCardProps { + accountId: string; + productId: string; +} + +/** + * Server component: fetches recent usage and renders the analytics card — + * the same downloads summary for every viewer (the full analytics page is + * reached via the manager-only ANALYTICS tab). Renders nothing when + * analytics is unconfigured or the query fails, so the page never depends + * on the analytics backend. Render inside . + */ +export async function UsageCard({ accountId, productId }: UsageCardProps) { + const usage = await getUsage(accountId, productId); + if (!usage) return null; + + return ( + // flexShrink 0: in the grid-stretched meta column an over-constrained + // flex layout would otherwise crush the card and clip the chart + // (Radix Card is overflow:hidden). + + {usage.days.length} days + } + > + + + + ); +} diff --git a/src/components/features/analytics/UsageCardSkeleton.tsx b/src/components/features/analytics/UsageCardSkeleton.tsx new file mode 100644 index 00000000..4a2d84fe --- /dev/null +++ b/src/components/features/analytics/UsageCardSkeleton.tsx @@ -0,0 +1,26 @@ +import { Box, Card, Flex, Skeleton } from "@radix-ui/themes"; +import { SectionHeader } from "@/components/core/SectionHeader"; + +/** + * Suspense fallback for UsageCard: reserves the card's space so the data + * fills in instead of the column reflowing when the stats stream in. + * Only render when analytics is configured — otherwise the real card + * resolves to nothing and the skeleton would flash and vanish. + */ +export function UsageCardSkeleton() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/src/components/features/analytics/UsagePanel.test.tsx b/src/components/features/analytics/UsagePanel.test.tsx new file mode 100644 index 00000000..e3b94f11 --- /dev/null +++ b/src/components/features/analytics/UsagePanel.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import { Theme } from "@radix-ui/themes"; +import { UsagePanel, parseActiveIndex } from "./UsagePanel"; +import type { UsagePoint } from "@/lib/clients/analytics"; + +// Radix Tooltip needs the Theme provider (the app supplies it at the root). +const renderPanel = (ui: React.ReactElement) => render({ui}); + +// recharts' ResponsiveContainer needs ResizeObserver, which jsdom lacks. +beforeAll(() => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + +const days: UsagePoint[] = Array.from({ length: 30 }, (_, i) => ({ + date: new Date(Date.UTC(2026, 5, 7 + i)).toISOString(), + bytes: i * 1024, + requests: i, + countries: 1, +})); + +const totals = { bytes: 10 * 1024 ** 3, requests: 12847, countries: 42 }; + +it("shows window downloads, data served, and countries", () => { + renderPanel(); + expect(screen.getByText("Downloads")).toBeInTheDocument(); + expect(screen.getByText("12,847")).toBeInTheDocument(); + expect(screen.getByText("Data served")).toBeInTheDocument(); + expect(screen.getByText("10 GB")).toBeInTheDocument(); + expect(screen.getByText("Countries")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("30-day downloads")).toBeInTheDocument(); +}); + +it("renders no tab selector or users content in the card", () => { + renderPanel(); + expect(screen.queryByRole("tab")).toBeNull(); + expect(screen.queryByText("Registered")).toBeNull(); +}); + +it("accepts recharts 3's string activeTooltipIndex", () => { + // recharts 3 passes the hover index as a numeric string, not a number. + expect(parseActiveIndex("5", 28)).toBe(5); + expect(parseActiveIndex(5, 28)).toBe(5); + expect(parseActiveIndex(null, 28)).toBeNull(); + expect(parseActiveIndex(undefined, 28)).toBeNull(); + expect(parseActiveIndex("", 28)).toBeNull(); + expect(parseActiveIndex("28", 28)).toBeNull(); // out of bounds + expect(parseActiveIndex("-1", 28)).toBeNull(); +}); diff --git a/src/components/features/analytics/UsagePanel.tsx b/src/components/features/analytics/UsagePanel.tsx new file mode 100644 index 00000000..16ab7da0 --- /dev/null +++ b/src/components/features/analytics/UsagePanel.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { Flex } from "@radix-ui/themes"; +import type { UsagePoint, UsageTotals } from "@/lib/clients/analytics"; +import { formatBytes } from "@/lib/format"; +import { + DownloadsChart, + HELP, + HoverCaption, + numberFormat, + Stat, +} from "./panels"; + +// Kept here for its existing unit tests / import sites. +export { parseActiveIndex } from "./panels"; + +interface UsagePanelProps { + days: UsagePoint[]; + totals: UsageTotals; +} + +/** + * Compact analytics card panel (issue #257 mocks): a stats row (downloads, + * data served, countries) over a daily downloads bar chart — hovering a bar + * shows that day's numbers. Users/audience detail lives on the full + * analytics page, not in the card. + */ +export function UsagePanel({ days, totals }: UsagePanelProps) { + const [hovered, setHovered] = useState(null); + const shown = hovered === null ? totals : days[hovered]; + + return ( + <> + + + + + + + + + + + + ); +} diff --git a/src/components/features/analytics/index.ts b/src/components/features/analytics/index.ts new file mode 100644 index 00000000..f3930b9c --- /dev/null +++ b/src/components/features/analytics/index.ts @@ -0,0 +1,7 @@ +export { UsageCard } from "./UsageCard"; +export { UsageCardSkeleton } from "./UsageCardSkeleton"; +export { UsagePanel } from "./UsagePanel"; +export { ProductAnalyticsView } from "./ProductAnalyticsView"; +export { ProductTabs } from "./ProductTabs"; +export { AdminBreakdownChart } from "./AdminBreakdownChart"; +export { SERIES_COLORS, OTHER_COLOR, seriesColor } from "./palette"; diff --git a/src/components/features/analytics/palette.ts b/src/components/features/analytics/palette.ts new file mode 100644 index 00000000..21ecce7a --- /dev/null +++ b/src/components/features/analytics/palette.ts @@ -0,0 +1,23 @@ +/** + * Fixed categorical order for analytics series, drawn from the Radix scales + * the app already ships. Validated (light #fff / dark #191919 surfaces) for + * lightness band, chroma floor, adjacent-pair CVD separation (worst ΔE 17.5), + * and ≥3:1 contrast. Hues are assigned by ranked position within one query + * result and never cycled — overflow folds into "Other" (gray) upstream. + */ +export const SERIES_COLORS = [ + "var(--green-9)", // #30A46C — also the single-series/highlight hue + "var(--plum-9)", // #AB4ABA + "var(--orange-10)", // #EF5F00 + "var(--cyan-10)", // #0797B9 + "var(--crimson-9)", // #E93D82 + "var(--indigo-9)", // #3E63DD +]; + +export const OTHER_COLOR = "var(--gray-8)"; + +export function seriesColor(key: string, index: number, otherKey: string) { + return key === otherKey + ? OTHER_COLOR + : SERIES_COLORS[index % SERIES_COLORS.length]; +} diff --git a/src/components/features/analytics/panels.tsx b/src/components/features/analytics/panels.tsx new file mode 100644 index 00000000..2f6e0869 --- /dev/null +++ b/src/components/features/analytics/panels.tsx @@ -0,0 +1,345 @@ +"use client"; + +/** + * Shared client-side pieces for the analytics card (UsagePanel) and the + * product analytics page (ProductAnalyticsView). + */ +import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { + BarChart, + Bar, + Cell, + ComposedChart, + Line, + XAxis, + YAxis, + ResponsiveContainer, + Tooltip as ChartTooltip, +} from "recharts"; +// Types only — a value import would drag the server data layer (CONFIG, +// LOGGER) into the client bundle. +import type { UsagePoint, UsageUsers } from "@/lib/clients/analytics"; +import { formatDateSSR } from "@/lib/format"; +import { HELP, mono } from "./style"; + +export { HELP, mono }; + +// Deterministic across server and client — no-arg toLocaleString() follows +// the runtime locale and causes hydration mismatches. +export const numberFormat = new Intl.NumberFormat("en-US"); +export const compactFormat = new Intl.NumberFormat("en-US", { + notation: "compact", +}); + +/** + * recharts 3 delivers activeTooltipIndex as a numeric string (or null); + * older typings claim number. Accept either, bounded to the data. + */ +export function parseActiveIndex(raw: unknown, length: number): number | null { + const index = + typeof raw === "number" || (typeof raw === "string" && raw !== "") + ? Number(raw) // Number("") would be 0, hence the guard above + : NaN; + return Number.isInteger(index) && index >= 0 && index < length ? index : null; +} + +export function MonoLabel({ + children, + help, +}: { + children: React.ReactNode; + help?: string; +}) { + const label = ( + + {children} + + ); + return help ? {label} : label; +} + +export function Stat({ + label, + help, + value, + divider, +}: { + label: string; + help: string; + value: string; + divider?: boolean; +}) { + return ( + + {label} + + {value} + + + ); +} + +/** Green swatch + "N-day downloads" caption, or the hovered day's date. */ +export function HoverCaption({ + days, + hovered, +}: { + days: UsagePoint[]; + hovered: number | null; +}) { + return ( + + {hovered !== null && ( + + )} + + {hovered === null + ? `${days.length}-day downloads` + : formatDateSSR(days[hovered].date)} + + + ); +} + +/** Daily downloads bars; hovering highlights a bar and reports its index. */ +export function DownloadsChart({ + days, + hovered, + onHover, + height, +}: { + days: UsagePoint[]; + hovered: number | null; + onHover: (index: number | null) => void; + height: number; +}) { + return ( + onHover(null)} + // Clicking the chart must not move focus (a click inside the Radix + // tab panel otherwise promotes focus to it and draws its focus ring + // around the panel). Keyboard focus is unaffected. + onMouseDown={(event) => event.preventDefault()} + // Clicking/dragging across the bars otherwise triggers the browser's + // text-selection overlay on the SVG. + style={{ userSelect: "none", WebkitUserSelect: "none" }} + > + + + onHover(parseActiveIndex(state?.activeTooltipIndex, days.length)) + } + > + + + + {days.map((day, i) => ( + + ))} + + + + + {formatDateSSR(days[0].date)} + {formatDateSSR(days[days.length - 1].date)} + + + ); +} + +/** USERS tab body: unique IPs, registered vs anonymous usage, per-IP histogram. */ +export function UsersContent({ users }: { users: UsageUsers }) { + return ( + <> + + + + + + + + IPs by download count + {users.uniqueIps === 0 ? ( + + No download activity in this period. + + ) : ( + + + + )} + + + ); +} + +/** + * Pareto-style chart of the per-IP population: quasi-log downloads-per-IP + * bins in their natural order (bars), with a line accumulating "IPs with + * this many downloads or fewer" toward the total. Single axis: the line + * accumulates IP counts, not a second percent scale; tooltips carry the + * percentage. Bins stay ordinal — ranking them by size would shuffle an + * ordered scale. + */ +function DistributionChart({ + distribution, +}: { + distribution: UsageUsers["distribution"]; +}) { + const total = distribution.reduce((sum, bin) => sum + bin.ips, 0); + // Trim trailing empty bins (a sparse product would waste half the axis); + // interior zeros stay — they are part of the distribution's shape. + let end = 0; + distribution.forEach((bin, i) => { + if (bin.ips > 0) end = i + 1; + }); + let running = 0; + const data = distribution.slice(0, end).map((bin) => { + running += bin.ips; + return { ...bin, cumulative: running, share: running / Math.max(1, total) }; + }); + return ( + event.preventDefault()} + style={{ userSelect: "none", WebkitUserSelect: "none" }} + > + + + + + { + if (!active || !payload?.length) return null; + const row = payload[0].payload as { + ips: number; + share: number; + }; + return ( + + + {numberFormat.format(row.ips)} IPs · {label} downloads + + + {(row.share * 100).toFixed(0)}% of IPs at or below this + + + ); + }} + /> + + + + + + + + + IPs + + + + + + cumulative + + + + + ); +} diff --git a/src/components/features/analytics/style.ts b/src/components/features/analytics/style.ts new file mode 100644 index 00000000..121e1bca --- /dev/null +++ b/src/components/features/analytics/style.ts @@ -0,0 +1,25 @@ +// Shared analytics styling constants. Deliberately NOT a "use client" +// module: server components (the admin page) call mono() and read HELP, +// and client-module exports can't be invoked across the RSC boundary. +import type { CSSProperties } from "react"; + +export const HELP = { + downloads: "Number of successful downloads.", + window: + "Dates are UTC calendar days: the window covers the most recent full days plus the in-progress UTC day.", + served: "Total bytes downloaded.", + bandwidth: "Data served divided by the elapsed time in the range.", + requests: "Successful data requests (GET, status 200/206).", + countries: "Distinct countries requests originated from.", + dailyAvg: "Average downloads per day over the period.", + uniqueIps: "Distinct IP addresses that downloaded data in this period.", + registered: "Distinct signed-in users who downloaded data in this period.", + anon: "Download requests made without a signed-in user.", + distribution: + "Unique IP addresses per download-count band; the line accumulates IPs with that many downloads or fewer, toward 100%. Band edges are log-spaced — the tail spans orders of magnitude.", +}; + +export const mono = (extra?: CSSProperties): CSSProperties => ({ + fontFamily: "var(--code-font-family)", + ...extra, +}); diff --git a/src/components/features/products/ProductHeader.tsx b/src/components/features/products/ProductHeader.tsx deleted file mode 100644 index a7cf51e8..00000000 --- a/src/components/features/products/ProductHeader.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// For product detail page header -import { Grid, Box } from "@radix-ui/themes"; -import type { Product } from "@/types"; -import { ProductSummaryCard } from "./ProductSummaryCard"; -import { ProductMetaCard } from "./ProductMetaCard"; - -interface ProductHeaderProps { - product: Product; -} - -export function ProductHeader({ product }: ProductHeaderProps) { - return ( - - - - - - - - - - - ); -} diff --git a/src/components/features/products/ProductMetaCard.tsx b/src/components/features/products/ProductMetaCard.tsx index 23d73e9b..c9b5a98e 100644 --- a/src/components/features/products/ProductMetaCard.tsx +++ b/src/components/features/products/ProductMetaCard.tsx @@ -9,7 +9,7 @@ interface ProductMetaCardProps { export function ProductMetaCard({ product }: ProductMetaCardProps) { return ( - + diff --git a/src/components/features/products/index.ts b/src/components/features/products/index.ts index 7004cbf9..fc616941 100644 --- a/src/components/features/products/index.ts +++ b/src/components/features/products/index.ts @@ -3,7 +3,6 @@ export * from "./ProductListItem"; export * from "./ProductSummaryCard"; export * from "./ProductMetaContent"; export * from "./ProductMetaCard"; -export * from "./ProductHeader"; export * from "./ProductSchemaMetadata"; export * from "./ProductSearchResult"; export * from "./TagList"; diff --git a/src/components/features/products/object-browser/ObjectPreview.tsx b/src/components/features/products/object-browser/ObjectPreview.tsx index 498402ad..2b6aa285 100644 --- a/src/components/features/products/object-browser/ObjectPreview.tsx +++ b/src/components/features/products/object-browser/ObjectPreview.tsx @@ -3,6 +3,7 @@ import { ObjectPreviewInternal, } from "./ObjectPreviewInternal"; import { ObjectPreviewExternal } from "./ObjectPreviewExternal"; +import { Box } from "@radix-ui/themes"; interface ObjectPreviewProps { account_id: string; @@ -11,9 +12,13 @@ interface ObjectPreviewProps { } export async function ObjectPreview(props: ObjectPreviewProps) { - if (canRenderInternally(props.object_path)) { - return ; - } else { - return ; - } + const Component = canRenderInternally(props.object_path) + ? ObjectPreviewInternal + : ObjectPreviewExternal; + + return ( + + + + ); } diff --git a/src/components/features/products/object-browser/ObjectPreviewExternal.tsx b/src/components/features/products/object-browser/ObjectPreviewExternal.tsx index b653361b..231be619 100644 --- a/src/components/features/products/object-browser/ObjectPreviewExternal.tsx +++ b/src/components/features/products/object-browser/ObjectPreviewExternal.tsx @@ -2,13 +2,16 @@ import "server-only"; import { LOGGER } from "@/lib"; import { fileSourceUrl } from "@/lib/urls"; -import { Box, Code, Flex, Link } from "@radix-ui/themes"; +import { Code, Flex, Link } from "@radix-ui/themes"; import { ExternalLinkIcon } from "@radix-ui/react-icons"; import type { CSSProperties } from "react"; import { getExtension } from "@/lib/files"; import { DuckDBConnection } from "@duckdb/node-api"; +import { cache } from "react"; -const isStacGeoParquet = async (sourceUrl: string): Promise => { +// cache(): the remote parquet-schema probe is requested by both the +// "Open in new tab" link and the preview iframe in one render. +const isStacGeoParquet = cache(async (sourceUrl: string): Promise => { let db: DuckDBConnection | undefined; try { db = await DuckDBConnection.create(); @@ -33,7 +36,7 @@ const isStacGeoParquet = async (sourceUrl: string): Promise => { // DuckDB connections should be closed to free up resources db?.closeSync(); } -}; +}); interface ObjectPreviewExternalProps { account_id: string; @@ -41,40 +44,25 @@ interface ObjectPreviewExternalProps { object_path: string; } -const getIframeAttributes = async ( +export const getIframeSrc = async ( sourceUrl: string, extension: string, -): Promise<{ src: string; style?: CSSProperties } | null> => { +): Promise => { const url = encodeURIComponent(sourceUrl); switch (extension) { case "pmtiles": - return { - src: `https://pmtiles.io/#url=${url}&iframe=true`, - style: { border: "none" }, - }; + return `https://pmtiles.io/#url=${url}&iframe=true`; case "parquet": if (await isStacGeoParquet(sourceUrl)) { - return { - src: `https://developmentseed.org/stac-map?href=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://developmentseed.org/stac-map?href=${url}`; } - return { - src: `https://source-cooperative.github.io/parquet-table/?iframe=true&url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/parquet-table/?iframe=true&url=${url}`; case "csv": case "tsv": - return { - src: `https://source-cooperative.github.io/csv-table/?iframe=true&url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/csv-table/?iframe=true&url=${url}`; case "tif": case "tiff": - return { - src: `https://source-cooperative.github.io/cog-viewer/?url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/cog-viewer/?url=${url}`; case "avif": case "bmp": case "gif": @@ -83,35 +71,20 @@ const getIframeAttributes = async ( case "png": case "svg": case "webp": - return { - src: `https://source-cooperative.github.io/image-viewer/?url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/image-viewer/?url=${url}`; case "pdf": - return { - src: `https://source-cooperative.github.io/pdf-viewer/?url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/pdf-viewer/?url=${url}`; case "glb": case "gltf": case "obj": case "stl": - return { - src: `https://source-cooperative.github.io/model-viewer/?url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/model-viewer/?url=${url}`; case "zip": - return { - src: `https://source-cooperative.github.io/zip-viewer/?url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/zip-viewer/?url=${url}`; case "json": case "jsonl": case "ndjson": - return { - src: `https://source-cooperative.github.io/json-viewer/?url=${url}`, - style: { border: "1px solid var(--gray-5)" }, - }; + return `https://source-cooperative.github.io/json-viewer/?url=${url}`; default: return null; } @@ -125,43 +98,30 @@ export async function ObjectPreviewExternal(props: ObjectPreviewExternalProps) { return null; } - const iframeProps = await getIframeAttributes(cloudUri, extension); - if (!iframeProps) { + const src = await getIframeSrc(cloudUri, extension); + if (!src) { return ( - -

- No preview available for file type .{extension}.{" "} - - Open an issue - {" "} - if you would like support for this file type. -

-
+

+ No preview available for file type .{extension}.{" "} + + Open an issue + {" "} + if you would like support for this file type. +

); } - const { src, style } = iframeProps; return ( - - - - - Open in new tab - - - - - - + ); } diff --git a/src/components/features/products/object-browser/ObjectPreviewInternal.tsx b/src/components/features/products/object-browser/ObjectPreviewInternal.tsx index 0d69175e..e4f56c50 100644 --- a/src/components/features/products/object-browser/ObjectPreviewInternal.tsx +++ b/src/components/features/products/object-browser/ObjectPreviewInternal.tsx @@ -1,4 +1,3 @@ -import { Box } from "@radix-ui/themes"; import { LOGGER } from "@/lib"; import { getStorageClient } from "@/lib/clients/storage"; import { MarkdownViewer } from "@/components/features/markdown/MarkdownViewer"; @@ -63,14 +62,11 @@ export async function ObjectPreviewInternal(props: ObjectPreviewInternalProps) { return null; } - const extension = getExtension(props.object_path); - return ( - - {extension === "md" || extension === "markdown" ? ( - - ) : ( - - )} - - ); + switch (getExtension(props.object_path)) { + case "md": + case "markdown": + return ; + default: + return ; + } } diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts new file mode 100644 index 00000000..2b678a7a --- /dev/null +++ b/src/lib/clients/analytics/index.test.ts @@ -0,0 +1,628 @@ +/** + * Tests for the Analytics Engine client: SQL construction (escaping, sampling + * weights, filters), response parsing/zero-filling, and top-N/"Other" math. + * The SQL API itself is mocked at the fetch layer. + */ +import { + getUsage, + getAdminBreakdown, + getProductBreakdowns, + USAGE_DAYS, +} from "./index"; +import { CONFIG } from "@/lib/config"; + +jest.mock("next/cache", () => ({ + unstable_cache: (fn: unknown) => fn, +})); + +jest.mock("@/lib/config", () => ({ + CONFIG: { + analytics: { + accountId: "cf-account", + apiToken: "cf-token", + dataset: "test_dataset", + }, + environment: { isDevelopment: false, isTest: true, stage: "test" }, + // logging.ts reads this at module load + auth: { accessToken: "test-token" }, + }, +})); + +const fetchMock = jest.fn(); +global.fetch = fetchMock as unknown as typeof fetch; + +function jsonResponse(rows: Record[]) { + return { + ok: true, + json: async () => ({ meta: [], data: rows, rows: rows.length }), + }; +} + +/** The SQL strings sent to the API, in call order. */ +const sentSql = () => fetchMock.mock.calls.map((call) => call[1].body as string); + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(jsonResponse([])); +}); + +/** AE DateTime string for the start of the current UTC day. */ +function todayUtc(): string { + const d = new Date(new Date().setUTCHours(0, 0, 0, 0)); + return d.toISOString().replace("T", " ").replace(".000Z", ""); +} + +/** YYYY-MM-DD for the UTC day n days ago. */ +const isoDaysAgo = (n: number) => + new Date(new Date().setUTCHours(0, 0, 0, 0) - n * 86_400_000) + .toISOString() + .slice(0, 10); + +/** Inclusive single-day range covering today (hourly buckets). */ +const TODAY_RANGE = { from: isoDaysAgo(0), to: isoDaysAgo(0) }; + +describe("getUsage", () => { + it("queries with sampling weights and served-bytes filters", async () => { + await getUsage("acct", "prod"); + + const [seriesSql, windowSql, ipsSql, registeredSql] = sentSql(); + for (const sql of [seriesSql, windowSql, ipsSql, registeredSql]) { + // Float literals: AE 422s on Double-vs-Integer comparisons. + expect(sql).toContain("blob4 = 'GET' AND double2 IN (200.0, 206.0)"); + expect(sql).toContain("blob1 = 'acct'"); + expect(sql).toContain("blob2 = 'prod'"); + // Day-aligned window: today (partial) + USAGE_DAYS-1 full UTC days, + // identical for series, totals, and breakdowns. + expect(sql).toContain( + `timestamp >= toStartOfDay(NOW() - INTERVAL '${USAGE_DAYS - 1}' DAY)`, + ); + expect(sql).toContain("FROM test_dataset"); + } + expect(seriesSql).toContain("toStartOfDay(timestamp)"); + expect(seriesSql).toContain("SUM(_sample_interval * double1) AS bytes"); + expect(seriesSql).toContain("SUM(_sample_interval) AS requests"); + expect(windowSql).toContain("COUNT(DISTINCT blob6) AS countries"); + expect(windowSql).toContain("sumIf(_sample_interval, blob5 = '') AS anon_requests"); + expect(windowSql).not.toContain("GROUP BY"); + expect(ipsSql).toContain("blob8 != ''"); + expect(ipsSql).toContain("GROUP BY ip"); + expect(registeredSql).toContain("COUNT(DISTINCT blob5) AS registered"); + expect(registeredSql).toContain("blob5 != ''"); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe("Bearer cf-token"); + }); + + it("escapes quotes, backslashes, and control chars in values", async () => { + await getUsage("a'; DROP--", "pr\\od", "dir/we'ird\u0000.txt"); + + const sql = sentSql()[0]; + expect(sql).toContain("blob1 = 'a\\'; DROP--'"); + expect(sql).toContain("blob2 = 'pr\\\\od'"); + expect(sql).toContain("blob3 = 'dir/we\\'ird.txt'"); + }); + + it("truncates the object path filter to 256 bytes like the data proxy", async () => { + // 300 two-byte chars: proxy stores the first 256 bytes = 128 chars. + await getUsage("acct", "prod", "é".repeat(300)); + expect(sentSql()[0]).toContain(`blob3 = '${"é".repeat(128)}'`); + }); + + it("zero-fills the day grid, coerces strings, and buckets user frequency", async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse([ + // UInt64 aggregates arrive as strings in the JSON format + { day: todayUtc(), bytes: 1024, requests: "7", countries: 2 }, + ]), + ) + .mockResolvedValueOnce( + jsonResponse([{ countries: 2, anon_requests: "5" }]), + ) + .mockResolvedValueOnce( + jsonResponse([ + { ip: "h1", requests: 1 }, + { ip: "h2", requests: "3" }, + { ip: "h3", requests: 7 }, + { ip: "h4", requests: 25 }, + // sampled fraction rounds down to 0 → floored into the 1× bucket + { ip: "h5", requests: 0.4 }, + ]), + ) + .mockResolvedValueOnce(jsonResponse([{ registered: "2" }])); + + const usage = await getUsage("acct", "prod"); + + expect(usage).not.toBeNull(); + expect(usage!.days).toHaveLength(USAGE_DAYS); + const today = usage!.days[USAGE_DAYS - 1]; + expect(today).toMatchObject({ bytes: 1024, requests: 7, countries: 2 }); + // Every earlier day is zero-filled + expect(usage!.days[0]).toMatchObject({ bytes: 0, requests: 0 }); + expect(usage!.totals).toEqual({ bytes: 1024, requests: 7, countries: 2 }); + // Quasi-log bins: the 0.4 sampled fraction floors into "1" alongside + // the exact-1 IP; 3 → "3–5", 7 → "6–10", 25 → "11–25"; the rest zero. + expect(usage!.users).toEqual({ + uniqueIps: 5, + registered: 2, + anonRequests: 5, + distribution: [ + { label: "1", ips: 2 }, + { label: "2", ips: 0 }, + { label: "3–5", ips: 1 }, + { label: "6–10", ips: 1 }, + { label: "11–25", ips: 1 }, + { label: "26–50", ips: 0 }, + { label: "51–100", ips: 0 }, + { label: "101–250", ips: 0 }, + { label: "251–1K", ips: 0 }, + { label: "1K+", ips: 0 }, + ], + }); + }); + + it("applies the requested window to the queries and the grid", async () => { + const usage = await getUsage("acct", "prod", undefined, 7); + expect(sentSql()[0]).toContain("toStartOfDay(NOW() - INTERVAL '6' DAY)"); + expect(usage!.days).toHaveLength(7); + }); + + it("returns null when analytics is not configured", async () => { + const token = CONFIG.analytics.apiToken; + (CONFIG.analytics as { apiToken: string }).apiToken = ""; + try { + expect(await getUsage("acct", "prod")).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + (CONFIG.analytics as { apiToken: string }).apiToken = token; + } + }); + + it("returns null when the query fails", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + text: async () => "boom", + }); + expect(await getUsage("acct", "prod")).toBeNull(); + }); +}); + +describe("getProductBreakdowns", () => { + it("ranks countries with an others aggregate and lists top files", async () => { + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + const sql = init.body; + if (sql.includes("GROUP BY country")) { + return jsonResponse([ + { country: "US", requests: 100 }, + { country: "DE", requests: 50 }, + { country: "BR", requests: 40 }, + { country: "GB", requests: 30 }, + { country: "IN", requests: "20" }, + { country: "FR", requests: 10 }, + { country: "", requests: 5 }, + ]); + } + return jsonResponse([ + { file: "a.tif", requests: 60, bytes: 1000 }, + { file: "b.json", requests: "40", bytes: "500" }, + ]); + }); + + const breakdowns = await getProductBreakdowns("acct", "prod", 30); + + expect(breakdowns!.countries).toHaveLength(5); + expect(breakdowns!.countries[0]).toEqual({ + code: "US", + name: "United States", + requests: 100, + }); + expect(breakdowns!.otherCountries).toEqual({ count: 2, requests: 15 }); + expect(breakdowns!.files).toEqual([ + { path: "a.tif", requests: 60, bytes: 1000 }, + { path: "b.json", requests: 40, bytes: 500 }, + ]); + + const fileSql = sentSql().find((sql) => sql.includes("GROUP BY file")); + expect(fileSql).toContain("ORDER BY requests DESC"); + expect(fileSql).toContain("LIMIT 10"); + expect(fileSql).toContain("blob1 = 'acct'"); + // Keyless product GETs (blob3 = '') are probes/listings, not files + expect(fileSql).toContain("blob3 != ''"); + }); + + it("returns no others aggregate when few countries", async () => { + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + return jsonResponse( + init.body.includes("GROUP BY country") + ? [{ country: "US", requests: 10 }] + : [], + ); + }); + const breakdowns = await getProductBreakdowns("acct", "prod", 7); + expect(breakdowns!.otherCountries).toBeNull(); + expect(breakdowns!.files).toEqual([]); + }); + + it("returns null when the query fails", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + text: async () => "boom", + }); + expect(await getProductBreakdowns("acct", "prod", 30)).toBeNull(); + }); +}); + +describe("getAdminBreakdown", () => { + it("returns a single 'All traffic' series when not grouping", async () => { + const bucket = todayUtc(); + fetchMock.mockResolvedValue( + jsonResponse([{ bucket, bytes: 500, requests: "5" }]), + ); + + const breakdown = await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [] }); + + // Bucket totals plus the headline distinct-counts query + expect(fetchMock).toHaveBeenCalledTimes(2); + const sql = sentSql()[0]; + // Single-day range → hourly buckets from today's UTC midnight, no upper bound + expect(sql).toContain("toStartOfInterval(timestamp, INTERVAL '1' HOUR)"); + expect(sql).toContain("timestamp >= toStartOfDay(NOW() - INTERVAL '0' DAY)"); + expect(sql).not.toContain("timestamp <"); + expect(sentSql()[1]).toContain("COUNT(DISTINCT blob6)"); + expect(sentSql()[1]).toContain("COUNT(DISTINCT blob8)"); + + expect(breakdown!.range).toEqual(TODAY_RANGE); + expect(breakdown!.series).toEqual(["All traffic"]); + expect(breakdown!.totals).toEqual({ + bytes: 500, + requests: 5, + uniqueIps: 0, + countries: 0, + }); + // Hourly buckets: midnight through the in-progress hour + expect(breakdown!.buckets.length).toBeGreaterThanOrEqual(1); + expect(breakdown!.buckets.length).toBeLessThanOrEqual(25); + const filled = breakdown!.points.filter((p) => p["All traffic"]); + expect(filled).toEqual([{ "All traffic": { bytes: 500, requests: 5 } }]); + }); + + it("applies dimension filters over a week range", async () => { + await getAdminBreakdown({ + from: isoDaysAgo(6), + to: isoDaysAgo(0), + groupBy: [], + filters: { + account: "ft'w", + product: "global", + country: "us", + client: "ab%_c", + }, + }); + const sql = sentSql()[0]; + expect(sql).toContain("blob1 = 'ft\\'w'"); + expect(sql).toContain("blob2 = 'global'"); + // Country codes are stored uppercase + expect(sql).toContain("blob6 = 'US'"); + // IP hashes prefix-match (the UI shows 12-char prefixes), with LIKE + // wildcards in the value escaped + expect(sql).toContain("blob8 LIKE 'ab\\\\%\\\\_c%'"); + expect(sql).toContain("timestamp >= toStartOfDay(NOW() - INTERVAL '6' DAY)"); + expect(sql).toContain("toStartOfInterval(timestamp, INTERVAL '6' HOUR)"); + }); + + it("honors a whitelisted sum interval and escalates unreadable ones", async () => { + await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [], bucketMinutes: 360 }); + expect(sentSql()[0]).toContain("toStartOfInterval(timestamp, INTERVAL '6' HOUR)"); + + fetchMock.mockClear(); + // Hourly over ~92 days would be ~2,200 bars — escalates until drawable. + await getAdminBreakdown({ + from: isoDaysAgo(91), + to: isoDaysAgo(0), + groupBy: [], + bucketMinutes: 60, + }); + expect(sentSql()[0]).toContain("toStartOfInterval(timestamp, INTERVAL '6' HOUR)"); + + fetchMock.mockClear(); + // Minute buckets over a full day would be 1,440 bars — escalates to the + // 15-minute ladder rung, via AE's dedicated function. + const escalated = await getAdminBreakdown({ + ...TODAY_RANGE, + groupBy: [], + bucketMinutes: 1, + }); + expect(sentSql()[0]).toContain("toStartOfFifteenMinutes(timestamp)"); + expect(escalated!.bucketMinutes).toBe(15); + + fetchMock.mockClear(); + // Non-whitelisted values fall back to auto (hourly for a single day). + await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [], bucketMinutes: 5 }); + expect(sentSql()[0]).toContain("toStartOfInterval(timestamp, INTERVAL '1' HOUR)"); + }); + + it("collapses midnight-aligned datetime bounds to the day-grained path", async () => { + // The filter form always submits datetime-local values; a pair landing + // on midnights is a plain day range and keeps the proven SQL forms. + const breakdown = await getAdminBreakdown({ + from: `${isoDaysAgo(7)}T00:00`, + to: `${isoDaysAgo(0)}T00:00`, // exclusive → through yesterday + groupBy: [], + }); + const sql = sentSql()[0]; + expect(sql).toContain("toStartOfDay(NOW() - INTERVAL '7' DAY)"); + expect(sql).not.toContain("toDateTime"); + expect(breakdown!.range).toEqual({ + from: isoDaysAgo(7), + to: isoDaysAgo(1), + }); + }); + + it("bounds sub-day drill ranges with toDateTime and buckets by minute", async () => { + const day = isoDaysAgo(1); + const breakdown = await getAdminBreakdown({ + from: `${day}T13:00`, + to: `${day}T14:00`, + groupBy: [], + bucketMinutes: 1, + }); + + const sql = sentSql()[0]; + expect(sql).toContain(`timestamp >= toDateTime('${day} 13:00:00')`); + expect(sql).toContain(`timestamp < toDateTime('${day} 14:00:00')`); + expect(sql).toContain("toStartOfMinute(timestamp) AS bucket"); + expect(breakdown!.bucketMinutes).toBe(1); + expect(breakdown!.range).toEqual({ + from: `${day}T13:00`, + to: `${day}T14:00`, + }); + // Zero-filled minute grid over the drilled hour + expect(breakdown!.buckets).toHaveLength(60); + expect(breakdown!.buckets[0]).toBe(`${day}T13:00:00.000Z`); + expect(breakdown!.buckets[59]).toBe(`${day}T13:59:00.000Z`); + }); + + it("folds daily SQL buckets into weekly buckets aligned to the range start", async () => { + // AE degrades >24h toStartOfInterval to daily buckets, so week buckets + // are assembled here from daily rows. + const aeDay = (n: number) => `${isoDaysAgo(n)} 00:00:00`; + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + const sql = init.body; + if (sql.includes("GROUP BY bucket, blob1, blob2")) { + return jsonResponse([ + { bucket: aeDay(13), blob1: "a1", blob2: "p1", bytes: 100, requests: 1 }, + { bucket: aeDay(12), blob1: "a1", blob2: "p1", bytes: 50, requests: 1 }, + ]); + } + if (sql.includes("GROUP BY bucket")) { + return jsonResponse([ + { bucket: aeDay(13), bytes: 120, requests: 2 }, + { bucket: aeDay(12), bytes: 60, requests: 1 }, + { bucket: aeDay(5), bytes: 30, requests: 1 }, + ]); + } + if (sql.includes("COUNT(DISTINCT")) { + // '' present among the hashes (no_ip > 0) → one distinct value dropped + return jsonResponse([{ countries: 3, ips: 5, no_ip: 10 }]); + } + return jsonResponse([{ blob1: "a1", blob2: "p1", bytes: 150, requests: 2 }]); + }); + + const breakdown = await getAdminBreakdown({ + from: isoDaysAgo(13), + to: isoDaysAgo(0), + groupBy: ["product"], + bucketMinutes: 10080, + }); + + expect(sentSql()[0]).toContain("toStartOfDay(timestamp) AS bucket"); + expect(sentSql()[0]).not.toContain("toStartOfInterval"); + expect(breakdown!.bucketMinutes).toBe(10080); + // Two week buckets starting at `from`, not at epoch-aligned Thursdays + expect(breakdown!.buckets).toEqual([ + `${isoDaysAgo(13)}T00:00:00.000Z`, + `${isoDaysAgo(6)}T00:00:00.000Z`, + ]); + // Days 13+12 accumulate into week one; Other = bucket total - charted + expect(breakdown!.points).toEqual([ + { + "a1/p1": { bytes: 150, requests: 2 }, + Other: { bytes: 30, requests: 1 }, + }, + { Other: { bytes: 30, requests: 1 } }, + ]); + expect(breakdown!.totals).toEqual({ + bytes: 210, + requests: 4, + uniqueIps: 4, + countries: 3, + }); + }); + + it("swaps reversed bounds and bounds ranges that end before today", async () => { + const breakdown = await getAdminBreakdown({ + from: isoDaysAgo(3), + to: isoDaysAgo(10), + groupBy: [], + }); + const sql = sentSql()[0]; + expect(sql).toContain("timestamp >= toStartOfDay(NOW() - INTERVAL '10' DAY)"); + // Exclusive upper bound: the start of the day after `to` (3 days ago) + expect(sql).toContain("timestamp < toStartOfDay(NOW() - INTERVAL '2' DAY)"); + // 8-day range → daily buckets, via the proven toStartOfDay form + expect(sql).toContain("toStartOfDay(timestamp) AS bucket"); + expect(breakdown!.range).toEqual({ from: isoDaysAgo(10), to: isoDaysAgo(3) }); + }); + + it("ranks groups, charts the top slice, and derives Other from totals", async () => { + const bucket = todayUtc(); + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + const sql = init.body; + if (sql.includes("GROUP BY bucket, blob1, blob2")) { + // Timeseries for the charted groups only + return jsonResponse([ + { bucket, blob1: "a1", blob2: "p1", bytes: 600, requests: 6 }, + { bucket, blob1: "a2", blob2: "p2", bytes: 300, requests: 3 }, + ]); + } + if (sql.includes("GROUP BY bucket")) { + // Overall per-bucket totals (includes long-tail traffic) + return jsonResponse([{ bucket, bytes: 1000, requests: 10 }]); + } + if (sql.includes("COUNT(DISTINCT")) { + // No '' hash seen (no_ip = 0) → nothing subtracted + return jsonResponse([{ countries: 7, ips: 4, no_ip: 0 }]); + } + // Ranked totals per group + return jsonResponse([ + { blob1: "a1", blob2: "p1", bytes: 600, requests: 6 }, + { blob1: "a2", blob2: "p2", bytes: 300, requests: 3 }, + ]); + }); + + const breakdown = await getAdminBreakdown({ + ...TODAY_RANGE, + groupBy: ["product"], + }); + + // The timeseries query is filtered to the charted groups + const seriesSql = sentSql().find((sql) => + sql.includes("GROUP BY bucket, blob1, blob2"), + ); + expect(seriesSql).toContain("(blob1 = 'a1' AND blob2 = 'p1')"); + expect(seriesSql).toContain("(blob1 = 'a2' AND blob2 = 'p2')"); + + // Ranking follows the metric — requests by default + const rankSql = sentSql().find( + (sql) => sql.includes("GROUP BY blob1, blob2") && sql.includes("LIMIT"), + ); + expect(rankSql).toContain("ORDER BY requests DESC"); + + expect(breakdown!.series).toEqual(["a1/p1", "a2/p2", "Other"]); + expect(breakdown!.totals).toEqual({ + bytes: 1000, + requests: 10, + uniqueIps: 4, + countries: 7, + }); + + const point = breakdown!.points.find((p) => p["a1/p1"]); + expect(point).toEqual({ + "a1/p1": { bytes: 600, requests: 6 }, + "a2/p2": { bytes: 300, requests: 3 }, + Other: { bytes: 100, requests: 1 }, + }); + + // Table rows: ranked groups plus the beyond-top-N remainder + expect(breakdown!.groups).toEqual([ + { key: "a1/p1", bytes: 600, requests: 6 }, + { key: "a2/p2", bytes: 300, requests: 3 }, + { key: "Other", bytes: 100, requests: 1 }, + ]); + }); + + it("ranks by bytes when that metric is selected", async () => { + await getAdminBreakdown({ + ...TODAY_RANGE, + groupBy: ["account"], + metric: "bytes", + }); + const rankSql = sentSql().find( + (sql) => sql.includes("GROUP BY blob1") && sql.includes("LIMIT"), + ); + expect(rankSql).toContain("ORDER BY bytes DESC"); + }); + + it("keeps a requests-only Other remainder visible", async () => { + const bucket = todayUtc(); + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + const sql = init.body; + if (sql.includes("GROUP BY bucket, blob1")) { + return jsonResponse([{ bucket, blob1: "a1", bytes: 500, requests: 5 }]); + } + if (sql.includes("GROUP BY bucket")) { + // Long tail served zero-byte responses: bytes covered, requests not. + return jsonResponse([{ bucket, bytes: 500, requests: 9 }]); + } + return jsonResponse([{ blob1: "a1", bytes: 500, requests: 5 }]); + }); + + const breakdown = await getAdminBreakdown({ + ...TODAY_RANGE, + groupBy: ["account"], + }); + + expect(breakdown!.series).toEqual(["a1", "Other"]); + const point = breakdown!.points.find((p) => p.Other); + expect(point!.Other).toEqual({ bytes: 0, requests: 4 }); + expect(breakdown!.groups).toEqual([ + { key: "a1", bytes: 500, requests: 5 }, + { key: "Other", bytes: 0, requests: 4 }, + ]); + }); + + it("composes keys across multiple group-bys", async () => { + const bucket = todayUtc(); + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + const sql = init.body; + if (sql.includes("GROUP BY bucket, blob1, blob6")) { + return jsonResponse([ + { bucket, blob1: "a1", blob6: "US", bytes: 100, requests: 1 }, + ]); + } + if (sql.includes("GROUP BY bucket")) { + return jsonResponse([{ bucket, bytes: 100, requests: 1 }]); + } + return jsonResponse([{ blob1: "a1", blob6: "US", bytes: 100, requests: 1 }]); + }); + + const breakdown = await getAdminBreakdown({ + ...TODAY_RANGE, + groupBy: ["account", "country"], + }); + expect(breakdown!.series).toEqual(["a1 · United States (US)"]); + }); + + it("labels empty dimension values as unknown", async () => { + const bucket = todayUtc(); + fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { + const sql = init.body; + if (sql.includes("GROUP BY bucket, blob6")) { + return jsonResponse([{ bucket, blob6: "", bytes: 50, requests: 1 }]); + } + if (sql.includes("GROUP BY bucket")) { + return jsonResponse([{ bucket, bytes: 50, requests: 1 }]); + } + return jsonResponse([{ blob6: "", bytes: 50, requests: 1 }]); + }); + + const breakdown = await getAdminBreakdown({ + ...TODAY_RANGE, + groupBy: ["country"], + }); + expect(breakdown!.series).toEqual(["(unknown)"]); + }); + + it("returns null when analytics is not configured", async () => { + const token = CONFIG.analytics.apiToken; + (CONFIG.analytics as { apiToken: string }).apiToken = ""; + try { + expect(await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [] })).toBeNull(); + } finally { + (CONFIG.analytics as { apiToken: string }).apiToken = token; + } + }); + + it("propagates query failures", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + text: async () => "denied", + }); + await expect( + getAdminBreakdown({ ...TODAY_RANGE, groupBy: ["account"] }), + ).rejects.toThrow("Analytics Engine query failed (403)"); + }); +}); diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts new file mode 100644 index 00000000..8690f4be --- /dev/null +++ b/src/lib/clients/analytics/index.ts @@ -0,0 +1,782 @@ +/** + * Cloudflare Analytics Engine client for data-proxy request analytics. + * + * The data proxy (data.source.coop) writes one event per request with this + * schema (see its src/analytics.rs): + * + * blob1: account_id blob6: country + * blob2: product_id blob7: content_type + * blob3: file_path blob8: client_ip_hash (empty if IP unknown) + * blob4: method blob9: range header + * blob5: user_id double1: bytes_sent + * double2: status_code + * double3: duration_ms + * + * Analytics Engine samples writes, so every count/sum must be weighted by + * `_sample_interval`; COUNT(DISTINCT …) is the best available estimate for + * uniques. It has no parameterized queries — every interpolated string goes + * through sqlQuote(), and windows/buckets/columns come from whitelists only. + * + * Server-only: queries run over the SQL API with an account-level token. + */ +import "server-only"; +import { unstable_cache } from "next/cache"; +import { CONFIG } from "@/lib/config"; +import { LOGGER } from "@/lib/logging"; +import { withTimeout } from "@/lib/with-timeout"; + +// Whole weeks only: a 30-day window sometimes holds 8 weekend days and +// sometimes 10, aliasing day-of-week patterns into period comparisons. +export const USAGE_DAYS = 28; + +/** Windows offered on the product analytics page (bounded by AE retention). */ +export const USAGE_WINDOWS = [7, 28, 91] as const; +export type UsageWindow = (typeof USAGE_WINDOWS)[number]; + +export interface UsageTotals { + bytes: number; + requests: number; // downloads: successful GETs, sample-weighted + countries: number; +} + +export interface UsagePoint extends UsageTotals { + /** ISO timestamp of the UTC day start */ + date: string; +} + +/** + * Downloads-per-IP histogram bins. Quasi-log edges: the distribution is + * heavy-tailed (a 1× spike and a tail spanning orders of magnitude), so + * linear bins waste the axis on empty slots and pool the tail into one + * lump. Last bin is open-ended. + */ +export const FREQUENCY_BINS = [ + { label: "1", max: 1 }, + { label: "2", max: 2 }, + { label: "3–5", max: 5 }, + { label: "6–10", max: 10 }, + { label: "11–25", max: 25 }, + { label: "26–50", max: 50 }, + { label: "51–100", max: 100 }, + { label: "101–250", max: 250 }, + { label: "251–1K", max: 1000 }, + { label: "1K+", max: Infinity }, +] as const; + +export interface UsageUsers { + /** Distinct client IP hashes (blob8) that downloaded in the window */ + uniqueIps: number; + /** Distinct signed-in users (blob5) that downloaded in the window */ + registered: number; + /** Sample-weighted requests with no signed-in user */ + anonRequests: number; + /** Unique IPs per FREQUENCY_BINS downloads-per-IP bin, zero-filled */ + distribution: { label: string; ips: number }[]; +} + +export interface Usage { + /** One point per UTC day, oldest first, zero-filled — always USAGE_DAYS long */ + days: UsagePoint[]; + totals: UsageTotals; + users: UsageUsers; +} + +const DAY_MS = 86_400_000; +/** Analytics Engine retains roughly three months of events. */ +export const RETENTION_DAYS = 92; + +export const ADMIN_DIMENSIONS = { + account: { label: "Account", columns: ["blob1"] }, + product: { label: "Product", columns: ["blob1", "blob2"] }, + country: { label: "Country", columns: ["blob6"] }, + // "IP hash", not "Client" — client reads as the client_id/User-Agent header + client: { label: "IP hash", columns: ["blob8"] }, +} as const; +export type AdminDimension = keyof typeof ADMIN_DIMENSIONS; + +/** + * How many series the stacked chart shows before folding into "Other" — + * matches the fixed categorical palette size (hues are never cycled). + */ +const CHART_SERIES_LIMIT = 6; +/** How many rows the ranked totals table shows. */ +const TABLE_GROUP_LIMIT = 25; +export const OTHER_KEY = "Other"; + +/** + * Ceiling on chart bars: beyond this the SVG gets unwieldy (stacked rects × + * series). Intervals that would exceed it are escalated to the next size. + */ +export const MAX_CHART_BUCKETS = 400; + +/** Sum intervals selectable in the admin explorer, in minutes. */ +export const BUCKET_INTERVALS = [ + { minutes: 1, label: "Minute" }, + { minutes: 60, label: "Hourly" }, + { minutes: 360, label: "6-hour" }, + { minutes: 1440, label: "Daily" }, + { minutes: 10080, label: "Weekly" }, +] as const; + +export interface AdminQuery { + /** + * UTC day "YYYY-MM-DD" (inclusive) or UTC instant "YYYY-MM-DDTHH:MM" + * (as `to`, the exclusive end). Invalid/out-of-range values are clamped. + */ + from: string; + to: string; + /** Sum interval override (a BUCKET_INTERVALS minutes value); omit for auto */ + bucketMinutes?: number; + /** Ranking metric: orders the groups table and picks the charted slice */ + metric?: "bytes" | "requests"; + groupBy: AdminDimension[]; + /** Per-dimension value filters (client = IP-hash prefix match) */ + filters?: Partial>; +} + +export interface AdminBreakdown { + /** ISO bucket-start timestamps, oldest first, zero-filled */ + buckets: string[]; + bucketMinutes: number; + /** Chart series keys ranked by bytes desc; last may be OTHER_KEY */ + series: string[]; + /** Per bucket, bytes/requests per series key (absent key = zero) */ + points: Record[]; + /** Ranked totals for the table (top groups, then optionally OTHER_KEY) */ + groups: { key: string; bytes: number; requests: number }[]; + /** Whole-range headline stats; the uniques are sampling estimates */ + totals: { + bytes: number; + requests: number; + uniqueIps: number; + countries: number; + }; + /** + * The resolved (validated/clamped) range actually queried, echoing the + * param grammar: day-grained "YYYY-MM-DD" pairs are inclusive, while + * time-grained "YYYY-MM-DDTHH:MM" pairs carry an exclusive `to`. + */ + range: { from: string; to: string }; + /** The SQL statements executed, for the admin "show SQL" viewer */ + queries: string[]; +} + +/** + * Only count responses that actually served product bytes: HEAD responses + * carry a Content-Length that the proxy logs as bytes_sent without a body, + * and listing requests have no product segment (blob2 = ''). + * + * Float literals are load-bearing: AE's type checker rejects comparing the + * Double column double2 against Integer literals (422 "IN expression types + * must be consistent"). + */ +const SERVED_FILTER = + "blob4 = 'GET' AND double2 IN (200.0, 206.0) AND blob2 != ''"; + +export function isAnalyticsConfigured(): boolean { + const { accountId, apiToken, dataset } = CONFIG.analytics; + return Boolean(accountId && apiToken && dataset); +} + +/** Quote a string for an Analytics Engine SQL literal (no parameterized queries exist). */ +function sqlQuote(value: string): string { + const cleaned = value + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f]/g, "") + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'"); + return `'${cleaned}'`; +} + +/** + * Truncate to `maxBytes` UTF-8 bytes on a char boundary — mirrors the data + * proxy's truncation of blob3, so filters match what was actually stored. + */ +function truncateToByteLimit(s: string, maxBytes: number): string { + const bytes = new TextEncoder().encode(s); + if (bytes.length <= maxBytes) return s; + return new TextDecoder() + .decode(bytes.slice(0, maxBytes)) + .replace(/�+$/, ""); +} + +const num = (v: unknown): number => { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : 0; +}; + +const str = (v: unknown): string => (typeof v === "string" ? v : ""); + +/** Parse an Analytics Engine DateTime ("2026-07-06 12:00:00") as UTC ISO. */ +function parseDateTime(v: unknown): string { + return new Date(`${str(v).replace(" ", "T")}Z`).toISOString(); +} + +type Row = Record; + +async function runQuery(sql: string): Promise { + const { accountId, apiToken } = CONFIG.analytics; + // withTimeout: a hung Analytics Engine API must not hold the page's + // Suspense boundary (and the serverless invocation) open indefinitely. + const res = await withTimeout( + fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`, + { + method: "POST", + headers: { Authorization: `Bearer ${apiToken}` }, + body: sql, + // The Analytics Engine response is cached by the unstable_cache + // wrappers below, not by the fetch data cache. + cache: "no-store", + }, + ), + 15_000, + "Analytics Engine query timed out", + ); + if (!res.ok) { + throw new Error( + `Analytics Engine query failed (${res.status}): ${(await res.text()).slice(0, 500)}`, + ); + } + const body = (await res.json()) as { data?: Row[] }; + return body.data ?? []; +} + +// unstable_cache includes the function arguments (the SQL string) in its key, +// so each (account, product, object, window) caches independently. +// +// Product/object stats are non-dynamic — 30-day aggregates barely move, so +// they rerun at most every 4 hours. The admin explorer is an interactive +// surface with a 24h window, so it stays comparatively fresh. +const usageQuery = unstable_cache(runQuery, ["analytics-usage"], { + revalidate: 4 * 3600, +}); +const adminQuery = unstable_cache(runQuery, ["analytics-admin"], { + revalidate: 900, +}); + +const USAGE_AGGREGATES = ` + SUM(_sample_interval * double1) AS bytes, + SUM(_sample_interval) AS requests, + COUNT(DISTINCT blob6) AS countries`; + +/** + * Shared FROM/WHERE for the usage queries. The window is day-aligned in SQL + * — today (partial) plus days-1 full UTC days — so the day grid, headline + * totals, and the country/file breakdowns all cover the identical span. (A + * rolling NOW()-Nd window would include the tail of an extra calendar day + * that the day grid drops, making breakdown sums exceed the headline.) + */ +function usageFrom( + accountId: string, + productId: string, + objectPath: string | undefined, + days: UsageWindow, +): string { + const filters = [ + SERVED_FILTER, + `timestamp >= toStartOfDay(NOW() - INTERVAL '${days - 1}' DAY)`, + `blob1 = ${sqlQuote(accountId)}`, + `blob2 = ${sqlQuote(productId)}`, + ]; + if (objectPath !== undefined) { + filters.push(`blob3 = ${sqlQuote(truncateToByteLimit(objectPath, 256))}`); + } + return `FROM ${CONFIG.analytics.dataset} WHERE ${filters.join(" AND ")}`; +} + +/** + * Recent usage (USAGE_DAYS) for a product, or a single object when `objectPath` is given. + * Returns null when analytics is unconfigured or the query fails — callers + * render nothing rather than breaking the page. + */ +export async function getUsage( + accountId: string, + productId: string, + objectPath?: string, + days: UsageWindow = USAGE_DAYS, +): Promise { + if (!isAnalyticsConfigured()) return null; + + const from = usageFrom(accountId, productId, objectPath, days); + + try { + const [seriesRows, windowRows, ipRows, registeredRows] = await Promise.all([ + usageQuery( + `SELECT toStartOfDay(timestamp) AS day, ${USAGE_AGGREGATES} ${from} GROUP BY day ORDER BY day`, + ), + // Separate query: window-wide DISTINCT can't be summed from days. + usageQuery( + `SELECT COUNT(DISTINCT blob6) AS countries, sumIf(_sample_interval, blob5 = '') AS anon_requests ${from}`, + ), + // Sample-weighted request count per unique client IP hash, for the + // download-frequency histogram (blob8 is empty when the IP is unknown). + // ponytail: capped at AE's ~10k response rows — a product with more + // unique IPs in the window gets an (arbitrary, roughly unbiased) + // sample; the histogram is labeled an estimate anyway. + usageQuery( + `SELECT blob8 AS ip, SUM(_sample_interval) AS requests ${from} AND blob8 != '' GROUP BY ip`, + ), + usageQuery( + `SELECT COUNT(DISTINCT blob5) AS registered ${from} AND blob5 != ''`, + ), + ]); + + const byDay = new Map( + seriesRows.map((row) => [parseDateTime(row.day), row]), + ); + const todayStart = new Date().setUTCHours(0, 0, 0, 0); + const points: UsagePoint[] = Array.from({ length: days }, (_, i) => { + const date = new Date( + todayStart - (days - 1 - i) * 86_400_000, + ).toISOString(); + const row = byDay.get(date) ?? {}; + return { date, ...parseUsageAggregates(row) }; + }); + + // Additive totals come from the grid days, so bars and headline always + // agree; the extra (off-grid) partial day the SQL window touches is + // dropped with them. The uniques (`countries` here, the users counts + // below) can't be re-summed from days, so they keep that sliver — + // they're estimates over a marginally wider span. + const totals = points.reduce( + (acc, day) => ({ + bytes: acc.bytes + day.bytes, + requests: acc.requests + day.requests, + countries: acc.countries, + }), + { bytes: 0, requests: 0, countries: num(windowRows[0]?.countries) }, + ); + + const distribution = FREQUENCY_BINS.map(({ label }) => ({ + label, + ips: 0, + })); + for (const row of ipRows) { + // Sampling makes per-IP counts fractional estimates; round, floor 1. + const downloads = Math.max(1, Math.round(num(row.requests))); + distribution[ + FREQUENCY_BINS.findIndex((bin) => downloads <= bin.max) + ].ips += 1; + } + + return { + days: points, + totals, + users: { + // Same population as the histogram, so headline and bars agree. + uniqueIps: ipRows.length, + registered: num(registeredRows[0]?.registered), + anonRequests: num(windowRows[0]?.anon_requests), + distribution, + }, + }; + } catch (error) { + LOGGER.warn("Analytics usage query failed", { + operation: "getUsage", + context: "analytics engine", + metadata: { accountId, productId, objectPath, error: String(error) }, + }); + return null; + } +} + +function parseUsageAggregates(row: Row): UsageTotals { + return { + bytes: num(row.bytes), + requests: num(row.requests), + countries: num(row.countries), + }; +} + +const COUNTRY_LIST_LIMIT = 5; +const FILE_LIST_LIMIT = 10; + +export interface ProductBreakdowns { + /** Top countries by downloads */ + countries: { code: string; name: string; requests: number }[]; + /** Aggregate of the remaining countries, if any */ + otherCountries: { count: number; requests: number } | null; + /** Top objects by downloads */ + files: { path: string; requests: number; bytes: number }[]; +} + +/** + * By-country and top-files breakdowns for the product analytics page. + * Same contract as getUsage: null when unconfigured or the query fails. + */ +export async function getProductBreakdowns( + accountId: string, + productId: string, + days: UsageWindow, +): Promise { + if (!isAnalyticsConfigured()) return null; + const from = usageFrom(accountId, productId, undefined, days); + + try { + const [countryRows, fileRows] = await Promise.all([ + usageQuery( + `SELECT blob6 AS country, SUM(_sample_interval) AS requests ${from} GROUP BY country ORDER BY requests DESC`, + ), + // blob3 = '' is a keyless product GET (trailing-slash/probe requests, + // not a real file) — keep those out of the top-files ranking. + usageQuery( + `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 != '' GROUP BY file ORDER BY requests DESC LIMIT ${FILE_LIST_LIMIT}`, + ), + ]); + + const rest = countryRows.slice(COUNTRY_LIST_LIMIT); + return { + countries: countryRows.slice(0, COUNTRY_LIST_LIMIT).map((row) => ({ + code: str(row.country) || "??", + name: countryName(str(row.country)), + requests: num(row.requests), + })), + otherCountries: rest.length + ? { + count: rest.length, + requests: rest.reduce((sum, row) => sum + num(row.requests), 0), + } + : null, + files: fileRows.map((row) => ({ + path: str(row.file), + requests: num(row.requests), + bytes: num(row.bytes), + })), + }; + } catch (error) { + LOGGER.warn("Analytics breakdown query failed", { + operation: "getProductBreakdowns", + context: "analytics engine", + metadata: { accountId, productId, days, error: String(error) }, + }); + return null; + } +} + +const countryNames = new Intl.DisplayNames(["en"], { type: "region" }); + +/** "US" → "United States"; non-ISO values (e.g. "T1", "") fall back to the code. */ +export function countryName(code: string): string { + if (!code) return "Unknown"; + try { + return countryNames.of(code) || code; + } catch { + return code; + } +} + +/** "US" → "United States (US)"; non-ISO values (e.g. "T1") pass through. */ +function countryLabel(code: string): string { + if (!code) return "(unknown)"; + const name = countryName(code); + return name !== code ? `${name} (${code})` : code; +} + +/** Display key for one grouped row, e.g. "ftw/global-data · United States (US)". */ +function rowKey(row: Row, groupBy: AdminDimension[]): string { + return groupBy + .map((dim) => { + switch (dim) { + case "account": + return str(row.blob1); + case "product": + return `${str(row.blob1)}/${str(row.blob2)}`; + case "country": + return countryLabel(str(row.blob6)); + case "client": + // Full HMAC hex is unwieldy; 12 chars is plenty to tell clients apart. + return str(row.blob8).slice(0, 12) || "(unknown)"; + } + }) + .join(" · "); +} + +/** Parse "YYYY-MM-DD" (UTC day start) or "YYYY-MM-DDTHH:MM" (UTC instant). */ +function utcInstant( + value: string, +): { ms: number; dayGrain: boolean } | null { + const dayGrain = /^\d{4}-\d{2}-\d{2}$/.test(value); + if (!dayGrain && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return null; + const ms = Date.parse(dayGrain ? `${value}T00:00:00Z` : `${value}:00Z`); + return Number.isNaN(ms) ? null : { ms, dayGrain }; +} + +const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); +const isoMinute = (ms: number) => new Date(ms).toISOString().slice(0, 16); +/** AE DateTime literal body, e.g. "2026-07-07 13:00:00". */ +const sqlDateTime = (ms: number) => + new Date(ms).toISOString().slice(0, 19).replace("T", " "); + +/** + * WHERE clause per filterable dimension. Countries are stored as uppercase + * ISO codes. IP hashes prefix-match (LIKE is in the AE pattern-matching + * operators) because the UI surfaces only the first 12 hash characters; + * LIKE wildcards in the value are escaped. + */ +const FILTER_SQL: Record string> = { + account: (value) => `blob1 = ${sqlQuote(value)}`, + product: (value) => `blob2 = ${sqlQuote(value)}`, + country: (value) => `blob6 = ${sqlQuote(value.toUpperCase())}`, + client: (value) => + `blob8 LIKE ${sqlQuote(`${value.replace(/[\\%_]/g, (m) => `\\${m}`)}%`)}`, +}; + +/** + * Traffic over an inclusive UTC day range, bucketed for a stacked timeseries + * and grouped by zero or more dimensions. Group cardinality is unbounded + * (client IP hashes especially), so this never fetches all groups: a totals + * query finds the top groups, the timeseries query is filtered to the + * chart's top slice, and a bucket-totals query provides the "Other" + * remainder per bucket. + * + * Returns null when analytics is unconfigured; throws on query failure (the + * admin page surfaces errors rather than hiding them). + */ +export async function getAdminBreakdown( + query: AdminQuery, +): Promise { + if (!isAnalyticsConfigured()) return null; + + // Resolve the range: default to the last 7 days, clamp to [retention, + // now], swap reversed bounds. Internally the range is [fromMs, endMs) — + // a day-grained `to` is inclusive, a time-grained `to` IS the end. + const today = new Date().setUTCHours(0, 0, 0, 0); + let a = utcInstant(query.from) ?? { ms: today - 6 * DAY_MS, dayGrain: true }; + let b = utcInstant(query.to) ?? { ms: today, dayGrain: true }; + if (a.ms > b.ms) [a, b] = [b, a]; + let fromMs = a.ms; + let endMs = b.dayGrain ? b.ms + DAY_MS : b.ms; + if (endMs <= fromMs) endMs = fromMs + DAY_MS; + endMs = Math.min(endMs, today + DAY_MS); + fromMs = Math.min( + Math.max(fromMs, today - RETENTION_DAYS * DAY_MS), + endMs - 1, + ); + // Grain is decided by alignment, not input format: a datetime pair that + // lands on midnights (e.g. the filter form's T00:00 bounds) is a day + // range — it keeps the proven NOW()-relative SQL and the day-grained + // range echo. Both clamps are day-aligned, so alignment survives them. + const dayGrained = fromMs % DAY_MS === 0 && endMs % DAY_MS === 0; + + // Bucket size: an explicit whitelisted interval, else auto by range + // length. Either way escalate until the bar count stays drawable — + // hourly over 92 days would be ~2,200 stacked bars. + const DAY_MIN = 1440; + const rangeMinutes = (endMs - fromMs) / 60_000; + const BUCKET_LADDER = [1, 15, 60, 180, 360, 1440, 4320, 10080]; + let bucketMinutes = BUCKET_INTERVALS.some( + (bucket) => bucket.minutes === query.bucketMinutes, + ) + ? (query.bucketMinutes as number) + : rangeMinutes <= 120 ? 1 + : rangeMinutes <= DAY_MIN ? 60 + : rangeMinutes <= 3 * DAY_MIN ? 180 + : rangeMinutes <= 7 * DAY_MIN ? 360 + : rangeMinutes <= 31 * DAY_MIN ? 1440 + : 4320; + while (rangeMinutes / bucketMinutes > MAX_CHART_BUCKETS) { + const next = BUCKET_LADDER.find((m) => m > bucketMinutes); + if (!next) break; + bucketMinutes = next; + } + + const groupBy = [...new Set(query.groupBy)]; + const filters = [SERVED_FILTER]; + if (dayGrained) { + // Day offsets relative to NOW() keep the SQL in the + // toStartOfDay(NOW() - INTERVAL) form AE's strict validator accepts. + const fromDaysAgo = Math.round((today - fromMs) / DAY_MS); + filters.push( + `timestamp >= toStartOfDay(NOW() - INTERVAL '${fromDaysAgo}' DAY)`, + ); + if (endMs <= today) { + const endDaysAgo = Math.round((today - endMs) / DAY_MS); + filters.push( + `timestamp < toStartOfDay(NOW() - INTERVAL '${endDaysAgo}' DAY)`, + ); + } + } else { + // Sub-day bounds (drill-down): absolute UTC literals via toDateTime, + // which the AE docs show accepting 'YYYY-MM-DD hh:mm:ss'. + filters.push( + `timestamp >= toDateTime('${sqlDateTime(fromMs)}')`, + `timestamp < toDateTime('${sqlDateTime(endMs)}')`, + ); + } + for (const [dim, value] of Object.entries(query.filters ?? {})) { + filters.push(FILTER_SQL[dim as AdminDimension](value)); + } + const from = `FROM ${CONFIG.analytics.dataset} WHERE ${filters.join(" AND ")}`; + + const aggregates = `SUM(_sample_interval * double1) AS bytes, SUM(_sample_interval) AS requests`; + // Sub-hour buckets use AE's dedicated toStartOf* functions (documented, + // unlike MINUTE units for toStartOfInterval); hour multiples below a day + // use the proven toStartOfInterval HOUR form; day multiples group by day + // in SQL and get folded into buckets here, aligned to the range start + // (AE silently degrades >24h hour intervals to daily). + const dayFold = bucketMinutes >= DAY_MIN; + const bucketExpr = + bucketMinutes === 1 + ? "toStartOfMinute(timestamp)" + : bucketMinutes === 15 + ? "toStartOfFifteenMinutes(timestamp)" + : dayFold + ? "toStartOfDay(timestamp)" + : `toStartOfInterval(timestamp, INTERVAL '${bucketMinutes / 60}' HOUR)`; + + const columns = [...new Set(groupBy.flatMap((d) => ADMIN_DIMENSIONS[d].columns))]; + + // Per-bucket overall totals — the chart's "Other" baseline and grand total. + const bucketTotalsSql = `SELECT ${bucketExpr} AS bucket, ${aggregates} ${from} GROUP BY bucket ORDER BY bucket`; + const rankBy = query.metric === "bytes" ? "bytes" : "requests"; + const groupTotalsSql = columns.length + ? `SELECT ${columns.join(", ")}, ${aggregates} ${from} GROUP BY ${columns.join(", ")} ORDER BY ${rankBy} DESC LIMIT ${TABLE_GROUP_LIMIT}` + : null; + // Headline uniques; blob8 = '' (IP unknown) is a value, not an IP, so + // detect it and drop it from the distinct count. + const distinctSql = `SELECT COUNT(DISTINCT blob6) AS countries, COUNT(DISTINCT blob8) AS ips, sumIf(_sample_interval, blob8 = '') AS no_ip ${from}`; + const queries = [ + bucketTotalsSql, + ...(groupTotalsSql ? [groupTotalsSql] : []), + distinctSql, + ]; + + const [bucketTotals, groupTotals, distinctRows] = await Promise.all([ + adminQuery(bucketTotalsSql), + groupTotalsSql ? adminQuery(groupTotalsSql) : Promise.resolve([]), + adminQuery(distinctSql), + ]); + + const distinct = distinctRows[0] ?? {}; + const totals = { + bytes: bucketTotals.reduce((sum, row) => sum + num(row.bytes), 0), + requests: bucketTotals.reduce((sum, row) => sum + num(row.requests), 0), + uniqueIps: Math.max(0, num(distinct.ips) - (num(distinct.no_ip) > 0 ? 1 : 0)), + countries: num(distinct.countries), + }; + + // Zero-filled bucket grid anchored at the range start (day starts are also + // on AE's epoch-aligned sub-daily grid, since 1/15/60/180/360 minutes all + // divide a day); union in any returned bucket that lands off-grid so data + // is never dropped. + const bucketMs = bucketMinutes * 60_000; + const gridEnd = Math.min(Date.now(), endMs - 1); + const grid = new Set(); + for (let t = fromMs; t <= gridEnd; t += bucketMs) { + grid.add(new Date(t).toISOString()); + } + const fold = (iso: string): string => { + if (!dayFold) return iso; + const step = Math.max(0, Math.floor((Date.parse(iso) - fromMs) / bucketMs)); + return new Date(fromMs + step * bucketMs).toISOString(); + }; + + const totalByBucket = new Map(); + for (const row of bucketTotals) { + const bucket = fold(parseDateTime(row.bucket)); + const acc = totalByBucket.get(bucket) ?? { bytes: 0, requests: 0 }; + acc.bytes += num(row.bytes); + acc.requests += num(row.requests); + totalByBucket.set(bucket, acc); + } + for (const bucket of totalByBucket.keys()) grid.add(bucket); + const buckets = [...grid].sort(); + + const range = dayGrained + ? { from: isoDay(fromMs), to: isoDay(endMs - DAY_MS) } + : { from: isoMinute(fromMs), to: isoMinute(endMs) }; + + if (!columns.length) { + // No grouping: a single "All traffic" series. + const key = "All traffic"; + return { + buckets, + bucketMinutes, + range, + series: [key], + points: buckets.map((b): AdminBreakdown["points"][number] => { + const row = totalByBucket.get(b); + return row ? { [key]: row } : {}; + }), + groups: + totals.bytes || totals.requests + ? [{ key, bytes: totals.bytes, requests: totals.requests }] + : [], + totals, + queries, + }; + } + + const groups = groupTotals.map((row) => ({ + key: rowKey(row, groupBy), + bytes: num(row.bytes), + requests: num(row.requests), + })); + + // Timeseries only for the chart's top slice, matched on the raw columns. + const chartRows = groupTotals.slice(0, CHART_SERIES_LIMIT); + let seriesRows: Row[] = []; + if (chartRows.length) { + const match = chartRows + .map( + (row) => + `(${columns.map((c) => `${c} = ${sqlQuote(str(row[c]))}`).join(" AND ")})`, + ) + .join(" OR "); + const seriesSql = `SELECT ${bucketExpr} AS bucket, ${columns.join(", ")}, ${aggregates} ${from} AND (${match}) GROUP BY bucket, ${columns.join(", ")} ORDER BY bucket`; + queries.push(seriesSql); + seriesRows = await adminQuery(seriesSql); + } + + const chartKeys = chartRows.map((row) => rowKey(row, groupBy)); + const points: AdminBreakdown["points"] = buckets.map(() => ({})); + const bucketIndex = new Map(buckets.map((b, i) => [b, i])); + for (const row of seriesRows) { + const i = bucketIndex.get(fold(parseDateTime(row.bucket))); + if (i === undefined) continue; + const key = rowKey(row, groupBy); + const acc = points[i][key] ?? { bytes: 0, requests: 0 }; + points[i][key] = { + bytes: acc.bytes + num(row.bytes), + requests: acc.requests + num(row.requests), + }; + } + + // "Other" per bucket = overall minus the charted slice (clamped: sampling + // estimates can put the slice a hair above the total). Gate on either + // metric — a remainder can be requests-only (zero-byte 200s pass the + // served filter) and must still show in the Requests view. + let hasOther = false; + buckets.forEach((bucket, i) => { + const total = totalByBucket.get(bucket); + if (!total) return; + const charted = Object.values(points[i]).reduce( + (acc, v) => ({ bytes: acc.bytes + v.bytes, requests: acc.requests + v.requests }), + { bytes: 0, requests: 0 }, + ); + const other = { + bytes: Math.max(0, total.bytes - charted.bytes), + requests: Math.max(0, total.requests - charted.requests), + }; + if (other.bytes > 0 || other.requests > 0) { + points[i][OTHER_KEY] = other; + hasOther = true; + } + }); + const series = hasOther ? [...chartKeys, OTHER_KEY] : chartKeys; + + // Table remainder beyond the top TABLE_GROUP_LIMIT groups. + const remainder = { + bytes: Math.max(0, totals.bytes - groups.reduce((sum, g) => sum + g.bytes, 0)), + requests: Math.max( + 0, + totals.requests - groups.reduce((sum, g) => sum + g.requests, 0), + ), + }; + if (remainder.bytes > 0 || remainder.requests > 0) { + groups.push({ key: OTHER_KEY, ...remainder }); + } + + return { buckets, bucketMinutes, range, series, points, groups, totals, queries }; +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 2c4fa709..616ad3ef 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -66,6 +66,14 @@ export const CONFIG = { }, }, + // Cloudflare Analytics Engine — request analytics written by the data proxy. + // All three must be set for analytics UI to appear; otherwise it hides itself. + analytics: { + accountId: process.env.CF_ANALYTICS_ACCOUNT_ID || "", + apiToken: process.env.CF_ANALYTICS_API_TOKEN || "", + dataset: process.env.CF_ANALYTICS_DATASET || "", + }, + // Location WebSocket for live globe locationWs: { url: process.env.NEXT_PUBLIC_LOCATION_WS_URL, diff --git a/src/lib/format.ts b/src/lib/format.ts index cc4fcb30..092bb324 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,14 +1,15 @@ /** * Format a number of bytes into a human-readable string * @param bytes The number of bytes to format + * @param decimals Maximum decimal places (default 2) * @returns A formatted string with appropriate units */ -export function formatBytes(bytes: number): string { +export function formatBytes(bytes: number, decimals: number = 2): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`; } /** diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 0fff5907..8ddff493 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -17,6 +17,7 @@ export const docsUrl = () => "https://docs.source.coop"; // Admin URLs export const adminUrl = () => "/admin"; +export const adminAnalyticsUrl = () => "/admin/analytics"; export const adminUserLookupUrl = () => "/admin/user-lookup"; export const adminDataConnectionsUrl = () => "/admin/data-connections"; export const adminDataConnectionCreateUrl = () => @@ -43,6 +44,13 @@ export const loginUrl = (returnTo?: string) => { }; export const onboardingUrl = () => "/onboarding"; +// Product analytics (maintainers/owners/admins). The query param is +// rewritten by middleware to the internal /-/analytics route, so object +// paths can never be shadowed and layouts (which can't read search params) +// stay out of the loop. +export const productAnalyticsUrl = (account_id: string, product_id: string) => + `/${account_id}/${product_id}?tab=analytics`; + // Object URLs export const objectUrl = ( account_id: string, diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 00000000..e9cdf1bf --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,41 @@ +/** + * The ?tab=analytics → /-/analytics rewrite is invisible indirection whose + * failure mode is a silently dead ANALYTICS tab — pin its behavior. + */ +import { NextRequest } from "next/server"; +import { handleProductAnalyticsTab } from "./middleware"; + +const rewriteTarget = (url: string): string | null => + handleProductAnalyticsTab(new NextRequest(url))?.headers.get( + "x-middleware-rewrite", + ) ?? null; + +it("rewrites the product analytics tab URL to the internal route", () => { + expect(rewriteTarget("https://source.coop/acct/prod?tab=analytics")).toBe( + "https://source.coop/acct/prod/-/analytics", + ); +}); + +it("preserves other query params and drops tab", () => { + expect( + rewriteTarget("https://source.coop/acct/prod?tab=analytics&window=7"), + ).toBe("https://source.coop/acct/prod/-/analytics?window=7"); +}); + +it("ignores non-matching requests", () => { + // No tab param / wrong value + expect(rewriteTarget("https://source.coop/acct/prod")).toBeNull(); + expect(rewriteTarget("https://source.coop/acct/prod?tab=other")).toBeNull(); + // Not a two-segment product path + expect(rewriteTarget("https://source.coop/acct?tab=analytics")).toBeNull(); + expect( + rewriteTarget("https://source.coop/acct/prod/file.txt?tab=analytics"), + ).toBeNull(); + // Two-segment top-level app routes are not products + expect( + rewriteTarget("https://source.coop/admin/analytics?tab=analytics"), + ).toBeNull(); + expect( + rewriteTarget("https://source.coop/products/new?tab=analytics"), + ).toBeNull(); +}); diff --git a/src/middleware.ts b/src/middleware.ts index 3e25ea82..e4ff51ba 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -63,6 +63,47 @@ const handleLegacyRedirects = (request: NextRequest): NextResponse | null => { return null; }; +// Top-level routes that share the /{segment}/{segment} shape but are not +// account/product pages — the analytics rewrite must leave them alone. +const NON_ACCOUNT_SEGMENTS = new Set([ + "admin", + "edit", + "email-verified", + "feed.xml", + "featured", + "logout", + "onboarding", + "products", + "repositories", +]); + +/** + * Serve the maintainer analytics view on the product root via a query param + * (`/{account}/{product}?tab=analytics`). Layouts can't read search params, + * so the view lives at the internal `/-/analytics` route (which also keeps + * it from shadowing real object paths) and the query-param URL is rewritten + * to it here. Other params (e.g. `window`) pass through. + * + * Exported for tests: the failure mode is a silently dead ANALYTICS tab. + */ +export const handleProductAnalyticsTab = ( + request: NextRequest, +): NextResponse | null => { + const { pathname, searchParams } = request.nextUrl; + const match = pathname.match(/^\/([^/]+)\/[^/]+$/); + if ( + searchParams.get("tab") === "analytics" && + match && + !NON_ACCOUNT_SEGMENTS.has(match[1]) + ) { + const url = request.nextUrl.clone(); + url.pathname = `${pathname}/-/analytics`; + url.searchParams.delete("tab"); + return NextResponse.rewrite(url); + } + return null; +}; + const ory = createOryMiddleware({}); // Paths the Ory middleware proxies (self-service flows, session checks). For @@ -83,7 +124,9 @@ export const middleware = async (request: NextRequest) => { } // Let Ory handle its own endpoints (it returns proxied responses with - // redirects / Set-Cookie that we must not discard). + // redirects / Set-Cookie that we must not discard). This runs before the + // analytics rewrite so two-segment Ory paths (e.g. /sessions/whoami) can + // never be diverted by a stray ?tab=analytics. if ( ORY_PROXIED_PREFIXES.some((prefix) => request.nextUrl.pathname.startsWith(prefix), @@ -92,9 +135,18 @@ export const middleware = async (request: NextRequest) => { return ory(request); } + const analyticsRewrite = handleProductAnalyticsTab(request); + if (analyticsRewrite) return analyticsRewrite; + return NextResponse.next(); }; export const config = { - matcher: ["/((?!api|_next/static|_next/image|favicon.ico|logo|favicon).*)"], + // Exclusions are anchored as directories/files — an unanchored prefix + // (e.g. `api`) would also skip middleware for any account whose id merely + // starts with it (apiuser, logophile, …), silently disabling rewrites + // and Ory handling on their pages. + matcher: [ + "/((?!api/|_next/static|_next/image|favicon\\.ico|favicon/|logo/).*)", + ], };