Files
bee-dashboard/src/modules/filemanager/utils/bee.ts
T
Bálint Ujvári 7e05a56073 fix: cannot forget expired drives (#214)
* fix: forget expired drives
2026-03-20 16:02:17 +01:00

426 lines
12 KiB
TypeScript

import { BatchId, Bee, BZZ, Duration, PostageBatch, RedundancyLevel, Size } from '@ethersphere/bee-js'
import {
DriveInfo,
estimateDriveListMetadataSize,
estimateFileInfoMetadataSize,
FileInfo,
FileManagerBase,
} from '@solarpunkltd/file-manager-lib'
import React from 'react'
import { getHumanReadableFileSize } from '../../../utils/file'
import { ActionTag } from '../constants/transfers'
export const getUsableStamps = async (bee: Bee | null): Promise<PostageBatch[]> => {
if (!bee) {
return []
}
try {
return (await bee.getPostageBatches())
.filter(s => s.usable)
.sort((a, b) => (a.label || '').localeCompare(b.label || ''))
} catch {
return []
}
}
export const validateStampStillExists = async (bee: Bee, batchId: BatchId): Promise<boolean> => {
try {
const stamp = await bee.getPostageBatch(batchId.toString())
return stamp.usable
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`Failed to validate stamp ${batchId.toString().slice(0, 8)}...:`, error)
return false
}
}
export const fmGetStorageCost = async (
capacity: number,
validityEndDate: Date,
encryption: boolean,
erasureCodeLevel: RedundancyLevel,
beeApi: Bee | null,
): Promise<BZZ | undefined> => {
try {
if (Size.fromBytes(capacity).toGigabytes() >= 0 && validityEndDate.getTime() >= new Date().getTime()) {
const cost = await beeApi?.getStorageCost(
Size.fromBytes(capacity),
Duration.fromEndDate(validityEndDate),
undefined,
encryption,
erasureCodeLevel,
)
return cost
}
return undefined
} catch {
return undefined
}
}
export const fmFetchCost = async (
capacity: number,
validityEndDate: Date,
encryption: boolean,
erasureCodeLevel: RedundancyLevel,
beeApi: Bee | null,
setCost: (cost: BZZ) => void,
currentFetch: React.MutableRefObject<Promise<void> | null>,
onError?: (error: unknown) => void,
) => {
if (currentFetch.current) {
await currentFetch.current
}
let isCurrentFetch = true
const fetchPromise = (async () => {
try {
const cost = await fmGetStorageCost(capacity, validityEndDate, encryption, erasureCodeLevel, beeApi)
if (isCurrentFetch) {
if (cost) {
setCost(cost)
} else {
setCost(BZZ.fromDecimalString('0'))
onError?.(new Error('Storage cost unavailable - node may be syncing'))
}
}
} catch (error) {
if (isCurrentFetch) {
setCost(BZZ.fromDecimalString('0'))
onError?.(error)
}
}
})()
currentFetch.current = fetchPromise
await fetchPromise
isCurrentFetch = false
currentFetch.current = null
}
export interface CreateDriveOptions {
beeApi: Bee | null
fm: FileManagerBase | null
size: Size
duration: Duration
label: string
encryption: boolean
redundancyLevel: RedundancyLevel
adminRedundancy: RedundancyLevel
isAdmin: boolean
resetState: boolean
existingBatch: PostageBatch | null
onSuccess?: () => void
onError?: (error: unknown) => void
}
export const handleCreateDrive = async (options: CreateDriveOptions): Promise<void> => {
const {
beeApi,
fm,
size,
duration,
label,
encryption,
redundancyLevel,
adminRedundancy,
isAdmin,
resetState,
existingBatch,
onSuccess,
onError,
} = { ...options }
if (!beeApi || !fm) {
// eslint-disable-next-line no-console
console.error('Error creating drive: Bee API or FM is invalid!')
onError?.('Error creating drive: Bee API or FM is invalid!')
return
}
try {
let batchId: BatchId
if (!existingBatch) {
if (!isAdmin) {
if (!fm.adminStamp) {
// eslint-disable-next-line no-console
console.error('Error creating drive: admin stamp is not available')
throw new Error('Error creating drive: admin stamp is not available')
}
verifyDriveSpace({
fm,
redundancyLevel,
stamp: fm.adminStamp,
adminRedundancy,
cb: err => {
throw new Error(err)
},
})
}
batchId = await beeApi.buyStorage(size, duration, { label }, undefined, encryption, redundancyLevel)
} else {
const isValid = await validateStampStillExists(beeApi, existingBatch.batchID)
if (!isValid) {
throw new Error(
'The stamp is no longer valid or has been deleted. Please select a different stamp from the list.',
)
}
verifyDriveSpace({
fm,
redundancyLevel,
stamp: existingBatch,
adminRedundancy,
cb: err => {
throw new Error(err)
},
})
batchId = existingBatch.batchID
}
await fm.createDrive(batchId, label, isAdmin, redundancyLevel, resetState)
onSuccess?.()
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error creating drive:', e instanceof Error ? e.message : String(e))
onError?.(e)
}
}
export interface DestroyDriveOptions {
beeApi?: Bee | null
fm: FileManagerBase | null
drive: DriveInfo
adminDrive: DriveInfo | null
isDestroy: boolean
onSuccess?: () => void
onError?: (error: unknown) => void
}
export const handleDestroyAndForgetDrive = async (options: DestroyDriveOptions): Promise<void> => {
const { beeApi, fm, adminDrive, drive, isDestroy, onSuccess, onError } = { ...options }
if (!beeApi) {
onError?.('Bee API is invalid!')
return
}
if (!fm || !fm.adminStamp || !adminDrive) {
onError?.('FM is invalid!')
return
}
try {
verifyDriveSpace({
fm,
driveId: drive.id.toString(),
redundancyLevel: drive.redundancyLevel,
stamp: fm.adminStamp,
isRemove: true,
adminRedundancy: adminDrive.redundancyLevel,
cb: err => {
throw new Error(err)
},
})
if (!isDestroy) {
await fm.forgetDrive(drive)
onSuccess?.()
return
}
const driveStamp = (await getUsableStamps(beeApi)).find(s => s.batchID.toString() === drive.batchId.toString())
const ttlDays = driveStamp?.duration.toDays() ?? 0
if (!driveStamp || ttlDays <= 2) {
// eslint-disable-next-line no-console
console.warn(`Stamp not found or TTL ${ttlDays} <= 2 days, skipping drive destruction: forgetting the drive.`)
await fm.forgetDrive(drive)
onSuccess?.()
return
}
await fm.destroyDrive(drive, driveStamp)
onSuccess?.()
} catch (e) {
onError?.(e)
}
}
export interface StampCapacityMetrics {
capacityPct: number
usedSize: string
stampSize: string
usedBytes: number
stampSizeBytes: number
remainingBytes: number
}
export const calculateStampCapacityMetrics = (
stamp: PostageBatch,
files: FileInfo[],
redundancyLevel?: RedundancyLevel,
useReportedOnly?: boolean,
): StampCapacityMetrics => {
let stampSizeBytes = 0
let remainingReportedBytes = 0
if (redundancyLevel !== undefined) {
stampSizeBytes = stamp.calculateSize(false, redundancyLevel).toBytes()
remainingReportedBytes = stamp.calculateRemainingSize(false, redundancyLevel).toBytes()
} else {
stampSizeBytes = stamp.size.toBytes()
remainingReportedBytes = stamp.remainingSize.toBytes()
}
const usedBytesReported = stampSizeBytes - remainingReportedBytes
const pctReportedStampUsage = stamp.usage * 100
let usedSizeMaxBytes = usedBytesReported
let pctFromDriveUsage = pctReportedStampUsage
let remainingBytes = remainingReportedBytes
if (!useReportedOnly) {
const usedBytesFromFiles = files
.map(f => {
let rawSize = 0
const lifecycle = (f.customMetadata?.lifecycle || '').toString().toLowerCase()
const isLifecycleOperation =
lifecycle === ActionTag.Trashed || lifecycle === ActionTag.Recovered || lifecycle === ActionTag.Restored
if (
(f.customMetadata?.size && !isLifecycleOperation) ||
(f.customMetadata?.size && !f.customMetadata?.accumulatedSize)
) {
rawSize = Number(f.customMetadata.size)
}
const accumulatedSize = Number(f.customMetadata?.accumulatedSize || rawSize || 0)
return accumulatedSize
})
.reduce((acc, current) => acc + current, 0)
const remainingBytesFromFiles = stampSizeBytes - usedBytesFromFiles > 0 ? stampSizeBytes - usedBytesFromFiles : 0
remainingBytes = Math.min(remainingReportedBytes, remainingBytesFromFiles)
usedSizeMaxBytes = Math.max(usedBytesFromFiles, usedBytesReported)
pctFromDriveUsage = stampSizeBytes > 0 ? (usedSizeMaxBytes / stampSizeBytes) * 100 : 0
}
const usedSizeMax = getHumanReadableFileSize(usedSizeMaxBytes)
const capacityPct = Math.max(pctFromDriveUsage, pctReportedStampUsage)
const stampSize = getHumanReadableFileSize(stampSizeBytes)
return {
capacityPct,
usedSize: usedSizeMax,
stampSize,
usedBytes: usedSizeMaxBytes,
stampSizeBytes,
remainingBytes,
}
}
export interface DriveSpaceOptions {
fm: FileManagerBase
driveId?: string
redundancyLevel: RedundancyLevel
stamp: PostageBatch
adminRedundancy?: RedundancyLevel
useInfoSize?: boolean
isRemove?: boolean
fileSize?: number
fileCount?: number
cb?: (msg: string) => void
}
export const verifyDriveSpace = (
options: DriveSpaceOptions,
): { remainingBytes: number; totalSizeBytes: number; ok: boolean } => {
const { fm, driveId, redundancyLevel, stamp, adminRedundancy, useInfoSize, isRemove, fileSize, fileCount, cb } = {
...options,
}
const drives = [...fm.driveList]
let filesPerDrives: FileInfo[] = []
// new drivelist state size calc.
if (isRemove) {
const driveIx = drives.findIndex(d => d.id.toString() === driveId?.toString())
if (driveIx === -1) {
cb?.(`Admin drive not found during stamp verification`)
return { remainingBytes: 0, totalSizeBytes: 0, ok: false }
}
drives.splice(driveIx, 1)
filesPerDrives = fm.fileInfoList.filter(fi => fi.driveId !== driveId)
} else {
filesPerDrives = driveId ? fm.fileInfoList.filter(fi => fi.driveId === driveId) : []
}
// admin stamp capacity calcl., needed for forget, destroy, create
if (adminRedundancy !== undefined && fm.adminStamp) {
// upper limit estimate on the drivelist metadata state size based on the number of drives and files
const estimatedDlSizeBytes = estimateDriveListMetadataSize(drives) * drives.length
const { remainingBytes: remainingAdminBytes } = calculateStampCapacityMetrics(fm.adminStamp, [], adminRedundancy)
const ok = remainingAdminBytes >= estimatedDlSizeBytes
if (!ok) {
cb?.(
`Insufficient admin drive capacity. Required: ~${getHumanReadableFileSize(
estimatedDlSizeBytes,
)} bytes, Available: ${getHumanReadableFileSize(
remainingAdminBytes,
)} bytes. Please top up the admin drive/stamp.`,
)
return { remainingBytes: remainingAdminBytes, totalSizeBytes: estimatedDlSizeBytes, ok }
}
}
// other fileinfo metadata size calc.
const estimatedFiSize = estimateFileInfoMetadataSize()
const count = fileCount ?? 1
const estimateReqSizeBytes = Number(Boolean(useInfoSize)) * estimatedFiSize * count + (fileSize ? fileSize : 0)
const { remainingBytes } = calculateStampCapacityMetrics(stamp, filesPerDrives, redundancyLevel)
const ok = remainingBytes >= estimateReqSizeBytes
if (!ok) {
cb?.(
`Insufficient capacity. Required: ~${getHumanReadableFileSize(
estimateReqSizeBytes,
)} bytes, Available: ${getHumanReadableFileSize(remainingBytes)} bytes. Please top up the drive/stamp.`,
)
}
return { remainingBytes, totalSizeBytes: estimateReqSizeBytes, ok }
}