fix: use accountinfo for bucket statistics (#158)

This commit is contained in:
Henry Guo
2026-07-11 09:26:08 +08:00
committed by GitHub
parent f1fe150005
commit ceea725995
5 changed files with 146 additions and 101 deletions
+28 -57
View File
@@ -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<string, { objects_count?: number; size?: number } | undefined>
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<BucketRow[]>([])
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("")
+29 -44
View File
@@ -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<string, { objects_count?: number; size?: number } | undefined>
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<BucketListRow[]>([])
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<BucketListRow>[] = React.useMemo(
+39
View File
@@ -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<string, unknown> | null | undefined} accountInfo
* @returns {Map<string, AccountBucketUsage>}
*/
export function getAccountBucketUsage(accountInfo) {
/** @type {Map<string, AccountBucketUsage>} */
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
}
+35
View File
@@ -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)
})
+15
View File
@@ -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/)
})
}