d0d3c068ba
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>
100 lines
2.6 KiB
TypeScript
100 lines
2.6 KiB
TypeScript
import { render, screen } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
|
import { StackFileVersionInfo } from '@/react/common/stacks/types';
|
|
|
|
import { StackVersionSelector } from './StackVersionSelector';
|
|
|
|
function createInfo(
|
|
overrides: Partial<StackFileVersionInfo> = {}
|
|
): StackFileVersionInfo {
|
|
return {
|
|
Version: 1,
|
|
CreatedAt: 1751464320, // fixed unix timestamp (seconds)
|
|
CreatedBy: 'admin',
|
|
Note: '',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it('should render nothing when there are no versions', () => {
|
|
const { container } = render(
|
|
<StackVersionSelector versions={[]} onChange={vi.fn()} />
|
|
);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it('should render a rich label with version, date and author for a single version', () => {
|
|
const info = createInfo({ Version: 3, CreatedBy: 'alice' });
|
|
|
|
render(
|
|
<StackVersionSelector
|
|
versions={[3]}
|
|
versionsInfo={[info]}
|
|
onChange={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const expected = `v3 · ${isoDateFromTimestamp(info.CreatedAt)} · alice`;
|
|
expect(screen.getByText(expected)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should fall back to the bare version number when no metadata is available', () => {
|
|
render(<StackVersionSelector versions={[7]} onChange={vi.fn()} />);
|
|
|
|
expect(screen.getByText('v7')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render a select with rich labels when multiple versions exist', () => {
|
|
const versionsInfo = [
|
|
createInfo({ Version: 2, CreatedBy: 'bob' }),
|
|
createInfo({ Version: 1, CreatedBy: 'alice' }),
|
|
];
|
|
|
|
render(
|
|
<StackVersionSelector
|
|
versions={[2, 1]}
|
|
versionsInfo={versionsInfo}
|
|
onChange={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const select = screen.getByRole('combobox', { name: /version/i });
|
|
expect(select).toBeInTheDocument();
|
|
|
|
const options = screen.getAllByRole('option');
|
|
expect(options).toHaveLength(2);
|
|
expect(options[0]).toHaveTextContent(
|
|
`v2 · ${isoDateFromTimestamp(versionsInfo[0].CreatedAt)} · bob`
|
|
);
|
|
expect(options[1]).toHaveTextContent(
|
|
`v1 · ${isoDateFromTimestamp(versionsInfo[1].CreatedAt)} · alice`
|
|
);
|
|
});
|
|
|
|
it('should call onChange with the selected version number', async () => {
|
|
const user = userEvent.setup();
|
|
const onChange = vi.fn();
|
|
|
|
render(
|
|
<StackVersionSelector
|
|
versions={[3, 2, 1]}
|
|
versionsInfo={[
|
|
createInfo({ Version: 3 }),
|
|
createInfo({ Version: 2 }),
|
|
createInfo({ Version: 1 }),
|
|
]}
|
|
onChange={onChange}
|
|
/>
|
|
);
|
|
|
|
await user.selectOptions(
|
|
screen.getByRole('combobox', { name: /version/i }),
|
|
'2'
|
|
);
|
|
|
|
expect(onChange).toHaveBeenCalledWith(2);
|
|
});
|