1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server/web): jobs clear button + queue status (#2144)

* feat(server/web): jobs clear button + queue status

* adjust design and colors

* Adjust some styling

* show status next to buttons instead of on top

* Update rounded corner for badge

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Michel Heusschen 2023-04-01 22:46:07 +02:00 committed by GitHub
parent d04f340b5b
commit b06ddec2d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 333 additions and 204 deletions

View File

@ -53,6 +53,7 @@ doc/JobCommand.md
doc/JobCommandDto.md
doc/JobCountsDto.md
doc/JobName.md
doc/JobStatusDto.md
doc/LoginCredentialDto.md
doc/LoginResponseDto.md
doc/LogoutResponseDto.md
@ -60,6 +61,7 @@ doc/OAuthApi.md
doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/QueueStatusDto.md
doc/RemoveAssetsDto.md
doc/SearchAlbumResponseDto.md
doc/SearchApi.md
@ -170,12 +172,14 @@ lib/model/job_command.dart
lib/model/job_command_dto.dart
lib/model/job_counts_dto.dart
lib/model/job_name.dart
lib/model/job_status_dto.dart
lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart
lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
lib/model/queue_status_dto.dart
lib/model/remove_assets_dto.dart
lib/model/search_album_response_dto.dart
lib/model/search_asset_dto.dart
@ -264,6 +268,7 @@ test/job_command_dto_test.dart
test/job_command_test.dart
test/job_counts_dto_test.dart
test/job_name_test.dart
test/job_status_dto_test.dart
test/login_credential_dto_test.dart
test/login_response_dto_test.dart
test/logout_response_dto_test.dart
@ -271,6 +276,7 @@ test/o_auth_api_test.dart
test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/queue_status_dto_test.dart
test/remove_assets_dto_test.dart
test/search_album_response_dto_test.dart
test/search_api_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/JobStatusDto.md generated Normal file

Binary file not shown.

BIN
mobile/openapi/doc/QueueStatusDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,4 @@
import { AllJobStatusResponseDto, JobCommandDto, JobIdDto, JobService } from '@app/domain';
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto, JobIdDto, JobService } from '@app/domain';
import { Body, Controller, Get, Param, Put, UsePipes, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator';
@ -16,7 +16,8 @@ export class JobController {
}
@Put('/:jobId')
sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<void> {
return this.service.handleCommand(jobId, dto);
async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
await this.service.handleCommand(jobId, dto);
return await this.service.getJobStatus(jobId);
}
}

View File

@ -541,7 +541,14 @@
},
"responses": {
"200": {
"description": ""
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobStatusDto"
}
}
}
}
},
"tags": [
@ -4088,32 +4095,62 @@
"paused"
]
},
"QueueStatusDto": {
"type": "object",
"properties": {
"isActive": {
"type": "boolean"
},
"isPaused": {
"type": "boolean"
}
},
"required": [
"isActive",
"isPaused"
]
},
"JobStatusDto": {
"type": "object",
"properties": {
"jobCounts": {
"$ref": "#/components/schemas/JobCountsDto"
},
"queueStatus": {
"$ref": "#/components/schemas/QueueStatusDto"
}
},
"required": [
"jobCounts",
"queueStatus"
]
},
"AllJobStatusResponseDto": {
"type": "object",
"properties": {
"thumbnail-generation-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
},
"metadata-extraction-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
},
"video-conversion-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
},
"object-tagging-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
},
"clip-encoding-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
},
"storage-template-migration-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
},
"background-task-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
},
"search-queue": {
"$ref": "#/components/schemas/JobCountsDto"
"$ref": "#/components/schemas/JobStatusDto"
}
},
"required": [

View File

@ -18,6 +18,11 @@ export interface JobCounts {
paused: number;
}
export interface QueueStatus {
isActive: boolean;
isPaused: boolean;
}
export type JobItem =
// Asset Upload
| { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob }
@ -73,6 +78,6 @@ export interface IJobRepository {
pause(name: QueueName): Promise<void>;
resume(name: QueueName): Promise<void>;
empty(name: QueueName): Promise<void>;
isActive(name: QueueName): Promise<boolean>;
getQueueStatus(name: QueueName): Promise<QueueStatus>;
getJobCounts(name: QueueName): Promise<JobCounts>;
}

View File

@ -25,72 +25,35 @@ describe(JobService.name, () => {
waiting: 1,
paused: 1,
});
jobMock.getQueueStatus.mockResolvedValue({
isActive: true,
isPaused: true,
});
const expectedJobStatus = {
jobCounts: {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
queueStatus: {
isActive: true,
isPaused: true,
},
};
await expect(sut.getAllJobsStatus()).resolves.toEqual({
'background-task-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'clip-encoding-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'metadata-extraction-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'object-tagging-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'search-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'storage-template-migration-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'thumbnail-generation-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'video-conversion-queue': {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
'background-task-queue': expectedJobStatus,
'clip-encoding-queue': expectedJobStatus,
'metadata-extraction-queue': expectedJobStatus,
'object-tagging-queue': expectedJobStatus,
'search-queue': expectedJobStatus,
'storage-template-migration-queue': expectedJobStatus,
'thumbnail-generation-queue': expectedJobStatus,
'video-conversion-queue': expectedJobStatus,
});
});
});
@ -115,7 +78,7 @@ describe(JobService.name, () => {
});
it('should not start a job that is already running', async () => {
jobMock.isActive.mockResolvedValue(true);
jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false });
await expect(
sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }),
@ -125,7 +88,7 @@ describe(JobService.name, () => {
});
it('should handle a start video conversion command', async () => {
jobMock.isActive.mockResolvedValue(false);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false });
@ -133,7 +96,7 @@ describe(JobService.name, () => {
});
it('should handle a start storage template migration command', async () => {
jobMock.isActive.mockResolvedValue(false);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false });
@ -141,7 +104,7 @@ describe(JobService.name, () => {
});
it('should handle a start object tagging command', async () => {
jobMock.isActive.mockResolvedValue(false);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false });
@ -149,7 +112,7 @@ describe(JobService.name, () => {
});
it('should handle a start clip encoding command', async () => {
jobMock.isActive.mockResolvedValue(false);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.CLIP_ENCODING, { command: JobCommand.START, force: false });
@ -157,7 +120,7 @@ describe(JobService.name, () => {
});
it('should handle a start metadata extraction command', async () => {
jobMock.isActive.mockResolvedValue(false);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false });
@ -165,7 +128,7 @@ describe(JobService.name, () => {
});
it('should handle a start thumbnail generation command', async () => {
jobMock.isActive.mockResolvedValue(false);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false });
@ -173,7 +136,7 @@ describe(JobService.name, () => {
});
it('should throw a bad request when an invalid queue is used', async () => {
jobMock.isActive.mockResolvedValue(false);
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await expect(
sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }),

View File

@ -3,7 +3,7 @@ import { assertMachineLearningEnabled } from '../domain.constant';
import { JobCommandDto } from './dto';
import { JobCommand, JobName, QueueName } from './job.constants';
import { IJobRepository } from './job.repository';
import { AllJobStatusResponseDto } from './response-dto';
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
@Injectable()
export class JobService {
@ -29,16 +29,25 @@ export class JobService {
}
}
async getJobStatus(queueName: QueueName): Promise<JobStatusDto> {
const [jobCounts, queueStatus] = await Promise.all([
this.jobRepository.getJobCounts(queueName),
this.jobRepository.getQueueStatus(queueName),
]);
return { jobCounts, queueStatus };
}
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
const response = new AllJobStatusResponseDto();
for (const queueName of Object.values(QueueName)) {
response[queueName] = await this.jobRepository.getJobCounts(queueName);
response[queueName] = await this.getJobStatus(queueName);
}
return response;
}
private async start(name: QueueName, { force }: JobCommandDto): Promise<void> {
const isActive = await this.jobRepository.isActive(name);
const { isActive } = await this.jobRepository.getQueueStatus(name);
if (isActive) {
throw new BadRequestException(`Job is already running`);
}

View File

@ -16,28 +16,41 @@ export class JobCountsDto {
paused!: number;
}
export class AllJobStatusResponseDto implements Record<QueueName, JobCountsDto> {
@ApiProperty({ type: JobCountsDto })
[QueueName.THUMBNAIL_GENERATION]!: JobCountsDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.METADATA_EXTRACTION]!: JobCountsDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.VIDEO_CONVERSION]!: JobCountsDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.OBJECT_TAGGING]!: JobCountsDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.CLIP_ENCODING]!: JobCountsDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobCountsDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.BACKGROUND_TASK]!: JobCountsDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.SEARCH]!: JobCountsDto;
export class QueueStatusDto {
isActive!: boolean;
isPaused!: boolean;
}
export class JobStatusDto {
@ApiProperty({ type: JobCountsDto })
jobCounts!: JobCountsDto;
@ApiProperty({ type: QueueStatusDto })
queueStatus!: QueueStatusDto;
}
export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto> {
@ApiProperty({ type: JobStatusDto })
[QueueName.THUMBNAIL_GENERATION]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.METADATA_EXTRACTION]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.VIDEO_CONVERSION]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.OBJECT_TAGGING]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.CLIP_ENCODING]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.BACKGROUND_TASK]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.SEARCH]!: JobStatusDto;
}

View File

@ -6,7 +6,7 @@ export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
pause: jest.fn(),
resume: jest.fn(),
queue: jest.fn().mockImplementation(() => Promise.resolve()),
isActive: jest.fn(),
getQueueStatus: jest.fn(),
getJobCounts: jest.fn(),
};
};

View File

@ -7,6 +7,7 @@ import {
JobItem,
JobName,
QueueName,
QueueStatus,
} from '@app/domain';
import { InjectQueue } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
@ -36,9 +37,13 @@ export class JobRepository implements IJobRepository {
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
) {}
async isActive(name: QueueName): Promise<boolean> {
const counts = await this.getJobCounts(name);
return !!counts.active;
async getQueueStatus(name: QueueName): Promise<QueueStatus> {
const queue = this.queueMap[name];
return {
isActive: !!(await queue.getActiveCount()),
isPaused: await queue.isPaused(),
};
}
pause(name: QueueName) {

View File

@ -291,52 +291,52 @@ export interface AlbumResponseDto {
export interface AllJobStatusResponseDto {
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'thumbnail-generation-queue': JobCountsDto;
'thumbnail-generation-queue': JobStatusDto;
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'metadata-extraction-queue': JobCountsDto;
'metadata-extraction-queue': JobStatusDto;
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'video-conversion-queue': JobCountsDto;
'video-conversion-queue': JobStatusDto;
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'object-tagging-queue': JobCountsDto;
'object-tagging-queue': JobStatusDto;
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'clip-encoding-queue': JobCountsDto;
'clip-encoding-queue': JobStatusDto;
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'storage-template-migration-queue': JobCountsDto;
'storage-template-migration-queue': JobStatusDto;
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'background-task-queue': JobCountsDto;
'background-task-queue': JobStatusDto;
/**
*
* @type {JobCountsDto}
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'search-queue': JobCountsDto;
'search-queue': JobStatusDto;
}
/**
*
@ -1311,6 +1311,25 @@ export const JobName = {
export type JobName = typeof JobName[keyof typeof JobName];
/**
*
* @export
* @interface JobStatusDto
*/
export interface JobStatusDto {
/**
*
* @type {JobCountsDto}
* @memberof JobStatusDto
*/
'jobCounts': JobCountsDto;
/**
*
* @type {QueueStatusDto}
* @memberof JobStatusDto
*/
'queueStatus': QueueStatusDto;
}
/**
*
* @export
@ -1467,6 +1486,25 @@ export interface OAuthConfigResponseDto {
*/
'autoLaunch'?: boolean;
}
/**
*
* @export
* @interface QueueStatusDto
*/
export interface QueueStatusDto {
/**
*
* @type {boolean}
* @memberof QueueStatusDto
*/
'isActive': boolean;
/**
*
* @type {boolean}
* @memberof QueueStatusDto
*/
'isPaused': boolean;
}
/**
*
* @export
@ -6270,7 +6308,7 @@ export const JobApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
async sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<JobStatusDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
@ -6299,7 +6337,7 @@ export const JobApiFactory = function (configuration?: Configuration, basePath?:
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<void> {
sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: any): AxiosPromise<JobStatusDto> {
return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath));
},
};

View File

@ -109,8 +109,4 @@ input:focus-visible {
display: none;
scrollbar-width: none;
}
.job-play-button {
@apply h-full flex flex-col place-items-center place-content-center px-8 text-gray-600 transition-all hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary text-sm dark:hover:text-black gap-2;
}
}

View File

@ -0,0 +1,21 @@
<script lang="ts" context="module">
export type Colors = 'light-gray' | 'gray';
</script>
<script lang="ts">
export let color: Colors;
const colorClasses: Record<Colors, string> = {
'light-gray': 'bg-gray-300/90 dark:bg-gray-600/90',
gray: 'bg-gray-300 dark:bg-gray-600'
};
</script>
<button
class="h-full flex gap-2 flex-col place-items-center place-content-center px-8 text-gray-600 transition-colors hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary text-xs dark:hover:text-black {colorClasses[
color
]}"
on:click
>
<slot />
</button>

View File

@ -0,0 +1,16 @@
<script lang="ts" context="module">
export type Color = 'success' | 'warning';
</script>
<script lang="ts">
export let color: Color;
const colorClasses: Record<Color, string> = {
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
warning: 'bg-orange-400/70 text-gray-900 dark:bg-orange-900 dark:text-gray-100'
};
</script>
<div class="w-full text-center text-sm p-2 {colorClasses[color]}">
<slot />
</div>

View File

@ -4,40 +4,49 @@
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 Close from 'svelte-material-icons/Close.svelte';
import { locale } from '$lib/stores/preferences.store';
import { createEventDispatcher } from 'svelte';
import { JobCommand, JobCommandDto, JobCountsDto } from '@api';
import { JobCommand, JobCommandDto, JobCountsDto, QueueStatusDto } from '@api';
import Badge from '$lib/components/elements/badge.svelte';
import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte';
export let title: string;
export let subtitle: string | undefined = undefined;
export let jobCounts: JobCountsDto;
export let queueStatus: QueueStatusDto;
export let allowForceCommand = true;
$: isRunning = jobCounts.active > 0 || jobCounts.waiting > 0;
$: waitingCount = jobCounts.waiting + jobCounts.paused;
$: isPause = jobCounts.paused > 0;
$: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
$: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
const dispatch = createEventDispatcher<{ command: JobCommandDto }>();
</script>
<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 bg-gray-100 dark:bg-immich-dark-gray rounded-3xl overflow-hidden">
<div class="flex flex-col w-full">
{#if queueStatus.isPaused}
<JobTileStatus color="warning">Paused</JobTileStatus>
{:else if queueStatus.isActive}
<JobTileStatus color="success">Active</JobTileStatus>
{/if}
<div class="flex flex-col gap-2 p-9">
<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">
<Badge color="primary">
{jobCounts.failed.toLocaleString($locale)} failed
</Badge>
{/if}
{#if jobCounts.delayed > 0}
<Badge color="secondary">
{jobCounts.delayed.toLocaleString($locale)} delayed
</Badge>
{/if}
</div>
</div>
@ -69,43 +78,54 @@
</div>
</div>
</div>
<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"
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })}
>
<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>
<div class="flex flex-col w-32 overflow-hidden">
{#if !isIdle}
{#if waitingCount > 0}
<JobTileButton
color="gray"
on:click={() => dispatch('command', { command: JobCommand.Empty, force: false })}
>
<Close size="24" /> CLEAR
</JobTileButton>
{/if}
{#if queueStatus.isPaused}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })}
>
{@const size = waitingCount > 0 ? '24' : '48'}
<!-- size property is not reactive, so have to use width and height -->
<FastForward width={size} height={size} /> RESUME
</JobTileButton>
{:else}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })}
>
<Pause size="24" /> PAUSE
</JobTileButton>
{/if}
{:else if allowForceCommand}
<button
class="job-play-button bg-gray-300 dark:bg-gray-600"
<JobTileButton
color="gray"
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"
<AllInclusive size="24" /> ALL
</JobTileButton>
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<SelectionSearch size="18" /> MISSING
</button>
<SelectionSearch size="24" /> MISSING
</JobTileButton>
{:else}
<button
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90"
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<Play size="48" /> START
</button>
</JobTileButton>
{/if}
</div>
</div>

View File

@ -1,4 +1,8 @@
<script lang="ts">
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { AllJobStatusResponseDto, api, JobCommand, JobCommandDto, JobName } from '@api';
import type { ComponentType } from 'svelte';
@ -49,21 +53,15 @@
const title = jobDetails[jobId]?.title;
try {
await api.jobApi.sendJobCommand(jobId, jobCommand);
const { data } = await api.jobApi.sendJobCommand(jobId, jobCommand);
jobs[jobId] = data;
// 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;
case JobCommand.Empty:
notificationController.show({
message: `Cleared jobs for: ${title}`,
type: NotificationType.Info
});
break;
}
} catch (error) {
@ -74,12 +72,14 @@
<div class="flex flex-col gap-7">
{#each jobDetailsArray as [jobName, { title, subtitle, allowForceCommand, component }]}
{@const { jobCounts, queueStatus } = jobs[jobName]}
<JobTile
{title}
{subtitle}
{allowForceCommand}
{jobCounts}
{queueStatus}
on:command={({ detail }) => runJob(jobName, detail)}
jobCounts={jobs[jobName]}
>
<svelte:component this={component} />
</JobTile>

View File

@ -1,27 +1,25 @@
<script lang="ts" context="module">
export type BadgeColor = 'primary' | 'dark' | 'warning' | 'success' | 'danger';
export type BadgeRounded = false | true | 'full';
export type Color = 'primary' | 'secondary';
export type Rounded = false | true | 'full';
</script>
<script lang="ts">
export let color: BadgeColor = 'primary';
export let rounded: BadgeRounded = true;
export let color: Color = 'primary';
export let rounded: Rounded = true;
const colorClasses: { [Key in BadgeColor]: string } = {
const colorClasses: Record<Color, string> = {
primary:
'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary',
dark: 'text-neutral-50 dark:text-neutral-50 bg-neutral-900 dark:bg-neutral-900',
warning: 'text-yellow-900 bg-yellow-200',
success: 'text-green-900 bg-green-200',
danger: 'text-red-900 bg-red-200'
secondary:
'text-immich-dark-bg dark:text-immich-gray dark:bg-gray-600 bg-gray-300 dark:text-immich-gray'
};
</script>
<span
class="inline-block h-min whitespace-nowrap px-[0.65em] pt-[0.35em] pb-[0.25em] text-center align-baseline text-[0.65em] font-bold leading-none {colorClasses[
class="inline-block h-min whitespace-nowrap px-4 pt-[0.55em] pb-[0.55em] text-center align-baseline text-xs leading-none {colorClasses[
color
]}"
class:rounded={rounded === true}
class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'}
>
<slot />

View File

@ -5,9 +5,10 @@
import type { PageData } from './$types';
export let data: PageData;
let jobs = data.jobs;
let timer: NodeJS.Timer;
$: jobs = data.jobs;
const load = async () => {
const { data } = await api.jobApi.getAllJobsStatus();
jobs = data;