From b06ddec2d5a61fa8e09b61607e3caf8103b80512 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sat, 1 Apr 2023 22:46:07 +0200 Subject: [PATCH] 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 --- mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | Bin 15133 -> 15217 bytes mobile/openapi/doc/AllJobStatusResponseDto.md | Bin 942 -> 942 bytes mobile/openapi/doc/JobApi.md | Bin 3513 -> 3574 bytes mobile/openapi/doc/JobStatusDto.md | Bin 0 -> 500 bytes mobile/openapi/doc/QueueStatusDto.md | Bin 0 -> 440 bytes mobile/openapi/lib/api.dart | Bin 5085 -> 5155 bytes mobile/openapi/lib/api/job_api.dart | Bin 3105 -> 3582 bytes mobile/openapi/lib/api_client.dart | Bin 17042 -> 17198 bytes .../model/all_job_status_response_dto.dart | Bin 6508 -> 6508 bytes mobile/openapi/lib/model/job_status_dto.dart | Bin 0 -> 3610 bytes .../openapi/lib/model/queue_status_dto.dart | Bin 0 -> 3580 bytes .../all_job_status_response_dto_test.dart | Bin 1542 -> 1542 bytes mobile/openapi/test/job_api_test.dart | Bin 751 -> 765 bytes mobile/openapi/test/job_status_dto_test.dart | Bin 0 -> 683 bytes .../openapi/test/queue_status_dto_test.dart | Bin 0 -> 663 bytes .../immich/src/controllers/job.controller.ts | 7 +- server/immich-openapi-specs.json | 55 +++++++-- server/libs/domain/src/job/job.repository.ts | 7 +- .../libs/domain/src/job/job.service.spec.ts | 107 ++++++------------ server/libs/domain/src/job/job.service.ts | 15 ++- .../all-job-status-response.dto.ts | 61 ++++++---- .../libs/domain/test/job.repository.mock.ts | 2 +- .../infra/src/repositories/job.repository.ts | 11 +- web/src/api/open-api/api.ts | 74 +++++++++--- web/src/app.css | 4 - .../admin-page/jobs/job-tile-button.svelte | 21 ++++ .../admin-page/jobs/job-tile-status.svelte | 16 +++ .../admin-page/jobs/job-tile.svelte | 100 +++++++++------- .../admin-page/jobs/jobs-panel.svelte | 28 ++--- web/src/lib/components/elements/badge.svelte | 20 ++-- web/src/routes/admin/jobs-status/+page.svelte | 3 +- 32 files changed, 333 insertions(+), 204 deletions(-) create mode 100644 mobile/openapi/doc/JobStatusDto.md create mode 100644 mobile/openapi/doc/QueueStatusDto.md create mode 100644 mobile/openapi/lib/model/job_status_dto.dart create mode 100644 mobile/openapi/lib/model/queue_status_dto.dart create mode 100644 mobile/openapi/test/job_status_dto_test.dart create mode 100644 mobile/openapi/test/queue_status_dto_test.dart create mode 100644 web/src/lib/components/admin-page/jobs/job-tile-button.svelte create mode 100644 web/src/lib/components/admin-page/jobs/job-tile-status.svelte diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index abe3d9b878..ecc9db7814 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e6a30506fbfb401104b375be7fcdb1fb9afd1255..a1dcba3a18087f28d2819b5642f373f90978cfe1 100644 GIT binary patch delta 82 zcmbPR_OWb3n4!E^eo}BrVo7PSOG$pLMoNCNzCN=7>7&}qX7=7>7&}qX7M+cMf3M8|52VDb`p3MU_^a7S8YsNFP z+DQPtZMAn>?p*BFT7KVTDx_;L$R#a7GbRgjxkAYw@@fTCt zs!!|%i_2a*B<%x#268rvoo+0A!PBSjpWMbeI8P2+Mmr={sM%q1hLrjX3n}05aE)C{ zQA$=9Qu1CS!Fx$rF6TIFOv)<0?EL6#XU7VDy!R(+a{p##eo+)x+z=4Lw(k>;E>c-8 zvuyRSS?AjgYZHTY*VvY8=8e@e1~qgWQ#iTT`X#?K zRq-r>#Z{5cPF=`kV2e@g^n~yZ4{yF)Eyf{O&z4+9JL0TRXD1iWkWznPA*GGY!1fiT z%wj1YiN^Vv(ad%_MKr&$V!yx6e*`AjmP}x@kq?K9x?bI_n|8adCsP?3(u6Jtm3LN8 h6x5tfT*6m()7|{DH)o~L8Bf;nH;d1KKZY-b5FbEMhJOG6 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4ccf97fad267769822e239aeef1dddf31213aac3..c30576da81d5c35edf7107827ad80f27fe7aa5ae 100644 GIT binary patch delta 50 ycmcbszF1?!GH%YS{G|BelEjkI$%WjKlh1LpOkL>LPI delta 22 dcmew-y-;Gq7cQ2v{LGZijNGb>EVW#vqNOq zyh3tLW`S#7a(+r?UV30@YH2EpVvrFElM9(-(agBUtN^iX^Ka%#Zm7kE0-}@q_+5~A nZ}=VHyZ|^$bn zyh3tLW`S#7a(+r?UV30@YH2EpVvrFElM9(-(agBUtN^iX^Ka%#Zm7kE0-}@q_+5~A nZ}=VHyZ|^$bny?3N2 zQL(q>ZNLJ=mdN|@-Q9Oby5H~g`|$S9yYcBCqwCSl+q=;vy#MfN)Pu`0T#xVI^Z4?^ z`@fD*j3nR8nYP2T^wn91Ud6psnr928vxO-507_XJKF@f;S6td&co(a+()OSRD>iIv zvUO=Q&A*jGqq<}({9H4Q-bt%Ao6Kp%s@;kMM$00YT4 zFo|WRARMfmrzjlnB%}=xLIt}NCVfz|cdC@u^bTx(D|yMiciP^aAS!3ms}Q1E-)((q z6lgQjciV5&cdj+23$4tQDkxyhtkN(XCga%y&oK>l$@K{gFTkfYXGKCtdtv9t5B~(> zNpfDkbRqcNCNQ+!poF$i1dzm{*FC&KTDa#Hmdq^QsX0f4&RtDKVxZ(XH-_mgF$hVq zz4_5hy#O0_P7kE5uQdk zTJ+-D@!grHg&ooG*?j;^&cTlX1Hu~k&We&7SZKAX>`dGew$1_ty3|(_Ov|(fvMi9L zM9GaXuA`!QuQgv_GrKvU%N%)ZGGcg!W5J3%!wIrfbE>~zXm#Uj=ig;jBgd@d&Y^ZQpr;5nDF(D;58an1Y%RY_+n&U*Y zs@`(n2aVp~t!f6VOCA*291t7pIi6Bzj*OJ3b_<^z`LluiN1Zu< zFRf|6XCjjAP8%atBs&MG!b;KCja@a%CxM)fgSdDN1+KjhNJHBY;vPJ@1La<;4Vx7> zongTW!+X2H2H!QBo3gMp#9EdoQ=ds{Ix0EqJ2`CRU4u% zsRnfS zPtJ;)sa5LZBcYWini>c5A7J{3X|_b?M9V}tEaFt7zkkfMD+!+BnBKn~bu{Drop1p4 z^)18B#6`Lg`ePVt(o_vs^)}Qkr8mJ%h~&BTb4F5EBZiEK%zd};e~)u5?{i(N+%-S4Di0N yDsgMZ0WUlT@l(q%e?~Qoakrpa`6KDpBjDoE4W;N_=uebxd_=fk80Q_FLq7qbz=*{F literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..9bb5dea0d3083a6b07d2076520543c46eac0b06a GIT binary patch literal 3580 zcmds4ZExE)5dQ98aVd&g!4!GTry;4`28%QFE$)(Rz+e~xEz>bunbb%shLQTe_l^{0 z$#&Q5(|`qtEs^(r&vT@Ey-u$O7k}K1PJSC+4{t8+hL>>l_QNoS%Mo0U?%?C-^6k}M zM<_;;Z)Qx}{%P{^v_r4rUMkJgxzg!eD@*%Chsrt#ZyZP2(jht)GBjb+lNVulXITyX8w%|U0mklduXlJ_WP7fhzV z{G4VBrcI}V?kuP|sDevYhy?%7JDn_N#=!3-FZr!ywltSk!D9!Yhw0yNTWV>5f#fR~ zx1}Z^Txc0jP`tetkp+N|FxZ7Ki7*$L6%RZmC7OLG*`T&TX~}% zxYk&jAdksuW|fA1KN?Lx^9)nA3$Bl$KLB6CjO7u<6NjCzU;Pt0+ergnyl}Dl{W<|O zUI(@(;-m8---%R63-{c@f|j z3OnBAc4x0lw^j>z4_5hy$W^A>Zr?a~=syonF=p&-)0%&pbe;8&ta#;(#V}hVwna8T z`|pgBXx$@WE+ot0GcG|N_M{&-=f2KPVywHtYj=X;E%59K`cRsJe#HxFhhS%x7B&Q| ze}QaZat5B<281>6!19tCm}|AHEREd~l3|VlUE<0Z3o?m8mU-@hvl7N}q|x6(^Epz~ zv0IlJBG#mYRk*6Sk1UiMgNa;RglP;XtOK+f;Y5;* z8N1h#$diy*jA0L@DK*DVFpD1?maG7b-7*{jZlPxS7rr($4=TlLWjVY&b23%!2+ds! z*@9>8$gt+3)#P7sw!o=_2Y&7G1t%qt`6;I~0U7X_LU5+_d1nZW>Y z@2Cm`2OQTns$#ALKEDop38kNG*pAXiWF9t zJS=Xa<88yM{dPW4tscmEMlkr32r-)9tqcB&CiWnrV#Uzl@bQtK_~br}jvwk)wBG7g z@o!uhRfLptP3wv%9|WSg9s1xkC=g`FP-D)YS)~vy# z@s$RMtC8||rj@V@Tq4%cm9gD{6EB21)p>}AJqpv!nQS414j_ThvrC-;iPOeGOvLas zW}=NIS`Iq;-(ZG1Qdg^io4DrVmYMN?0Hz+?;q`!`B;15kmd;1IC*bX2S>o1|173g( q#!uv7{ky*e@6FNpIFPNZ+02YN98 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/all_job_status_response_dto_test.dart b/mobile/openapi/test/all_job_status_response_dto_test.dart index 1154f4fa9f61d48ce9b4b57aeef08f9d39c0e156..319eb2b10cd9f7777a1af7d05bf04e1e84c11918 100644 GIT binary patch delta 87 zcmZqUY2(@OhLJV6B(bD)@<+xtD65941Hc9se#tA%9(lqJbp17*!( G%>e*)MITQ9 delta 87 zcmZqUY2(@OhLP1dzcjC8@<+xtD65941Hc9se#tA%9(lqJbp17*!( G%>e*u-5(DC diff --git a/mobile/openapi/test/job_api_test.dart b/mobile/openapi/test/job_api_test.dart index 6f980417e081b716135074586a0fa07246068c54..7cc1a4b2a080b702f1dc806f206c08cd9b27c6c8 100644 GIT binary patch delta 26 hcmaFQ`j>S>G83PTSAJ4(Nn%N9u}ew5-Q;AZ9sr2_3Eltz delta 11 Scmey%`kr+|GSlQjrXBzv1_Yh} diff --git a/mobile/openapi/test/job_status_dto_test.dart b/mobile/openapi/test/job_status_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..ae353baf0cef80ec85b997fe4700cc77f6e8d91a GIT binary patch literal 683 zcma)2OHaZ;5We?Uj3*#Ls+^33L}H7$Yu*ZA^>8}gya>7ut0&(-xD-j zy@+U~R<3R~yy@O`qYYn1!HS|Tm6O&-!OIqq9zq{7P~ipa9HG{5BUmi&;~_)|tdbNSl4!YjyCBSww>j5#HXdD%12>9N zDvhjAniZnL3AC~`m@}-fMrntKS=5cv_N0PWUwmg+(^{tSvlW`;lGk)LT+`d4He{~N zX?dokv0PdvataiMK<(w`WLPC68dfP;5@t7CvYRWGZMZf;&~=1f!?O=w;zTtlc_Tu4 zMsDlg^dDl;*w7sZz=gWm0DTg8YO%!^%WZ2StAec(In-Rp{s6uM0AdRvc?H6+kRi;D zB>llOAh6V`ZTdZDd<67{@N4Gn-jyOZGtaEpA_>8lA*J#cup5L%(_-50V0{4BoeMWR jnfYQ)NafNbO1#OwtMNDDk-PtVMnngm7eRJDY9IUn|8&)z literal 0 HcmV?d00001 diff --git a/server/apps/immich/src/controllers/job.controller.ts b/server/apps/immich/src/controllers/job.controller.ts index a22f110d9a..f86b60e1b2 100644 --- a/server/apps/immich/src/controllers/job.controller.ts +++ b/server/apps/immich/src/controllers/job.controller.ts @@ -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 { - return this.service.handleCommand(jobId, dto); + async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise { + await this.service.handleCommand(jobId, dto); + return await this.service.getJobStatus(jobId); } } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 6920d0e9ef..dd6e71ac5f 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -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": [ diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index fbbc02fb6d..6f3a5c396c 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -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; resume(name: QueueName): Promise; empty(name: QueueName): Promise; - isActive(name: QueueName): Promise; + getQueueStatus(name: QueueName): Promise; getJobCounts(name: QueueName): Promise; } diff --git a/server/libs/domain/src/job/job.service.spec.ts b/server/libs/domain/src/job/job.service.spec.ts index a07e779c97..bb3f62dc0a 100644 --- a/server/libs/domain/src/job/job.service.spec.ts +++ b/server/libs/domain/src/job/job.service.spec.ts @@ -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 }), diff --git a/server/libs/domain/src/job/job.service.ts b/server/libs/domain/src/job/job.service.ts index ccb1709aae..fcf9696822 100644 --- a/server/libs/domain/src/job/job.service.ts +++ b/server/libs/domain/src/job/job.service.ts @@ -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 { + const [jobCounts, queueStatus] = await Promise.all([ + this.jobRepository.getJobCounts(queueName), + this.jobRepository.getQueueStatus(queueName), + ]); + + return { jobCounts, queueStatus }; + } + async getAllJobsStatus(): Promise { 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 { - const isActive = await this.jobRepository.isActive(name); + const { isActive } = await this.jobRepository.getQueueStatus(name); if (isActive) { throw new BadRequestException(`Job is already running`); } diff --git a/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts index dd0d1fb65a..5004923115 100644 --- a/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts +++ b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts @@ -16,28 +16,41 @@ export class JobCountsDto { paused!: number; } -export class AllJobStatusResponseDto implements Record { - @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 { + @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; } diff --git a/server/libs/domain/test/job.repository.mock.ts b/server/libs/domain/test/job.repository.mock.ts index a6c8fff2a1..cb347bb09b 100644 --- a/server/libs/domain/test/job.repository.mock.ts +++ b/server/libs/domain/test/job.repository.mock.ts @@ -6,7 +6,7 @@ export const newJobRepositoryMock = (): jest.Mocked => { pause: jest.fn(), resume: jest.fn(), queue: jest.fn().mockImplementation(() => Promise.resolve()), - isActive: jest.fn(), + getQueueStatus: jest.fn(), getJobCounts: jest.fn(), }; }; diff --git a/server/libs/infra/src/repositories/job.repository.ts b/server/libs/infra/src/repositories/job.repository.ts index cf39d324cc..8619a048d1 100644 --- a/server/libs/infra/src/repositories/job.repository.ts +++ b/server/libs/infra/src/repositories/job.repository.ts @@ -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 { - const counts = await this.getJobCounts(name); - return !!counts.active; + async getQueueStatus(name: QueueName): Promise { + const queue = this.queueMap[name]; + + return { + isActive: !!(await queue.getActiveCount()), + isPaused: await queue.isPaused(), + }; } pause(name: QueueName) { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 4bf1cd687f..2313411d99 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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> { + async sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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 { + sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: any): AxiosPromise { return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath)); }, }; diff --git a/web/src/app.css b/web/src/app.css index f073f352cf..d46ca41cec 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -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; - } } diff --git a/web/src/lib/components/admin-page/jobs/job-tile-button.svelte b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte new file mode 100644 index 0000000000..ba98ea1ccd --- /dev/null +++ b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/web/src/lib/components/admin-page/jobs/job-tile-status.svelte b/web/src/lib/components/admin-page/jobs/job-tile-status.svelte new file mode 100644 index 0000000000..4f31ad6ffb --- /dev/null +++ b/web/src/lib/components/admin-page/jobs/job-tile-status.svelte @@ -0,0 +1,16 @@ + + + + +
+ +
diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index f22294431f..eeb35a24a6 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -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 }>(); -
-
-
+
+
+ {#if queueStatus.isPaused} + Paused + {:else if queueStatus.isActive} + Active + {/if} +
{title.toUpperCase()}
{#if jobCounts.failed > 0} - + {jobCounts.failed.toLocaleString($locale)} failed {/if} + {#if jobCounts.delayed > 0} + + {jobCounts.delayed.toLocaleString($locale)} delayed + + {/if}
@@ -69,43 +78,54 @@
-
- {#if isRunning} - - {:else if jobCounts.paused > 0} - +
+ {#if !isIdle} + {#if waitingCount > 0} + dispatch('command', { command: JobCommand.Empty, force: false })} + > + CLEAR + + {/if} + {#if queueStatus.isPaused} + dispatch('command', { command: JobCommand.Resume, force: false })} + > + {@const size = waitingCount > 0 ? '24' : '48'} + + + RESUME + + {:else} + dispatch('command', { command: JobCommand.Pause, force: false })} + > + PAUSE + + {/if} {:else if allowForceCommand} - - + MISSING + {:else} - + {/if}
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 267210b005..b103d4deb0 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -1,4 +1,8 @@ diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 263db4df48..e69cb68987 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -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;