Add backup job editing

This commit is contained in:
2026-09-05 19:02:58 +02:00
parent 3983adbdd7
commit 820417df7b
7 changed files with 149 additions and 26 deletions

View File

@@ -6,6 +6,7 @@ import type { Client } from 'ssh2';
import type { AppConfig } from './config.js';
import { decryptJson } from './crypto.js';
import type { HostRecord, JobRecord, RunRecord } from './db.js';
import { normalizeStepSecrets } from './job-credentials.js';
import { normalizeJobConfig, type JobConfig, type JobStep } from './schemas.js';
import { connect, download, exec, getSftp, shellQuote, uploadText, type HostSecret } from './ssh.js';
@@ -188,12 +189,7 @@ export class BackupExecutor {
private readStepSecrets(job: JobRecord, config: JobConfig): Record<string, string> {
if (!job.secret) return {};
const decrypted = decryptJson<Record<string, string>>(job.secret, this.appConfig.masterKey);
if (typeof decrypted.password === 'string') {
const databaseStep = config.steps.find((step) => step.type === 'postgres' || step.type === 'mysql');
return databaseStep ? { [databaseStep.id]: decrypted.password } : {};
}
return decrypted;
return normalizeStepSecrets(decryptJson<Record<string, string>>(job.secret, this.appConfig.masterKey), config);
}
private log(runId: number, message: string): void {

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { normalizeStepSecrets, resolveUpdatedStepSecrets } from './job-credentials.js';
import type { JobUpdateInput } from './schemas.js';
const databaseStep = { id: 'database-1', name: 'Database', continueOnError: false, type: 'postgres' as const, database: 'app', username: 'postgres', databaseHost: 'localhost', databasePort: 5432, outputName: 'app', timeoutSeconds: 300 };
const base: JobUpdateInput = { name: 'backup', hostId: 1, config: { steps: [databaseStep] }, stepSecrets: {}, timezone: 'UTC', retentionCount: 10 };
describe('job credential updates', () => {
it('maps a legacy database password to its normalized step ID', () => {
expect(normalizeStepSecrets({ password: 'existing' }, base.config)).toEqual({ 'database-1': 'existing' });
});
it('preserves an existing database password', () => {
expect(resolveUpdatedStepSecrets({ 'database-1': 'existing' }, base)).toEqual({ 'database-1': 'existing' });
});
it('replaces a database password when supplied', () => {
expect(resolveUpdatedStepSecrets({ 'database-1': 'existing' }, { ...base, stepSecrets: { 'database-1': 'replacement' } })).toEqual({ 'database-1': 'replacement' });
});
it('requires a password for a newly added database step', () => {
expect(() => resolveUpdatedStepSecrets({}, base)).toThrow('Password is required for step 1');
});
it('drops passwords for removed database steps', () => {
const directory = { id: 'directory-1', name: 'Files', continueOnError: false, type: 'directory' as const, path: '/srv/files', outputName: 'files', timeoutSeconds: 300 };
expect(resolveUpdatedStepSecrets({ 'database-1': 'existing' }, { ...base, config: { steps: [directory] } })).toEqual({});
});
});

18
src/job-credentials.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { JobConfig, JobUpdateInput } from './schemas.js';
export function normalizeStepSecrets(secrets: Record<string, string>, config: JobConfig): Record<string, string> {
if (typeof secrets.password !== 'string') return secrets;
const databaseStep = config.steps.find((step) => step.type === 'postgres' || step.type === 'mysql');
return databaseStep ? { [databaseStep.id]: secrets.password } : {};
}
export function resolveUpdatedStepSecrets(currentSecrets: Record<string, string>, input: JobUpdateInput): Record<string, string> {
const secrets: Record<string, string> = {};
for (const [index, step] of input.config.steps.entries()) {
if (step.type !== 'postgres' && step.type !== 'mysql') continue;
const secret = input.stepSecrets[step.id] || currentSecrets[step.id];
if (!secret) throw new Error(`Password is required for step ${index + 1}`);
secrets[step.id] = secret;
}
return secrets;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, jobUpdateInputSchema, normalizeJobConfig } from './schemas.js';
describe('host validation', () => {
it('requires the selected authentication credential', () => {
@@ -58,6 +58,14 @@ describe('job validation', () => {
expect(result.success).toBe(false);
});
it('allows an update to preserve a stored database password', () => {
const result = jobUpdateInputSchema.safeParse({
...dockerJob,
config: { steps: [{ id: 'database-1', name: 'Database', continueOnError: false, type: 'postgres', database: 'app', username: 'postgres', databaseHost: 'localhost', databasePort: 5432, outputName: 'app', timeoutSeconds: 300 }] },
});
expect(result.success).toBe(true);
});
it('rejects jobs with only commands that produce no artifacts', () => {
const result = jobInputSchema.safeParse({ ...dockerJob, config: { steps: [{ ...dockerStep, outputs: [] }] } });
expect(result.success).toBe(false);

View File

@@ -147,9 +147,28 @@ export const jobInputSchema = z.object({
}
});
export const jobUpdateInputSchema = z.object({
name: z.string().trim().min(1).max(100),
hostId: z.number().int().positive(),
config: jobConfigSchema,
stepSecrets: z.record(z.string(), z.string()).default({}),
schedule: z.string().trim().optional(),
timezone: z.string().trim().default('UTC'),
retentionCount: z.number().int().min(1).max(1000).default(10),
}).superRefine((value, context) => {
if (value.schedule) {
try {
CronExpressionParser.parse(value.schedule, { tz: value.timezone });
} catch {
context.addIssue({ code: 'custom', path: ['schedule'], message: 'Invalid cron expression or timezone' });
}
}
});
export type HostInput = z.infer<typeof hostInputSchema>;
export type HostUpdateInput = z.infer<typeof hostUpdateInputSchema>;
export type JobInput = z.infer<typeof jobInputSchema>;
export type JobUpdateInput = z.infer<typeof jobUpdateInputSchema>;
function finalArtifactNames(step: JobStep): string[] {
if (step.type === 'directory') return [step.outputName.endsWith('.tar.gz') ? step.outputName : `${step.outputName}.tar.gz`];

View File

@@ -8,8 +8,9 @@ import { loadConfig } from './config.js';
import { createSession, decryptJson, encryptJson, secureEqual, verifySession } from './crypto.js';
import { openDatabase, type HostRecord, type JobRecord } from './db.js';
import { resolveUpdatedHostSecret } from './host-credentials.js';
import { normalizeStepSecrets, resolveUpdatedStepSecrets } from './job-credentials.js';
import { type NotificationSettings } from './notifications.js';
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, jobUpdateInputSchema, normalizeJobConfig } from './schemas.js';
import { BackupService, nextRun } from './service.js';
import { connect, probeFingerprint, type HostSecret } from './ssh.js';
@@ -112,11 +113,15 @@ app.delete('/api/hosts/:id', async (request, reply) => {
app.get('/api/jobs', async () => {
const rows = db.prepare('SELECT jobs.*, hosts.name AS host_name FROM jobs JOIN hosts ON hosts.id = jobs.host_id ORDER BY jobs.name').all() as Array<JobRecord & { host_name: string }>;
return rows.map((row) => ({
id: row.id, name: row.name, hostId: row.host_id, hostName: row.host_name, config: normalizeJobConfig(JSON.parse(row.config), String(row.id)),
schedule: row.schedule, timezone: row.timezone, enabled: Boolean(row.enabled), retentionCount: row.retention_count,
nextRunAt: row.next_run_at, hasDatabasePassword: Boolean(row.secret), createdAt: row.created_at,
}));
return rows.map((row) => {
const jobConfig = normalizeJobConfig(JSON.parse(row.config), String(row.id));
const stepSecrets = row.secret ? normalizeStepSecrets(decryptJson<Record<string, string>>(row.secret, config.masterKey), jobConfig) : {};
return {
id: row.id, name: row.name, hostId: row.host_id, hostName: row.host_name, config: jobConfig,
schedule: row.schedule, timezone: row.timezone, enabled: Boolean(row.enabled), retentionCount: row.retention_count,
nextRunAt: row.next_run_at, credentialStepIds: Object.keys(stepSecrets), createdAt: row.created_at,
};
});
});
app.post('/api/jobs', async (request, reply) => {
@@ -127,6 +132,27 @@ app.post('/api/jobs', async (request, reply) => {
return reply.code(201).send({ id: Number(result.lastInsertRowid) });
});
app.put('/api/jobs/:id', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
const input = jobUpdateInputSchema.parse(request.body);
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(id) as JobRecord | undefined;
if (!job) return reply.code(404).send({ error: 'Job not found' });
const active = db.prepare("SELECT id FROM runs WHERE job_id = ? AND status IN ('queued', 'running')").get(id);
if (active) return reply.code(409).send({ error: 'Cannot edit an active job' });
const currentConfig = normalizeJobConfig(JSON.parse(job.config), String(job.id));
const currentSecrets = job.secret ? normalizeStepSecrets(decryptJson<Record<string, string>>(job.secret, config.masterKey), currentConfig) : {};
let stepSecrets: Record<string, string>;
try {
stepSecrets = resolveUpdatedStepSecrets(currentSecrets, input);
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
const secret = Object.keys(stepSecrets).length ? encryptJson(stepSecrets, config.masterKey) : null;
db.prepare('UPDATE jobs SET name = ?, host_id = ?, config = ?, secret = ?, schedule = ?, timezone = ?, retention_count = ?, next_run_at = ? WHERE id = ?')
.run(input.name, input.hostId, JSON.stringify(input.config), secret, input.schedule || null, input.timezone, input.retentionCount, nextRun(input.schedule || null, input.timezone), id);
return { updated: true };
});
app.post('/api/jobs/:id/run', async (request, reply) => {
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
if (!db.prepare('SELECT id FROM jobs WHERE id = ?').get(id)) return reply.code(404).send({ error: 'Job not found' });

View File

@@ -6,13 +6,19 @@ import './styles.css';
type Tab = 'overview' | 'hosts' | 'jobs' | 'settings';
type Host = { id: number; name: string; hostname: string; port: number; username: string; fingerprint: string; authType: 'password' | 'privateKey' };
type StepType = 'dockerCommand' | 'remoteCommand' | 'directory' | 'postgres' | 'mysql';
type StepOutput = { path: string; name: string; archive: boolean };
type PersistedStep = {
id: string; name: string; type: StepType; continueOnError: boolean; timeoutSeconds: number;
executable?: string; arguments?: string[]; workingDirectory?: string; container?: string; user?: string; outputs?: StepOutput[];
path?: string; outputName?: string; database?: string; username?: string; databaseHost?: string; databasePort?: number;
};
type StepDraft = {
id: string; name: string; type: StepType; continueOnError: boolean; timeoutSeconds: string;
executable: string; arguments: string; workingDirectory: string; container: string; dockerUser: string;
collectOutput: boolean; outputPath: string; outputName: string; archive: boolean; directoryPath: string;
database: string; databaseUsername: string; databaseHost: string; databasePort: string; databasePassword: string;
additionalOutputs: StepOutput[]; database: string; databaseUsername: string; databaseHost: string; databasePort: string; databasePassword: string; hasDatabasePassword: boolean;
};
type Job = { id: number; name: string; hostName: string; config: { steps: Array<{ type: string }> }; schedule?: string; nextRunAt?: string; retentionCount: number };
type Job = { id: number; name: string; hostId: number; hostName: string; config: { steps: PersistedStep[] }; schedule?: string | null; timezone: string; nextRunAt?: string; retentionCount: number; credentialStepIds: string[] };
type Run = { id: number; jobName: string; status: string; trigger: string; createdAt: string; startedAt?: string; finishedAt?: string; error?: string; artifactCount: number };
type RunDetail = { run: { id: number; status: string; log: string; error?: string }; artifacts: Array<{ id: number; name: string; size: number; checksum: string }> };
@@ -163,6 +169,7 @@ function Jobs() {
const [hosts, setHosts] = useState<Host[]>([]);
const [addType, setAddType] = useState<StepType>('dockerCommand');
const [steps, setSteps] = useState<StepDraft[]>([]);
const [editingJob, setEditingJob] = useState<Job>();
const [message, setMessage] = useState('');
async function load() { const [jobData, hostData] = await Promise.all([api<Job[]>('/api/jobs'), api<Host[]>('/api/hosts')]); setJobs(jobData); setHosts(hostData); }
useEffect(() => { void load(); }, []);
@@ -179,7 +186,7 @@ function Jobs() {
const stepSecrets: Record<string, string> = {};
const configuredSteps = steps.map((step) => {
const base = { id: step.id, name: step.name, type: step.type, continueOnError: step.continueOnError, timeoutSeconds: Number(step.timeoutSeconds) };
const outputs = step.collectOutput ? [{ path: step.outputPath, name: step.outputName, archive: step.archive }] : [];
const outputs = step.collectOutput ? [{ path: step.outputPath, name: step.outputName, archive: step.archive }, ...step.additionalOutputs] : [];
if (step.type === 'dockerCommand') return { ...base, container: step.container, executable: step.executable, arguments: parseArguments(step.arguments), user: step.dockerUser || undefined, workingDirectory: step.workingDirectory || undefined, outputs };
if (step.type === 'remoteCommand') return { ...base, executable: step.executable, arguments: parseArguments(step.arguments), workingDirectory: step.workingDirectory || undefined, outputs };
if (step.type === 'directory') return { ...base, path: step.directoryPath, outputName: step.outputName };
@@ -187,25 +194,35 @@ function Jobs() {
return { ...base, database: step.database, username: step.databaseUsername, databaseHost: step.databaseHost, databasePort: Number(step.databasePort), outputName: step.outputName };
});
try {
await api('/api/jobs', { method: 'POST', body: JSON.stringify({
await api(editingJob ? `/api/jobs/${editingJob.id}` : '/api/jobs', { method: editingJob ? 'PUT' : 'POST', body: JSON.stringify({
name: data.get('name'), hostId: Number(data.get('hostId')), config: { steps: configuredSteps }, stepSecrets,
schedule: data.get('schedule') || undefined,
timezone: data.get('timezone'), enabled: true, retentionCount: Number(data.get('retentionCount')),
}) });
form.reset(); setSteps([]); setMessage('Job created.'); await load();
form.reset(); setSteps([]); setEditingJob(undefined); setMessage(editingJob ? 'Job updated.' : 'Job created.'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
}
function edit(job: Job) {
setEditingJob(job);
setSteps(job.config.steps.map((step) => draftFromStep(step, job.credentialStepIds.includes(step.id))));
setMessage(`Editing ${job.name}. Stored database passwords remain hidden.`);
}
function cancelEdit() {
setEditingJob(undefined);
setSteps([]);
setMessage('');
}
return <>
<PageTitle eyebrow="AUTOMATION / JOBS" title="Backup definitions" detail="Ordered remote operations with explicit outputs and failure policy." />
<div className="split jobs-split"><section className="panel form-panel"><div className="panel-head"><h2>New job</h2><span>runs remotely</span></div>
{hosts.length === 0 ? <Empty text="Add an SSH host before creating a job." /> : <form onSubmit={submit}>
<div className="field-row"><label>Job name<input name="name" required placeholder="immich-db" /></label><label>Remote host<select name="hostId" required>{hosts.map((host) => <option value={host.id} key={host.id}>{host.name}</option>)}</select></label></div>
<div className="split jobs-split"><section className="panel form-panel"><div className="panel-head"><h2>{editingJob ? `Edit ${editingJob.name}` : 'New job'}</h2><span>{editingJob ? 'run history preserved' : 'runs remotely'}</span></div>
{hosts.length === 0 ? <Empty text="Add an SSH host before creating a job." /> : <form key={editingJob?.id ?? 'new'} onSubmit={submit}>
<div className="field-row"><label>Job name<input name="name" required placeholder="immich-db" defaultValue={editingJob?.name} /></label><label>Remote host<select name="hostId" required defaultValue={editingJob?.hostId}>{hosts.map((host) => <option value={host.id} key={host.id}>{host.name}</option>)}</select></label></div>
<div className="step-list">{steps.map((step, index) => <StepEditor step={step} index={index} count={steps.length} update={updateStep} move={moveStep} remove={(id) => setSteps((current) => current.filter((item) => item.id !== id))} key={step.id} />)}{steps.length === 0 && <div className="empty step-empty">Select a type below to add the first step.</div>}</div>
<div className="add-step"><select value={addType} onChange={(event) => setAddType(event.target.value as StepType)}><option value="dockerCommand">Docker command</option><option value="remoteCommand">Remote command</option><option value="directory">Directory archive</option><option value="postgres">PostgreSQL dump</option><option value="mysql">MySQL dump</option></select><button type="button" onClick={() => setSteps((current) => [...current, createStep(addType)])}>+ Add step</button></div>
<fieldset><legend>Job policy</legend><div className="field-row"><label>Cron schedule <small>optional</small><input name="schedule" placeholder="0 2 * * *" /></label><label>Timezone<input name="timezone" required defaultValue="UTC" /></label></div><label>Successful runs to retain<input name="retentionCount" type="number" min="1" defaultValue="10" required /></label></fieldset>
{message && <p className="form-message">{message}</p>}<button type="submit" disabled={steps.length === 0}>Create backup job</button>
<fieldset><legend>Job policy</legend><div className="field-row"><label>Cron schedule <small>optional</small><input name="schedule" placeholder="0 2 * * *" defaultValue={editingJob?.schedule ?? ''} /></label><label>Timezone<input name="timezone" required defaultValue={editingJob?.timezone ?? 'UTC'} /></label></div><label>Successful runs to retain<input name="retentionCount" type="number" min="1" defaultValue={editingJob?.retentionCount ?? 10} required /></label></fieldset>
{message && <p className="form-message">{message}</p>}<div className="form-actions"><button type="submit" disabled={steps.length === 0}>{editingJob ? 'Update job' : 'Create backup job'}</button>{editingJob && <button type="button" className="secondary" onClick={cancelEdit}>Cancel editing</button>}</div>
</form>}
</section><section className="panel"><div className="panel-head"><h2>Configured jobs</h2><span>{jobs.length} total</span></div>{jobs.map((job) => <article className="job-card" key={job.id}><div><span className="job-type">{job.config.steps.length} step{job.config.steps.length === 1 ? '' : 's'}</span><h3>{job.name}</h3><p>{job.hostName} · keep {job.retentionCount}</p><small>{job.schedule ? `${job.schedule} · next ${formatDate(job.nextRunAt)}` : 'MANUAL ONLY'}</small></div><div className="card-actions"><button className="run" onClick={() => void api<{ runId: number }>(`/api/jobs/${job.id}/run`, { method: 'POST' }).then((value) => setMessage(`Run #${value.runId} queued`)).catch((error) => setMessage(error.message))}>Run now</button><button className="danger" onClick={() => confirm(`Delete ${job.name} and its run history?`) && void api(`/api/jobs/${job.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{jobs.length === 0 && <Empty text="No backup jobs defined." />}</section></div>
</section><section className="panel"><div className="panel-head"><h2>Configured jobs</h2><span>{jobs.length} total</span></div>{jobs.map((job) => <article className="job-card" key={job.id}><div><span className="job-type">{job.config.steps.length} step{job.config.steps.length === 1 ? '' : 's'}</span><h3>{job.name}</h3><p>{job.hostName} · keep {job.retentionCount}</p><small>{job.schedule ? `${job.schedule} · next ${formatDate(job.nextRunAt)}` : 'MANUAL ONLY'}</small></div><div className="card-actions"><button onClick={() => edit(job)}>Edit</button><button className="run" onClick={() => void api<{ runId: number }>(`/api/jobs/${job.id}/run`, { method: 'POST' }).then((value) => setMessage(`Run #${value.runId} queued`)).catch((error) => setMessage(error.message))}>Run now</button><button className="danger" onClick={() => confirm(`Delete ${job.name} and its run history?`) && void api(`/api/jobs/${job.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{jobs.length === 0 && <Empty text="No backup jobs defined." />}</section></div>
</>;
}
@@ -217,14 +234,24 @@ function StepEditor({ step, index, count, update, move, remove }: { step: StepDr
{step.type === 'remoteCommand' && <><label>Executable<input value={step.executable} onChange={(event) => set({ executable: event.target.value })} required placeholder="/usr/local/bin/prepare-backup" /></label><label>Working directory <small>optional</small><input value={step.workingDirectory} onChange={(event) => set({ workingDirectory: event.target.value })} placeholder="/srv/app" /></label></>}
{command && <><label>Arguments <small>one argument per line</small><textarea value={step.arguments} onChange={(event) => set({ arguments: event.target.value })} rows={3} placeholder={'--output\n/tmp/export'} /></label><label className="check"><input type="checkbox" checked={step.collectOutput} onChange={(event) => set({ collectOutput: event.target.checked })} /> Collect an output from this command</label>{step.collectOutput && <><div className="field-row"><label>{step.type === 'dockerCommand' ? 'Container' : 'Remote'} output path<input value={step.outputPath} onChange={(event) => set({ outputPath: event.target.value })} required placeholder="/tmp/export" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="export" /></label></div><label className="check"><input type="checkbox" checked={step.archive} onChange={(event) => set({ archive: event.target.checked })} /> Archive output as tar.gz</label></>}</>}
{step.type === 'directory' && <div className="field-row"><label>Absolute remote path<input value={step.directoryPath} onChange={(event) => set({ directoryPath: event.target.value })} required placeholder="/srv/documents" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="documents" /></label></div>}
{(step.type === 'postgres' || step.type === 'mysql') && <><div className="field-row"><label>Database host<input value={step.databaseHost} onChange={(event) => set({ databaseHost: event.target.value })} required /></label><label>Port<input value={step.databasePort} onChange={(event) => set({ databasePort: event.target.value })} type="number" required /></label></div><div className="field-row"><label>Database<input value={step.database} onChange={(event) => set({ database: event.target.value })} required /></label><label>Username<input value={step.databaseUsername} onChange={(event) => set({ databaseUsername: event.target.value })} required /></label></div><div className="field-row"><label>Password<input value={step.databasePassword} onChange={(event) => set({ databasePassword: event.target.value })} type="password" required /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="database" /></label></div></>}
{(step.type === 'postgres' || step.type === 'mysql') && <><div className="field-row"><label>Database host<input value={step.databaseHost} onChange={(event) => set({ databaseHost: event.target.value })} required /></label><label>Port<input value={step.databasePort} onChange={(event) => set({ databasePort: event.target.value })} type="number" required /></label></div><div className="field-row"><label>Database<input value={step.database} onChange={(event) => set({ database: event.target.value })} required /></label><label>Username<input value={step.databaseUsername} onChange={(event) => set({ databaseUsername: event.target.value })} required /></label></div><div className="field-row"><label>Password {step.hasDatabasePassword && <small>leave blank to keep current password</small>}<input value={step.databasePassword} onChange={(event) => set({ databasePassword: event.target.value })} type="password" required={!step.hasDatabasePassword} /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="database" /></label></div></>}
<div className="field-row step-policy"><label>Timeout (seconds)<input value={step.timeoutSeconds} onChange={(event) => set({ timeoutSeconds: event.target.value })} type="number" min="1" required /></label><label className="check"><input type="checkbox" checked={step.continueOnError} onChange={(event) => set({ continueOnError: event.target.checked })} /> Continue if this step fails</label></div>
</fieldset>;
}
function createStep(type: StepType): StepDraft {
const id = crypto.randomUUID();
return { id, type, name: stepTypeLabel(type), continueOnError: false, timeoutSeconds: '3600', executable: '', arguments: '', workingDirectory: '', container: '', dockerUser: '', collectOutput: type === 'dockerCommand', outputPath: '', outputName: '', archive: true, directoryPath: '', database: '', databaseUsername: '', databaseHost: 'localhost', databasePort: type === 'mysql' ? '3306' : '5432', databasePassword: '' };
return { id, type, name: stepTypeLabel(type), continueOnError: false, timeoutSeconds: '3600', executable: '', arguments: '', workingDirectory: '', container: '', dockerUser: '', collectOutput: type === 'dockerCommand', outputPath: '', outputName: '', archive: true, directoryPath: '', additionalOutputs: [], database: '', databaseUsername: '', databaseHost: 'localhost', databasePort: type === 'mysql' ? '3306' : '5432', databasePassword: '', hasDatabasePassword: false };
}
function draftFromStep(step: PersistedStep, hasDatabasePassword: boolean): StepDraft {
const output = step.outputs?.[0];
return {
...createStep(step.type), id: step.id, name: step.name, continueOnError: step.continueOnError, timeoutSeconds: String(step.timeoutSeconds),
executable: step.executable ?? '', arguments: step.arguments?.join('\n') ?? '', workingDirectory: step.workingDirectory ?? '', container: step.container ?? '', dockerUser: step.user ?? '',
collectOutput: Boolean(output), outputPath: output?.path ?? '', outputName: step.outputName ?? output?.name ?? '', archive: output?.archive ?? true, directoryPath: step.path ?? '', additionalOutputs: step.outputs?.slice(1) ?? [],
database: step.database ?? '', databaseUsername: step.username ?? '', databaseHost: step.databaseHost ?? 'localhost', databasePort: String(step.databasePort ?? (step.type === 'mysql' ? 3306 : 5432)), databasePassword: '', hasDatabasePassword,
};
}
function stepTypeLabel(type: StepType): string { return { dockerCommand: 'Docker command', remoteCommand: 'Remote command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump' }[type]; }