1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00: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/JobCommandDto.md
doc/JobCountsDto.md doc/JobCountsDto.md
doc/JobName.md doc/JobName.md
doc/JobStatusDto.md
doc/LoginCredentialDto.md doc/LoginCredentialDto.md
doc/LoginResponseDto.md doc/LoginResponseDto.md
doc/LogoutResponseDto.md doc/LogoutResponseDto.md
@ -60,6 +61,7 @@ doc/OAuthApi.md
doc/OAuthCallbackDto.md doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md doc/OAuthConfigResponseDto.md
doc/QueueStatusDto.md
doc/RemoveAssetsDto.md doc/RemoveAssetsDto.md
doc/SearchAlbumResponseDto.md doc/SearchAlbumResponseDto.md
doc/SearchApi.md doc/SearchApi.md
@ -170,12 +172,14 @@ lib/model/job_command.dart
lib/model/job_command_dto.dart lib/model/job_command_dto.dart
lib/model/job_counts_dto.dart lib/model/job_counts_dto.dart
lib/model/job_name.dart lib/model/job_name.dart
lib/model/job_status_dto.dart
lib/model/login_credential_dto.dart lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart lib/model/login_response_dto.dart
lib/model/logout_response_dto.dart lib/model/logout_response_dto.dart
lib/model/o_auth_callback_dto.dart lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart lib/model/o_auth_config_response_dto.dart
lib/model/queue_status_dto.dart
lib/model/remove_assets_dto.dart lib/model/remove_assets_dto.dart
lib/model/search_album_response_dto.dart lib/model/search_album_response_dto.dart
lib/model/search_asset_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_command_test.dart
test/job_counts_dto_test.dart test/job_counts_dto_test.dart
test/job_name_test.dart test/job_name_test.dart
test/job_status_dto_test.dart
test/login_credential_dto_test.dart test/login_credential_dto_test.dart
test/login_response_dto_test.dart test/login_response_dto_test.dart
test/logout_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_callback_dto_test.dart
test/o_auth_config_dto_test.dart test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart test/o_auth_config_response_dto_test.dart
test/queue_status_dto_test.dart
test/remove_assets_dto_test.dart test/remove_assets_dto_test.dart
test/search_album_response_dto_test.dart test/search_album_response_dto_test.dart
test/search_api_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 { Body, Controller, Get, Param, Put, UsePipes, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../decorators/authenticated.decorator'; import { Authenticated } from '../decorators/authenticated.decorator';
@ -16,7 +16,8 @@ export class JobController {
} }
@Put('/:jobId') @Put('/:jobId')
sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<void> { async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
return this.service.handleCommand(jobId, dto); await this.service.handleCommand(jobId, dto);
return await this.service.getJobStatus(jobId);
} }
} }

View file

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

View file

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

View file

@ -25,72 +25,35 @@ describe(JobService.name, () => {
waiting: 1, waiting: 1,
paused: 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({ await expect(sut.getAllJobsStatus()).resolves.toEqual({
'background-task-queue': { 'background-task-queue': expectedJobStatus,
active: 1, 'clip-encoding-queue': expectedJobStatus,
completed: 1, 'metadata-extraction-queue': expectedJobStatus,
delayed: 1, 'object-tagging-queue': expectedJobStatus,
failed: 1, 'search-queue': expectedJobStatus,
waiting: 1, 'storage-template-migration-queue': expectedJobStatus,
paused: 1, 'thumbnail-generation-queue': expectedJobStatus,
}, 'video-conversion-queue': expectedJobStatus,
'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,
},
}); });
}); });
}); });
@ -115,7 +78,7 @@ describe(JobService.name, () => {
}); });
it('should not start a job that is already running', async () => { it('should not start a job that is already running', async () => {
jobMock.isActive.mockResolvedValue(true); jobMock.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false });
await expect( await expect(
sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }), 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 () => { 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 }); 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 () => { 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 }); 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 () => { 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 }); 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 () => { 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 }); 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 () => { 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 }); 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 () => { 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 }); 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 () => { 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( await expect(
sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }), 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 { JobCommandDto } from './dto';
import { JobCommand, JobName, QueueName } from './job.constants'; import { JobCommand, JobName, QueueName } from './job.constants';
import { IJobRepository } from './job.repository'; import { IJobRepository } from './job.repository';
import { AllJobStatusResponseDto } from './response-dto'; import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
@Injectable() @Injectable()
export class JobService { 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> { async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
const response = new AllJobStatusResponseDto(); const response = new AllJobStatusResponseDto();
for (const queueName of Object.values(QueueName)) { for (const queueName of Object.values(QueueName)) {
response[queueName] = await this.jobRepository.getJobCounts(queueName); response[queueName] = await this.getJobStatus(queueName);
} }
return response; return response;
} }
private async start(name: QueueName, { force }: JobCommandDto): Promise<void> { 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) { if (isActive) {
throw new BadRequestException(`Job is already running`); throw new BadRequestException(`Job is already running`);
} }

View file

@ -16,28 +16,41 @@ export class JobCountsDto {
paused!: number; paused!: number;
} }
export class AllJobStatusResponseDto implements Record<QueueName, JobCountsDto> { export class QueueStatusDto {
@ApiProperty({ type: JobCountsDto }) isActive!: boolean;
[QueueName.THUMBNAIL_GENERATION]!: JobCountsDto; isPaused!: boolean;
}
@ApiProperty({ type: JobCountsDto })
[QueueName.METADATA_EXTRACTION]!: JobCountsDto; export class JobStatusDto {
@ApiProperty({ type: JobCountsDto })
@ApiProperty({ type: JobCountsDto }) jobCounts!: JobCountsDto;
[QueueName.VIDEO_CONVERSION]!: JobCountsDto;
@ApiProperty({ type: QueueStatusDto })
@ApiProperty({ type: JobCountsDto }) queueStatus!: QueueStatusDto;
[QueueName.OBJECT_TAGGING]!: JobCountsDto; }
@ApiProperty({ type: JobCountsDto }) export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto> {
[QueueName.CLIP_ENCODING]!: JobCountsDto; @ApiProperty({ type: JobStatusDto })
[QueueName.THUMBNAIL_GENERATION]!: JobStatusDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobCountsDto; @ApiProperty({ type: JobStatusDto })
[QueueName.METADATA_EXTRACTION]!: JobStatusDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.BACKGROUND_TASK]!: JobCountsDto; @ApiProperty({ type: JobStatusDto })
[QueueName.VIDEO_CONVERSION]!: JobStatusDto;
@ApiProperty({ type: JobCountsDto })
[QueueName.SEARCH]!: JobCountsDto; @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(), pause: jest.fn(),
resume: jest.fn(), resume: jest.fn(),
queue: jest.fn().mockImplementation(() => Promise.resolve()), queue: jest.fn().mockImplementation(() => Promise.resolve()),
isActive: jest.fn(), getQueueStatus: jest.fn(),
getJobCounts: jest.fn(), getJobCounts: jest.fn(),
}; };
}; };

View file

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

View file

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

View file

@ -109,8 +109,4 @@ input:focus-visible {
display: none; display: none;
scrollbar-width: 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 Pause from 'svelte-material-icons/Pause.svelte';
import FastForward from 'svelte-material-icons/FastForward.svelte'; import FastForward from 'svelte-material-icons/FastForward.svelte';
import AllInclusive from 'svelte-material-icons/AllInclusive.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 { locale } from '$lib/stores/preferences.store';
import { createEventDispatcher } from 'svelte'; 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 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 title: string;
export let subtitle: string | undefined = undefined; export let subtitle: string | undefined = undefined;
export let jobCounts: JobCountsDto; export let jobCounts: JobCountsDto;
export let queueStatus: QueueStatusDto;
export let allowForceCommand = true; export let allowForceCommand = true;
$: isRunning = jobCounts.active > 0 || jobCounts.waiting > 0; $: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
$: waitingCount = jobCounts.waiting + jobCounts.paused; $: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
$: isPause = jobCounts.paused > 0;
const dispatch = createEventDispatcher<{ command: JobCommandDto }>(); const dispatch = createEventDispatcher<{ command: JobCommandDto }>();
</script> </script>
<div <div class="flex bg-gray-100 dark:bg-immich-dark-gray rounded-3xl overflow-hidden">
class="flex justify-between rounded-3xl bg-gray-100 dark:bg-immich-dark-gray transition-all <div class="flex flex-col w-full">
{isRunning ? 'dark:bg-immich-primary/30 bg-immich-primary/20' : ''} {#if queueStatus.isPaused}
{isPause ? 'dark:bg-yellow-100/30 bg-yellow-500/20' : ''}" <JobTileStatus color="warning">Paused</JobTileStatus>
> {:else if queueStatus.isActive}
<div id="job-info" class="w-full p-9"> <JobTileStatus color="success">Active</JobTileStatus>
<div class="flex flex-col gap-2 "> {/if}
<div class="flex flex-col gap-2 p-9">
<div <div
class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary" class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary"
> >
<span>{title.toUpperCase()}</span> <span>{title.toUpperCase()}</span>
<div class="flex gap-2"> <div class="flex gap-2">
{#if jobCounts.failed > 0} {#if jobCounts.failed > 0}
<Badge color="danger"> <Badge color="primary">
{jobCounts.failed.toLocaleString($locale)} failed {jobCounts.failed.toLocaleString($locale)} failed
</Badge> </Badge>
{/if} {/if}
{#if jobCounts.delayed > 0}
<Badge color="secondary">
{jobCounts.delayed.toLocaleString($locale)} delayed
</Badge>
{/if}
</div> </div>
</div> </div>
@ -69,43 +78,54 @@
</div> </div>
</div> </div>
</div> </div>
<div id="job-action" class="flex flex-col rounded-r-3xl w-32 overflow-hidden"> <div class="flex flex-col w-32 overflow-hidden">
{#if isRunning} {#if !isIdle}
<button {#if waitingCount > 0}
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90" <JobTileButton
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })} color="gray"
> on:click={() => dispatch('command', { command: JobCommand.Empty, force: false })}
<Pause size="48" /> PAUSE >
</button> <Close size="24" /> CLEAR
{:else if jobCounts.paused > 0} </JobTileButton>
<button {/if}
class="job-play-button bg-gray-300 dark:bg-gray-600/90" {#if queueStatus.isPaused}
on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })} <JobTileButton
> color="light-gray"
<span class=" {isPause ? 'animate-pulse' : ''}"> on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })}
<FastForward size="48" /> RESUME >
</span> {@const size = waitingCount > 0 ? '24' : '48'}
</button>
<!-- 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} {:else if allowForceCommand}
<button <JobTileButton
class="job-play-button bg-gray-300 dark:bg-gray-600" color="gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: true })} on:click={() => dispatch('command', { command: JobCommand.Start, force: true })}
> >
<AllInclusive size="18" /> ALL <AllInclusive size="24" /> ALL
</button> </JobTileButton>
<button <JobTileButton
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90" color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })} on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
> >
<SelectionSearch size="18" /> MISSING <SelectionSearch size="24" /> MISSING
</button> </JobTileButton>
{:else} {:else}
<button <JobTileButton
class="job-play-button bg-gray-300/90 dark:bg-gray-600/90" color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })} on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
> >
<Play size="48" /> START <Play size="48" /> START
</button> </JobTileButton>
{/if} {/if}
</div> </div>
</div> </div>

View file

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

View file

@ -1,27 +1,25 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type BadgeColor = 'primary' | 'dark' | 'warning' | 'success' | 'danger'; export type Color = 'primary' | 'secondary';
export type BadgeRounded = false | true | 'full'; export type Rounded = false | true | 'full';
</script> </script>
<script lang="ts"> <script lang="ts">
export let color: BadgeColor = 'primary'; export let color: Color = 'primary';
export let rounded: BadgeRounded = true; export let rounded: Rounded = true;
const colorClasses: { [Key in BadgeColor]: string } = { const colorClasses: Record<Color, string> = {
primary: primary:
'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-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', secondary:
warning: 'text-yellow-900 bg-yellow-200', 'text-immich-dark-bg dark:text-immich-gray dark:bg-gray-600 bg-gray-300 dark:text-immich-gray'
success: 'text-green-900 bg-green-200',
danger: 'text-red-900 bg-red-200'
}; };
</script> </script>
<span <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 color
]}" ]}"
class:rounded={rounded === true} class:rounded-md={rounded === true}
class:rounded-full={rounded === 'full'} class:rounded-full={rounded === 'full'}
> >
<slot /> <slot />

View file

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