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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 73 additions & 64 deletions components/buckets/lifecycle-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,9 @@ 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, type LifecycleRule, type LifecycleAction } from "@/lib/lifecycle-display"
import type { ColumnDef } from "@tanstack/react-table"

interface LifecycleRule {
ID?: string
Status?: string
Filter?: {
Prefix?: string
Tag?: { Key: string; Value: string }
And?: { Prefix?: string; Tags?: Array<{ Key: string; Value: string }> }
}
Expiration?: {
Days?: number
Date?: string
StorageClass?: string
ExpiredObjectDeleteMarker?: boolean
}
NoncurrentVersionExpiration?: { NoncurrentDays?: number }
Transitions?: Array<{ Days?: number; StorageClass?: string }>
NoncurrentVersionTransitions?: Array<{
NoncurrentDays?: number
StorageClass?: string
}>
}

interface BucketLifecycleTabProps {
bucketName: string
hideTitle?: boolean
Expand Down Expand Up @@ -146,46 +125,67 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
[bucketName, dialog, t, handleRowDelete],
)

const actionValue = React.useCallback(
(action: LifecycleAction, field: "type" | "version" | "deleteMarker" | "tier" | "timeCycle") => {
switch (field) {
case "type":
return t(action.type)
case "version":
return t(action.version)
case "deleteMarker":
return action.deleteMarker ? t("On") : t("Off")
case "tier":
return action.tier ?? "--"
default:
return action.days != null
? `${action.days} ${t("Days")}`
: ((action.date instanceof Date ? action.date.toISOString() : action.date) ?? "--")
}
},
[t],
)

const columns: ColumnDef<LifecycleRule>[] = React.useMemo(
() => [
{
id: "type",
header: () => t("Type"),
accessorFn: (row) => (row.Transitions || row.NoncurrentVersionTransitions ? "Transition" : "Expire"),
},
{
id: "version",
header: () => t("Version"),
accessorFn: (row) =>
row.NoncurrentVersionExpiration || row.NoncurrentVersionTransitions
? t("Non-current Version")
: t("Current Version"),
},
{
id: "deleteMarker",
header: () => t("Expiration Delete Mark"),
accessorFn: (row) => (row.Expiration?.ExpiredObjectDeleteMarker ? t("On") : t("Off")),
},
{
id: "tier",
header: () => t("Tier"),
accessorFn: (row) =>
row.Transitions?.[0]?.StorageClass || row.NoncurrentVersionTransitions?.[0]?.StorageClass || "--",
},
...(["type", "version", "deleteMarker", "tier"] as const).map((field) => ({
id: field,
header: () =>
t({ type: "Type", version: "Version", deleteMarker: "Expiration Delete Mark", tier: "Tier" }[field]),
accessorFn: (row: LifecycleRule) =>
getLifecycleActions(row)
.map((action) => actionValue(action, field))
.join(" · "),
cell: ({ row }: { row: { original: LifecycleRule } }) => (
<div className="space-y-2">
{getLifecycleActions(row.original).map((action, index) => (
<div key={index} className="whitespace-nowrap">
{actionValue(action, field)}
</div>
))}
</div>
),
})),
{
id: "prefix",
header: () => t("Prefix"),
accessorFn: (row) => row.Filter?.Prefix || row.Filter?.And?.Prefix || "",
},
{
id: "timeCycle",
header: () => `${t("Time Cycle")} (${t("Days")})`,
header: () => t("Time Cycle"),
accessorFn: (row) =>
row.Expiration?.Days ??
row.NoncurrentVersionExpiration?.NoncurrentDays ??
row.Transitions?.[0]?.Days ??
row.NoncurrentVersionTransitions?.[0]?.NoncurrentDays ??
"",
getLifecycleActions(row)
.map((action) => actionValue(action, "timeCycle"))
.join(" · "),
cell: ({ row }) => (
<div className="space-y-2">
{getLifecycleActions(row.original).map((action, index) => (
<div key={index} className="whitespace-nowrap tabular-nums">
{actionValue(action, "timeCycle")}
</div>
))}
</div>
),
},
{
id: "status",
Expand Down Expand Up @@ -219,7 +219,7 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
),
},
],
[canEditLifecycle, confirmDelete, loadError, loading, mutatingRuleId, t],
[actionValue, canEditLifecycle, confirmDelete, loadError, loading, mutatingRuleId, t],
)

const { table } = useDataTable<LifecycleRule>({
Expand Down Expand Up @@ -286,18 +286,11 @@ 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
return (
<article key={identity} className="space-y-4 border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-all text-sm font-medium">{rule.ID ?? t("Unnamed rule")}</h3>
<p className="mt-1 text-xs text-muted-foreground">{type}</p>
</div>
<Badge variant={rule.Status === "Enabled" ? "secondary" : "outline"}>
{rule.Status === "Enabled" ? t("Enabled") : t("Disabled")}
Expand All @@ -308,11 +301,27 @@ export function BucketLifecycleTab({ bucketName, hideTitle = false, renderHeader
<dt className="text-xs text-muted-foreground">{t("Prefix")}</dt>
<dd className="break-all">{rule.Filter?.Prefix || rule.Filter?.And?.Prefix || "-"}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">{t("Time Cycle")}</dt>
<dd className="tabular-nums">{cycle ? `${cycle} ${t("Days")}` : "-"}</dd>
</div>
</dl>
{getLifecycleActions(rule).map((action, index) => (
<dl key={index} className="grid grid-cols-2 gap-3 text-sm">
{(["type", "version", "deleteMarker", "tier", "timeCycle"] as const).map((field) => (
<div key={field}>
<dt className="text-xs text-muted-foreground">
{t(
{
type: "Type",
version: "Version",
deleteMarker: "Expiration Delete Mark",
tier: "Tier",
timeCycle: "Time Cycle",
}[field],
)}
</dt>
<dd className="break-all">{actionValue(action, field)}</dd>
</div>
))}
</dl>
))}
{canEditLifecycle ? (
<Button
variant="outline"
Expand Down
68 changes: 68 additions & 0 deletions lib/lifecycle-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export interface LifecycleRule {
ID?: string
Status?: string
Filter?: {
Prefix?: string
Tag?: { Key: string; Value: string }
And?: { Prefix?: string; Tags?: Array<{ Key: string; Value: string }> }
}
Expiration?: {
Days?: number
Date?: string | Date
StorageClass?: string
ExpiredObjectDeleteMarker?: boolean
}
NoncurrentVersionExpiration?: { NoncurrentDays?: number }
Transitions?: Array<{ Days?: number; Date?: string | Date; StorageClass?: string }>
NoncurrentVersionTransitions?: Array<{
NoncurrentDays?: number
StorageClass?: string
}>
}

export interface LifecycleAction {
type: "Expire" | "Transition"
version: "Current Version" | "Non-current Version"
days?: number
date?: string | Date
tier?: string
deleteMarker?: boolean
}

export function getLifecycleActions(rule: LifecycleRule): LifecycleAction[] {
const actions: LifecycleAction[] = []
if (rule.Expiration) {
actions.push({
type: "Expire",
version: "Current Version",
days: rule.Expiration.Days,
date: rule.Expiration.Date,
deleteMarker: rule.Expiration.ExpiredObjectDeleteMarker,
})
}
if (rule.NoncurrentVersionExpiration) {
actions.push({
type: "Expire",
version: "Non-current Version",
days: rule.NoncurrentVersionExpiration.NoncurrentDays,
})
}
for (const transition of rule.Transitions ?? []) {
actions.push({
type: "Transition",
version: "Current Version",
days: transition.Days,
date: transition.Date,
tier: transition.StorageClass,
})
}
for (const transition of rule.NoncurrentVersionTransitions ?? []) {
actions.push({
type: "Transition",
version: "Non-current Version",
days: transition.NoncurrentDays,
tier: transition.StorageClass,
})
}
return actions
}
73 changes: 73 additions & 0 deletions tests/lib/lifecycle-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import test from "node:test"
import assert from "node:assert/strict"
import { getLifecycleActions } from "../../lib/lifecycle-display"
import { removeMatchingBucketRule } from "../../lib/bucket-configuration"

test("mixed expiration rules retain both version labels and their own days (rustfs#8036)", () => {
for (const [current, noncurrent] of [
[30, 40],
[10, 20],
]) {
assert.deepEqual(
getLifecycleActions({
Expiration: { Days: current },
NoncurrentVersionExpiration: { NoncurrentDays: noncurrent },
}).map(({ type, version, days }) => ({ type, version, days })),
[
{ type: "Expire", version: "Current Version", days: current },
{ type: "Expire", version: "Non-current Version", days: noncurrent },
],
)
}
})

test("each transition keeps its version, tier and zero-day schedule", () => {
assert.deepEqual(
getLifecycleActions({
Transitions: [
{ Days: 0, StorageClass: "WARM" },
{ Days: 90, StorageClass: "COLD" },
],
NoncurrentVersionTransitions: [{ NoncurrentDays: 20, StorageClass: "ARCHIVE" }],
}).map(({ version, days, tier }) => ({ version, days, tier })),
[
{ version: "Current Version", days: 0, tier: "WARM" },
{ version: "Current Version", days: 90, tier: "COLD" },
{ version: "Non-current Version", days: 20, tier: "ARCHIVE" },
],
)
})

test("marker cleanup is separate from noncurrent expiration", () => {
const actions = getLifecycleActions({
Expiration: { ExpiredObjectDeleteMarker: true },
NoncurrentVersionExpiration: { NoncurrentDays: 40 },
})
assert.deepEqual(
actions.map(({ version, days, deleteMarker }) => ({ version, days, deleteMarker })),
[
{ version: "Current Version", days: undefined, deleteMarker: true },
{ version: "Non-current Version", days: 40, deleteMarker: undefined },
],
)
})

test("date schedules and empty transition arrays do not invent noncurrent actions", () => {
const date = new Date("2027-01-01T00:00:00Z")
assert.equal(getLifecycleActions({ Expiration: { Date: date }, NoncurrentVersionTransitions: [] })[0].date, date)
assert.deepEqual(getLifecycleActions({ Transitions: [], NoncurrentVersionTransitions: [] }), [])
})

test("display expansion leaves the original combined rule intact for deletion", () => {
const rule = {
ID: undefined,
Status: "Enabled",
Expiration: { Days: 30 },
NoncurrentVersionExpiration: { NoncurrentDays: 40 },
}
const original = structuredClone(rule)
const other = { ID: "other", Expiration: { Days: 60 } }
getLifecycleActions(rule)
assert.deepEqual(rule, original)
assert.deepEqual(removeMatchingBucketRule([rule, other], rule), [other])
})
Loading