forked from immich-app/immich
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(web): pause and resume jobs (immich-app#2125)
* feat(web): pause and resume jobs * add bg color to status instead of using badge * styling --------- Co-authored-by: Alex Tran <[email protected]>
- Loading branch information
1 parent
23e4449
commit aaaf1a6
Showing
7 changed files
with
215 additions
and
170 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,102 +1,111 @@ | ||
<script lang="ts"> | ||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | ||
import SelectionSearch from 'svelte-material-icons/SelectionSearch.svelte'; | ||
import Play from 'svelte-material-icons/Play.svelte'; | ||
import Pause from 'svelte-material-icons/Pause.svelte'; | ||
import FastForward from 'svelte-material-icons/FastForward.svelte'; | ||
import AllInclusive from 'svelte-material-icons/AllInclusive.svelte'; | ||
import { locale } from '$lib/stores/preferences.store'; | ||
import { createEventDispatcher } from 'svelte'; | ||
import { JobCountsDto } from '@api'; | ||
import { JobCommand, JobCommandDto, JobCountsDto } from '@api'; | ||
import Badge from '$lib/components/elements/badge.svelte'; | ||
export let title: string; | ||
export let subtitle: string; | ||
export let subtitle: string | undefined = undefined; | ||
export let jobCounts: JobCountsDto; | ||
/** | ||
* Show options to run job on all assets of just missing ones | ||
*/ | ||
export let showOptions = true; | ||
export let allowForceCommand = true; | ||
$: isRunning = jobCounts.active > 0 || jobCounts.waiting > 0; | ||
$: waitingCount = jobCounts.waiting + jobCounts.paused; | ||
$: isPause = jobCounts.paused > 0; | ||
const dispatch = createEventDispatcher(); | ||
const run = (force: boolean) => { | ||
dispatch('click', { force }); | ||
}; | ||
const dispatch = createEventDispatcher<{ command: JobCommandDto }>(); | ||
</script> | ||
|
||
<div class="flex justify-between rounded-3xl bg-gray-100 dark:bg-immich-dark-gray"> | ||
<div id="job-info" class="w-[70%] p-9"> | ||
<div class="flex flex-col gap-2"> | ||
<div class="text-xl font-semibold text-immich-primary dark:text-immich-dark-primary"> | ||
{title.toUpperCase()} | ||
<div | ||
class="flex justify-between rounded-3xl bg-gray-100 dark:bg-immich-dark-gray transition-all | ||
{isRunning ? 'dark:bg-immich-primary/30 bg-immich-primary/20' : ''} | ||
{isPause ? 'dark:bg-yellow-100/30 bg-yellow-500/20' : ''}" | ||
> | ||
<div id="job-info" class="w-full p-9"> | ||
<div class="flex flex-col gap-2 "> | ||
<div | ||
class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary" | ||
> | ||
<span>{title.toUpperCase()}</span> | ||
<div class="flex gap-2"> | ||
{#if jobCounts.failed > 0} | ||
<Badge color="danger"> | ||
{jobCounts.failed.toLocaleString($locale)} failed | ||
</Badge> | ||
{/if} | ||
</div> | ||
</div> | ||
|
||
{#if subtitle.length > 0} | ||
<div class="text-sm dark:text-white">{subtitle}</div> | ||
{#if subtitle} | ||
<div class="text-sm dark:text-white whitespace-pre-line">{subtitle}</div> | ||
{/if} | ||
<div class="text-sm dark:text-white"><slot /></div> | ||
<div class="text-sm dark:text-white"> | ||
<slot /> | ||
</div> | ||
|
||
<div class="flex w-full mt-4"> | ||
<div class="flex w-full max-w-md mt-2"> | ||
<div | ||
class="flex place-items-center justify-between bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray w-full rounded-tl-lg rounded-bl-lg py-4 pl-4 pr-6" | ||
> | ||
<p>Active</p> | ||
<p class="text-2xl"> | ||
{#if jobCounts.active !== undefined} | ||
{jobCounts.active.toLocaleString($locale)} | ||
{:else} | ||
<LoadingSpinner /> | ||
{/if} | ||
{jobCounts.active.toLocaleString($locale)} | ||
</p> | ||
</div> | ||
|
||
<div | ||
class="flex place-items-center justify-between bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray w-full rounded-tr-lg rounded-br-lg py-4 pr-4 pl-6" | ||
> | ||
<p class="text-2xl"> | ||
{#if jobCounts.waiting !== undefined} | ||
{jobCounts.waiting.toLocaleString($locale)} | ||
{:else} | ||
<LoadingSpinner /> | ||
{/if} | ||
{waitingCount.toLocaleString($locale)} | ||
</p> | ||
<p>Waiting</p> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
<div id="job-action" class="flex flex-col"> | ||
<div id="job-action" class="flex flex-col rounded-r-3xl w-32 overflow-hidden"> | ||
{#if isRunning} | ||
<button | ||
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90 rounded-br-3xl rounded-tr-3xl disabled:cursor-not-allowed" | ||
disabled | ||
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90" | ||
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })} | ||
> | ||
<LoadingSpinner /> | ||
<Pause size="48" /> PAUSE | ||
</button> | ||
{:else if jobCounts.paused > 0} | ||
<button | ||
class="job-play-button bg-gray-300 dark:bg-gray-600/90" | ||
on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })} | ||
> | ||
<span class=" {isPause ? 'animate-pulse' : ''}"> | ||
<FastForward size="48" /> RESUME | ||
</span> | ||
</button> | ||
{:else if allowForceCommand} | ||
<button | ||
class="job-play-button bg-gray-300 dark:bg-gray-600" | ||
on:click={() => dispatch('command', { command: JobCommand.Start, force: true })} | ||
> | ||
<AllInclusive size="18" /> ALL | ||
</button> | ||
<button | ||
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90" | ||
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })} | ||
> | ||
<SelectionSearch size="18" /> MISSING | ||
</button> | ||
{:else} | ||
<button | ||
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90" | ||
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })} | ||
> | ||
<Play size="48" /> START | ||
</button> | ||
{/if} | ||
|
||
{#if !isRunning} | ||
{#if showOptions} | ||
<button | ||
class="job-play-button bg-gray-300 dark:bg-gray-600 rounded-tr-3xl" | ||
on:click={() => run(true)} | ||
> | ||
<AllInclusive size="18" /> ALL | ||
</button> | ||
<button | ||
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90 rounded-br-3xl" | ||
on:click={() => run(false)} | ||
> | ||
<SelectionSearch size="18" /> MISSING | ||
</button> | ||
{:else} | ||
<button | ||
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90 rounded-br-3xl rounded-tr-3xl" | ||
on:click={() => run(true)} | ||
> | ||
<Play size="48" /> | ||
</button> | ||
{/if} | ||
{/if} | ||
</div> | ||
</div> |
162 changes: 67 additions & 95 deletions
162
web/src/lib/components/admin-page/jobs/jobs-panel.svelte
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,115 +1,87 @@ | ||
<script lang="ts"> | ||
import { | ||
notificationController, | ||
NotificationType | ||
} from '$lib/components/shared-components/notification/notification'; | ||
import { handleError } from '$lib/utils/handle-error'; | ||
import { AllJobStatusResponseDto, api, JobCommand, JobName } from '@api'; | ||
import { onDestroy, onMount } from 'svelte'; | ||
import { AllJobStatusResponseDto, api, JobCommand, JobCommandDto, JobName } from '@api'; | ||
import type { ComponentType } from 'svelte'; | ||
import JobTile from './job-tile.svelte'; | ||
import StorageMigrationDescription from './storage-migration-description.svelte'; | ||
let jobs: AllJobStatusResponseDto; | ||
let timer: NodeJS.Timer; | ||
export let jobs: AllJobStatusResponseDto; | ||
const load = async () => { | ||
const { data } = await api.jobApi.getAllJobsStatus(); | ||
jobs = data; | ||
type JobDetails = { | ||
title: string; | ||
subtitle?: string; | ||
allowForceCommand?: boolean; | ||
component?: ComponentType; | ||
}; | ||
onMount(async () => { | ||
await load(); | ||
timer = setInterval(async () => await load(), 5_000); | ||
}); | ||
onDestroy(() => { | ||
clearInterval(timer); | ||
}); | ||
function getJobLabel(jobName: JobName) { | ||
const names: Record<JobName, string> = { | ||
[JobName.ThumbnailGenerationQueue]: 'Generate Thumbnails', | ||
[JobName.MetadataExtractionQueue]: 'Extract Metadata', | ||
[JobName.VideoConversionQueue]: 'Transcode Videos', | ||
[JobName.ObjectTaggingQueue]: 'Tag Objects', | ||
[JobName.ClipEncodingQueue]: 'Clip Encoding', | ||
[JobName.BackgroundTaskQueue]: 'Background Task', | ||
[JobName.StorageTemplateMigrationQueue]: 'Storage Template Migration', | ||
[JobName.SearchQueue]: 'Search' | ||
}; | ||
const jobDetails: { [Key in JobName]?: JobDetails } = { | ||
[JobName.ThumbnailGenerationQueue]: { | ||
title: 'Generate Thumbnails', | ||
subtitle: 'Regenerate JPEG and WebP thumbnails' | ||
}, | ||
[JobName.MetadataExtractionQueue]: { | ||
title: 'Extract Metadata', | ||
subtitle: 'Extract metadata information i.e. GPS, resolution...etc' | ||
}, | ||
[JobName.ObjectTaggingQueue]: { | ||
title: 'Tag Objects', | ||
subtitle: | ||
'Run machine learning to tag objects\nNote that some assets may not have any objects detected' | ||
}, | ||
[JobName.ClipEncodingQueue]: { | ||
title: 'Encode Clip', | ||
subtitle: 'Run machine learning to generate clip embeddings' | ||
}, | ||
[JobName.VideoConversionQueue]: { | ||
title: 'Transcode Videos', | ||
subtitle: 'Transcode videos not in the desired format' | ||
}, | ||
[JobName.StorageTemplateMigrationQueue]: { | ||
title: 'Storage Template Migration', | ||
allowForceCommand: false, | ||
component: StorageMigrationDescription | ||
} | ||
}; | ||
return names[jobName]; | ||
} | ||
const jobDetailsArray = Object.entries(jobDetails) as [JobName, JobDetails][]; | ||
const start = async (jobId: JobName, force: boolean) => { | ||
const label = getJobLabel(jobId); | ||
async function runJob(jobId: JobName, jobCommand: JobCommandDto) { | ||
const title = jobDetails[jobId]?.title; | ||
try { | ||
await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start, force }); | ||
jobs[jobId].active += 1; | ||
notificationController.show({ | ||
message: `Started job: ${label}`, | ||
type: NotificationType.Info | ||
}); | ||
await api.jobApi.sendJobCommand(jobId, jobCommand); | ||
// TODO: Return actual job status from server and use that. | ||
switch (jobCommand.command) { | ||
case JobCommand.Start: | ||
jobs[jobId].active += 1; | ||
break; | ||
case JobCommand.Resume: | ||
jobs[jobId].active += 1; | ||
jobs[jobId].paused = 0; | ||
break; | ||
case JobCommand.Pause: | ||
jobs[jobId].paused += 1; | ||
jobs[jobId].active = 0; | ||
jobs[jobId].waiting = 0; | ||
break; | ||
} | ||
} catch (error) { | ||
handleError(error, `Unable to start job: ${label}`); | ||
handleError(error, `Command '${jobCommand.command}' failed for job: ${title}`); | ||
} | ||
}; | ||
} | ||
</script> | ||
|
||
<div class="flex flex-col gap-7"> | ||
{#if jobs} | ||
<JobTile | ||
title="Generate thumbnails" | ||
subtitle="Regenerate JPEG and WebP thumbnails" | ||
on:click={(e) => start(JobName.ThumbnailGenerationQueue, e.detail.force)} | ||
jobCounts={jobs[JobName.ThumbnailGenerationQueue]} | ||
/> | ||
|
||
<JobTile | ||
title="Extract Metadata" | ||
subtitle="Extract metadata information i.e. GPS, resolution...etc" | ||
on:click={(e) => start(JobName.MetadataExtractionQueue, e.detail.force)} | ||
jobCounts={jobs[JobName.MetadataExtractionQueue]} | ||
/> | ||
|
||
<JobTile | ||
title="Tag Objects" | ||
subtitle="Run machine learning to tag objects" | ||
on:click={(e) => start(JobName.ObjectTaggingQueue, e.detail.force)} | ||
jobCounts={jobs[JobName.ObjectTaggingQueue]} | ||
> | ||
Note that some assets may not have any objects detected | ||
</JobTile> | ||
|
||
<JobTile | ||
title="Encode Clip" | ||
subtitle="Run machine learning to generate clip embeddings" | ||
on:click={(e) => start(JobName.ClipEncodingQueue, e.detail.force)} | ||
jobCounts={jobs[JobName.ClipEncodingQueue]} | ||
/> | ||
|
||
<JobTile | ||
title="Transcode Videos" | ||
subtitle="Transcode videos not in the desired format" | ||
on:click={(e) => start(JobName.VideoConversionQueue, e.detail.force)} | ||
jobCounts={jobs[JobName.VideoConversionQueue]} | ||
/> | ||
|
||
{#each jobDetailsArray as [jobName, { title, subtitle, allowForceCommand, component }]} | ||
<JobTile | ||
title="Storage migration" | ||
showOptions={false} | ||
subtitle={''} | ||
on:click={(e) => start(JobName.StorageTemplateMigrationQueue, e.detail.force)} | ||
jobCounts={jobs[JobName.StorageTemplateMigrationQueue]} | ||
{title} | ||
{subtitle} | ||
{allowForceCommand} | ||
on:command={({ detail }) => runJob(jobName, detail)} | ||
jobCounts={jobs[jobName]} | ||
> | ||
Apply the current | ||
<a | ||
href="/admin/system-settings?open=storage-template" | ||
class="text-immich-primary dark:text-immich-dark-primary">Storage template</a | ||
> | ||
to previously uploaded assets | ||
<svelte:component this={component} /> | ||
</JobTile> | ||
{/if} | ||
{/each} | ||
</div> |
10 changes: 10 additions & 0 deletions
10
web/src/lib/components/admin-page/jobs/storage-migration-description.svelte
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<script lang="ts"> | ||
import { AppRoute } from '$lib/constants'; | ||
</script> | ||
|
||
Apply the current | ||
<a | ||
href={`${AppRoute.ADMIN_SETTINGS}?open=storage-template`} | ||
class="text-immich-primary dark:text-immich-dark-primary">Storage template</a | ||
> | ||
to previously uploaded assets |
Oops, something went wrong.