mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
fix: use accountinfo for bucket statistics (#158)
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user