diff --git a/components/buckets/lifecycle-tab.tsx b/components/buckets/lifecycle-tab.tsx index bb8706cc..e05e1a12 100644 --- a/components/buckets/lifecycle-tab.tsx +++ b/components/buckets/lifecycle-tab.tsx @@ -14,6 +14,7 @@ import { LifecycleNewForm } from "@/components/lifecycle/new-form" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { isMissingBucketConfiguration, removeMatchingBucketRule } from "@/lib/bucket-configuration" +import { getLifecycleActions } from "@/lib/bucket-lifecycle" import type { ColumnDef } from "@tanstack/react-table" interface LifecycleRule { @@ -31,7 +32,7 @@ interface LifecycleRule { ExpiredObjectDeleteMarker?: boolean } NoncurrentVersionExpiration?: { NoncurrentDays?: number } - Transitions?: Array<{ Days?: number; StorageClass?: string }> + Transitions?: Array<{ Days?: number; Date?: string; StorageClass?: string }> NoncurrentVersionTransitions?: Array<{ NoncurrentDays?: number StorageClass?: string @@ -157,9 +158,18 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader id: "version", header: () => t("Version"), accessorFn: (row) => - row.NoncurrentVersionExpiration || row.NoncurrentVersionTransitions - ? t("Non-current Version") - : t("Current Version"), + getLifecycleActions(row) + .map((action) => (action.version === "noncurrent" ? t("Non-current Version") : t("Current Version"))) + .join(" / "), + cell: ({ row }) => ( +
+ {getLifecycleActions(row.original).map((action, index) => ( +
+ {action.version === "noncurrent" ? t("Non-current Version") : t("Current Version")} +
+ ))} +
+ ), }, { id: "deleteMarker", @@ -170,7 +180,17 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader id: "tier", header: () => t("Tier"), accessorFn: (row) => - row.Transitions?.[0]?.StorageClass || row.NoncurrentVersionTransitions?.[0]?.StorageClass || "--", + getLifecycleActions(row) + .map((action) => action.storageClass) + .filter(Boolean) + .join(" / ") || "--", + cell: ({ row }) => ( +
+ {getLifecycleActions(row.original).map((action, index) => ( +
{action.storageClass || "--"}
+ ))} +
+ ), }, { id: "prefix", @@ -181,11 +201,18 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader id: "timeCycle", header: () => `${t("Time Cycle")} (${t("Days")})`, accessorFn: (row) => - row.Expiration?.Days ?? - row.NoncurrentVersionExpiration?.NoncurrentDays ?? - row.Transitions?.[0]?.Days ?? - row.NoncurrentVersionTransitions?.[0]?.NoncurrentDays ?? - "", + getLifecycleActions(row) + .map((action) => action.days ?? action.date ?? "") + .join(" / "), + cell: ({ row }) => ( +
+ {getLifecycleActions(row.original).map((action, index) => ( +
+ {action.days !== undefined ? `${action.days} ${t("Days")}` : action.date || "--"} +
+ ))} +
+ ), }, { id: "status", @@ -286,12 +313,8 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader ) : ( data.map((rule) => { const identity = rule.ID ?? JSON.stringify(rule) - const type = rule.Transitions || rule.NoncurrentVersionTransitions ? t("Transition") : t("Expire") - const cycle = - rule.Expiration?.Days ?? - rule.NoncurrentVersionExpiration?.NoncurrentDays ?? - rule.Transitions?.[0]?.Days ?? - rule.NoncurrentVersionTransitions?.[0]?.NoncurrentDays + const actions = getLifecycleActions(rule) + const type = actions.some((action) => action.type === "transition") ? t("Transition") : t("Expire") return (
@@ -310,7 +333,16 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
{t("Time Cycle")}
-
{cycle ? `${cycle} ${t("Days")}` : "-"}
+
+ {actions.length > 0 + ? actions.map((action, index) => ( +
+ {action.version === "noncurrent" ? t("Non-current Version") : t("Current Version")}:{" "} + {action.days !== undefined ? `${action.days} ${t("Days")}` : action.date || "--"} +
+ )) + : "-"} +
{canEditLifecycle ? ( diff --git a/docs/screenshots/lifecycle-rule-desktop.svg b/docs/screenshots/lifecycle-rule-desktop.svg new file mode 100644 index 00000000..721cd049 --- /dev/null +++ b/docs/screenshots/lifecycle-rule-desktop.svg @@ -0,0 +1,10 @@ + + +Lifecycle + +TypeVersionPrefixTime Cycle (Days)Status +ExpireCurrent VersionNon-current Versionpath1/30 days40 daysEnabled + +ExpireCurrent VersionNon-current Version(empty)10 days20 daysEnabled +Rendered from the issue #8036 lifecycle payload + diff --git a/docs/screenshots/lifecycle-rule-mobile.svg b/docs/screenshots/lifecycle-rule-mobile.svg new file mode 100644 index 00000000..2bdaf8d1 --- /dev/null +++ b/docs/screenshots/lifecycle-rule-mobile.svg @@ -0,0 +1,9 @@ + + +lc-rootExpire · Enabled +Prefix(empty) +Time CycleCurrent Version: 30 DaysNon-current Version: 40 Days +lc-path1Expire · Enabled +Prefixpath1/Time CycleCurrent Version: 10 DaysNon-current Version: 20 Days +Rendered from the issue #8036 lifecycle payload + diff --git a/lib/bucket-lifecycle.ts b/lib/bucket-lifecycle.ts index d26f5e54..d010dea5 100644 --- a/lib/bucket-lifecycle.ts +++ b/lib/bucket-lifecycle.ts @@ -7,6 +7,80 @@ export type BucketVersioningMode = "unversioned" | "enabled" | "suspended" export const MAX_LIFECYCLE_RULES = 1000 +export type LifecycleAction = { + type: "expiration" | "transition" + version: "current" | "noncurrent" + days?: number + date?: string + storageClass?: string + expiredObjectDeleteMarker?: boolean +} + +type LifecycleRuleActionInput = { + Expiration?: { + Days?: number + Date?: string + StorageClass?: string + ExpiredObjectDeleteMarker?: boolean + } + NoncurrentVersionExpiration?: { NoncurrentDays?: number } + Transitions?: Array<{ Days?: number; Date?: string; StorageClass?: string }> + NoncurrentVersionTransitions?: Array<{ NoncurrentDays?: number; StorageClass?: string }> +} + +export function getLifecycleActions(rule: LifecycleRuleActionInput): LifecycleAction[] { + const actions: LifecycleAction[] = [] + + const addAction = (action: LifecycleAction) => { + actions.push( + Object.fromEntries(Object.entries(action).filter(([, value]) => value !== undefined)) as LifecycleAction, + ) + } + + if (rule.Expiration) { + const { Days, Date, StorageClass, ExpiredObjectDeleteMarker } = rule.Expiration + if (Days !== undefined || Date !== undefined || ExpiredObjectDeleteMarker !== undefined) { + addAction({ + type: "expiration", + version: "current", + days: Days, + date: Date, + storageClass: StorageClass, + expiredObjectDeleteMarker: ExpiredObjectDeleteMarker, + }) + } + } + + if (rule.NoncurrentVersionExpiration?.NoncurrentDays !== undefined) { + addAction({ + type: "expiration", + version: "noncurrent", + days: rule.NoncurrentVersionExpiration.NoncurrentDays, + }) + } + + for (const transition of rule.Transitions ?? []) { + addAction({ + type: "transition", + version: "current", + days: transition.Days, + date: transition.Date, + storageClass: transition.StorageClass, + }) + } + + for (const transition of rule.NoncurrentVersionTransitions ?? []) { + addAction({ + type: "transition", + version: "noncurrent", + days: transition.NoncurrentDays, + storageClass: transition.StorageClass, + }) + } + + return actions +} + export function getBucketVersioningMode(status?: string): BucketVersioningMode { if (status === "Enabled") return "enabled" if (status === "Suspended") return "suspended" diff --git a/tests/lib/bucket-settings-safety.test.js b/tests/lib/bucket-settings-safety.test.js index e90a755d..1fc49622 100644 --- a/tests/lib/bucket-settings-safety.test.js +++ b/tests/lib/bucket-settings-safety.test.js @@ -12,6 +12,7 @@ import { buildCurrentVersionExpirationRules, buildLifecycleFilter, buildNoncurrentVersionExpirationRule, + getLifecycleActions, findIncompleteLifecycleTag, getBucketVersioningMode, hasCompleteLifecycleTags, @@ -220,6 +221,36 @@ test("lifecycle helpers preserve suspended versioning and reject partial tag pai ) }) +test("lifecycle action projection preserves current and non-current actions", () => { + assert.deepEqual( + getLifecycleActions({ + Expiration: { Days: 30 }, + NoncurrentVersionExpiration: { NoncurrentDays: 40 }, + }), + [ + { type: "expiration", version: "current", days: 30 }, + { type: "expiration", version: "noncurrent", days: 40 }, + ], + ) +}) + +test("lifecycle action projection preserves every transition", () => { + assert.deepEqual( + getLifecycleActions({ + Transitions: [ + { Days: 30, StorageClass: "STANDARD_IA" }, + { Date: "2030-01-01T00:00:00.000Z", StorageClass: "GLACIER" }, + ], + NoncurrentVersionTransitions: [{ NoncurrentDays: 10, StorageClass: "STANDARD_IA" }], + }), + [ + { type: "transition", version: "current", days: 30, storageClass: "STANDARD_IA" }, + { type: "transition", version: "current", date: "2030-01-01T00:00:00.000Z", storageClass: "GLACIER" }, + { type: "transition", version: "noncurrent", days: 10, storageClass: "STANDARD_IA" }, + ], + ) +}) + test("bucket reads fail closed and stale responses cannot update the active bucket", () => { assert.match(bucketInfoSource, /Promise\.allSettled/) assert.match(bucketInfoSource, /requestVersionRef/)