UI for longhorn 14272 (recurring group snapshots). Backend: longhorn-manager PR 5332, still open, built at e6eb37c and running on the dev cluster. Two local longhorn-ui branches, not pushed: 14272-solution-1 (6b11866) and 14272-solution-2 (cddcd57, Solution 1 plus one commit). 2026-10-26.
Previews: main http://13.60.211.184:30081 (0f560ca), Solution 1 http://13.60.211.184:30082, Solution 2 http://13.60.211.184:30083. Same backend for all three; jobs created on one show up on the others.
snapshot-max-count). An hourly group job fills that in about 10 days; after that the volume can't take any new snapshot, so this job and the volume's other snapshot jobs fail.retain to at least 1 and accepts age-based retention.The LEP says both things:
Non-goals: A group-level retention task: which lets the job keep the newest N groups it created and deletes older whole groups, so group snapshots do not pile up on member volumes.
Implementation overview: Generate a group name create one SnapshotGroup. Apply retention.
The code follows the non-goal.
| main | Solution 1: Retain editable | Solution 2: no retention | |
|---|---|---|---|
| Task list | "Snapshot Group" missing; a job made with kubectl or the API still shows in the list | "Snapshot Group" added | "Snapshot Group" added |
| Retain field | n/a | Editable, like Snapshot (default 1) | Disabled at 0, like Snapshot Cleanup |
| Extra text | n/a | None | Warning: groups are not deleted automatically; delete them from Snapshot Groups before the volumes hit their snapshot limit |
| Retain column in the job lists | Shows the stored value (1) | Shows the stored value (1, 3, …) | "-" for Snapshot Group jobs |
| What the backend must do | n/a | Add group retention: keep the newest N groups the job created and delete older ones. That is new backend work, listed as a non-goal in the LEP today. | Small: add snapshot-group to the no-retention tasks (like Snapshot Cleanup) so it stops forcing retain ≥ 1 and stops accepting age-based retention. Works with today's backend as is: the UI sends 0, the backend stores 1, the UI shows "-". |
| If the backend never adds retention | n/a | Misleading: users set Retain 3 and still get unlimited groups (see the test below) | Accurate |
| If the backend adds retention later | n/a | Already right | A small follow-up to re-enable Retain |
| Code (vs main) | n/a | 1 file, +14/-2 (the shared part only) | 3 files, +33/-7 (shared part + retention changes) |


| Point | Detail |
|---|---|
| Concurrency column | Both solutions hide Concurrency in the form (one group per run). The backend then stores its default, 10, and the list shows 10. Could show "-" there too; not done in either solution yet. |
| Backend gaps to raise on PR 5332 | Retain/age-based handling above; and the author's pending fix so a failed group fails the Kubernetes Job (agreed on 2026-10-21, not pushed yet). |
| Per-volume recurring job form | Snapshot Group is offered only in the global Recurring Jobs form, like System Backup, in both solutions. |
Solution 1 changes only src/routes/recurringJob/CreateRecurringJob.js. Solution 2 adds one commit on top: retain handling in the form, and the "-" in RecurringJobList.js and volume/detail/RecurringJob.js.
diff --git a/src/routes/recurringJob/CreateRecurringJob.js b/src/routes/recurringJob/CreateRecurringJob.js
index ab97231..ed9975e 100644
--- a/src/routes/recurringJob/CreateRecurringJob.js
+++ b/src/routes/recurringJob/CreateRecurringJob.js
@@ -66,6 +66,7 @@ const modal = ({
},
}) => {
const isSystemBackup = getFieldValue('task') === 'system-backup'
+ const isSnapshotGroup = getFieldValue('task') === 'snapshot-group'
function handleOk() {
validateFields((errors) => {
@@ -233,7 +234,16 @@ const modal = ({
const formGroups = keys.map((k, index) => (
<Form.Item
{...(index === 0 ? formItemLayout : formItemLayoutWithOutLabel)}
- label={index === 0 ? 'Groups' : ''}
+ label={index === 0 ? (
+ <span>
+ Groups
+ {isSnapshotGroup && (
+ <Tooltip title="On each run, the volumes in these groups are snapshotted together as one Snapshot Group.">
+ <Icon type="question-circle-o" style={{ marginLeft: 4 }} />
+ </Tooltip>
+ )}
+ </span>
+ ) : ''}
key={k.index}
>
{getFieldDecorator(`groups[${k.index}]`, {
@@ -322,7 +332,8 @@ const modal = ({
const disableAddDefaultGroup = getFieldValue('keys').some((k) => getFieldValue('groups')[k.index] === 'default')
const isParametersValueRequired = !!getFieldValue('parametersKey')
const showParametersField = getFieldValue('task') === 'backup' || getFieldValue('task') === 'backup-force-create' || isSystemBackup
- const showConcurrency = !isSystemBackup
+ // A snapshot-group job creates one SnapshotGroup per run, so concurrency doesn't apply.
+ const showConcurrency = !isSystemBackup && !isSnapshotGroup
const showGroup = !isSystemBackup
const showLabels = !isSystemBackup
const systemBackupParameterOptions = ['if-not-present', 'always', 'disabled']
@@ -379,6 +390,7 @@ const modal = ({
] : [
<Option key="backup" value="backup">Backup</Option>,
<Option key="snapshot" value="snapshot">Snapshot</Option>,
+ <Option key="snapshot-group" value="snapshot-group">Snapshot Group</Option>,
<Option key="snapshot-delete" value="snapshot-delete">Snapshot Delete</Option>,
<Option key="snapshot-cleanup" value="snapshot-cleanup">Snapshot Cleanup</Option>,
<Option key="system-backup" value="system-backup">System Backup</Option>,diff --git a/src/routes/recurringJob/CreateRecurringJob.js b/src/routes/recurringJob/CreateRecurringJob.js index ed9975e..326af8f 100644 --- a/src/routes/recurringJob/CreateRecurringJob.js +++ b/src/routes/recurringJob/CreateRecurringJob.js @@ -1,6 +1,6 @@ import React from 'react' import PropTypes from 'prop-types' -import { Form, Input, Icon, Button, Select, InputNumber, Checkbox, Tooltip, Radio } from 'antd' +import { Form, Input, Icon, Button, Select, InputNumber, Checkbox, Tooltip, Radio, Alert } from 'antd' import { ModalBlur, ReactCron } from '../../components' import { parseRetainAge } from '../../utils/recurringJob' @@ -41,8 +41,9 @@ const formItemLayoutWithOutForAddLabels = { }, } +// snapshot-group jobs never delete the groups they create, so retain has no effect. const noRetain = (val) => { - return val === 'snapshot-cleanup' || val === 'filesystem-trim' + return val === 'snapshot-cleanup' || val === 'filesystem-trim' || val === 'snapshot-group' } // Tasks that support the age-based retention policy. @@ -342,6 +343,10 @@ const modal = ({ // Retention policy is only editable when the task is backup / snapshot / system-backup. const canEditRetentionPolicy = AGE_BASED_TASKS.includes((getFieldValue('task') || '').replace('-force-create', '')) const initialRetainAge = parseRetainAge(isEdit ? item.retainAge : '') + let initialRetain = 1 + if (isEdit) { + initialRetain = noRetain(item.task) ? 0 : item.retain + } return ( <ModalBlur {...modalOpts}> @@ -411,7 +416,7 @@ const modal = ({ </div> <FormItem label="Retain" hasFeedback {...formItemLayout} style={{ display: isAgeBased ? 'none' : undefined }}> {getFieldDecorator('retain', { - initialValue: isEdit ? item.retain : 1, + initialValue: initialRetain, rules: [ { required: !isAgeBased, @@ -419,6 +424,15 @@ const modal = ({ ], })(<InputNumber disabled={noRetain(getFieldValue('task'))} style={{ width: '80%' }} min={0} />)} </FormItem> + {isSnapshotGroup && ( + <FormItem {...formItemLayoutWithOutLabel}> + <Alert + type="warning" + showIcon + message="Snapshot groups created by this job are not deleted automatically. Delete old groups from the Snapshot Groups page before the volumes reach their snapshot limit." + /> + </FormItem> + )} {isAgeBased && ( <FormItem label="Retain Age" required {...formItemLayout}> <div style={{ display: 'flex', alignItems: 'center' }}> diff --git a/src/routes/recurringJob/RecurringJobList.js b/src/routes/recurringJob/RecurringJobList.js index d830769..b6cf6b6 100644 --- a/src/routes/recurringJob/RecurringJobList.js +++ b/src/routes/recurringJob/RecurringJobList.js @@ -60,7 +60,7 @@ function list({ loading, dataSource, rowSelection, height, deleteRecurringJob, e width: 120, render: (record) => { return ( - <div>{record.retentionPolicy === 'age-based' ? '-' : record.retain}</div> + <div>{record.retentionPolicy === 'age-based' || record.task === 'snapshot-group' ? '-' : record.retain}</div> ) }, }, { diff --git a/src/routes/volume/detail/RecurringJob.js b/src/routes/volume/detail/RecurringJob.js index 0f60885..f8e2f27 100644 --- a/src/routes/volume/detail/RecurringJob.js +++ b/src/routes/volume/detail/RecurringJob.js @@ -466,7 +466,7 @@ class RecurringJob extends React.Component { width: 120, render: (record) => { return ( - <div>{record.retentionPolicy === 'age-based' ? '-' : record.retain}</div> + <div>{record.retentionPolicy === 'age-based' || record.task === 'snapshot-group' ? '-' : record.retain}</div> ) }, }, {