Files
portainer/app/react/components/StackVersionSelector/StackVersionSelector.tsx
T
agent_coder d0d3c068ba 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>
2026-07-02 16:07:26 +03:00

90 lines
2.4 KiB
TypeScript

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;
}
/**
* 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;
}
const showSelector = versions.length > 1;
const versionOptions = versions.map((version) => ({
value: version,
label: buildVersionLabel(
version,
versionsInfo?.find((info) => info.Version === version)
),
}));
return (
<div className="flex">
{!showSelector && (
<>
<label className="text-muted mr-2" htmlFor="version_id">
<span>Version:</span>
</label>
<span className="text-muted" id="version_id">
{versionOptions[0].label}
</span>
</>
)}
{showSelector && (
<div className="text-muted">
<label className="mr-2" htmlFor="version_id">
<span>Version:</span>
</label>
<select
className="form-select"
data-cy="version-selector"
id="version_id"
style={{
height: '24px',
borderRadius: '4px',
borderColor: 'hsl(0, 0%, 80%)',
padding: '2px 8px',
}}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
>
{versionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
</div>
);
}