Skip to content

Commit ceea725

Browse files
authored
fix: use accountinfo for bucket statistics (#158)
1 parent f1fe150 commit ceea725

5 files changed

Lines changed: 146 additions & 101 deletions

File tree

app/(dashboard)/browser/page.tsx

Lines changed: 28 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import { BucketNewForm } from "@/components/buckets/new-form"
1515
import { Spinner } from "@/components/ui/spinner"
1616
import { useBucket } from "@/hooks/use-bucket"
1717
import { useObject } from "@/hooks/use-object"
18-
import { useSystem } from "@/hooks/use-system"
1918
import { usePermissions } from "@/hooks/use-permissions"
2019
import { useDialog } from "@/lib/feedback/dialog"
2120
import { useMessage } from "@/lib/feedback/message"
2221
import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions"
2322
import { normalizeDateToIso } from "@/lib/safe-date"
23+
import { getAccountBucketUsage } from "@/lib/account-bucket-usage"
2424
import { BrowserContent } from "./content"
2525
import type { ColumnDef } from "@tanstack/react-table"
2626

@@ -33,70 +33,23 @@ interface BucketRow {
3333
IsPublic?: boolean
3434
}
3535

36-
type BucketUsageMap = Record<string, { objects_count?: number; size?: number } | undefined>
37-
3836
function BrowserBucketsPage() {
3937
const { t } = useTranslation()
4038
const router = useRouter()
4139
const message = useMessage()
4240
const dialog = useDialog()
43-
const { canCapability } = usePermissions()
41+
const { canCapability, userInfo, isLoading: accountInfoLoading, hasFetchedPolicy, fetchUserPolicy } = usePermissions()
4442
const { listBuckets, deleteBucket, getBucketPolicyStatus } = useBucket()
45-
const { getDataUsageInfo } = useSystem()
4643

4744
const [formVisible, setFormVisible] = useState(false)
4845
const [searchTerm, setSearchTerm] = useState("")
4946
const [data, setData] = useState<BucketRow[]>([])
5047
const [pending, setPending] = useState(true)
51-
const [usageLoading, setUsageLoading] = useState(false)
5248
const [policyLoading, setPolicyLoading] = useState(false)
5349
const fetchIdRef = useRef(0)
5450

5551
const canCreateBucket = canCapability("bucket.create")
5652

57-
const loadBucketUsage = useCallback(
58-
async (fetchId: number, bucketNames: string[]) => {
59-
if (bucketNames.length === 0) {
60-
setUsageLoading(false)
61-
return
62-
}
63-
64-
try {
65-
const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined
66-
if (fetchId !== fetchIdRef.current) return
67-
68-
// getDataUsageInfo returns undefined on 403; don't update data so table shows "--"
69-
if (!usage) {
70-
return
71-
}
72-
73-
const bucketUsage = usage?.buckets_usage ?? {}
74-
75-
setData((prev) =>
76-
prev.map((row) => {
77-
const stats = bucketUsage[row.Name]
78-
const objectsCount = typeof stats?.objects_count === "number" ? stats.objects_count : 0
79-
const totalSize = typeof stats?.size === "number" ? stats.size : 0
80-
return {
81-
...row,
82-
Count: objectsCount,
83-
Size: niceBytes(String(totalSize)),
84-
SizeBytes: totalSize,
85-
}
86-
}),
87-
)
88-
} catch {
89-
if (fetchId !== fetchIdRef.current) return
90-
// On error, don't update the data - keep showing "--"
91-
} finally {
92-
if (fetchId === fetchIdRef.current) {
93-
setUsageLoading(false)
94-
}
95-
}
96-
},
97-
[getDataUsageInfo],
98-
)
99-
10053
const loadPolicyStatus = useCallback(
10154
async (fetchId: number, bucketNames: string[]) => {
10255
if (bucketNames.length === 0) {
@@ -165,11 +118,9 @@ function BrowserBucketsPage() {
165118
setData(buckets)
166119
setPending(false)
167120

168-
setUsageLoading(true)
169-
void loadBucketUsage(
170-
fetchId,
171-
buckets.map((bucket) => bucket.Name),
172-
)
121+
if (options?.force) {
122+
void fetchUserPolicy()
123+
}
173124

174125
setPolicyLoading(true)
175126
void loadPolicyStatus(
@@ -186,16 +137,36 @@ function BrowserBucketsPage() {
186137
}
187138
}
188139
},
189-
[listBuckets, loadBucketUsage, loadPolicyStatus],
140+
[fetchUserPolicy, listBuckets, loadPolicyStatus],
190141
)
191142

192143
useEffect(() => {
193144
fetchBuckets()
194145
}, [fetchBuckets])
195146

147+
const usageLoading = accountInfoLoading || !hasFetchedPolicy
148+
const bucketUsage = useMemo(() => getAccountBucketUsage(userInfo), [userInfo])
149+
const rowsWithUsage = useMemo(
150+
() =>
151+
data.map((row) => {
152+
const usage = bucketUsage.get(row.Name)
153+
if (!usage) return row
154+
155+
return {
156+
...row,
157+
Count: usage.objectsCount,
158+
Size: niceBytes(String(usage.sizeBytes)),
159+
SizeBytes: usage.sizeBytes,
160+
}
161+
}),
162+
[bucketUsage, data],
163+
)
196164
const filteredData = useMemo(
197-
() => (searchTerm ? data.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) : data),
198-
[data, searchTerm],
165+
() =>
166+
searchTerm
167+
? rowsWithUsage.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase()))
168+
: rowsWithUsage,
169+
[rowsWithUsage, searchTerm],
199170
)
200171

201172
const objectApi = useObject("")

components/buckets/list.tsx

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import { SearchInput } from "@/components/search-input"
99
import { DataTable } from "@/components/data-table/data-table"
1010
import { useDataTable } from "@/hooks/use-data-table"
1111
import { useBucket } from "@/hooks/use-bucket"
12-
import { useSystem } from "@/hooks/use-system"
12+
import { usePermissions } from "@/hooks/use-permissions"
1313
import { Spinner } from "@/components/ui/spinner"
1414
import { formatDateTime, formatInteger, niceBytes } from "@/lib/functions"
1515
import { normalizeDateToIso } from "@/lib/safe-date"
16+
import { getAccountBucketUsage } from "@/lib/account-bucket-usage"
1617
import type { ColumnDef } from "@tanstack/react-table"
1718

1819
export interface BucketListRow {
@@ -24,8 +25,6 @@ export interface BucketListRow {
2425
IsPublic?: boolean
2526
}
2627

27-
type BucketUsageMap = Record<string, { objects_count?: number; size?: number } | undefined>
28-
2928
interface BucketListProps {
3029
title: React.ReactNode
3130
emptyDescription: string
@@ -35,49 +34,14 @@ interface BucketListProps {
3534
export function BucketList({ title, emptyDescription, getBucketHref }: BucketListProps) {
3635
const { t } = useTranslation()
3736
const { listBuckets, getBucketPolicyStatus } = useBucket()
38-
const { getDataUsageInfo } = useSystem()
37+
const { userInfo, isLoading: accountInfoLoading, hasFetchedPolicy, fetchUserPolicy } = usePermissions()
3938

4039
const [searchTerm, setSearchTerm] = React.useState("")
4140
const [data, setData] = React.useState<BucketListRow[]>([])
4241
const [pending, setPending] = React.useState(true)
43-
const [usageLoading, setUsageLoading] = React.useState(false)
4442
const [policyLoading, setPolicyLoading] = React.useState(false)
4543
const fetchIdRef = React.useRef(0)
4644

47-
const loadBucketUsage = React.useCallback(
48-
async (fetchId: number, bucketNames: string[]) => {
49-
if (bucketNames.length === 0) {
50-
setUsageLoading(false)
51-
return
52-
}
53-
54-
try {
55-
const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined
56-
if (fetchId !== fetchIdRef.current || !usage) return
57-
58-
const bucketUsage = usage.buckets_usage ?? {}
59-
setData((prev) =>
60-
prev.map((row) => {
61-
const stats = bucketUsage[row.Name]
62-
const objectsCount = typeof stats?.objects_count === "number" ? stats.objects_count : 0
63-
const totalSize = typeof stats?.size === "number" ? stats.size : 0
64-
return {
65-
...row,
66-
Count: objectsCount,
67-
Size: niceBytes(String(totalSize)),
68-
SizeBytes: totalSize,
69-
}
70-
}),
71-
)
72-
} finally {
73-
if (fetchId === fetchIdRef.current) {
74-
setUsageLoading(false)
75-
}
76-
}
77-
},
78-
[getDataUsageInfo],
79-
)
80-
8145
const loadPolicyStatus = React.useCallback(
8246
async (fetchId: number, bucketNames: string[]) => {
8347
if (bucketNames.length === 0) {
@@ -141,8 +105,9 @@ export function BucketList({ title, emptyDescription, getBucketHref }: BucketLis
141105
setData(buckets)
142106

143107
const bucketNames = buckets.map((bucket) => bucket.Name)
144-
setUsageLoading(true)
145-
void loadBucketUsage(fetchId, bucketNames)
108+
if (options?.force) {
109+
void fetchUserPolicy()
110+
}
146111

147112
setPolicyLoading(true)
148113
void loadPolicyStatus(fetchId, bucketNames)
@@ -155,16 +120,36 @@ export function BucketList({ title, emptyDescription, getBucketHref }: BucketLis
155120
}
156121
}
157122
},
158-
[listBuckets, loadBucketUsage, loadPolicyStatus],
123+
[fetchUserPolicy, listBuckets, loadPolicyStatus],
159124
)
160125

161126
React.useEffect(() => {
162127
fetchBuckets()
163128
}, [fetchBuckets])
164129

130+
const usageLoading = accountInfoLoading || !hasFetchedPolicy
131+
const bucketUsage = React.useMemo(() => getAccountBucketUsage(userInfo), [userInfo])
132+
const rowsWithUsage = React.useMemo(
133+
() =>
134+
data.map((row) => {
135+
const usage = bucketUsage.get(row.Name)
136+
if (!usage) return row
137+
138+
return {
139+
...row,
140+
Count: usage.objectsCount,
141+
Size: niceBytes(String(usage.sizeBytes)),
142+
SizeBytes: usage.sizeBytes,
143+
}
144+
}),
145+
[bucketUsage, data],
146+
)
165147
const filteredData = React.useMemo(
166-
() => (searchTerm ? data.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase())) : data),
167-
[data, searchTerm],
148+
() =>
149+
searchTerm
150+
? rowsWithUsage.filter((bucket) => bucket.Name.toLowerCase().includes(searchTerm.toLowerCase()))
151+
: rowsWithUsage,
152+
[rowsWithUsage, searchTerm],
168153
)
169154

170155
const columns: ColumnDef<BucketListRow>[] = React.useMemo(

lib/account-bucket-usage.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @typedef {{ objectsCount: number, sizeBytes: number }} AccountBucketUsage
3+
*/
4+
5+
/**
6+
* Extract bucket statistics from the authoritative accountinfo response.
7+
* Invalid or incomplete entries are omitted so callers can display an
8+
* unavailable state instead of fabricating zero usage.
9+
*
10+
* @param {Record<string, unknown> | null | undefined} accountInfo
11+
* @returns {Map<string, AccountBucketUsage>}
12+
*/
13+
export function getAccountBucketUsage(accountInfo) {
14+
/** @type {Map<string, AccountBucketUsage>} */
15+
const usage = new Map()
16+
const buckets = Array.isArray(accountInfo?.buckets) ? accountInfo.buckets : []
17+
18+
for (const bucket of buckets) {
19+
if (!bucket || typeof bucket !== "object") continue
20+
21+
const { name, objects, size } = bucket
22+
if (
23+
typeof name !== "string" ||
24+
!name ||
25+
typeof objects !== "number" ||
26+
!Number.isFinite(objects) ||
27+
objects < 0 ||
28+
typeof size !== "number" ||
29+
!Number.isFinite(size) ||
30+
size < 0
31+
) {
32+
continue
33+
}
34+
35+
usage.set(name, { objectsCount: objects, sizeBytes: size })
36+
}
37+
38+
return usage
39+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
import { getAccountBucketUsage } from "../../lib/account-bucket-usage.js"
4+
5+
test("getAccountBucketUsage reads authoritative bucket statistics from accountinfo", () => {
6+
const usage = getAccountBucketUsage({
7+
buckets: [
8+
{ name: "agent", objects: 1600, size: 209715200 },
9+
{ name: "cccc", objects: 1, size: 57344 },
10+
],
11+
})
12+
13+
assert.deepEqual(usage.get("agent"), { objectsCount: 1600, sizeBytes: 209715200 })
14+
assert.deepEqual(usage.get("cccc"), { objectsCount: 1, sizeBytes: 57344 })
15+
})
16+
17+
test("getAccountBucketUsage preserves valid empty bucket statistics", () => {
18+
const usage = getAccountBucketUsage({ buckets: [{ name: "empty", objects: 0, size: 0 }] })
19+
20+
assert.deepEqual(usage.get("empty"), { objectsCount: 0, sizeBytes: 0 })
21+
})
22+
23+
test("getAccountBucketUsage does not turn missing or malformed statistics into zero", () => {
24+
const usage = getAccountBucketUsage({
25+
buckets: [
26+
{ name: "missing-size", objects: 3 },
27+
{ name: "negative", objects: -1, size: 10 },
28+
{ name: "not-finite", objects: 1, size: Number.NaN },
29+
{ name: "wrong-types", objects: "4", size: "12" },
30+
{ objects: 1, size: 12 },
31+
],
32+
})
33+
34+
assert.equal(usage.size, 0)
35+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
import { readFile } from "node:fs/promises"
4+
5+
const bucketPages = ["components/buckets/list.tsx", "app/(dashboard)/browser/page.tsx"]
6+
7+
for (const file of bucketPages) {
8+
test(`${file} uses accountinfo as the bucket statistics source`, async () => {
9+
const source = await readFile(file, "utf8")
10+
11+
assert.match(source, /getAccountBucketUsage\(userInfo\)/)
12+
assert.match(source, /fetchUserPolicy\(\)/)
13+
assert.doesNotMatch(source, /getDataUsageInfo/)
14+
})
15+
}

0 commit comments

Comments
 (0)