|
|
|
|
@@ -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]; }
|
|
|
|
|
|