diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index fbb4cd8c23..2ca965624f 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -41,7 +41,7 @@ By default, Immich will keep the last 14 database dumps and create a new dump ev #### Trigger Dump -You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status). +You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/queues). Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm". A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder. This dumps will count towards the last `X` dumps that will be kept based on your settings. diff --git a/docs/docs/features/searching.md b/docs/docs/features/searching.md index d7ebd1a468..e8985b0c92 100644 --- a/docs/docs/features/searching.md +++ b/docs/docs/features/searching.md @@ -1222,4 +1222,4 @@ Feel free to make a feature request if there's a model you want to use that we d [huggingface-clip]: https://huggingface.co/collections/immich-app/clip-654eaefb077425890874cd07 [huggingface-multilingual-clip]: https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7 [smart-search-settings]: https://my.immich.app/admin/system-settings?isOpen=machine-learning+smart-search -[job-status-page]: https://my.immich.app/admin/jobs-status +[job-status-page]: https://my.immich.app/admin/queues diff --git a/docs/docs/guides/remote-machine-learning.md b/docs/docs/guides/remote-machine-learning.md index 0a8ddf2577..b677d83b0d 100644 --- a/docs/docs/guides/remote-machine-learning.md +++ b/docs/docs/guides/remote-machine-learning.md @@ -53,7 +53,7 @@ Version mismatches between both hosts may cause bugs and instability, so remembe Adding a new URL to the settings is recommended over replacing the existing URL. This is because it will allow machine learning tasks to be processed successfully when the remote server is down by falling back to the local machine learning container. If you do not want machine learning tasks to be processed locally when the remote server is not available, you can instead replace the existing URL and only provide the remote container's URL. If doing this, you can remove the `immich-machine-learning` section of the local `docker-compose.yml` file to save resources, as this service will never be used. -Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/jobs-status) page for the jobs to be retried. +Do note that this will mean that Smart Search and Face Detection jobs will fail to be processed when the remote instance is not available. This in turn means that tasks dependent on these features—Duplicate Detection and Facial Recognition—will not run for affected assets. If this occurs, you must manually click the _Missing_ button next to Smart Search and Face Detection in the [Job Status](http://my.immich.app/admin/queues) page for the jobs to be retried. ## Load balancing diff --git a/i18n/en.json b/i18n/en.json index ef1bbc76d6..6495e45215 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -7,6 +7,7 @@ "action_common_update": "Update", "actions": "Actions", "active": "Active", + "active_count": "Active: {count}", "activity": "Activity", "activity_changed": "Activity is {enabled, select, true {enabled} other {disabled}}", "add": "Add", @@ -111,10 +112,9 @@ "job_not_concurrency_safe": "This job is not concurrency-safe.", "job_settings": "Job Settings", "job_settings_description": "Manage job concurrency", - "job_status": "Job Status", "jobs_delayed": "{jobCount, plural, other {# delayed}}", "jobs_failed": "{jobCount, plural, other {# failed}}", - "jobs_page_description": "Admin jobs page", + "jobs_over_time": "Jobs over time", "library_created": "Created library: {library}", "library_deleted": "Library deleted", "library_details": "Library details", @@ -277,10 +277,14 @@ "password_settings_description": "Manage password login settings", "paths_validated_successfully": "All paths validated successfully", "person_cleanup_job": "Person cleanup", + "queue_details": "Queue Details", + "queues": "Job Queues", + "queues_page_description": "Admin job queues page", "quota_size_gib": "Quota Size (GiB)", "refreshing_all_libraries": "Refreshing all libraries", "registration": "Admin Registration", "registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.", + "remove_failed_jobs": "Remove failed jobs", "require_password_change_on_login": "Require user to change password on first login", "reset_settings_to_default": "Reset settings to default", "reset_settings_to_recent_saved": "Reset settings to the recent saved settings", @@ -1102,6 +1106,7 @@ "external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "face_unassigned": "Unassigned", "failed": "Failed", + "failed_count": "Failed: {count}", "failed_to_authenticate": "Failed to authenticate", "failed_to_load_assets": "Failed to load assets", "failed_to_load_folder": "Failed to load folder", @@ -2209,6 +2214,7 @@ "viewer_unstack": "Un-Stack", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "waiting": "Waiting", + "waiting_count": "Waiting: {count}", "warning": "Warning", "week": "Week", "welcome": "Welcome", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95e2d4945e..43d2848e16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -818,6 +818,9 @@ importers: thumbhash: specifier: ^0.1.1 version: 0.1.1 + uplot: + specifier: ^1.6.32 + version: 1.6.32 devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -11276,6 +11279,9 @@ packages: resolution: {integrity: sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==} engines: {node: '>=14.16'} + uplot@1.6.32: + resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -24536,6 +24542,8 @@ snapshots: semver-diff: 4.0.0 xdg-basedir: 5.1.0 + uplot@1.6.32: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/web/package.json b/web/package.json index 72be145acb..2e7b740153 100644 --- a/web/package.json +++ b/web/package.json @@ -61,7 +61,8 @@ "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", - "thumbhash": "^0.1.1" + "thumbhash": "^0.1.1", + "uplot": "^1.6.32" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/web/src/lib/components/jobs/JobTile.svelte b/web/src/lib/components/QueueCard.svelte similarity index 65% rename from web/src/lib/components/jobs/JobTile.svelte rename to web/src/lib/components/QueueCard.svelte index 8bdd7c169a..f57fb984a2 100644 --- a/web/src/lib/components/jobs/JobTile.svelte +++ b/web/src/lib/components/QueueCard.svelte @@ -1,11 +1,15 @@ + +
+ {#if data[0].length === 0} + + {/if} +
diff --git a/web/src/lib/components/QueuePanel.svelte b/web/src/lib/components/QueuePanel.svelte new file mode 100644 index 0000000000..177cbf33c3 --- /dev/null +++ b/web/src/lib/components/QueuePanel.svelte @@ -0,0 +1,132 @@ + + +
+ {#each queueList as [queueName, props] (queueName)} + {@const queue = queues.find(({ name }) => name === queueName)} + {#if queue} + handleCommand(queueName, command)} {...props} /> + {/if} + {/each} +
diff --git a/web/src/lib/components/jobs/StorageMigrationDescription.svelte b/web/src/lib/components/QueueStorageMigrationDescription.svelte similarity index 100% rename from web/src/lib/components/jobs/StorageMigrationDescription.svelte rename to web/src/lib/components/QueueStorageMigrationDescription.svelte diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index 3dbf697de1..e119e8d8b0 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -254,7 +254,7 @@ values={{ job: $t('admin.storage_template_migration_job') }} > {#snippet children({ message })} - + {message} {/snippet} diff --git a/web/src/lib/components/jobs/JobsPanel.svelte b/web/src/lib/components/jobs/JobsPanel.svelte deleted file mode 100644 index 8a991971f9..0000000000 --- a/web/src/lib/components/jobs/JobsPanel.svelte +++ /dev/null @@ -1,197 +0,0 @@ - - -
- {#each jobList as [jobName, { title, subtitle, description, disabled, allText, refreshText, missingText, icon, handleCommand: handleCommandOverride }] (jobName)} - {@const { jobCounts: statistics, queueStatus } = jobs[jobName]} - (handleCommandOverride || handleCommand)(jobName, command)} - /> - {/each} -
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 4cd238cb52..5e74214e78 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -23,7 +23,7 @@ export enum AppRoute { ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', ADMIN_SETTINGS = '/admin/system-settings', ADMIN_STATS = '/admin/server-status', - ADMIN_JOBS = '/admin/jobs-status', + ADMIN_QUEUES = '/admin/queues', ADMIN_REPAIR = '/admin/repair', ALBUMS = '/albums', diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index effc5325ed..66a2db8787 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -4,6 +4,7 @@ import type { AlbumResponseDto, LibraryResponseDto, LoginResponseDto, + QueueResponseDto, SharedLinkResponseDto, SystemConfigDto, UserAdminResponseDto, @@ -21,6 +22,8 @@ export type Events = { AlbumDelete: [AlbumResponseDto]; + QueueUpdate: [QueueResponseDto]; + SharedLinkCreate: [SharedLinkResponseDto]; SharedLinkUpdate: [SharedLinkResponseDto]; SharedLinkDelete: [SharedLinkResponseDto]; diff --git a/web/src/lib/managers/queue-manager.svelte.ts b/web/src/lib/managers/queue-manager.svelte.ts new file mode 100644 index 0000000000..a7950e455a --- /dev/null +++ b/web/src/lib/managers/queue-manager.svelte.ts @@ -0,0 +1,45 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +import type { QueueSnapshot } from '$lib/types'; +import { getQueues, type QueueResponseDto } from '@immich/sdk'; +import { DateTime } from 'luxon'; + +export class QueueManager { + #snapshots = $state([]); + #queues: QueueResponseDto[] = $derived(this.#snapshots.at(-1)?.snapshot ?? []); + + #interval?: ReturnType; + #listenerCount = 0; + + get snapshots() { + return this.#snapshots; + } + + get queues() { + return this.#queues; + } + + constructor() { + eventManager.on('QueueUpdate', () => void this.refresh()); + } + + listen() { + if (!this.#interval) { + this.#interval = setInterval(() => void this.refresh(true), 3000); + } + + this.#listenerCount++; + void this.refresh(); + + return () => this.#listenerCount--; + } + + async refresh(tick = false) { + this.#snapshots.push({ + timestamp: DateTime.now().toMillis(), + snapshot: this.#listenerCount > 0 || !tick ? await getQueues().catch(() => undefined) : undefined, + }); + this.#snapshots = this.#snapshots.slice(-30); + } +} + +export const queueManager = new QueueManager(); diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index 84e4edc32a..4b4878e46d 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -89,7 +89,7 @@ {$t('admin.note_apply_storage_label_previous_assets')} - + {$t('admin.storage_template_migration_job')} diff --git a/web/src/lib/services/queue.service.ts b/web/src/lib/services/queue.service.ts new file mode 100644 index 0000000000..2372461d1a --- /dev/null +++ b/web/src/lib/services/queue.service.ts @@ -0,0 +1,246 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import JobCreateModal from '$lib/modals/JobCreateModal.svelte'; +import { user } from '$lib/stores/user.store'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { emptyQueue, getQueue, QueueName, updateQueue, type QueueResponseDto } from '@immich/sdk'; +import { modalManager, toastManager, type ActionItem, type IconLike } from '@immich/ui'; +import { + mdiClose, + mdiCog, + mdiContentDuplicate, + mdiDatabaseOutline, + mdiFaceRecognition, + mdiFileJpgBox, + mdiFileXmlBox, + mdiFolderMove, + mdiImageSearch, + mdiLibraryShelves, + mdiOcr, + mdiPause, + mdiPlay, + mdiPlus, + mdiStateMachine, + mdiSync, + mdiTable, + mdiTagFaces, + mdiTrashCanOutline, + mdiTrayFull, + mdiVideo, +} from '@mdi/js'; +import type { MessageFormatter } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +type QueueItem = { + icon: IconLike; + title: string; + subtitle?: string; +}; + +export const getQueuesActions = ($t: MessageFormatter) => { + const ViewQueues: ActionItem = { + title: $t('admin.queues'), + description: $t('admin.queues_page_description'), + icon: mdiSync, + type: $t('page'), + isGlobal: true, + $if: () => get(user)?.isAdmin, + onAction: () => goto(AppRoute.ADMIN_QUEUES), + }; + + const CreateJob: ActionItem = { + icon: mdiPlus, + title: $t('admin.create_job'), + type: $t('command'), + shortcuts: { shift: true, key: 'n' }, + onAction: async () => { + await modalManager.show(JobCreateModal, {}); + }, + }; + + const ManageConcurrency: ActionItem = { + icon: mdiCog, + title: $t('admin.manage_concurrency'), + description: $t('admin.manage_concurrency_description'), + type: $t('page'), + onAction: () => goto(`${AppRoute.ADMIN_SETTINGS}?isOpen=job`), + }; + + return { ViewQueues, ManageConcurrency, CreateJob }; +}; + +export const getQueueActions = ($t: MessageFormatter, queue: QueueResponseDto) => { + const Pause: ActionItem = { + icon: mdiPause, + title: $t('pause'), + $if: () => !queue.isPaused, + onAction: () => handlePauseQueue(queue), + }; + + const Resume: ActionItem = { + icon: mdiPlay, + title: $t('resume'), + $if: () => queue.isPaused, + onAction: () => handleResumeQueue(queue), + }; + + const Empty: ActionItem = { + icon: mdiClose, + title: $t('clear'), + onAction: () => handleEmptyQueue(queue), + }; + + const RemoveFailedJobs: ActionItem = { + icon: mdiTrashCanOutline, + color: 'danger', + title: $t('admin.remove_failed_jobs'), + onAction: () => handleRemoveFailedJobs(queue), + }; + + return { Pause, Resume, Empty, RemoveFailedJobs }; +}; + +export const handlePauseQueue = async (queue: QueueResponseDto) => { + const response = await updateQueue({ name: queue.name, queueUpdateDto: { isPaused: true } }); + eventManager.emit('QueueUpdate', response); +}; + +export const handleResumeQueue = async (queue: QueueResponseDto) => { + const response = await updateQueue({ name: queue.name, queueUpdateDto: { isPaused: false } }); + eventManager.emit('QueueUpdate', response); +}; + +export const handleEmptyQueue = async (queue: QueueResponseDto) => { + const $t = await getFormatter(); + const item = asQueueItem($t, queue); + + try { + await emptyQueue({ name: queue.name, queueDeleteDto: { failed: false } }); + const response = await getQueue({ name: queue.name }); + eventManager.emit('QueueUpdate', response); + toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } })); + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + } +}; + +const handleRemoveFailedJobs = async (queue: QueueResponseDto) => { + const $t = await getFormatter(); + + try { + await emptyQueue({ name: queue.name, queueDeleteDto: { failed: true } }); + const response = await getQueue({ name: queue.name }); + eventManager.emit('QueueUpdate', response); + toastManager.success(); + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + } +}; + +export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): QueueItem => { + // TODO merge this mapping with data from QueuePanel.svelte + const items: Record = { + [QueueName.ThumbnailGeneration]: { + icon: mdiFileJpgBox, + title: $t('admin.thumbnail_generation_job'), + subtitle: $t('admin.thumbnail_generation_job_description'), + }, + [QueueName.MetadataExtraction]: { + icon: mdiTable, + title: $t('admin.metadata_extraction_job'), + subtitle: $t('admin.metadata_extraction_job_description'), + }, + [QueueName.Library]: { + icon: mdiLibraryShelves, + title: $t('external_libraries'), + subtitle: $t('admin.library_tasks_description'), + }, + [QueueName.Sidecar]: { + title: $t('admin.sidecar_job'), + icon: mdiFileXmlBox, + subtitle: $t('admin.sidecar_job_description'), + }, + [QueueName.SmartSearch]: { + icon: mdiImageSearch, + title: $t('admin.machine_learning_smart_search'), + subtitle: $t('admin.smart_search_job_description'), + }, + [QueueName.DuplicateDetection]: { + icon: mdiContentDuplicate, + title: $t('admin.machine_learning_duplicate_detection'), + subtitle: $t('admin.duplicate_detection_job_description'), + }, + [QueueName.FaceDetection]: { + icon: mdiFaceRecognition, + title: $t('admin.face_detection'), + subtitle: $t('admin.face_detection_description'), + }, + [QueueName.FacialRecognition]: { + icon: mdiTagFaces, + title: $t('admin.machine_learning_facial_recognition'), + subtitle: $t('admin.facial_recognition_job_description'), + }, + [QueueName.Ocr]: { + icon: mdiOcr, + title: $t('admin.machine_learning_ocr'), + subtitle: $t('admin.ocr_job_description'), + }, + [QueueName.VideoConversion]: { + icon: mdiVideo, + title: $t('admin.video_conversion_job'), + subtitle: $t('admin.video_conversion_job_description'), + }, + [QueueName.StorageTemplateMigration]: { + icon: mdiFolderMove, + title: $t('admin.storage_template_migration'), + }, + [QueueName.Migration]: { + icon: mdiFolderMove, + title: $t('admin.migration_job'), + subtitle: $t('admin.migration_job_description'), + }, + [QueueName.BackgroundTask]: { + icon: mdiTrayFull, + title: $t('admin.background_task_job'), + }, + [QueueName.Search]: { + icon: '', + title: $t('search'), + }, + [QueueName.Notifications]: { + icon: '', + title: $t('notifications'), + }, + [QueueName.BackupDatabase]: { + icon: mdiDatabaseOutline, + title: $t('admin.backup_database'), + }, + [QueueName.Workflow]: { + icon: mdiStateMachine, + title: $t('workflow'), + }, + }; + + return items[queue.name]; +}; + +export const asQueueSlug = (name: QueueName) => { + return name.replaceAll(/[A-Z]/g, (m) => '-' + m.toLowerCase()); +}; + +export const fromQueueSlug = (slug: string): QueueName | undefined => { + const name = slug.replaceAll(/-([a-z])/g, (_, c) => c.toUpperCase()); + if (Object.values(QueueName).includes(name as QueueName)) { + return name as QueueName; + } +}; + +export const getQueueDetailUrl = (queue: QueueResponseDto) => { + return `${AppRoute.ADMIN_QUEUES}/${asQueueSlug(queue.name)}`; +}; + +export const handleViewQueue = (queue: QueueResponseDto) => { + return goto(getQueueDetailUrl(queue)); +}; diff --git a/web/src/lib/sidebars/AdminSidebar.svelte b/web/src/lib/sidebars/AdminSidebar.svelte index 2fecaebf49..919c072527 100644 --- a/web/src/lib/sidebars/AdminSidebar.svelte +++ b/web/src/lib/sidebars/AdminSidebar.svelte @@ -2,16 +2,16 @@ import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import { AppRoute } from '$lib/constants'; import { NavbarItem } from '@immich/ui'; - import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync } from '@mdi/js'; + import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull } from '@mdi/js'; import { t } from 'svelte-i18n';
- - + +
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d95e7b7cf2..e7d38b1a25 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,4 +1,4 @@ -import type { ServerVersionResponseDto } from '@immich/sdk'; +import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk'; export interface ReleaseEvent { isAvailable: boolean; @@ -7,3 +7,5 @@ export interface ReleaseEvent { serverVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto; } + +export type QueueSnapshot = { timestamp: number; snapshot?: QueueResponseDto[] }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 3d065ab2f1..c8f41b6fbc 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -14,6 +14,7 @@ import { themeManager } from '$lib/managers/theme-manager.svelte'; import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte'; import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte'; + import { getQueuesActions } from '$lib/services/queue.service'; import { user } from '$lib/stores/user.store'; import { closeWebsocketConnection, openWebsocketConnection, websocketStore } from '$lib/stores/websocket'; import type { ReleaseEvent } from '$lib/types'; @@ -21,7 +22,7 @@ import { maintenanceShouldRedirect } from '$lib/utils/maintenance'; import { isAssetViewerRoute } from '$lib/utils/navigation'; import { CommandPaletteContext, modalManager, setTranslations, type ActionItem } from '@immich/ui'; - import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js'; + import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiThemeLightDark } from '@mdi/js'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; import '../app.css'; @@ -142,15 +143,9 @@ icon: mdiAccountMultipleOutline, onAction: () => goto(AppRoute.ADMIN_USERS), }, - { - title: $t('jobs'), - description: $t('admin.jobs_page_description'), - icon: mdiSync, - onAction: () => goto(AppRoute.ADMIN_JOBS), - }, { title: $t('settings'), - description: $t('admin.jobs_page_description'), + description: $t('admin.settings_page_description'), icon: mdiCog, onAction: () => goto(AppRoute.ADMIN_SETTINGS), }, @@ -168,7 +163,7 @@ }, ].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin })); - const commands = $derived([...userCommands, ...adminCommands]); + const commands = $derived([...userCommands, ...adminCommands, ...Object.values(getQueuesActions($t))]); diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte deleted file mode 100644 index 1a61ea6b23..0000000000 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - {#snippet buttons()} - - {#if pausedJobs.length > 0} - - {/if} - - - - {/snippet} -
-
- {#if jobs} - - {/if} -
-
-
diff --git a/web/src/routes/admin/jobs-status/+page.ts b/web/src/routes/admin/jobs-status/+page.ts index 90057ff969..92971ccd9a 100644 --- a/web/src/routes/admin/jobs-status/+page.ts +++ b/web/src/routes/admin/jobs-status/+page.ts @@ -1,18 +1,5 @@ -import { authenticate } from '$lib/utils/auth'; -import { getFormatter } from '$lib/utils/i18n'; -import { getQueuesLegacy } from '@immich/sdk'; +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async ({ url }) => { - await authenticate(url, { admin: true }); - - const jobs = await getQueuesLegacy(); - const $t = await getFormatter(); - - return { - jobs, - meta: { - title: $t('admin.job_status'), - }, - }; -}) satisfies PageLoad; +export const load = (() => redirect(307, AppRoute.ADMIN_QUEUES)) satisfies PageLoad; diff --git a/web/src/routes/admin/queues/+page.svelte b/web/src/routes/admin/queues/+page.svelte new file mode 100644 index 0000000000..ae75fa760c --- /dev/null +++ b/web/src/routes/admin/queues/+page.svelte @@ -0,0 +1,83 @@ + + + + + + + + {#snippet buttons()} + + {#if pausedQueues.length > 0} + + {/if} + + + + {/snippet} + +
+
+ {#if queues} + + {/if} +
+
+
diff --git a/web/src/routes/admin/queues/+page.ts b/web/src/routes/admin/queues/+page.ts new file mode 100644 index 0000000000..59aded520a --- /dev/null +++ b/web/src/routes/admin/queues/+page.ts @@ -0,0 +1,18 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getQueues } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); + + const queues = await getQueues(); + const $t = await getFormatter(); + + return { + queues, + meta: { + title: $t('admin.queues'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/queues/[name]/+page.svelte b/web/src/routes/admin/queues/[name]/+page.svelte new file mode 100644 index 0000000000..354a5a7057 --- /dev/null +++ b/web/src/routes/admin/queues/[name]/+page.svelte @@ -0,0 +1,82 @@ + + + + + + {#snippet buttons()} + + + + + + + {/snippet} +
+ +
+ {item.title} + {#if queue.isPaused} + + {$t('paused')} + + {/if} +
+ {item.subtitle} + +
+ {$t('active_count', { values: { count: queue.statistics.active } })} + {$t('waiting_count', { values: { count: queue.statistics.waiting } })} + {#if queue.statistics.failed > 0} + {$t('failed_count', { values: { count: queue.statistics.failed } })} + {/if} +
+ +
+ + +
+ + {$t('admin.jobs_over_time')} +
+
+ + + +
+
+
+
+
diff --git a/web/src/routes/admin/queues/[name]/+page.ts b/web/src/routes/admin/queues/[name]/+page.ts new file mode 100644 index 0000000000..3c111abf7c --- /dev/null +++ b/web/src/routes/admin/queues/[name]/+page.ts @@ -0,0 +1,31 @@ +import { AppRoute } from '$lib/constants'; +import { fromQueueSlug } from '$lib/services/queue.service'; +import { authenticate, requestServerInfo } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getQueue, getQueueJobs, QueueJobStatus } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(url, { admin: true }); + await requestServerInfo(); + + const name = fromQueueSlug(params.name); + if (!name) { + redirect(302, AppRoute.ADMIN_QUEUES); + } + + const [queue, failedJobs] = await Promise.all([ + getQueue({ name }), + getQueueJobs({ name, status: [QueueJobStatus.Failed, QueueJobStatus.Paused] }), + ]); + const $t = await getFormatter(); + + return { + queue, + failedJobs, + meta: { + title: $t('admin.queue_details'), + }, + }; +}) satisfies PageLoad;