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 @@
+
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 @@
+
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/)