diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 4b64a4e7..e3756b91 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -9,7 +9,7 @@ import type { UsageTotals, UsageUsers, } from "@/lib/clients/analytics"; -import { formatBytes } from "@/lib/format"; +import { formatBytes, formatDateSSR } from "@/lib/format"; import { objectUrl } from "@/lib/urls"; import { DownloadsChart, @@ -44,13 +44,176 @@ export function ProductAnalyticsView({ 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, - ); + // One pin per dimension (country, file, day), each toggled by clicking a + // row or chart bar; the pins compose into an intersection filter. A hover + // substitutes for its own dimension's pin while the cursor is there, so + // sweeping rows previews siblings without dropping the other pins. Lists + // never take values from their own dimension, so hovering/pinning a row + // re-values only the *other* panels and rows can't shuffle under the + // cursor. + const [hover, setHover] = useState< + | { type: "day"; index: number } + | { type: "country"; code: string } + | { type: "file"; path: string } + | null + >(null); + const [pins, setPins] = useState<{ + country: string | null; + file: string | null; + day: number | null; + }>({ country: null, file: null, day: null }); + const effCountry = hover?.type === "country" ? hover.code : pins.country; + const effFile = hover?.type === "file" ? hover.path : pins.file; + const effDay = hover?.type === "day" ? hover.index : pins.day; + const day = effDay === null ? null : days[effDay].date; + + const zeroEntry = { + requests: 0, + bytes: 0, + byDay: {} as Record, + }; + // The "·" code is the others aggregate's row. + const countryEntity = + effCountry !== null + ? effCountry === "·" + ? breakdowns?.otherCountries + : breakdowns?.countries.find((c) => c.code === effCountry) + : undefined; + const fileEntity = + effFile !== null + ? breakdowns?.files.find((f) => f.path === effFile) + : undefined; + // The active country∩file entity: window totals plus a per-day series + // (from the day×country×file cube when both dimensions are set). + const entity = + countryEntity && fileEntity + ? ((effCountry === "·" + ? fileEntity.otherCountries + : fileEntity.byCountry[effCountry!]) ?? zeroEntry) + : (countryEntity ?? fileEntity ?? null); + + // Countries stat: 1 for one country; the others row's distinct count is + // window-wide only, so it's unknowable (NaN → "—") when further narrowed. + const shownCountries = countryEntity + ? effCountry === "·" + ? fileEntity || day !== null + ? NaN + : (breakdowns?.otherCountries?.count ?? 0) + : 1 + : fileEntity + ? day !== null + ? (fileEntity.byDay[day]?.countries ?? 0) + : fileEntity.countries + : day !== null + ? days[effDay!].countries + : totals.countries; + const shown = { + ...(entity + ? day !== null + ? (entity.byDay[day] ?? zeroEntry) + : entity + : day !== null + ? days[effDay!] + : totals), + countries: shownCountries, + }; + const avgRequests = (entity?.requests ?? totals.requests) / days.length; + + let chartDays = days; + if (entity) { + chartDays = days.map((d) => ({ + ...d, + requests: entity.byDay[d.date]?.requests ?? 0, + bytes: entity.byDay[d.date]?.bytes ?? 0, + })); + } + + // Country list: driven by the effective file and day, never by a country. + const countryRows = breakdowns + ? [ + ...breakdowns.countries.map((c) => { + const e = fileEntity + ? (fileEntity.byCountry[c.code] ?? zeroEntry) + : c; + return { + code: c.code, + label: c.name, + requests: day !== null ? (e.byDay[day]?.requests ?? 0) : e.requests, + }; + }), + ...(breakdowns.otherCountries + ? [ + { + code: "·", + label: `${breakdowns.otherCountries.count} others`, + requests: (() => { + const e = fileEntity + ? fileEntity.otherCountries + : breakdowns.otherCountries; + return day !== null + ? (e.byDay[day]?.requests ?? 0) + : e.requests; + })(), + }, + ] + : []), + ] + : []; + // Re-rank by the active filter's values, others pinned last. + if (fileEntity || day !== null) { + countryRows.sort( + (a, b) => + Number(a.code === "·") - Number(b.code === "·") || + b.requests - a.requests, + ); + } + const maxCountry = Math.max(1, ...countryRows.map((row) => row.requests)); + + // Files table: driven by the effective country and day, never by a file. + const fileRows = (breakdowns?.files ?? []).map((file) => { + const e = + effCountry !== null + ? effCountry === "·" + ? file.otherCountries + : (file.byCountry[effCountry] ?? zeroEntry) + : file; + return { + path: file.path, + shown: day !== null ? (e.byDay[day] ?? zeroEntry) : e, + }; + }); + if (effCountry !== null || day !== null) { + fileRows.sort((a, b) => b.shown.requests - a.shown.requests); + } + + const countryRowLabel = (code: string) => + countryRows.find((row) => row.code === code)?.label; + // Persistent pins live on their own caption line above the transient hover + // line, so the two states stay visually distinct. + const pinnedLabel = + [ + pins.country !== null ? countryRowLabel(pins.country) : null, + pins.file, + pins.day !== null ? formatDateSSR(days[pins.day].date) : null, + ] + .filter(Boolean) + .join(" · ") || null; + const hoverLabel = + hover && hover.type !== "day" + ? hover.type === "country" + ? countryRowLabel(hover.code) + : hover.path + : null; + const togglePin = ( + dim: K, + value: NonNullable<(typeof pins)[K]>, + ) => setPins((p) => ({ ...p, [dim]: p[dim] === value ? null : value })); + const rowHighlight = (isPin: boolean, isHover: boolean) => + isPin + ? { background: "var(--green-a3)", cursor: "pointer" } + : isHover + ? { background: "var(--green-a2)", cursor: "pointer" } + : { cursor: "pointer" }; return ( @@ -77,7 +240,7 @@ export function ProductAnalyticsView({ - - + + pinned: {pinnedLabel} + + )} + + + setHover(index === null ? null : { type: "day", index }) + } + onSelect={(index) => index !== null && togglePin("day", index)} height={180} /> @@ -113,23 +297,22 @@ export function ProductAnalyticsView({ ) : ( - {[ - ...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) => ( - + {countryRows.map((row) => ( + + setHover({ type: "country", code: row.code }) + } + onMouseLeave={() => setHover(null)} + onClick={() => togglePin("country", row.code)} + style={rowHighlight( + pins.country === row.code, + hover?.type === "country" && hover.code === row.code, + )} + > - {breakdowns.files.map((file) => ( - + {fileRows.map((file) => ( + + setHover({ type: "file", path: file.path }) + } + onMouseLeave={() => setHover(null)} + onClick={() => togglePin("file", file.path)} + style={rowHighlight( + pins.file === file.path, + hover?.type === "file" && hover.path === file.path, + )} + > @@ -207,16 +401,44 @@ export function ProductAnalyticsView({ - {numberFormat.format(Math.round(file.requests))} + {numberFormat.format(Math.round(file.shown.requests))} - {formatBytes(file.bytes, 1)} + {formatBytes(file.shown.bytes, 1)} ))} + {breakdowns.otherFiles && ( + + + + {numberFormat.format(breakdowns.otherFiles.count)} other + files + + + {/* The remainder is window-wide only — no per-day/country + slice data, so it blanks while a slice is active. */} + + + {effCountry !== null || day !== null + ? "—" + : numberFormat.format( + Math.round(breakdowns.otherFiles.requests), + )} + + + + + {effCountry !== null || day !== null + ? "—" + : formatBytes(breakdowns.otherFiles.bytes, 1)} + + + + )} )} diff --git a/src/components/features/analytics/panels.tsx b/src/components/features/analytics/panels.tsx index 2f6e0869..20e008e1 100644 --- a/src/components/features/analytics/panels.tsx +++ b/src/components/features/analytics/panels.tsx @@ -105,13 +105,16 @@ export function Stat({ export function HoverCaption({ days, hovered, + filterLabel, }: { days: UsagePoint[]; hovered: number | null; + /** Transient hover preview (hovered country/file name) */ + filterLabel?: string | null; }) { return ( - {hovered !== null && ( + {(hovered !== null || filterLabel) && ( )} - {hovered === null - ? `${days.length}-day downloads` - : formatDateSSR(days[hovered].date)} + {hovered !== null + ? formatDateSSR(days[hovered].date) + : (filterLabel ?? `${days.length}-day downloads`)} ); @@ -132,11 +135,14 @@ export function DownloadsChart({ days, hovered, onHover, + onSelect, height, }: { days: UsagePoint[]; hovered: number | null; onHover: (index: number | null) => void; + /** Bar click, for pinning a day */ + onSelect?: (index: number | null) => void; height: number; }) { return ( @@ -164,6 +170,11 @@ export function DownloadsChart({ onMouseMove={(state) => onHover(parseActiveIndex(state?.activeTooltipIndex, days.length)) } + onClick={ + onSelect && + ((state) => + onSelect(parseActiveIndex(state?.activeTooltipIndex, days.length))) + } > diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts index 2b678a7a..39123f78 100644 --- a/src/lib/clients/analytics/index.test.ts +++ b/src/lib/clients/analytics/index.test.ts @@ -192,20 +192,71 @@ 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 day, country, file")) { + return jsonResponse([ + { + day: "2026-07-27 00:00:00", + country: "US", + file: "a.tif", + requests: 12, + bytes: 150, + }, + ]); + } + if (sql.includes("NOT IN") && sql.includes("GROUP BY day, file")) { + return jsonResponse([ + { day: "2026-07-27 00:00:00", file: "b.json", requests: 2, bytes: 20 }, + ]); + } + if (sql.includes("GROUP BY day, country")) { + return jsonResponse([ + { day: "2026-07-27 00:00:00", country: "US", requests: 60, bytes: 600 }, + { day: "2026-07-26 00:00:00", country: "US", requests: "40", bytes: 300 }, + { day: "2026-07-27 00:00:00", country: "DE", requests: 50, bytes: 200 }, + ]); + } + if (sql.includes("GROUP BY day, file")) { + return jsonResponse([ + { + day: "2026-07-27 00:00:00", + file: "a.tif", + requests: 25, + bytes: 400, + countries: 2, + }, + ]); + } + if (sql.includes("GROUP BY country, file")) { + return jsonResponse([ + { country: "US", file: "a.tif", requests: 20, bytes: 300 }, + { country: "DE", file: "a.tif", requests: 5, bytes: 100 }, + ]); + } + if (sql.includes("NOT IN") && sql.includes("GROUP BY file")) { + return jsonResponse([{ file: "b.json", requests: 4, bytes: 50 }]); + } + if (sql.includes("NOT IN")) { + return jsonResponse([ + { day: "2026-07-27 00:00:00", requests: 15, bytes: 70 }, + ]); + } + if (sql.includes("COUNT(DISTINCT blob3)")) { + return jsonResponse([{ files: 12, requests: 500, bytes: 5000 }]); + } 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 }, + { country: "US", requests: 100, bytes: 900 }, + { country: "DE", requests: 50, bytes: 500 }, + { country: "BR", requests: 40, bytes: 400 }, + { country: "GB", requests: 30, bytes: 300 }, + { country: "IN", requests: "20", bytes: "200" }, + { country: "FR", requests: 10, bytes: 100 }, + { country: "", requests: 5, bytes: 40 }, ]); } return jsonResponse([ - { file: "a.tif", requests: 60, bytes: 1000 }, - { file: "b.json", requests: "40", bytes: "500" }, + { file: "a.tif", requests: 60, bytes: 1000, countries: 3 }, + { file: "b.json", requests: "40", bytes: "500", countries: "2" }, ]); }); @@ -216,12 +267,77 @@ describe("getProductBreakdowns", () => { code: "US", name: "United States", requests: 100, + bytes: 900, + byDay: { + "2026-07-27T00:00:00.000Z": { requests: 60, bytes: 600 }, + "2026-07-26T00:00:00.000Z": { requests: 40, bytes: 300 }, + }, + }); + expect(breakdowns!.otherCountries).toEqual({ + count: 2, + requests: 15, + bytes: 140, + byDay: { "2026-07-27T00:00:00.000Z": { requests: 15, bytes: 70 } }, }); - 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 }, + { + path: "a.tif", + requests: 60, + bytes: 1000, + countries: 3, + byDay: { + "2026-07-27T00:00:00.000Z": { requests: 25, bytes: 400, countries: 2 }, + }, + byCountry: { + US: { + requests: 20, + bytes: 300, + byDay: { + "2026-07-27T00:00:00.000Z": { requests: 12, bytes: 150 }, + }, + }, + DE: { requests: 5, bytes: 100, byDay: {} }, + }, + otherCountries: { requests: 0, bytes: 0, byDay: {} }, + }, + { + path: "b.json", + requests: 40, + bytes: 500, + countries: 2, + byDay: {}, + byCountry: {}, + otherCountries: { + requests: 4, + bytes: 50, + byDay: { "2026-07-27T00:00:00.000Z": { requests: 2, bytes: 20 } }, + }, + }, ]); + // Remainder reconciles the table to the file-traffic total: 12 distinct + // files less the 2 listed; 500-100 requests; 5000-1500 bytes. + expect(breakdowns!.otherFiles).toEqual({ + count: 10, + requests: 400, + bytes: 3500, + }); + + // Per-day wave is scoped to the window's top entries, escaped and quoted. + const dayCountrySql = sentSql().find((sql) => + sql.includes("GROUP BY day, country"), + ); + expect(dayCountrySql).toContain("blob6 IN ('US', 'DE', 'BR', 'GB', 'IN')"); + const othersSql = sentSql().find((sql) => sql.includes("NOT IN")); + expect(othersSql).toContain("blob6 NOT IN ('US', 'DE', 'BR', 'GB', 'IN')"); + const dayFileSql = sentSql().find((sql) => + sql.includes("GROUP BY day, file"), + ); + expect(dayFileSql).toContain("blob3 IN ('a.tif', 'b.json')"); + const crossSql = sentSql().find((sql) => + sql.includes("GROUP BY country, file"), + ); + expect(crossSql).toContain("blob6 IN ('US', 'DE', 'BR', 'GB', 'IN')"); + expect(crossSql).toContain("blob3 IN ('a.tif', 'b.json')"); const fileSql = sentSql().find((sql) => sql.includes("GROUP BY file")); expect(fileSql).toContain("ORDER BY requests DESC"); diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 8690f4be..09321927 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -392,13 +392,45 @@ function parseUsageAggregates(row: Row): UsageTotals { const COUNTRY_LIST_LIMIT = 5; const FILE_LIST_LIMIT = 10; +/** Per-UTC-day values keyed by the day's ISO timestamp (UsagePoint.date). */ +type ByDay = Record; + +interface SliceTotals { + requests: number; + bytes: number; +} + export interface ProductBreakdowns { /** Top countries by downloads */ - countries: { code: string; name: string; requests: number }[]; + countries: { + code: string; + name: string; + requests: number; + bytes: number; + byDay: ByDay; + }[]; /** Aggregate of the remaining countries, if any */ - otherCountries: { count: number; requests: number } | null; + otherCountries: + | { count: number; requests: number; bytes: number; byDay: ByDay } + | null; /** Top objects by downloads */ - files: { path: string; requests: number; bytes: number }[]; + files: { + path: string; + requests: number; + bytes: number; + /** Distinct countries the object was downloaded from */ + countries: number; + byDay: ByDay; + /** Window and per-day values per top-country code (countries[].code) */ + byCountry: Record }>; + /** Window and per-day values from countries outside the top list */ + otherCountries: SliceTotals & { byDay: ByDay }; + }[]; + /** + * Remainder of file traffic outside the top list — lets the table sum to + * the file-traffic total. Window-wide only (no per-day/country slices). + */ + otherFiles: { count: number; requests: number; bytes: number } | null; } /** @@ -414,35 +446,207 @@ export async function getProductBreakdowns( const from = usageFrom(accountId, productId, undefined, days); try { - const [countryRows, fileRows] = await Promise.all([ + const [countryRows, fileRows, fileTotalRows] = await Promise.all([ usageQuery( - `SELECT blob6 AS country, SUM(_sample_interval) AS requests ${from} GROUP BY country ORDER BY requests DESC`, + `SELECT blob6 AS country, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${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}`, + `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes, COUNT(DISTINCT blob6) AS countries ${from} AND blob3 != '' GROUP BY file ORDER BY requests DESC LIMIT ${FILE_LIST_LIMIT}`, + ), + usageQuery( + `SELECT COUNT(DISTINCT blob3) AS files, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 != ''`, ), ]); const rest = countryRows.slice(COUNTRY_LIST_LIMIT); + const topCountries = countryRows + .slice(0, COUNTRY_LIST_LIMIT) + .map((row) => str(row.country)); + const topFiles = fileRows.map((row) => str(row.file)); + + // Second wave, scoped to the window's top entries with IN (bounded rows, + // unlike a full GROUP BY day+file) — per-day values for hover. + const countryIn = topCountries.map(sqlQuote).join(", "); + const fileIn = topFiles.map(sqlQuote).join(", "); + const [ + countryDayRows, + otherDayRows, + fileDayRows, + crossRows, + fileOtherRows, + cubeRows, + otherCubeRows, + ] = await Promise.all([ + topCountries.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 IN (${countryIn}) GROUP BY day, country`, + ) + : [], + rest.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 NOT IN (${countryIn}) GROUP BY day`, + ) + : [], + topFiles.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes, COUNT(DISTINCT blob6) AS countries ${from} AND blob3 IN (${fileIn}) GROUP BY day, file`, + ) + : [], + // Country × file cross — serves both hover directions (a country's + // per-file values and a file's per-country values). + topCountries.length && topFiles.length + ? usageQuery( + `SELECT blob6 AS country, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY country, file`, + ) + : [], + rest.length && topFiles.length + ? usageQuery( + `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 NOT IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY file`, + ) + : [], + // Day × country × file cube for the pinned-intersection chart. Bounded + // to top-5 × top-10 × window days (< AE's ~10k row cap; sparse in + // practice since rows only exist where traffic occurred). + topCountries.length && topFiles.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY day, country, file`, + ) + : [], + rest.length && topFiles.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 NOT IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY day, file`, + ) + : [], + ]); + + const countryByDay = new Map< + string, + Record + >(); + for (const row of countryDayRows) { + const key = str(row.country); + const days = countryByDay.get(key) ?? {}; + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; + countryByDay.set(key, days); + } + const fileByDay = new Map< + string, + Record + >(); + for (const row of fileDayRows) { + const key = str(row.file); + const days = fileByDay.get(key) ?? {}; + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + countries: num(row.countries), + }; + fileByDay.set(key, days); + } + const cube = new Map>(); + for (const row of cubeRows) { + const key = `${str(row.file)}${str(row.country) || "??"}`; + const days = cube.get(key) ?? {}; + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; + cube.set(key, days); + } + const otherCube = new Map>(); + for (const row of otherCubeRows) { + const key = str(row.file); + const days = otherCube.get(key) ?? {}; + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; + otherCube.set(key, days); + } + const fileByCountry = new Map< + string, + Record }> + >(); + for (const row of crossRows) { + const key = str(row.file); + const countries = fileByCountry.get(key) ?? {}; + // Same code normalization as countries[].code, so lookups line up. + const code = str(row.country) || "??"; + countries[code] = { + requests: num(row.requests), + bytes: num(row.bytes), + byDay: cube.get(`${key}${code}`) ?? {}, + }; + fileByCountry.set(key, countries); + } + const fileOthers = new Map( + fileOtherRows.map((row) => [ + str(row.file), + { + requests: num(row.requests), + bytes: num(row.bytes), + byDay: otherCube.get(str(row.file)) ?? {}, + }, + ]), + ); + return { countries: countryRows.slice(0, COUNTRY_LIST_LIMIT).map((row) => ({ code: str(row.country) || "??", name: countryName(str(row.country)), requests: num(row.requests), + bytes: num(row.bytes), + byDay: countryByDay.get(str(row.country)) ?? {}, })), otherCountries: rest.length ? { count: rest.length, requests: rest.reduce((sum, row) => sum + num(row.requests), 0), + bytes: rest.reduce((sum, row) => sum + num(row.bytes), 0), + byDay: Object.fromEntries( + otherDayRows.map((row) => [ + parseDateTime(row.day), + { requests: num(row.requests), bytes: num(row.bytes) }, + ]), + ), } : null, files: fileRows.map((row) => ({ path: str(row.file), requests: num(row.requests), bytes: num(row.bytes), + countries: num(row.countries), + byDay: fileByDay.get(str(row.file)) ?? {}, + byCountry: fileByCountry.get(str(row.file)) ?? {}, + otherCountries: fileOthers.get(str(row.file)) ?? { + requests: 0, + bytes: 0, + byDay: {}, + }, })), + // Sampling makes the parts fractional estimates; clamp the remainder. + otherFiles: (() => { + const count = num(fileTotalRows[0]?.files) - fileRows.length; + if (count <= 0) return null; + return { + count, + requests: Math.max( + 0, + num(fileTotalRows[0]?.requests) - + fileRows.reduce((sum, row) => sum + num(row.requests), 0), + ), + bytes: Math.max( + 0, + num(fileTotalRows[0]?.bytes) - + fileRows.reduce((sum, row) => sum + num(row.bytes), 0), + ), + }; + })(), }; } catch (error) { LOGGER.warn("Analytics breakdown query failed", {