@@ -4,7 +4,7 @@ import { api } from './api';
import './styles.css' ;
type Tab = 'overview' | 'hosts' | 'jobs' | 'settings' ;
type Host = { id : number ; name : string ; hostname : string ; port : number ; username : string ; fingerprint : string ; authType : string } ;
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 StepDraft = {
id : string ; name : string ; type : StepType ; continueOnError : boolean ; timeoutSeconds : string ;
@@ -106,7 +106,8 @@ function Status({ value }: { value: string }) { return <span className={`status
function Hosts() {
const [ hosts , setHosts ] = useState < Host [ ] > ( [ ] ) ;
const [ authType , setAuthType ] = useState ( 'password' ) ;
const [ authType , setAuthType ] = useState < 'password' | 'privateKey' > ( 'password' ) ;
const [ editingHost , setEditingHost ] = useState < Host > ( ) ;
const [ message , setMessage ] = useState ( '' ) ;
async function load() { setHosts ( await api < Host [ ] > ( '/api/hosts' ) ) ; }
useEffect ( ( ) = > { void load ( ) ; } , [ ] ) ;
@@ -125,25 +126,35 @@ function Hosts() {
const form = event . currentTarget ;
const data = new FormData ( form ) ;
try {
await api ( '/api/hosts' , { method : 'POST' , body : JSON.stringify ( {
await api ( editingHost ? ` /api/hosts/ ${ editingHost . id } ` : '/api/hosts' , { method : editingHost ? 'PUT' : 'POST' , body : JSON.stringify ( {
name : data.get ( 'name' ) , hostname : data.get ( 'hostname' ) , port : Number ( data . get ( 'port' ) ) , username : data.get ( 'username' ) ,
fingerprint : data.get ( 'fingerprint' ) , authType , password : data.get ( 'password' ) || undefined ,
privateKey : data.get ( 'privateKey' ) || undefined , passphrase : data.get ( 'passphrase' ) || undefined ,
} ) } ) ;
form . reset ( ) ; setMessage ( 'Host saved.' ) ; await load ( ) ;
form . reset ( ) ; setEditingHost ( undefined ) ; setAuthType ( 'password' ) ; setMessage ( editingHost ? 'Host updated.' : 'Host saved.' ) ; await load ( ) ;
} catch ( error ) { setMessage ( error instanceof Error ? error.message : String ( error ) ) ; }
}
function edit ( host : Host ) {
setEditingHost ( host ) ;
setAuthType ( host . authType ) ;
setMessage ( ` Editing ${ host . name } . Stored credentials remain hidden. ` ) ;
}
function cancelEdit() {
setEditingHost ( undefined ) ;
setAuthType ( 'password' ) ;
setMessage ( '' ) ;
}
return < >
< PageTitle eyebrow = "INVENTORY / SSH" title = "Remote hosts" detail = "Pinned connections used to execute and retrieve backups." / >
< div className = "split" > < section className = "panel form-panel" > < div className = "panel-head" > < h2 > Add host < / h2 > < span > encrypted at rest < / span > < / div > < form onSubmit = { submit } >
< div className = "field-row" > < label > Display name < input name = "name" required placeholder = "media-node" / > < / label > < label > SSH username < input name = "username" required placeholder = "backup" / > < / label > < / div >
< div className = "field-row wide" > < label > Hostname or IP < input name = "hostname" required placeholder = "192.168.1.20" / > < / label > < label className = "port" > Port < input name = "port" type = "number" defaultValue = "22" required / > < / label > < / div >
< label > Host - key fingerprint < div className = "input-action" > < input name = "fingerprint" required placeholder = "SHA256:..." / > < button type = "button" onClick = { ( event ) = > void probe ( event . currentTarget . form ! ) } > Probe < / button > < / div > < / label >
< div className = "split" > < section className = "panel form-panel" > < div className = "panel-head" > < h2 > { editingHost ? ` Edit ${ editingHost . name } ` : 'Add host' } < / h2 > < span > { editingHost ? 'credentials stay hidden' : 'encrypted at rest' } < / span > < / div > < form key = { editingHost ? . id ? ? 'new' } onSubmit = { submit } >
< div className = "field-row" > < label > Display name < input name = "name" required placeholder = "media-node" defaultValue = { editingHost ? . name } / > < / label > < label > SSH username < input name = "username" required placeholder = "backup" defaultValue = { editingHost ? . username } / > < / label > < / div >
< div className = "field-row wide" > < label > Hostname or IP < input name = "hostname" required placeholder = "192.168.1.20" defaultValue = { editingHost ? . hostname } / > < / label > < label className = "port" > Port < input name = "port" type = "number" defaultValue = { editingHost ? . port ? ? 22 } required / > < / label > < / div >
< label > Host - key fingerprint < div className = "input-action" > < input name = "fingerprint" required placeholder = "SHA256:..." defaultValue = { editingHost ? . fingerprint } / > < button type = "button" onClick = { ( event ) = > void probe ( event . currentTarget . form ! ) } > Probe < / button > < / div > < / label >
< div className = "segmented" > < button type = "button" className = { authType === 'password' ? 'selected' : '' } onClick = { ( ) = > setAuthType ( 'password' ) } > Password < / button > < button type = "button" className = { authType === 'privateKey' ? 'selected' : '' } onClick = { ( ) = > setAuthType ( 'privateKey' ) } > Private key < / button > < / div >
{ authType === 'password' ? < label > Password < input name = "password" type = "password" required / > < / label > : < > < label > PEM private key < textarea name = "privateKey" rows = { 7 } required placeholder = "-----BEGIN OPENSSH PRIVATE KEY-----" / > < / label > < label > Key passphrase < small > optional< / small > < input name = "passphrase" type = "password" / > < / label > < / > }
{ message && < p className = "form-message" > { message } < / p > } < button type = "submit" > Save encrypted host < / button >
{ authType === 'password' ? < label > Password { editingHost ? . authType === 'password' && < small > leave blank to keep current password < / small > } < input name = "password" type = "password" required = { ! editingHost || editingHost . authType !== 'password' } / > < / label > : < > < label > PEM private key { editingHost ? . authType === 'privateKey' && < small > leave blank to keep current key < / small > } < textarea name = "privateKey" rows = { 7 } required = { ! editingHost || editingHost . authType !== 'privateKey' } placeholder = "-----BEGIN OPENSSH PRIVATE KEY-----" / > < / label > < label > Key passphrase < small > { editingHost ? . authType === 'privateKey' ? 'used only when replacing the key' : ' optional' } < / small > < input name = "passphrase" type = "password" / > < / label > < / > }
{ message && < p className = "form-message" > { message } < / p > } < div className = "form-actions" > < button type = "submit" > { editingHost ? 'Update host' : ' Save encrypted host' } < / button > { editingHost && < button type = "button" className = "secondary" onClick = { cancelEdit } > Cancel editing < / button > } < / div >
< / form > < / section >
< section className = "panel" > < div className = "panel-head" > < h2 > Known hosts < / h2 > < span > { hosts . length } configured < / span > < / div > { hosts . map ( ( host ) = > < article className = "host-card" key = { host . id } > < div className = "host-icon" > { host . name . slice ( 0 , 2 ) . toUpperCase ( ) } < / div > < div > < h3 > { host . name } < / h3 > < p className = "mono" > { host . username } @ { host . hostname } : { host . port } < / p > < small > { host . authType === 'privateKey' ? 'PRIVATE KEY' : 'PASSWORD' } · { host . fingerprint } < / small > < / div > < div className = "card-actions" > < button onClick = { ( ) = > void api ( ` /api/hosts/ ${ host . id } /test ` , { method : 'POST' } ) . then ( ( ) = > setMessage ( ` ${ host . name } : connection successful ` ) ) . catch ( ( error ) = > setMessage ( error . message ) ) } > Test < / button > < button className = "danger" onClick = { ( ) = > confirm ( ` Delete ${ host . name } ? ` ) && void api ( ` /api/hosts/ ${ host . id } ` , { method : 'DELETE' } ) . then ( load ) . catch ( ( error ) = > setMessage ( error . message ) ) } > Delete < / button > < / div > < / article > ) } { hosts . length === 0 && < Empty text = "No remote hosts configured." / > } < / section > < / div >
< section className = "panel" > < div className = "panel-head" > < h2 > Known hosts < / h2 > < span > { hosts . length } configured < / span > < / div > { hosts . map ( ( host ) = > < article className = "host-card" key = { host . id } > < div className = "host-icon" > { host . name . slice ( 0 , 2 ) . toUpperCase ( ) } < / div > < div > < h3 > { host . name } < / h3 > < p className = "mono" > { host . username } @ { host . hostname } : { host . port } < / p > < small > { host . authType === 'privateKey' ? 'PRIVATE KEY' : 'PASSWORD' } · { host . fingerprint } < / small > < / div > < div className = "card-actions" > < button onClick = { ( ) = > edit ( host ) } > Edit < / button > < button onClick = { ( ) = > void api ( ` /api/hosts/ ${ host . id } /test ` , { method : 'POST' } ) . then ( ( ) = > setMessage ( ` ${ host . name } : connection successful ` ) ) . catch ( ( error ) = > setMessage ( error . message ) ) } > Test < / button > < button className = "danger" onClick = { ( ) = > confirm ( ` Delete ${ host . name } ? ` ) && void api ( ` /api/hosts/ ${ host . id } ` , { method : 'DELETE' } ) . then ( load ) . catch ( ( error ) = > setMessage ( error . message ) ) } > Delete < / button > < / div > < / article > ) } { hosts . length === 0 && < Empty text = "No remote hosts configured." / > } < / section > < / div >
< / > ;
}
@@ -151,7 +162,7 @@ function Jobs() {
const [ jobs , setJobs ] = useState < Job [ ] > ( [ ] ) ;
const [ hosts , setHosts ] = useState < Host [ ] > ( [ ] ) ;
const [ addType , setAddType ] = useState < StepType > ( 'dockerCommand' ) ;
const [ steps , setSteps ] = useState < StepDraft [ ] > ( [ createStep ( 'dockerCommand' ) ] ) ;
const [ steps , setSteps ] = useState < StepDraft [ ] > ( [ ] ) ;
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 ( ) ; } , [ ] ) ;
@@ -181,7 +192,7 @@ function Jobs() {
schedule : data.get ( 'schedule' ) || undefined ,
timezone : data.get ( 'timezone' ) , enabled : true , retentionCount : Number ( data . get ( 'retentionCount' ) ) ,
} ) } ) ;
form . reset ( ) ; setSteps ( [ createStep ( 'dockerCommand' ) ] ) ; setMessage ( 'Job created.' ) ; await load ( ) ;
form . reset ( ) ; setSteps ( [ ] ) ; setMessage ( 'Job created.' ) ; await load ( ) ;
} catch ( error ) { setMessage ( error instanceof Error ? error.message : String ( error ) ) ; }
}
return < >
@@ -189,10 +200,10 @@ function Jobs() {
< 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 = "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 } / > ) } < / 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" > Create backup job < / button >
{ message && < p className = "form-message" > { message } < / p > } < button type = "submit" disabled = { steps . length === 0 } > Create backup job < / button >
< / 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 >
< / > ;
@@ -201,7 +212,7 @@ function Jobs() {
function StepEditor ( { step , index , count , update , move , remove } : { step : StepDraft ; index : number ; count : number ; update : ( id : string , values : Partial < StepDraft > ) = > void ; move : ( index : number , direction : number ) = > void ; remove : ( id : string ) = > void } ) {
const set = ( values : Partial < StepDraft > ) = > update ( step . id , values ) ;
const command = step . type === 'dockerCommand' || step . type === 'remoteCommand' ;
return < fieldset className = "step-card" > < legend > Step { index + 1 } < / legend > < div className = "step-head" > < input aria-label = "Step name" value = { step . name } onChange = { ( event ) = > set ( { name : event.target.value } ) } required / > < span className = "job-type" > { stepTypeLabel ( step . type ) } < / span > < div className = "step-actions" > < button type = "button" disabled = { index === 0 } onClick = { ( ) = > move ( index , - 1 ) } > ↑ < / button > < button type = "button" disabled = { index === count - 1 } onClick = { ( ) = > move ( index , 1 ) } > ↓ < / button > < button type = "button" disabled = { count === 1 } onClick= { ( ) = > remove ( step . id ) } > × < / button > < / div > < / div >
return < fieldset className = "step-card" > < legend > Step { index + 1 } < / legend > < div className = "step-head" > < input aria-label = "Step name" value = { step . name } onChange = { ( event ) = > set ( { name : event.target.value } ) } required / > < span className = "job-type" > { stepTypeLabel ( step . type ) } < / span > < div className = "step-actions" > < button type = "button" disabled = { index === 0 } onClick = { ( ) = > move ( index , - 1 ) } > ↑ < / button > < button type = "button" disabled = { index === count - 1 } onClick = { ( ) = > move ( index , 1 ) } > ↓ < / button > < button type = "button" onClick = { ( ) = > remove ( step . id ) } > × < / button > < / div > < / div >
{ step . type === 'dockerCommand' && < > < div className = "field-row" > < label > Container < input value = { step . container } onChange = { ( event ) = > set ( { container : event.target.value } ) } required placeholder = "immich_server" / > < / label > < label > Executable < input value = { step . executable } onChange = { ( event ) = > set ( { executable : event.target.value } ) } required placeholder = "/app/backup" / > < / label > < / div > < div className = "field-row" > < label > Container user < small > optional < / small > < input value = { step . dockerUser } onChange = { ( event ) = > set ( { dockerUser : event.target.value } ) } / > < / label > < label > Working directory < small > optional < / small > < input value = { step . workingDirectory } onChange = { ( event ) = > set ( { workingDirectory : event.target.value } ) } placeholder = "/app" / > < / label > < / div > < / > }
{ 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 > < / > } < / > }