1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-28 06:32:44 +01:00

feat: tag cleanup job ()

This commit is contained in:
Jason Rasmussen 2024-09-16 16:49:12 -04:00 committed by GitHub
parent 4a1ff6abce
commit b74b20824a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 239 additions and 10 deletions

BIN
mobile/openapi/README.md generated

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

@ -2561,6 +2561,39 @@
"tags": [ "tags": [
"Jobs" "Jobs"
] ]
},
"post": {
"operationId": "createJob",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Jobs"
]
} }
}, },
"/jobs/{id}": { "/jobs/{id}": {
@ -9269,6 +9302,17 @@
], ],
"type": "object" "type": "object"
}, },
"JobCreateDto": {
"properties": {
"name": {
"$ref": "#/components/schemas/ManualJobName"
}
},
"required": [
"name"
],
"type": "object"
},
"JobName": { "JobName": {
"enum": [ "enum": [
"thumbnailGeneration", "thumbnailGeneration",
@ -9511,6 +9555,14 @@
], ],
"type": "object" "type": "object"
}, },
"ManualJobName": {
"enum": [
"person-cleanup",
"tag-cleanup",
"user-cleanup"
],
"type": "string"
},
"MapMarkerResponseDto": { "MapMarkerResponseDto": {
"properties": { "properties": {
"city": { "city": {

View file

@ -548,6 +548,9 @@ export type AllJobStatusResponseDto = {
thumbnailGeneration: JobStatusDto; thumbnailGeneration: JobStatusDto;
videoConversion: JobStatusDto; videoConversion: JobStatusDto;
}; };
export type JobCreateDto = {
name: ManualJobName;
};
export type JobCommandDto = { export type JobCommandDto = {
command: JobCommand; command: JobCommand;
force: boolean; force: boolean;
@ -1941,6 +1944,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) {
...opts ...opts
})); }));
} }
export function createJob({ jobCreateDto }: {
jobCreateDto: JobCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({
...opts,
method: "POST",
body: jobCreateDto
})));
}
export function sendJobCommand({ id, jobCommandDto }: { export function sendJobCommand({ id, jobCommandDto }: {
id: JobName; id: JobName;
jobCommandDto: JobCommandDto; jobCommandDto: JobCommandDto;
@ -3364,6 +3376,11 @@ export enum EntityType {
Asset = "ASSET", Asset = "ASSET",
Album = "ALBUM" Album = "ALBUM"
} }
export enum ManualJobName {
PersonCleanup = "person-cleanup",
TagCleanup = "tag-cleanup",
UserCleanup = "user-cleanup"
}
export enum JobName { export enum JobName {
ThumbnailGeneration = "thumbnailGeneration", ThumbnailGeneration = "thumbnailGeneration",
MetadataExtraction = "metadataExtraction", MetadataExtraction = "metadataExtraction",

View file

@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Authenticated } from 'src/middleware/auth.guard'; import { Authenticated } from 'src/middleware/auth.guard';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
@ -15,6 +15,12 @@ export class JobController {
return this.service.getAllJobsStatus(); return this.service.getAllJobsStatus();
} }
@Post()
@Authenticated({ admin: true })
createJob(@Body() dto: JobCreateDto): Promise<void> {
return this.service.create(dto);
}
@Put(':id') @Put(':id')
@Authenticated({ admin: true }) @Authenticated({ admin: true })
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> { sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {

View file

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator'; import { IsEnum, IsNotEmpty } from 'class-validator';
import { ManualJobName } from 'src/enum';
import { JobCommand, QueueName } from 'src/interfaces/job.interface'; import { JobCommand, QueueName } from 'src/interfaces/job.interface';
import { ValidateBoolean } from 'src/validation'; import { ValidateBoolean } from 'src/validation';
@ -20,6 +21,12 @@ export class JobCommandDto {
force!: boolean; force!: boolean;
} }
export class JobCreateDto {
@IsEnum(ManualJobName)
@ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' })
name!: ManualJobName;
}
export class JobCountsDto { export class JobCountsDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
active!: number; active!: number;

View file

@ -186,3 +186,9 @@ export enum SourceType {
MACHINE_LEARNING = 'machine-learning', MACHINE_LEARNING = 'machine-learning',
EXIF = 'exif', EXIF = 'exif',
} }
export enum ManualJobName {
PERSON_CLEANUP = 'person-cleanup',
TAG_CLEANUP = 'tag-cleanup',
USER_CLEANUP = 'user-cleanup',
}

View file

@ -60,6 +60,9 @@ export enum JobName {
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single',
// tags
TAG_CLEANUP = 'tag-cleanup',
// migration // migration
QUEUE_MIGRATION = 'queue-migration', QUEUE_MIGRATION = 'queue-migration',
MIGRATE_ASSET = 'migrate-asset', MIGRATE_ASSET = 'migrate-asset',
@ -262,6 +265,9 @@ export type JobItem =
| { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob }
| { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob }
// Tags
| { name: JobName.TAG_CLEANUP; data?: IBaseJob }
// Asset Deletion // Asset Deletion
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob } | { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
| { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob }

View file

@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset {
upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>; upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>;
upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>; upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>;
deleteEmptyTags(): Promise<void>;
} }

View file

@ -41,6 +41,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// tags
[JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK,
// metadata // metadata
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,

View file

@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { TagEntity } from 'src/entities/tag.entity'; import { TagEntity } from 'src/entities/tag.entity';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
import { DataSource, In, Repository } from 'typeorm'; import { DataSource, In, Repository, TreeRepository } from 'typeorm';
@Instrumentation() @Instrumentation()
@Injectable() @Injectable()
@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository {
constructor( constructor(
@InjectDataSource() private dataSource: DataSource, @InjectDataSource() private dataSource: DataSource,
@InjectRepository(TagEntity) private repository: Repository<TagEntity>, @InjectRepository(TagEntity) private repository: Repository<TagEntity>,
) {} @InjectRepository(TagEntity) private tree: TreeRepository<TagEntity>,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(TagRepository.name);
}
get(id: string): Promise<TagEntity | null> { get(id: string): Promise<TagEntity | null> {
return this.repository.findOne({ where: { id } }); return this.repository.findOne({ where: { id } });
@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository {
}); });
} }
async deleteEmptyTags() {
await this.dataSource.transaction(async (manager) => {
const ids = new Set<string>();
const tags = await manager.find(TagEntity);
for (const tag of tags) {
const count = await manager
.createQueryBuilder('assets', 'asset')
.innerJoin(
'asset.tags',
'asset_tags',
'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
{ tagId: tag.id },
)
.getCount();
if (count === 0) {
this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`);
ids.add(tag.id);
}
}
if (ids.size > 0) {
await manager.delete(TagEntity, { id: In([...ids]) });
this.logger.log(`Deleted ${ids.size} empty tags`);
}
});
}
private async save(partial: Partial<TagEntity>): Promise<TagEntity> { private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
const { id } = await this.repository.save(partial); const { id } = await this.repository.save(partial);
return this.repository.findOneOrFail({ where: { id } }); return this.repository.findOneOrFail({ where: { id } });

View file

@ -2,8 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { snakeCase } from 'lodash'; import { snakeCase } from 'lodash';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType } from 'src/enum'; import { AssetType, ManualJobName } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import { import {
@ -22,6 +22,26 @@ import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
const asJobItem = (dto: JobCreateDto): JobItem => {
switch (dto.name) {
case ManualJobName.TAG_CLEANUP: {
return { name: JobName.TAG_CLEANUP };
}
case ManualJobName.PERSON_CLEANUP: {
return { name: JobName.PERSON_CLEANUP };
}
case ManualJobName.USER_CLEANUP: {
return { name: JobName.USER_DELETE_CHECK };
}
default: {
throw new BadRequestException('Invalid job name');
}
}
};
@Injectable() @Injectable()
export class JobService { export class JobService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
@ -39,6 +59,10 @@ export class JobService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
} }
async create(dto: JobCreateDto): Promise<void> {
await this.jobRepository.queue(asJobItem(dto));
}
async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> { async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);

View file

@ -15,6 +15,7 @@ import { SessionService } from 'src/services/session.service';
import { SmartInfoService } from 'src/services/smart-info.service'; import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { TagService } from 'src/services/tag.service';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service'; import { VersionService } from 'src/services/version.service';
import { otelShutdown } from 'src/utils/instrumentation'; import { otelShutdown } from 'src/utils/instrumentation';
@ -34,6 +35,7 @@ export class MicroservicesService {
private sessionService: SessionService, private sessionService: SessionService,
private storageTemplateService: StorageTemplateService, private storageTemplateService: StorageTemplateService,
private storageService: StorageService, private storageService: StorageService,
private tagService: TagService,
private userService: UserService, private userService: UserService,
private duplicateService: DuplicateService, private duplicateService: DuplicateService,
private versionService: VersionService, private versionService: VersionService,
@ -93,6 +95,7 @@ export class MicroservicesService {
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),
[JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data),
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
[JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(),
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
}); });
} }

View file

@ -14,6 +14,7 @@ import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { checkAccess, requireAccess } from 'src/utils/access'; import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
@ -138,6 +139,11 @@ export class TagService {
return results; return results;
} }
async handleTagCleanup() {
await this.repository.deleteEmptyTags();
return JobStatus.SUCCESS;
}
private async findOrFail(id: string) { private async findOrFail(id: string) {
const tag = await this.repository.get(id); const tag = await this.repository.get(id);
if (!tag) { if (!tag) {

View file

@ -17,5 +17,6 @@ export const newTagRepositoryMock = (): Mocked<ITagRepository> => {
addAssetIds: vitest.fn(), addAssetIds: vitest.fn(),
removeAssetIds: vitest.fn(), removeAssetIds: vitest.fn(),
upsertAssetIds: vitest.fn(), upsertAssetIds: vitest.fn(),
deleteEmptyTags: vitest.fn(),
}; };
}; };

View file

@ -220,7 +220,7 @@
role="listbox" role="listbox"
id={listboxId} id={listboxId}
transition:fly={{ duration: 250 }} transition:fly={{ duration: 250 }}
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10" class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-[10000]"
class:border={isOpen} class:border={isOpen}
tabindex="-1" tabindex="-1"
> >

View file

@ -41,6 +41,7 @@
"confirm_email_below": "To confirm, type \"{email}\" below", "confirm_email_below": "To confirm, type \"{email}\" below",
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
"create_job": "Create job",
"disable_login": "Disable login", "disable_login": "Disable login",
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.", "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
@ -68,6 +69,7 @@
"image_thumbnail_resolution": "Thumbnail resolution", "image_thumbnail_resolution": "Thumbnail resolution",
"image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.", "image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.",
"job_concurrency": "{job} concurrency", "job_concurrency": "{job} concurrency",
"job_created": "Job created",
"job_not_concurrency_safe": "This job is not concurrency-safe.", "job_not_concurrency_safe": "This job is not concurrency-safe.",
"job_settings": "Job Settings", "job_settings": "Job Settings",
"job_settings_description": "Manage job concurrency", "job_settings_description": "Manage job concurrency",
@ -196,6 +198,7 @@
"password_settings": "Password Login", "password_settings": "Password Login",
"password_settings_description": "Manage password login settings", "password_settings_description": "Manage password login settings",
"paths_validated_successfully": "All paths validated successfully", "paths_validated_successfully": "All paths validated successfully",
"person_cleanup_job": "Person cleanup",
"quota_size_gib": "Quota Size (GiB)", "quota_size_gib": "Quota Size (GiB)",
"refreshing_all_libraries": "Refreshing all libraries", "refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration", "registration": "Admin Registration",
@ -209,6 +212,7 @@
"reset_settings_to_recent_saved": "Reset settings to the recent saved settings", "reset_settings_to_recent_saved": "Reset settings to the recent saved settings",
"scanning_library_for_changed_files": "Scanning library for changed files", "scanning_library_for_changed_files": "Scanning library for changed files",
"scanning_library_for_new_files": "Scanning library for new files", "scanning_library_for_new_files": "Scanning library for new files",
"search_jobs": "Search jobs...",
"send_welcome_email": "Send welcome email", "send_welcome_email": "Send welcome email",
"server_external_domain_settings": "External domain", "server_external_domain_settings": "External domain",
"server_external_domain_settings_description": "Domain for public shared links, including http(s)://", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://",
@ -236,6 +240,7 @@
"storage_template_settings_description": "Manage the folder structure and file name of the upload asset", "storage_template_settings_description": "Manage the folder structure and file name of the upload asset",
"storage_template_user_label": "<code>{label}</code> is the user's Storage Label", "storage_template_user_label": "<code>{label}</code> is the user's Storage Label",
"system_settings": "System Settings", "system_settings": "System Settings",
"tag_cleanup_job": "Tag cleanup",
"theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings": "Custom CSS",
"theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.",
"theme_settings": "Theme Settings", "theme_settings": "Theme Settings",
@ -309,6 +314,7 @@
"trash_settings_description": "Manage trash settings", "trash_settings_description": "Manage trash settings",
"untracked_files": "Untracked Files", "untracked_files": "Untracked Files",
"untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug",
"user_cleanup_job": "User cleanup",
"user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.",
"user_delete_delay_settings": "Delete delay", "user_delete_delay_settings": "Delete delay",
"user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.",

View file

@ -3,10 +3,17 @@
import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { asyncTimeout } from '$lib/utils'; import { asyncTimeout } from '$lib/utils';
import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk'; import { handleError } from '$lib/utils/handle-error';
import { mdiCog } from '@mdi/js'; import { createJob, getAllJobsStatus, ManualJobName, type AllJobStatusResponseDto } from '@immich/sdk';
import { mdiCog, mdiPlus } from '@mdi/js';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@ -16,6 +23,8 @@
let jobs: AllJobStatusResponseDto; let jobs: AllJobStatusResponseDto;
let running = true; let running = true;
let isOpen = false;
let selectedJob: ComboBoxOption | undefined = undefined;
onMount(async () => { onMount(async () => {
while (running) { while (running) {
@ -27,10 +36,38 @@
onDestroy(() => { onDestroy(() => {
running = false; running = false;
}); });
const options = [
{ title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup },
{ title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup },
{ title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup },
].map(({ value, title }) => ({ id: value, label: title, value }));
const handleCancel = () => (isOpen = false);
const handleCreate = async () => {
if (!selectedJob) {
return;
}
try {
await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } });
notificationController.show({ message: $t('admin.job_created'), type: NotificationType.Info });
handleCancel();
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}
};
</script> </script>
<UserPageLayout title={data.meta.title} admin> <UserPageLayout title={data.meta.title} admin>
<div class="flex justify-end" slot="buttons"> <div class="flex justify-end" slot="buttons">
<LinkButton on:click={() => (isOpen = true)}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlus} size="18" />
{$t('admin.create_job')}
</div>
</LinkButton>
<LinkButton href="{AppRoute.ADMIN_SETTINGS}?isOpen=job"> <LinkButton href="{AppRoute.ADMIN_SETTINGS}?isOpen=job">
<div class="flex place-items-center gap-2 text-sm"> <div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiCog} size="18" /> <Icon path={mdiCog} size="18" />
@ -46,3 +83,24 @@
</section> </section>
</section> </section>
</UserPageLayout> </UserPageLayout>
{#if isOpen}
<ConfirmDialog
confirmColor="primary"
title={$t('admin.create_job')}
disabled={!selectedJob}
onConfirm={handleCreate}
onCancel={handleCancel}
>
<form on:submit|preventDefault={handleCreate} autocomplete="off" id="create-tag-form" slot="prompt" class="w-full">
<div class="flex flex-col gap-1 text-left">
<Combobox
bind:selectedOption={selectedJob}
label={$t('jobs')}
{options}
placeholder={$t('admin.search_jobs')}
/>
</div>
</form>
</ConfirmDialog>
{/if}