Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 28 additions & 57 deletions app/(dashboard)/browser/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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("")
Expand Down
73 changes: 29 additions & 44 deletions components/buckets/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions lib/account-bucket-usage.js
Original file line number Diff line number Diff line change
@@ -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 changes: 35 additions & 0 deletions tests/lib/account-bucket-usage.test.js
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions tests/lib/bucket-usage-source.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
}