feat(stacks): file-based stack versioning with full history + rollback (#27)
Adds append-only version history on disk (compose/{id}/v{N}/<files>) for
file-based (WorkflowID==0) Compose/Swarm stacks, with rollback to any past
version. Git stacks (versioned by commit) and Kubernetes are untouched.
Backend:
- Stack model: StackFileVersion, PreviousDeploymentInfo, Versions[]; new
StackFileVersionInfo type. APIVersion 2.43.0 -> 2.44.0.
- Versioned multi-file snapshot (entrypoint + AdditionalFiles) into v{N}/;
ProjectPath repointed via GetStackProjectPathByVersion each deploy. Retention
cap (20): Versions[] trimmed in-tx, old dirs deleted only AFTER the tx commits.
- Update handlers: RollbackTo (content read server-side from the target version,
never trusted from the client; validated 1..current & present in Versions).
- Create paths seed v1. stackFile reads ?version= (validated; negative -> 400).
- New GET /stacks/{id}/versions endpoint.
- Migration 2.44.0: move existing file-based stacks' files into v1/ (idempotent,
atomic pre-read of the full file set, skips git/kube/orphans).
Frontend:
- useStackVersions query + stackVersions key; StackEditorTab builds the full
history list; StackVersionSelector shows 'v{N} · date · author'; file/versions
caches invalidated (by prefix) after deploy/rollback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,37 @@
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { StackFileVersionInfo } from '@/react/common/stacks/types';
|
||||
|
||||
interface Props {
|
||||
versions?: number[];
|
||||
/**
|
||||
* Optional richer metadata (date/author/note) used to build the option
|
||||
* labels. Looked up by version number; falls back to the bare version when a
|
||||
* given version has no metadata.
|
||||
*/
|
||||
versionsInfo?: StackFileVersionInfo[];
|
||||
onChange(value: number): void;
|
||||
}
|
||||
|
||||
export function StackVersionSelector({ versions, onChange }: Props) {
|
||||
/**
|
||||
* Build a human-readable label for a version, e.g. `v3 · 2026-07-02 14:12 · admin`.
|
||||
* Falls back to just the version number when no metadata is available.
|
||||
*/
|
||||
function buildVersionLabel(version: number, info?: StackFileVersionInfo) {
|
||||
const parts = [`v${version}`];
|
||||
if (info?.CreatedAt) {
|
||||
parts.push(isoDateFromTimestamp(info.CreatedAt));
|
||||
}
|
||||
if (info?.CreatedBy) {
|
||||
parts.push(info.CreatedBy);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function StackVersionSelector({
|
||||
versions,
|
||||
versionsInfo,
|
||||
onChange,
|
||||
}: Props) {
|
||||
if (!versions || versions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -12,7 +40,10 @@ export function StackVersionSelector({ versions, onChange }: Props) {
|
||||
|
||||
const versionOptions = versions.map((version) => ({
|
||||
value: version,
|
||||
label: version.toString(),
|
||||
label: buildVersionLabel(
|
||||
version,
|
||||
versionsInfo?.find((info) => info.Version === version)
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -23,7 +54,7 @@ export function StackVersionSelector({ versions, onChange }: Props) {
|
||||
<span>Version:</span>
|
||||
</label>
|
||||
<span className="text-muted" id="version_id">
|
||||
{versions[0]}
|
||||
{versionOptions[0].label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -38,7 +69,6 @@ export function StackVersionSelector({ versions, onChange }: Props) {
|
||||
data-cy="version-selector"
|
||||
id="version_id"
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
borderColor: 'hsl(0, 0%, 80%)',
|
||||
@@ -48,7 +78,7 @@ export function StackVersionSelector({ versions, onChange }: Props) {
|
||||
>
|
||||
{versionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.value}
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user