diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx index 1154a943..e38ae19b 100644 --- a/app/(dashboard)/browser/page.tsx +++ b/app/(dashboard)/browser/page.tsx @@ -15,12 +15,12 @@ import { BucketNewForm } from "@/components/buckets/new-form" import { Spinner } from "@/components/ui/spinner" import { useBucket } from "@/hooks/use-bucket" import { useObject } from "@/hooks/use-object" -import { useSystem } from "@/hooks/use-system" import { usePermissions } from "@/hooks/use-permissions" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions" import { normalizeDateToIso } from "@/lib/safe-date" +import { getAccountBucketUsage } from "@/lib/account-bucket-usage" import { BrowserContent } from "./content" import type { ColumnDef } from "@tanstack/react-table" @@ -33,70 +33,23 @@ interface BucketRow { IsPublic?: boolean } -type BucketUsageMap = Record - function BrowserBucketsPage() { const { t } = useTranslation() const router = useRouter() const message = useMessage() const dialog = useDialog() - const { canCapability } = usePermissions() + const { canCapability, userInfo, isLoading: accountInfoLoading, hasFetchedPolicy, fetchUserPolicy } = usePermissions() const { listBuckets, deleteBucket, getBucketPolicyStatus } = useBucket() - const { getDataUsageInfo } = useSystem() const [formVisible, setFormVisible] = useState(false) const [searchTerm, setSearchTerm] = useState("") const [data, setData] = useState([]) const [pending, setPending] = useState(true) - const [usageLoading, setUsageLoading] = useState(false) const [policyLoading, setPolicyLoading] = useState(false) const fetchIdRef = useRef(0) const canCreateBucket = canCapability("bucket.create") - const loadBucketUsage = useCallback( - async (fetchId: number, bucketNames: string[]) => { - if (bucketNames.length === 0) { - setUsageLoading(false) - return - } - - try { - const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined - if (fetchId !== fetchIdRef.current) return - - // getDataUsageInfo returns undefined on 403; don't update data so table shows "--" - if (!usage) { - return - } - - const bucketUsage = usage?.buckets_usage ?? {} - - setData((prev) => - prev.map((row) => { - const stats = bucketUsage[row.Name] - const objectsCount = typeof stats?.objects_count === "number" ? stats.objects_count : 0 - const totalSize = typeof stats?.size === "number" ? stats.size : 0 - return { - ...row, - Count: objectsCount, - Size: niceBytes(String(totalSize)), - SizeBytes: totalSize, - } - }), - ) - } catch { - if (fetchId !== fetchIdRef.current) return - // On error, don't update the data - keep showing "--" - } finally { - if (fetchId === fetchIdRef.current) { - setUsageLoading(false) - } - } - }, - [getDataUsageInfo], - ) - const loadPolicyStatus = useCallback( async (fetchId: number, bucketNames: string[]) => { if (bucketNames.length === 0) { @@ -165,11 +118,9 @@ function BrowserBucketsPage() { setData(buckets) setPending(false) - setUsageLoading(true) - void loadBucketUsage( - fetchId, - buckets.map((bucket) => bucket.Name), - ) + if (options?.force) { + void fetchUserPolicy() + } setPolicyLoading(true) void loadPolicyStatus( @@ -186,16 +137,36 @@ function BrowserBucketsPage() { } } }, - [listBuckets, loadBucketUsage, loadPolicyStatus], + [fetchUserPolicy, listBuckets, loadPolicyStatus], ) useEffect(() => { fetchBuckets() }, [fetchBuckets]) + const usageLoading = accountInfoLoading || !hasFetchedPolicy + const bucketUsage = useMemo(() => getAccountBucketUsage(userInfo), [userInfo]) + const rowsWithUsage = useMemo( + () => + data.map((row) => { + const usage = bucketUsage.get(row.Name) + if (!usage) return row + + return { + ...row, + Count: usage.objectsCount, + Size: niceBytes(String(usage.sizeBytes)), + SizeBytes: usage.sizeBytes, + } + }), + [bucketUsage, data], + ) const filteredData = useMemo( - () => (searchTerm ? data.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) : data), - [data, searchTerm], + () => + searchTerm + ? rowsWithUsage.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) + : rowsWithUsage, + [rowsWithUsage, searchTerm], ) const objectApi = useObject("") diff --git a/components/buckets/list.tsx b/components/buckets/list.tsx index 78fd4939..e389b845 100644 --- a/components/buckets/list.tsx +++ b/components/buckets/list.tsx @@ -9,10 +9,11 @@ import { SearchInput } from "@/components/search-input" import { DataTable } from "@/components/data-table/data-table" import { useDataTable } from "@/hooks/use-data-table" import { useBucket } from "@/hooks/use-bucket" -import { useSystem } from "@/hooks/use-system" +import { usePermissions } from "@/hooks/use-permissions" import { Spinner } from "@/components/ui/spinner" import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions" import { normalizeDateToIso } from "@/lib/safe-date" +import { getAccountBucketUsage } from "@/lib/account-bucket-usage" import type { ColumnDef } from "@tanstack/react-table" export interface BucketListRow { @@ -24,8 +25,6 @@ export interface BucketListRow { IsPublic?: boolean } -type BucketUsageMap = Record - interface BucketListProps { title: React.ReactNode emptyDescription: string @@ -35,49 +34,14 @@ interface BucketListProps { export function BucketList({ title, emptyDescription, getBucketHref }: BucketListProps) { const { t } = useTranslation() const { listBuckets, getBucketPolicyStatus } = useBucket() - const { getDataUsageInfo } = useSystem() + const { userInfo, isLoading: accountInfoLoading, hasFetchedPolicy, fetchUserPolicy } = usePermissions() const [searchTerm, setSearchTerm] = React.useState("") const [data, setData] = React.useState([]) const [pending, setPending] = React.useState(true) - const [usageLoading, setUsageLoading] = React.useState(false) const [policyLoading, setPolicyLoading] = React.useState(false) const fetchIdRef = React.useRef(0) - const loadBucketUsage = React.useCallback( - async (fetchId: number, bucketNames: string[]) => { - if (bucketNames.length === 0) { - setUsageLoading(false) - return - } - - try { - const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined - if (fetchId !== fetchIdRef.current || !usage) return - - const bucketUsage = usage.buckets_usage ?? {} - setData((prev) => - prev.map((row) => { - const stats = bucketUsage[row.Name] - const objectsCount = typeof stats?.objects_count === "number" ? stats.objects_count : 0 - const totalSize = typeof stats?.size === "number" ? stats.size : 0 - return { - ...row, - Count: objectsCount, - Size: niceBytes(String(totalSize)), - SizeBytes: totalSize, - } - }), - ) - } finally { - if (fetchId === fetchIdRef.current) { - setUsageLoading(false) - } - } - }, - [getDataUsageInfo], - ) - const loadPolicyStatus = React.useCallback( async (fetchId: number, bucketNames: string[]) => { if (bucketNames.length === 0) { @@ -141,8 +105,9 @@ export function BucketList({ title, emptyDescription, getBucketHref }: BucketLis setData(buckets) const bucketNames = buckets.map((bucket) => bucket.Name) - setUsageLoading(true) - void loadBucketUsage(fetchId, bucketNames) + if (options?.force) { + void fetchUserPolicy() + } setPolicyLoading(true) void loadPolicyStatus(fetchId, bucketNames) @@ -155,16 +120,36 @@ export function BucketList({ title, emptyDescription, getBucketHref }: BucketLis } } }, - [listBuckets, loadBucketUsage, loadPolicyStatus], + [fetchUserPolicy, listBuckets, loadPolicyStatus], ) React.useEffect(() => { fetchBuckets() }, [fetchBuckets]) + const usageLoading = accountInfoLoading || !hasFetchedPolicy + const bucketUsage = React.useMemo(() => getAccountBucketUsage(userInfo), [userInfo]) + const rowsWithUsage = React.useMemo( + () => + data.map((row) => { + const usage = bucketUsage.get(row.Name) + if (!usage) return row + + return { + ...row, + Count: usage.objectsCount, + Size: niceBytes(String(usage.sizeBytes)), + SizeBytes: usage.sizeBytes, + } + }), + [bucketUsage, data], + ) const filteredData = React.useMemo( - () => (searchTerm ? data.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) : data), - [data, searchTerm], + () => + searchTerm + ? rowsWithUsage.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) + : rowsWithUsage, + [rowsWithUsage, searchTerm], ) const columns: ColumnDef[] = React.useMemo( diff --git a/lib/account-bucket-usage.js b/lib/account-bucket-usage.js new file mode 100644 index 00000000..b39cd23a --- /dev/null +++ b/lib/account-bucket-usage.js @@ -0,0 +1,39 @@ +/** + * @typedef {{ objectsCount: number, sizeBytes: number }} AccountBucketUsage + */ + +/** + * Extract bucket statistics from the authoritative accountinfo response. + * Invalid or incomplete entries are omitted so callers can display an + * unavailable state instead of fabricating zero usage. + * + * @param {Record | null | undefined} accountInfo + * @returns {Map} + */ +export function getAccountBucketUsage(accountInfo) { + /** @type {Map} */ + const usage = new Map() + const buckets = Array.isArray(accountInfo?.buckets) ? accountInfo.buckets : [] + + for (const bucket of buckets) { + if (!bucket || typeof bucket !== "object") continue + + const { name, objects, size } = bucket + if ( + typeof name !== "string" || + !name || + typeof objects !== "number" || + !Number.isFinite(objects) || + objects < 0 || + typeof size !== "number" || + !Number.isFinite(size) || + size < 0 + ) { + continue + } + + usage.set(name, { objectsCount: objects, sizeBytes: size }) + } + + return usage +} diff --git a/tests/lib/account-bucket-usage.test.js b/tests/lib/account-bucket-usage.test.js new file mode 100644 index 00000000..11724475 --- /dev/null +++ b/tests/lib/account-bucket-usage.test.js @@ -0,0 +1,35 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { getAccountBucketUsage } from "../../lib/account-bucket-usage.js" + +test("getAccountBucketUsage reads authoritative bucket statistics from accountinfo", () => { + const usage = getAccountBucketUsage({ + buckets: [ + { name: "agent", objects: 1600, size: 209715200 }, + { name: "cccc", objects: 1, size: 57344 }, + ], + }) + + assert.deepEqual(usage.get("agent"), { objectsCount: 1600, sizeBytes: 209715200 }) + assert.deepEqual(usage.get("cccc"), { objectsCount: 1, sizeBytes: 57344 }) +}) + +test("getAccountBucketUsage preserves valid empty bucket statistics", () => { + const usage = getAccountBucketUsage({ buckets: [{ name: "empty", objects: 0, size: 0 }] }) + + assert.deepEqual(usage.get("empty"), { objectsCount: 0, sizeBytes: 0 }) +}) + +test("getAccountBucketUsage does not turn missing or malformed statistics into zero", () => { + const usage = getAccountBucketUsage({ + buckets: [ + { name: "missing-size", objects: 3 }, + { name: "negative", objects: -1, size: 10 }, + { name: "not-finite", objects: 1, size: Number.NaN }, + { name: "wrong-types", objects: "4", size: "12" }, + { objects: 1, size: 12 }, + ], + }) + + assert.equal(usage.size, 0) +}) diff --git a/tests/lib/bucket-usage-source.test.js b/tests/lib/bucket-usage-source.test.js new file mode 100644 index 00000000..2b95d068 --- /dev/null +++ b/tests/lib/bucket-usage-source.test.js @@ -0,0 +1,15 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" + +const bucketPages = ["components/buckets/list.tsx", "app/(dashboard)/browser/page.tsx"] + +for (const file of bucketPages) { + test(`${file} uses accountinfo as the bucket statistics source`, async () => { + const source = await readFile(file, "utf8") + + assert.match(source, /getAccountBucketUsage\(userInfo\)/) + assert.match(source, /fetchUserPolicy\(\)/) + assert.doesNotMatch(source, /getDataUsageInfo/) + }) +}