From 987817c75c4e169cc12325be9fdedaecab173701 Mon Sep 17 00:00:00 2001 From: Hauser Date: Sat, 31 Oct 2026 13:37:40 +0800 Subject: [PATCH 1/3] feat: calculate folder statistics on demand Calculate current object size and latest modification time only when requested, with bounded pagination and cancellation. Co-Authored-By: heihutu Co-Authored-By: zhi22915 --- components/object/list.tsx | 123 ++++++++++++++++++++- hooks/use-object.ts | 7 ++ i18n/locales/ar-MA.json | 8 ++ i18n/locales/de-DE.json | 8 ++ i18n/locales/en-US.json | 8 ++ i18n/locales/es-ES.json | 8 ++ i18n/locales/fr-FR.json | 8 ++ i18n/locales/id-ID.json | 8 ++ i18n/locales/it-IT.json | 8 ++ i18n/locales/ja-JP.json | 8 ++ i18n/locales/ko-KR.json | 8 ++ i18n/locales/pt-BR.json | 8 ++ i18n/locales/ru-RU.json | 8 ++ i18n/locales/tr-TR.json | 8 ++ i18n/locales/vi-VN.json | 8 ++ i18n/locales/zh-CN.json | 8 ++ lib/prefix-summary-request.ts | 19 ++++ lib/prefix-summary.ts | 45 ++++++++ tests/lib/force-cleanup-components.test.ts | 39 ++++++- tests/lib/prefix-summary-request.test.ts | 28 +++++ tests/lib/prefix-summary.test.ts | 82 ++++++++++++++ 21 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 lib/prefix-summary-request.ts create mode 100644 lib/prefix-summary.ts create mode 100644 tests/lib/prefix-summary-request.test.ts create mode 100644 tests/lib/prefix-summary.test.ts diff --git a/components/object/list.tsx b/components/object/list.tsx index 8e1dcc25..db10b550 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -67,6 +67,7 @@ import { downloadUrl } from "@/lib/export-file" import { getAttachmentContentDisposition } from "@/lib/content-disposition" import { formatBytes, formatDateTime } from "@/lib/functions" import { cn } from "@/lib/utils" +import { PrefixSummaryLimitError } from "@/lib/prefix-summary" import { normalizeDateToIso } from "@/lib/safe-date" import { buildBucketPath } from "@/lib/bucket-path" import { @@ -115,6 +116,7 @@ interface ObjectRow { type: "prefix" | "object" Size: number LastModified: string + summary?: { size: number; lastModified?: Date } deleted: boolean } @@ -168,7 +170,7 @@ export function ObjectList({ const { t } = useTranslation() const message = useMessage() const api = useApi() - const { listObject, getSignedUrl, renameObject } = useObject(bucket) + const { listObject, calculatePrefixSummary, getSignedUrl, renameObject } = useObject(bucket) const { getBucketVersioning } = useBucket() const { canCapability } = usePermissions() const addDeleteKeys = useAddDeleteKeys() @@ -185,6 +187,14 @@ export function ObjectList({ OBJECT_LIST_DEFAULT_PAGE_SIZE, ) const deletedView = resolveObjectListDeletedView(storedDeletedView) + const [prefixSummaries, setPrefixSummaries] = React.useState>( + {}, + ) + const [prefixScan, setPrefixScan] = React.useState<{ + key: string + state: "loading" | "failed" | "cancelled" | "limit" + } | null>(null) + const prefixScanRef = React.useRef(null) const [loading, setLoading] = React.useState(false) const [listError, setListError] = React.useState(null) const [loadMoreError, setLoadMoreError] = React.useState(false) @@ -374,7 +384,52 @@ export function ObjectList({ [bucket, prefix, resolvedPageSize, includeDeleted, listObject, message, t], ) + const scanPrefix = React.useCallback( + async (key: string) => { + if (prefixScanRef.current) return + const controller = new AbortController() + prefixScanRef.current = controller + setPrefixSummaries((current) => { + const next = { ...current } + delete next[key] + return next + }) + setPrefixScan({ key, state: "loading" }) + try { + const summary = await calculatePrefixSummary(key, controller.signal) + if (prefixScanRef.current !== controller) return + setPrefixSummaries((current) => ({ ...current, [key]: summary })) + setPrefixScan(null) + } catch (error) { + if (prefixScanRef.current !== controller) return + setPrefixScan({ + key, + state: controller.signal.aborted + ? "cancelled" + : error instanceof PrefixSummaryLimitError + ? "limit" + : "failed", + }) + } finally { + if (prefixScanRef.current === controller) prefixScanRef.current = null + } + }, + [calculatePrefixSummary], + ) + + React.useEffect( + () => () => { + prefixScanRef.current?.abort() + prefixScanRef.current = null + }, + [listScope], + ) + const resetAndFetchObjects = React.useCallback(() => { + prefixScanRef.current?.abort() + prefixScanRef.current = null + setPrefixScan(null) + setPrefixSummaries({}) setNextToken(undefined) setData([]) void fetchObjects({ resetPagination: true }) @@ -583,8 +638,44 @@ export function ObjectList({ { id: "size", header: () => t("Size"), - accessorFn: (row) => (row.type === "object" ? row.Size : -1), - cell: ({ row }) => (row.original.type === "object" ? formatBytes(row.original.Size) : "-"), + accessorFn: (row) => (row.type === "object" ? row.Size : (row.summary?.size ?? -1)), + cell: ({ row }) => { + if (row.original.type === "object") return formatBytes(row.original.Size) + const key = row.original.Key + const summary = row.original.summary + const state = prefixScan?.key === key ? prefixScan.state : null + return ( +
+ {summary ? {formatBytes(summary.size)} : -} + {state === "loading" ? ( + <> + {t("Calculating folder statistics...")} + + + ) : ( + + )} + {state === "failed" ? {t("Could not calculate folder statistics.")} : null} + {state === "limit" ? {t("Folder exceeds the scan limit.")} : null} + {state === "cancelled" ? {t("Calculation cancelled.")} : null} +
+ ) + }, }, { id: "lastModified", @@ -596,8 +687,14 @@ export function ObjectList({ ) : null} ), - accessorFn: (row) => (row.type === "prefix" || !row.LastModified ? "" : new Date(row.LastModified).getTime()), - cell: ({ row }) => (row.original.LastModified ? formatDateTime(row.original.LastModified) : "-"), + accessorFn: (row) => { + const date = row.type === "prefix" ? row.summary?.lastModified : row.LastModified + return date ? new Date(date).getTime() : -1 + }, + cell: ({ row }) => { + const date = row.original.type === "prefix" ? row.original.summary?.lastModified : row.original.LastModified + return date ? formatDateTime(date) : "-" + }, }, { id: "actions", @@ -728,11 +825,18 @@ export function ObjectList({ nextToken, setRowActionTrigger, getRowActionTrigger, + prefixScan, + scanPrefix, ], ) + const tableRows = React.useMemo( + () => + filteredData.map((row) => ({ ...row, summary: row.type === "prefix" ? prefixSummaries[row.Key] : undefined })), + [filteredData, prefixSummaries], + ) const { table, selectedRowIds } = useDataTable({ - data: filteredData, + data: tableRows, columns, getRowId: (row) => row.Key, enableRowSelection: true, @@ -965,6 +1069,13 @@ export function ObjectList({ {heading ??

{bucket}

} + {data.some((row) => row.type === "prefix") ? ( +

+ {t( + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.", + )} +

+ ) : null} fetchPrefixSummary(client, bucket, prefix, signal), + [client, bucket], + ) + const mapAllFiles = useCallback( async (bucketName: string, prefix: string, callback: (fileKey: string) => void) => { let isTruncated = true @@ -340,6 +346,7 @@ export function useObject(bucket: string) { restoreObjectVersion, getSignedUrl: getSignedUrlFn, listObject, + calculatePrefixSummary, mapAllFiles, getObjectInfo, getObjectTags, diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 72696aae..d91d69cf 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "اكتمل مسح التخزين المؤقت مع تحذيرات", "Cache cleared successfully": "تم مسح التخزين المؤقت بنجاح", "Cache time-to-live in seconds, default: 600": "مدة صلاحية التخزين المؤقت بالثواني، الافتراضي: 600", + "Calculate folder statistics": "حساب إحصاءات المجلد", + "Calculate statistics for {prefix}": "حساب إحصاءات {prefix}", + "Cancel statistics for {prefix}": "إلغاء إحصاءات {prefix}", + "Calculating folder statistics...": "جارٍ حساب إحصاءات المجلد...", + "Could not calculate folder statistics.": "تعذر حساب إحصاءات المجلد.", + "Calculation cancelled.": "تم إلغاء الحساب.", + "Folder exceeds the scan limit.": "يتجاوز المجلد حد الفحص.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "تفحص إحصاءات المجلد حتى 100,000 كائن حالي. يعتمد آخر تعديل على هذه الكائنات؛ قد تتغير النتائج أثناء الفحص.", "Cancel": "إلغاء", "Cancel Deletion": "إلغاء الحذف", "Cancel Key Deletion": "إلغاء حذف المفتاح", diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index a653368e..6cfaae47 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Cache-Löschung mit Warnungen abgeschlossen", "Cache cleared successfully": "Cache erfolgreich gelöscht", "Cache time-to-live in seconds, default: 600": "Cache-Lebensdauer in Sekunden, Standard: 600", + "Calculate folder statistics": "Ordnerstatistik berechnen", + "Calculate statistics for {prefix}": "Statistik für {prefix} berechnen", + "Cancel statistics for {prefix}": "Statistik für {prefix} abbrechen", + "Calculating folder statistics...": "Ordnerstatistik wird berechnet...", + "Could not calculate folder statistics.": "Ordnerstatistik konnte nicht berechnet werden.", + "Calculation cancelled.": "Berechnung abgebrochen.", + "Folder exceeds the scan limit.": "Ordner überschreitet das Scanlimit.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Die Ordnerstatistik durchsucht bis zu 100.000 aktuelle Objekte. Die letzte Änderung basiert auf diesen Objekten; Ergebnisse können sich während des Scans ändern.", "Cancel": "Abbrechen", "Cancel Deletion": "Löschung abbrechen", "Cancel Key Deletion": "Schlüssellöschung abbrechen", diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 090e80fe..c1ede6fd 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -176,6 +176,14 @@ "Cache clear completed with warnings": "Cache clear completed with warnings", "Cache cleared successfully": "Cache cleared successfully", "Cache time-to-live in seconds, default: 600": "Cache time-to-live in seconds, default: 600", + "Calculate folder statistics": "Calculate folder statistics", + "Calculate statistics for {prefix}": "Calculate statistics for {prefix}", + "Cancel statistics for {prefix}": "Cancel statistics for {prefix}", + "Calculating folder statistics...": "Calculating folder statistics...", + "Could not calculate folder statistics.": "Could not calculate folder statistics.", + "Calculation cancelled.": "Calculation cancelled.", + "Folder exceeds the scan limit.": "Folder exceeds the scan limit.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.", "Cancel": "Cancel", "Cancel Deletion": "Cancel Deletion", "Cancel Key Deletion": "Cancel Key Deletion", diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index 3fc25920..6e7b0f97 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Limpieza de caché completada con advertencias", "Cache cleared successfully": "Caché limpiada exitosamente", "Cache time-to-live in seconds, default: 600": "Tiempo de vida de la caché en segundos, predeterminado: 600", + "Calculate folder statistics": "Calcular estadísticas de carpeta", + "Calculate statistics for {prefix}": "Calcular estadísticas de {prefix}", + "Cancel statistics for {prefix}": "Cancelar estadísticas de {prefix}", + "Calculating folder statistics...": "Calculando estadísticas de carpeta...", + "Could not calculate folder statistics.": "No se pudieron calcular las estadísticas.", + "Calculation cancelled.": "Cálculo cancelado.", + "Folder exceeds the scan limit.": "La carpeta supera el límite de exploración.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Las estadísticas exploran hasta 100.000 objetos actuales. La última modificación se basa en estos objetos; los resultados pueden cambiar durante la exploración.", "Cancel": "Cancelar", "Cancel Deletion": "Cancelar eliminación", "Cancel Key Deletion": "Cancelar eliminación de clave", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 4caff00b..d6f2a876 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Effacement du cache terminé avec des avertissements", "Cache cleared successfully": "Cache effacé avec succès", "Cache time-to-live in seconds, default: 600": "Durée de vie du cache en secondes, par défaut : 600", + "Calculate folder statistics": "Calculer les statistiques du dossier", + "Calculate statistics for {prefix}": "Calculer les statistiques de {prefix}", + "Cancel statistics for {prefix}": "Annuler les statistiques de {prefix}", + "Calculating folder statistics...": "Calcul des statistiques du dossier...", + "Could not calculate folder statistics.": "Impossible de calculer les statistiques.", + "Calculation cancelled.": "Calcul annulé.", + "Folder exceeds the scan limit.": "Le dossier dépasse la limite d’analyse.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Les statistiques analysent jusqu’à 100 000 objets actuels. La dernière modification concerne ces objets ; les résultats peuvent changer pendant l’analyse.", "Cancel": "Annuler", "Cancel Deletion": "Annuler la suppression", "Cancel Key Deletion": "Annuler la suppression de la clé", diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index a09f8c0a..e5f55fd2 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Pembersihan cache selesai dengan peringatan", "Cache cleared successfully": "Cache berhasil dibersihkan", "Cache time-to-live in seconds, default: 600": "Time-to-live cache dalam detik, default: 600", + "Calculate folder statistics": "Hitung statistik folder", + "Calculate statistics for {prefix}": "Hitung statistik untuk {prefix}", + "Cancel statistics for {prefix}": "Batalkan statistik untuk {prefix}", + "Calculating folder statistics...": "Menghitung statistik folder...", + "Could not calculate folder statistics.": "Statistik folder tidak dapat dihitung.", + "Calculation cancelled.": "Penghitungan dibatalkan.", + "Folder exceeds the scan limit.": "Folder melampaui batas pemindaian.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Statistik folder memindai hingga 100.000 objek saat ini. Modifikasi terakhir berdasarkan objek tersebut; hasil dapat berubah selama pemindaian.", "Cancel": "Batal", "Cancel Deletion": "Batalkan Penghapusan", "Cancel Key Deletion": "Batalkan Penghapusan Kunci", diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 0f2e5c4f..78f85f56 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Pulizia cache completata con avvisi", "Cache cleared successfully": "Cache pulita con successo", "Cache time-to-live in seconds, default: 600": "Tempo di vita cache in secondi, predefinito: 600", + "Calculate folder statistics": "Calcola statistiche cartella", + "Calculate statistics for {prefix}": "Calcola statistiche per {prefix}", + "Cancel statistics for {prefix}": "Annulla statistiche per {prefix}", + "Calculating folder statistics...": "Calcolo statistiche cartella...", + "Could not calculate folder statistics.": "Impossibile calcolare le statistiche.", + "Calculation cancelled.": "Calcolo annullato.", + "Folder exceeds the scan limit.": "La cartella supera il limite di scansione.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Le statistiche analizzano fino a 100.000 oggetti correnti. L’ultima modifica si basa su questi oggetti; i risultati possono cambiare durante la scansione.", "Cancel": "Annulla", "Cancel Deletion": "Annulla eliminazione", "Cancel Key Deletion": "Annulla eliminazione chiave", diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index f8ee7b02..4ef37d84 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "警告付きでキャッシュクリアが完了しました", "Cache cleared successfully": "キャッシュが正常にクリアされました", "Cache time-to-live in seconds, default: 600": "キャッシュの生存時間(秒)、デフォルト: 600", + "Calculate folder statistics": "フォルダーの統計を計算", + "Calculate statistics for {prefix}": "{prefix} の統計を計算", + "Cancel statistics for {prefix}": "{prefix} の統計をキャンセル", + "Calculating folder statistics...": "フォルダーの統計を計算中...", + "Could not calculate folder statistics.": "フォルダーの統計を計算できませんでした。", + "Calculation cancelled.": "計算をキャンセルしました。", + "Folder exceeds the scan limit.": "フォルダーがスキャン上限を超えています。", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "フォルダーの統計は現在のオブジェクトを最大 100,000 件スキャンします。最終更新はこれらに基づき、スキャン中に結果が変わる場合があります。", "Cancel": "キャンセル", "Cancel Deletion": "削除をキャンセル", "Cancel Key Deletion": "キー削除をキャンセル", diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 2760cfcf..ced4ef88 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "경고와 함께 캐시 지우기 완료", "Cache cleared successfully": "캐시가 성공적으로 지워짐", "Cache time-to-live in seconds, default: 600": "캐시 생존 시간(초), 기본값: 600", + "Calculate folder statistics": "폴더 통계 계산", + "Calculate statistics for {prefix}": "{prefix} 통계 계산", + "Cancel statistics for {prefix}": "{prefix} 통계 취소", + "Calculating folder statistics...": "폴더 통계 계산 중...", + "Could not calculate folder statistics.": "폴더 통계를 계산할 수 없습니다.", + "Calculation cancelled.": "계산이 취소되었습니다.", + "Folder exceeds the scan limit.": "폴더가 스캔 한도를 초과했습니다.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "폴더 통계는 현재 객체를 최대 100,000개 스캔합니다. 최근 수정 시간은 이 객체를 기준으로 하며 스캔 중 결과가 바뀔 수 있습니다.", "Cancel": "취소", "Cancel Deletion": "삭제 취소", "Cancel Key Deletion": "키 삭제 취소", diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index c49c08fd..05bdfe99 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Limpeza de cache concluída com avisos", "Cache cleared successfully": "Cache limpo com sucesso", "Cache time-to-live in seconds, default: 600": "Tempo de vida do cache em segundos, padrão: 600", + "Calculate folder statistics": "Calcular estatísticas da pasta", + "Calculate statistics for {prefix}": "Calcular estatísticas de {prefix}", + "Cancel statistics for {prefix}": "Cancelar estatísticas de {prefix}", + "Calculating folder statistics...": "Calculando estatísticas da pasta...", + "Could not calculate folder statistics.": "Não foi possível calcular as estatísticas.", + "Calculation cancelled.": "Cálculo cancelado.", + "Folder exceeds the scan limit.": "A pasta excede o limite de varredura.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "As estatísticas verificam até 100.000 objetos atuais. A última modificação considera esses objetos; os resultados podem mudar durante a varredura.", "Cancel": "Cancelar", "Cancel Deletion": "Cancelar exclusão", "Cancel Key Deletion": "Cancelar exclusão da chave", diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index f35fe3a0..4fdc21e5 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Очистка кэша завершена с предупреждениями", "Cache cleared successfully": "Кэш успешно очищен", "Cache time-to-live in seconds, default: 600": "Время жизни кэша в секундах, по умолчанию: 600", + "Calculate folder statistics": "Рассчитать статистику папки", + "Calculate statistics for {prefix}": "Рассчитать статистику для {prefix}", + "Cancel statistics for {prefix}": "Отменить статистику для {prefix}", + "Calculating folder statistics...": "Расчёт статистики папки...", + "Could not calculate folder statistics.": "Не удалось рассчитать статистику папки.", + "Calculation cancelled.": "Расчёт отменён.", + "Folder exceeds the scan limit.": "Папка превышает лимит сканирования.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Статистика папки охватывает до 100 000 текущих объектов. Время изменения относится к ним; результаты могут измениться во время сканирования.", "Cancel": "Отмена", "Cancel Deletion": "Отменить удаление", "Cancel Key Deletion": "Отменить удаление ключа", diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index ef6fded4..0231086f 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Önbellek temizleme işlemi uyarılarla tamamlandı", "Cache cleared successfully": "Önbellek başarıyla temizlendi", "Cache time-to-live in seconds, default: 600": "Saniye cinsinden önbellek yaşam süresi, varsayılan: 600", + "Calculate folder statistics": "Klasör istatistiklerini hesapla", + "Calculate statistics for {prefix}": "{prefix} için istatistikleri hesapla", + "Cancel statistics for {prefix}": "{prefix} istatistiklerini iptal et", + "Calculating folder statistics...": "Klasör istatistikleri hesaplanıyor...", + "Could not calculate folder statistics.": "Klasör istatistikleri hesaplanamadı.", + "Calculation cancelled.": "Hesaplama iptal edildi.", + "Folder exceeds the scan limit.": "Klasör tarama sınırını aşıyor.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Klasör istatistikleri en fazla 100.000 geçerli nesneyi tarar. Son değişiklik bu nesnelere dayanır; sonuçlar tarama sırasında değişebilir.", "Cancel": "İptal", "Cancel Deletion": "Silmeyi iptal et", "Cancel Key Deletion": "Anahtar silmeyi iptal et", diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index a10eefd5..82eae824 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -179,6 +179,14 @@ "Cache clear completed with warnings": "Hoàn tất xóa bộ nhớ đệm với một số cảnh báo", "Cache cleared successfully": "Đã xóa bộ nhớ đệm thành công", "Cache time-to-live in seconds, default: 600": "Thời gian tồn tại của bộ nhớ đệm tính bằng giây, mặc định: 600", + "Calculate folder statistics": "Tính thống kê thư mục", + "Calculate statistics for {prefix}": "Tính thống kê cho {prefix}", + "Cancel statistics for {prefix}": "Hủy thống kê cho {prefix}", + "Calculating folder statistics...": "Đang tính thống kê thư mục...", + "Could not calculate folder statistics.": "Không thể tính thống kê thư mục.", + "Calculation cancelled.": "Đã hủy tính toán.", + "Folder exceeds the scan limit.": "Thư mục vượt giới hạn quét.", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Thống kê thư mục quét tối đa 100.000 đối tượng hiện tại. Thời gian sửa đổi dựa trên các đối tượng này; kết quả có thể thay đổi trong khi quét.", "Cancel": "Hủy bỏ", "Cancel Deletion": "Hủy xóa", "Cancel Key Deletion": "Hủy xóa khóa", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index c36b3bcb..d27d89b5 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -176,6 +176,14 @@ "Cache clear completed with warnings": "缓存清除完成,但有警告", "Cache cleared successfully": "缓存清除成功", "Cache time-to-live in seconds, default: 600": "缓存生存时间(秒),默认:600", + "Calculate folder statistics": "计算文件夹统计", + "Calculate statistics for {prefix}": "计算 {prefix} 的统计", + "Cancel statistics for {prefix}": "取消 {prefix} 的统计", + "Calculating folder statistics...": "正在计算文件夹统计...", + "Could not calculate folder statistics.": "无法计算文件夹统计。", + "Calculation cancelled.": "计算已取消。", + "Folder exceeds the scan limit.": "文件夹超出扫描上限。", + "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "文件夹统计最多扫描 100,000 个当前对象。最近修改时间基于这些对象;扫描期间结果可能发生变化。", "Cancel": "取消", "Cancel Deletion": "取消删除", "Cancel Key Deletion": "取消密钥删除", diff --git a/lib/prefix-summary-request.ts b/lib/prefix-summary-request.ts new file mode 100644 index 00000000..e6f0c39b --- /dev/null +++ b/lib/prefix-summary-request.ts @@ -0,0 +1,19 @@ +import { ListObjectsV2Command, type S3Client } from "@aws-sdk/client-s3" +import { summarizePrefix } from "./prefix-summary" + +export function fetchPrefixSummary(client: S3Client, bucket: string, prefix: string, signal: AbortSignal) { + return summarizePrefix( + (token, abortSignal) => + client.send( + new ListObjectsV2Command({ + Bucket: bucket, + Prefix: prefix, + MaxKeys: 1000, + ContinuationToken: token, + EncodingType: "url", + }), + { abortSignal }, + ), + signal, + ) +} diff --git a/lib/prefix-summary.ts b/lib/prefix-summary.ts new file mode 100644 index 00000000..f0cf0576 --- /dev/null +++ b/lib/prefix-summary.ts @@ -0,0 +1,45 @@ +export interface PrefixSummaryPage { + Contents?: Array<{ Size?: number; LastModified?: Date }> + IsTruncated?: boolean + NextContinuationToken?: string +} + +export class PrefixSummaryLimitError extends Error {} + +// A bounded, explicit scan. S3 pagination is not a point-in-time snapshot. +export async function summarizePrefix( + fetchPage: (token: string | undefined, signal: AbortSignal) => Promise, + signal: AbortSignal, +) { + let count = 0 + let size = 0 + let lastModified: Date | undefined + let token: string | undefined + const seen = new Set() + for (let page = 0; page < 100; page++) { + signal.throwIfAborted() + const response = await fetchPage(token, signal) + signal.throwIfAborted() + for (const object of response.Contents ?? []) { + if (++count > 100_000) throw new PrefixSummaryLimitError("Prefix is too large to calculate in one scan") + const timestamp = object.LastModified?.getTime() + if ( + !Number.isSafeInteger(object.Size) || + object.Size! < 0 || + timestamp === undefined || + !Number.isFinite(timestamp) + ) { + throw new Error("Incomplete object metadata") + } + size += object.Size! + if (!Number.isSafeInteger(size)) throw new Error("Prefix size exceeds the supported range") + if (!lastModified || timestamp > lastModified.getTime()) lastModified = object.LastModified + } + if (response.IsTruncated === false) return { size, lastModified } + const next = response.NextContinuationToken + if (response.IsTruncated !== true || !next || seen.has(next)) throw new Error("Invalid pagination response") + seen.add(next) + token = next + } + throw new PrefixSummaryLimitError("Prefix is too large to calculate in one scan") +} diff --git a/tests/lib/force-cleanup-components.test.ts b/tests/lib/force-cleanup-components.test.ts index 8a5868bd..0092ea0a 100644 --- a/tests/lib/force-cleanup-components.test.ts +++ b/tests/lib/force-cleanup-components.test.ts @@ -182,7 +182,12 @@ const fullKey = "parent/中文%2F+space /child/" const consequences = "Permanently delete all objects, versions, delete markers, and residual files in this folder. This cannot be undone." -async function mount(t: TestContext, initialProps: ObjectListProps, versioning: string = "Suspended") { +async function mount( + t: TestContext, + initialProps: ObjectListProps, + versioning: string = "Suspended", + calculatePrefixSummary?: (key: string, signal: AbortSignal) => Promise<{ size: number; lastModified?: Date }>, +) { const browser = new Window() const globals = { window: browser, document: browser.document, IS_REACT_ACT_ENVIRONMENT: true } const descriptors = Object.keys(globals).map( @@ -205,6 +210,7 @@ async function mount(t: TestContext, initialProps: ObjectListProps, versioning: CommonPrefixes: folders.map((Prefix) => ({ Prefix })), Contents: [{ Key: "file.txt" }, { Key: "literal-object/" }], }), + calculatePrefixSummary, getSignedUrl: async () => "", renameObject: async () => {}, } @@ -251,7 +257,7 @@ async function mount(t: TestContext, initialProps: ObjectListProps, versioning: (element) => element.getAttribute("data-object-key") === key, ) assert.ok(row, `expected row ${key}`) - await click(row.querySelector("button[aria-label]")) + await click(row.querySelector('button[aria-label^="Actions:"]')) return row } t.after(async () => { @@ -422,3 +428,32 @@ test("successful tasks do not display stale backend errors", async (t) => { await React.act(async () => ui.root.render(React.createElement(TaskItem, { task }))) assert.equal(ui.container.querySelector('[role="alert"]'), null) }) + +test("folder statistics do not land in a new bucket after a scope change", async (t) => { + const response = Promise.withResolvers<{ size: number; lastModified?: Date }>() + let usedSignal: AbortSignal | undefined + const ui = await mount(t, props, "Suspended", async (_key, signal) => { + usedSignal = signal + return response.promise + }) + await ui.click(ui.findButton("Calculate folder statistics")) + assert.equal(usedSignal?.aborted, false) + await ui.render({ bucket: "other-bucket" }) + assert.equal(usedSignal?.aborted, true) + await React.act(async () => response.resolve({ size: 731, lastModified: new Date("2026-01-01") })) + assert.equal(ui.container.textContent?.includes("731"), false) +}) + +test("refresh cancels folder statistics and ignores a late result", async (t) => { + const response = Promise.withResolvers<{ size: number; lastModified?: Date }>() + let usedSignal: AbortSignal | undefined + const ui = await mount(t, props, "Suspended", async (_key, signal) => { + usedSignal = signal + return response.promise + }) + await ui.click(ui.findButton("Calculate folder statistics")) + await ui.render({ refreshTrigger: 1 }) + assert.equal(usedSignal?.aborted, true) + await React.act(async () => response.resolve({ size: 731 })) + assert.equal(ui.container.textContent?.includes("731"), false) +}) diff --git a/tests/lib/prefix-summary-request.test.ts b/tests/lib/prefix-summary-request.test.ts new file mode 100644 index 00000000..0cc2b527 --- /dev/null +++ b/tests/lib/prefix-summary-request.test.ts @@ -0,0 +1,28 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { ListObjectsV2Command, type S3Client } from "@aws-sdk/client-s3" +import { fetchPrefixSummary } from "../../lib/prefix-summary-request" + +test("requests each page using the literal prefix, URL encoding and abort signal without a delimiter", async () => { + const controller = new AbortController() + const calls: Array<{ command: ListObjectsV2Command; signal: AbortSignal }> = [] + const client = { + send: async (command: ListObjectsV2Command, { abortSignal }: { abortSignal: AbortSignal }) => { + calls.push({ command, signal: abortSignal }) + return calls.length === 1 + ? { IsTruncated: true, NextContinuationToken: "opaque" } + : { IsTruncated: false, Contents: [{ Size: 5, LastModified: new Date("2026-01-01") }] } + }, + } as unknown as S3Client + const summary = await fetchPrefixSummary(client, "bucket", "a//%☃/", controller.signal) + assert.equal(summary.size, 5) + assert.equal(calls.length, 2) + for (const { command, signal } of calls) { + assert.ok(command instanceof ListObjectsV2Command) + assert.equal(command.input.Prefix, "a//%☃/") + assert.equal(command.input.Delimiter, undefined) + assert.equal(command.input.EncodingType, "url") + assert.equal(signal, controller.signal) + } + assert.equal(calls[1].command.input.ContinuationToken, "opaque") +}) diff --git a/tests/lib/prefix-summary.test.ts b/tests/lib/prefix-summary.test.ts new file mode 100644 index 00000000..e0fd2456 --- /dev/null +++ b/tests/lib/prefix-summary.test.ts @@ -0,0 +1,82 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { summarizePrefix } from "../../lib/prefix-summary" + +const signal = () => new AbortController().signal +const object = (size: number, time: string) => ({ Size: size, LastModified: new Date(time) }) + +test("combines pages and returns the newest object time", async () => { + const result = await summarizePrefix( + async (token) => + token + ? { Contents: [object(4, "2026-02-01")], IsTruncated: false } + : { Contents: [object(3, "2026-01-01")], IsTruncated: true, NextContinuationToken: "next" }, + signal(), + ) + assert.deepEqual(result, { size: 7, lastModified: new Date("2026-02-01") }) +}) + +test("empty prefixes have zero size and no object time", async () => { + assert.deepEqual(await summarizePrefix(async () => ({ IsTruncated: false }), signal()), { + size: 0, + lastModified: undefined, + }) +}) + +test("rejects missing metadata and unsafe totals", async () => { + await assert.rejects(summarizePrefix(async () => ({ Contents: [{ Size: 2 }], IsTruncated: false }), signal())) + await assert.rejects( + summarizePrefix( + async () => ({ + Contents: [object(Number.MAX_SAFE_INTEGER, "2026-01-01"), object(1, "2026-01-01")], + IsTruncated: false, + }), + signal(), + ), + ) +}) + +test("rejects missing and repeated continuation tokens", async () => { + await assert.rejects(summarizePrefix(async () => ({ IsTruncated: true }), signal())) + await assert.rejects(summarizePrefix(async () => ({ IsTruncated: true, NextContinuationToken: "same" }), signal())) +}) + +test("limits long scans and propagates network errors and cancellation", async () => { + let pages = 0 + await assert.rejects( + summarizePrefix(async () => ({ IsTruncated: true, NextContinuationToken: String(++pages) }), signal()), + ) + assert.equal(pages, 100) + await assert.rejects( + summarizePrefix(async () => { + throw new Error("network") + }, signal()), + /network/, + ) + const controller = new AbortController() + controller.abort() + await assert.rejects(summarizePrefix(async () => ({ IsTruncated: false }), controller.signal)) +}) + +test("rejects malformed timestamps, oversized pages and missing truncation state", async () => { + await assert.rejects( + summarizePrefix(async () => ({ Contents: [object(1, "invalid")], IsTruncated: false }), signal()), + ) + await assert.rejects(summarizePrefix(async () => ({ IsTruncated: undefined }), signal())) + await assert.rejects( + summarizePrefix( + async () => ({ Contents: Array(100_001).fill(object(0, "2026-01-01")), IsTruncated: false }), + signal(), + ), + ) +}) + +test("cancellation after a page request never reports a partial result", async () => { + const controller = new AbortController() + await assert.rejects( + summarizePrefix(async () => { + controller.abort() + return { Contents: [object(1, "2026-01-01")], IsTruncated: false } + }, controller.signal), + ) +}) From 2d00c513c0c69f3e3cfa87d180f15cbf31f2b32e Mon Sep 17 00:00:00 2001 From: Hauser Date: Sat, 31 Oct 2026 14:42:42 +0800 Subject: [PATCH 2/3] perf: reuse unchanged object list rows Avoid cloning every loaded row when prefix statistics change, and explain the page limit alongside the object limit. Co-Authored-By: heihutu Co-Authored-By: zhi22915 --- components/object/list.tsx | 14 ++++++---- i18n/locales/ar-MA.json | 2 +- i18n/locales/de-DE.json | 2 +- i18n/locales/en-US.json | 2 +- i18n/locales/es-ES.json | 2 +- i18n/locales/fr-FR.json | 2 +- i18n/locales/id-ID.json | 2 +- i18n/locales/it-IT.json | 2 +- i18n/locales/ja-JP.json | 2 +- i18n/locales/ko-KR.json | 2 +- i18n/locales/pt-BR.json | 2 +- i18n/locales/ru-RU.json | 2 +- i18n/locales/tr-TR.json | 2 +- i18n/locales/vi-VN.json | 2 +- i18n/locales/zh-CN.json | 2 +- tests/lib/force-cleanup-components.test.ts | 13 +++++++++ tests/lib/prefix-summary.test.ts | 32 +++++++++++++++++++++- 17 files changed, 66 insertions(+), 21 deletions(-) diff --git a/components/object/list.tsx b/components/object/list.tsx index db10b550..a1fbc896 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -830,11 +830,13 @@ export function ObjectList({ ], ) - const tableRows = React.useMemo( - () => - filteredData.map((row) => ({ ...row, summary: row.type === "prefix" ? prefixSummaries[row.Key] : undefined })), - [filteredData, prefixSummaries], - ) + const tableRows = React.useMemo(() => { + if (Object.keys(prefixSummaries).length === 0) return filteredData + return filteredData.map((row) => { + const summary = row.type === "prefix" ? prefixSummaries[row.Key] : undefined + return summary ? { ...row, summary } : row + }) + }, [filteredData, prefixSummaries]) const { table, selectedRowIds } = useDataTable({ data: tableRows, columns, @@ -1072,7 +1074,7 @@ export function ObjectList({ {data.some((row) => row.type === "prefix") ? (

{t( - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.", )}

) : null} diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index d91d69cf..0b720c8c 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "تعذر حساب إحصاءات المجلد.", "Calculation cancelled.": "تم إلغاء الحساب.", "Folder exceeds the scan limit.": "يتجاوز المجلد حد الفحص.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "تفحص إحصاءات المجلد حتى 100,000 كائن حالي. يعتمد آخر تعديل على هذه الكائنات؛ قد تتغير النتائج أثناء الفحص.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "تفحص إحصاءات المجلد حتى 100 صفحة أو 100,000 كائن حالي، أيهما يتحقق أولاً. يعتمد آخر تعديل على هذه الكائنات؛ قد تتغير النتائج أثناء الفحص.", "Cancel": "إلغاء", "Cancel Deletion": "إلغاء الحذف", "Cancel Key Deletion": "إلغاء حذف المفتاح", diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 6cfaae47..707312ff 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Ordnerstatistik konnte nicht berechnet werden.", "Calculation cancelled.": "Berechnung abgebrochen.", "Folder exceeds the scan limit.": "Ordner überschreitet das Scanlimit.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Die Ordnerstatistik durchsucht bis zu 100.000 aktuelle Objekte. Die letzte Änderung basiert auf diesen Objekten; Ergebnisse können sich während des Scans ändern.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Die Ordnerstatistik durchsucht höchstens 100 Seiten oder 100.000 aktuelle Objekte, je nachdem, welches Limit zuerst erreicht wird. Die letzte Änderung basiert auf diesen Objekten; Ergebnisse können sich während des Scans ändern.", "Cancel": "Abbrechen", "Cancel Deletion": "Löschung abbrechen", "Cancel Key Deletion": "Schlüssellöschung abbrechen", diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index c1ede6fd..61f29494 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -183,7 +183,7 @@ "Could not calculate folder statistics.": "Could not calculate folder statistics.", "Calculation cancelled.": "Calculation cancelled.", "Folder exceeds the scan limit.": "Folder exceeds the scan limit.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.", "Cancel": "Cancel", "Cancel Deletion": "Cancel Deletion", "Cancel Key Deletion": "Cancel Key Deletion", diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index 6e7b0f97..321fb373 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "No se pudieron calcular las estadísticas.", "Calculation cancelled.": "Cálculo cancelado.", "Folder exceeds the scan limit.": "La carpeta supera el límite de exploración.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Las estadísticas exploran hasta 100.000 objetos actuales. La última modificación se basa en estos objetos; los resultados pueden cambiar durante la exploración.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Las estadísticas exploran hasta 100 páginas o 100.000 objetos actuales, lo que ocurra primero. La última modificación se basa en estos objetos; los resultados pueden cambiar durante la exploración.", "Cancel": "Cancelar", "Cancel Deletion": "Cancelar eliminación", "Cancel Key Deletion": "Cancelar eliminación de clave", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index d6f2a876..90840554 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Impossible de calculer les statistiques.", "Calculation cancelled.": "Calcul annulé.", "Folder exceeds the scan limit.": "Le dossier dépasse la limite d’analyse.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Les statistiques analysent jusqu’à 100 000 objets actuels. La dernière modification concerne ces objets ; les résultats peuvent changer pendant l’analyse.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Les statistiques analysent au maximum 100 pages ou 100 000 objets actuels, selon la première limite atteinte. La dernière modification concerne ces objets ; les résultats peuvent changer pendant l’analyse.", "Cancel": "Annuler", "Cancel Deletion": "Annuler la suppression", "Cancel Key Deletion": "Annuler la suppression de la clé", diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index e5f55fd2..1e22dca0 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Statistik folder tidak dapat dihitung.", "Calculation cancelled.": "Penghitungan dibatalkan.", "Folder exceeds the scan limit.": "Folder melampaui batas pemindaian.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Statistik folder memindai hingga 100.000 objek saat ini. Modifikasi terakhir berdasarkan objek tersebut; hasil dapat berubah selama pemindaian.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Statistik folder memindai hingga 100 halaman atau 100.000 objek saat ini, mana pun yang tercapai lebih dahulu. Modifikasi terakhir berdasarkan objek tersebut; hasil dapat berubah selama pemindaian.", "Cancel": "Batal", "Cancel Deletion": "Batalkan Penghapusan", "Cancel Key Deletion": "Batalkan Penghapusan Kunci", diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 78f85f56..4fe41a25 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Impossibile calcolare le statistiche.", "Calculation cancelled.": "Calcolo annullato.", "Folder exceeds the scan limit.": "La cartella supera il limite di scansione.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Le statistiche analizzano fino a 100.000 oggetti correnti. L’ultima modifica si basa su questi oggetti; i risultati possono cambiare durante la scansione.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Le statistiche analizzano fino a 100 pagine o 100.000 oggetti correnti, a seconda del limite raggiunto per primo. L’ultima modifica si basa su questi oggetti; i risultati possono cambiare durante la scansione.", "Cancel": "Annulla", "Cancel Deletion": "Annulla eliminazione", "Cancel Key Deletion": "Annulla eliminazione chiave", diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 4ef37d84..81d64f42 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "フォルダーの統計を計算できませんでした。", "Calculation cancelled.": "計算をキャンセルしました。", "Folder exceeds the scan limit.": "フォルダーがスキャン上限を超えています。", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "フォルダーの統計は現在のオブジェクトを最大 100,000 件スキャンします。最終更新はこれらに基づき、スキャン中に結果が変わる場合があります。", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "フォルダーの統計は最大 100 ページまたは現在のオブジェクト 100,000 件のいずれか先に達するまでスキャンします。最終更新はこれらに基づき、スキャン中に結果が変わる場合があります。", "Cancel": "キャンセル", "Cancel Deletion": "削除をキャンセル", "Cancel Key Deletion": "キー削除をキャンセル", diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index ced4ef88..67eafa77 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "폴더 통계를 계산할 수 없습니다.", "Calculation cancelled.": "계산이 취소되었습니다.", "Folder exceeds the scan limit.": "폴더가 스캔 한도를 초과했습니다.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "폴더 통계는 현재 객체를 최대 100,000개 스캔합니다. 최근 수정 시간은 이 객체를 기준으로 하며 스캔 중 결과가 바뀔 수 있습니다.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "폴더 통계는 최대 100페이지 또는 현재 객체 100,000개 중 먼저 도달하는 한도까지 스캔합니다. 최근 수정 시간은 이 객체를 기준으로 하며 스캔 중 결과가 바뀔 수 있습니다.", "Cancel": "취소", "Cancel Deletion": "삭제 취소", "Cancel Key Deletion": "키 삭제 취소", diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 05bdfe99..5d35237e 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Não foi possível calcular as estatísticas.", "Calculation cancelled.": "Cálculo cancelado.", "Folder exceeds the scan limit.": "A pasta excede o limite de varredura.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "As estatísticas verificam até 100.000 objetos atuais. A última modificação considera esses objetos; os resultados podem mudar durante a varredura.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "As estatísticas verificam até 100 páginas ou 100.000 objetos atuais, o que ocorrer primeiro. A última modificação considera esses objetos; os resultados podem mudar durante a varredura.", "Cancel": "Cancelar", "Cancel Deletion": "Cancelar exclusão", "Cancel Key Deletion": "Cancelar exclusão da chave", diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index 4fdc21e5..8d0c2777 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Не удалось рассчитать статистику папки.", "Calculation cancelled.": "Расчёт отменён.", "Folder exceeds the scan limit.": "Папка превышает лимит сканирования.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Статистика папки охватывает до 100 000 текущих объектов. Время изменения относится к ним; результаты могут измениться во время сканирования.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Статистика папки охватывает до 100 страниц или 100 000 текущих объектов, в зависимости от того, какой предел достигнут первым. Время изменения относится к ним; результаты могут измениться во время сканирования.", "Cancel": "Отмена", "Cancel Deletion": "Отменить удаление", "Cancel Key Deletion": "Отменить удаление ключа", diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 0231086f..0b09da5a 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Klasör istatistikleri hesaplanamadı.", "Calculation cancelled.": "Hesaplama iptal edildi.", "Folder exceeds the scan limit.": "Klasör tarama sınırını aşıyor.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Klasör istatistikleri en fazla 100.000 geçerli nesneyi tarar. Son değişiklik bu nesnelere dayanır; sonuçlar tarama sırasında değişebilir.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Klasör istatistikleri en fazla 100 sayfa veya 100.000 geçerli nesneyi, hangisine önce ulaşılırsa, tarar. Son değişiklik bu nesnelere dayanır; sonuçlar tarama sırasında değişebilir.", "Cancel": "İptal", "Cancel Deletion": "Silmeyi iptal et", "Cancel Key Deletion": "Anahtar silmeyi iptal et", diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index 82eae824..d4f84c0f 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -186,7 +186,7 @@ "Could not calculate folder statistics.": "Không thể tính thống kê thư mục.", "Calculation cancelled.": "Đã hủy tính toán.", "Folder exceeds the scan limit.": "Thư mục vượt giới hạn quét.", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "Thống kê thư mục quét tối đa 100.000 đối tượng hiện tại. Thời gian sửa đổi dựa trên các đối tượng này; kết quả có thể thay đổi trong khi quét.", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "Thống kê thư mục quét tối đa 100 trang hoặc 100.000 đối tượng hiện tại, tùy giới hạn nào đạt trước. Thời gian sửa đổi dựa trên các đối tượng này; kết quả có thể thay đổi trong khi quét.", "Cancel": "Hủy bỏ", "Cancel Deletion": "Hủy xóa", "Cancel Key Deletion": "Hủy xóa khóa", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index d27d89b5..9513dfe8 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -183,7 +183,7 @@ "Could not calculate folder statistics.": "无法计算文件夹统计。", "Calculation cancelled.": "计算已取消。", "Folder exceeds the scan limit.": "文件夹超出扫描上限。", - "Folder statistics scan up to 100,000 current objects. Latest modification is based on these objects; results may change during the scan.": "文件夹统计最多扫描 100,000 个当前对象。最近修改时间基于这些对象;扫描期间结果可能发生变化。", + "Folder statistics scan up to 100 pages or 100,000 current objects, whichever comes first. Latest modification is based on these objects; results may change during the scan.": "文件夹统计最多扫描 100 页或 100,000 个当前对象,以先达到的上限为准。最近修改时间基于这些对象;扫描期间结果可能发生变化。", "Cancel": "取消", "Cancel Deletion": "取消删除", "Cancel Key Deletion": "取消密钥删除", diff --git a/tests/lib/force-cleanup-components.test.ts b/tests/lib/force-cleanup-components.test.ts index 0092ea0a..b99400b5 100644 --- a/tests/lib/force-cleanup-components.test.ts +++ b/tests/lib/force-cleanup-components.test.ts @@ -457,3 +457,16 @@ test("refresh cancels folder statistics and ignores a late result", async (t) => await React.act(async () => response.resolve({ size: 731 })) assert.equal(ui.container.textContent?.includes("731"), false) }) + +test("folder statistics update only the requested row and clear on refresh", async (t) => { + const ui = await mount(t, props, "Suspended", async () => ({ size: 731 })) + await ui.click(ui.findButton("Calculate folder statistics")) + const rows = Array.from(ui.container.querySelectorAll("[data-object-key]")) + assert.ok(rows.find((row) => row.getAttribute("data-object-key") === fullKey)?.textContent?.includes("731")) + assert.equal( + rows.find((row) => row.getAttribute("data-object-key") === "other/")?.textContent?.includes("731"), + false, + ) + await ui.render({ refreshTrigger: 1 }) + assert.equal(ui.container.textContent?.includes("731"), false) +}) diff --git a/tests/lib/prefix-summary.test.ts b/tests/lib/prefix-summary.test.ts index e0fd2456..973d66af 100644 --- a/tests/lib/prefix-summary.test.ts +++ b/tests/lib/prefix-summary.test.ts @@ -1,6 +1,6 @@ import test from "node:test" import assert from "node:assert/strict" -import { summarizePrefix } from "../../lib/prefix-summary" +import { PrefixSummaryLimitError, summarizePrefix } from "../../lib/prefix-summary" const signal = () => new AbortController().signal const object = (size: number, time: string) => ({ Size: size, LastModified: new Date(time) }) @@ -80,3 +80,33 @@ test("cancellation after a page request never reports a partial result", async ( }, controller.signal), ) }) + +test("accepts a short final page at the 100-page limit", async () => { + let pages = 0 + const result = await summarizePrefix(async () => { + pages++ + return { + Contents: [object(1, "2026-01-01")], + IsTruncated: pages < 100, + NextContinuationToken: pages < 100 ? String(pages) : undefined, + } + }, signal()) + assert.equal(pages, 100) + assert.equal(result.size, 100) +}) + +test("rejects a truncated 100th short page without presenting partial statistics", async () => { + let pages = 0 + await assert.rejects( + summarizePrefix(async () => { + pages++ + return { + Contents: [object(1, "2026-01-01")], + IsTruncated: true, + NextContinuationToken: String(pages), + } + }, signal()), + PrefixSummaryLimitError, + ) + assert.equal(pages, 100) +}) From 3ffa56e0a9581c810f6f0caeb44249b7fbc674b5 Mon Sep 17 00:00:00 2001 From: Hauser Date: Sat, 31 Oct 2026 15:06:50 +0800 Subject: [PATCH 3/3] fix: keep folder scan status accessible Keep progress and outcomes available when filtering, sorting, or virtual scrolling hides the scanned row, and ignore responses that arrive after cancellation. Co-Authored-By: heihutu Co-Authored-By: zhi22915 --- components/object/list.tsx | 76 ++++++++++---- tests/lib/force-cleanup-components.test.ts | 115 ++++++++++++++++++++- 2 files changed, 170 insertions(+), 21 deletions(-) diff --git a/components/object/list.tsx b/components/object/list.tsx index a1fbc896..24239eec 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -192,7 +192,7 @@ export function ObjectList({ ) const [prefixScan, setPrefixScan] = React.useState<{ key: string - state: "loading" | "failed" | "cancelled" | "limit" + state: "loading" | "failed" | "cancelled" | "limit" | "success" } | null>(null) const prefixScanRef = React.useRef(null) const [loading, setLoading] = React.useState(false) @@ -398,8 +398,9 @@ export function ObjectList({ try { const summary = await calculatePrefixSummary(key, controller.signal) if (prefixScanRef.current !== controller) return + controller.signal.throwIfAborted() setPrefixSummaries((current) => ({ ...current, [key]: summary })) - setPrefixScan(null) + setPrefixScan({ key, state: "success" }) } catch (error) { if (prefixScanRef.current !== controller) return setPrefixScan({ @@ -647,19 +648,7 @@ export function ObjectList({ return (
{summary ? {formatBytes(summary.size)} : -} - {state === "loading" ? ( - <> - {t("Calculating folder statistics...")} - - - ) : ( + {state !== "loading" ? ( - )} - {state === "failed" ? {t("Could not calculate folder statistics.")} : null} - {state === "limit" ? {t("Folder exceeds the scan limit.")} : null} - {state === "cancelled" ? {t("Calculation cancelled.")} : null} + ) : null}
) }, @@ -1078,6 +1064,58 @@ export function ObjectList({ )}

) : null} + {prefixScan ? ( +
+
+
+ + {prefixScan.state === "loading" + ? t("Calculating folder statistics...") + : prefixScan.state === "cancelled" + ? t("Calculation cancelled.") + : prefixScan.state === "limit" + ? t("Folder exceeds the scan limit.") + : prefixScan.state === "success" + ? t("Complete") + : t("Could not calculate folder statistics.")} + + {prefixScan.key} +
+ {prefixScan.state === "success" && prefixSummaries[prefixScan.key] ? ( +
+ + {t("Size")}: {formatBytes(prefixSummaries[prefixScan.key].size)} + + + {t("Last Modified")}:{" "} + {prefixSummaries[prefixScan.key].lastModified + ? formatDateTime(prefixSummaries[prefixScan.key].lastModified) + : "-"} + +
+ ) : null} +
+ {prefixScan.state === "loading" ? ( + + ) : ( + + )} +
+ ) : null} = { }) => React.createElement("input", { type: "checkbox", checked, id, onChange: (e) => onCheckedChange(e.target.checked) }), PageHeader: ({ children, actions }: UiProps) => React.createElement("header", null, actions, children), - DataTable: ({ table }: { table: MockTable }) => + DataTable: ({ table, toolbar }: { table: MockTable; toolbar?: React.ReactNode }) => React.createElement( "section", null, + toolbar, table.data.map((row) => React.createElement( "div", @@ -117,8 +119,22 @@ const mocks: Record = { }), useLocalStorage: (_key: string, initial: boolean) => React.useState(initial), useObjectListPagination() {}, + SearchInput: ({ + value, + onChange, + placeholder, + }: { + value: string + onChange: (value: string) => void + placeholder: string + }) => + React.createElement("input", { + value, + placeholder, + onInput: (event: React.FormEvent) => onChange(event.currentTarget.value), + }), formatBytes: (bytes: number) => String(bytes), - formatDateTime: (date: string) => date, + formatDateTime: (date: string | Date) => (date instanceof Date ? date.toISOString() : date), } for (const name of [ "AlertDialogContent", @@ -470,3 +486,98 @@ test("folder statistics update only the requested row and clear on refresh", asy await ui.render({ refreshTrigger: 1 }) assert.equal(ui.container.textContent?.includes("731"), false) }) + +test("folder scan remains cancellable when search hides its row, and a late response is ignored", async (t) => { + const response = Promise.withResolvers<{ size: number; lastModified?: Date }>() + let usedSignal: AbortSignal | undefined + let calls = 0 + const ui = await mount(t, props, "Suspended", async (_key, signal) => { + calls++ + usedSignal = signal + return response.promise + }) + await ui.click(ui.findButton("Calculate folder statistics")) + const search = ui.container.querySelector('input[placeholder="Filter loaded objects"]') as HTMLInputElement | null + assert.ok(search) + await React.act(async () => { + search.value = "other" + search.dispatchEvent(new search.ownerDocument.defaultView!.Event("input", { bubbles: true })) + }) + assert.equal(ui.container.querySelector(`[data-object-key="${fullKey}"]`), null) + assert.ok(ui.container.querySelector('[role="status"]')?.textContent?.includes(fullKey)) + const otherScan = ui.findButton("Calculate folder statistics") + assert.ok(otherScan) + assert.equal((otherScan as unknown as HTMLButtonElement).disabled, true) + await ui.click(otherScan) + assert.equal(calls, 1) + await ui.click(ui.findButton("Cancel")) + assert.equal(usedSignal?.aborted, true) + await React.act(async () => response.resolve({ size: 731, lastModified: new Date("2026-01-01") })) + assert.ok(ui.container.querySelector('[role="status"]')?.textContent?.includes("Calculation cancelled.")) + assert.ok(ui.container.querySelector('[role="status"]')?.textContent?.includes(fullKey)) + await ui.click(ui.findButton("Close")) + assert.equal(ui.container.querySelector('[role="status"]'), null) + await React.act(async () => { + search.value = "" + search.dispatchEvent(new search.ownerDocument.defaultView!.Event("input", { bubbles: true })) + }) + assert.equal(ui.container.textContent?.includes("731"), false) +}) + +for (const [error, expected] of [ + [new Error("network"), "Could not calculate folder statistics."], + [new PrefixSummaryLimitError("limit"), "Folder exceeds the scan limit."], +] as const) { + test(`a hidden folder scan reports ${expected} until closed`, async (t) => { + const response = Promise.withResolvers<{ size: number; lastModified?: Date }>() + const ui = await mount(t, props, "Suspended", async () => response.promise) + await ui.click(ui.findButton("Calculate folder statistics")) + const search = ui.container.querySelector('input[placeholder="Filter loaded objects"]') as HTMLInputElement | null + assert.ok(search) + await React.act(async () => { + search.value = "other" + search.dispatchEvent(new search.ownerDocument.defaultView!.Event("input", { bubbles: true })) + }) + assert.equal(ui.container.querySelector(`[data-object-key="${fullKey}"]`), null) + await React.act(async () => response.reject(error)) + assert.ok(ui.container.querySelector('[role="alert"]')?.textContent?.includes(expected)) + assert.ok(ui.container.querySelector('[role="alert"]')?.textContent?.includes(fullKey)) + await ui.click(ui.findButton("Close")) + assert.equal(ui.container.querySelector('[role="alert"]'), null) + }) +} + +for (const [summary, dateText] of [ + [{ size: 731, lastModified: new Date("2026-01-01") }, "2026-01-01T00:00:00.000Z"], + [{ size: 0 }, "-"], +] as const) { + test(`a hidden folder scan shows completed statistics (${dateText}) and can be closed`, async (t) => { + const response = Promise.withResolvers<{ size: number; lastModified?: Date }>() + const ui = await mount(t, props, "Suspended", async () => response.promise) + await ui.click(ui.findButton("Calculate folder statistics")) + const search = ui.container.querySelector('input[placeholder="Filter loaded objects"]') as HTMLInputElement | null + assert.ok(search) + await React.act(async () => { + search.value = "other" + search.dispatchEvent(new search.ownerDocument.defaultView!.Event("input", { bubbles: true })) + }) + assert.equal(ui.container.querySelector(`[data-object-key="${fullKey}"]`), null) + await React.act(async () => response.resolve(summary)) + const status = ui.container.querySelector('[role="status"]') + assert.ok(status?.textContent?.includes("Complete")) + assert.ok(status?.textContent?.includes(fullKey)) + assert.ok(status?.textContent?.includes(`Size: ${summary.size}`)) + assert.ok(status?.textContent?.includes(`Last Modified: ${dateText}`)) + await ui.click(ui.findButton("Close")) + assert.equal(ui.container.querySelector('[role="status"]'), null) + await React.act(async () => { + search.value = "" + search.dispatchEvent(new search.ownerDocument.defaultView!.Event("input", { bubbles: true })) + }) + assert.ok(ui.container.querySelector(`[data-object-key="${fullKey}"]`)?.textContent?.includes(String(summary.size))) + await ui.render({ refreshTrigger: 1 }) + assert.equal(ui.container.querySelector('[role="status"]'), null) + const row = ui.container.querySelector(`[data-object-key="${fullKey}"]`) + assert.equal(row?.textContent?.includes(String(summary.size)), false) + }) +}