From 2149b4cad5f84190deae9c9a9b7286bb1d2a5f77 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 19:04:06 +0100 Subject: [PATCH] refactor(bg tasks): modularize component Break down the monolithic BackgroundTasks component into smaller, highly cohesive modules to improve maintainability and readability. - Extract complex UI components into dedicated files (`BackgroundTaskSummary`, `ExpandedTaskPanel`, `TaskStagePill`, `TaskProgressBar`, `BackgroundTaskActions`, and `FailedWorkerItemRow`). - Move task construction, duplicate scan formatting, and progress calculation logic into a dedicated `taskModel.ts` utility file. - Relocate shared interfaces and constants to `types.ts` to cleanly decouple data structures from the presentation layer. --- src/components/BackgroundTasks.tsx | 592 ++---------------- .../backgroundTasks/BackgroundTaskActions.tsx | 66 ++ .../backgroundTasks/BackgroundTaskSummary.tsx | 78 +++ .../backgroundTasks/ExpandedTaskPanel.tsx | 84 +++ .../backgroundTasks/FailedWorkerItemRow.tsx | 28 + .../backgroundTasks/TaskProgressBar.tsx | 24 + .../backgroundTasks/TaskStagePill.tsx | 65 ++ src/components/backgroundTasks/taskModel.ts | 178 ++++++ src/components/backgroundTasks/types.ts | 37 ++ 9 files changed, 626 insertions(+), 526 deletions(-) create mode 100644 src/components/backgroundTasks/BackgroundTaskActions.tsx create mode 100644 src/components/backgroundTasks/BackgroundTaskSummary.tsx create mode 100644 src/components/backgroundTasks/ExpandedTaskPanel.tsx create mode 100644 src/components/backgroundTasks/FailedWorkerItemRow.tsx create mode 100644 src/components/backgroundTasks/TaskProgressBar.tsx create mode 100644 src/components/backgroundTasks/TaskStagePill.tsx create mode 100644 src/components/backgroundTasks/taskModel.ts create mode 100644 src/components/backgroundTasks/types.ts diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index 84d78cb..555168e 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -1,69 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { useGalleryStore, WorkerKey } from "../store"; -import { Tooltip } from "./Tooltip"; -import { ChevronDownIcon, CloseIcon, PlayIcon, WarningIcon } from "./icons"; - -const WORKER_FOR_STAGE: Record = { - Thumbnails: "thumbnail", - Metadata: "metadata", - Embeddings: "embedding", - Tags: "tagging", -}; - -interface TaskStage { - label: string; - detail: string; - progress: number | null; // 0–100, or null for indeterminate - failed: boolean; -} - -interface Task { - id: number; - name: string; - stages: TaskStage[]; - isActive: boolean; - hasFailedEmbeddings: boolean; - hasFailedTagging: boolean; - hasFailedCaptions: boolean; - pendingMediaWork: number; - embeddingProcessed: number; - embeddingTotal: number; - currentFile: string | null; - snapshot: string; -} - -interface FailedWorkerItem { - image_id: number; - filename: string; - path: string; - error: string | null; -} - -function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { - return ( -
- -
-

{item.filename}

- {item.error && ( -

{item.error}

- )} -
- - - -
- ); -} +import { useGalleryStore, type WorkerKey } from "../store"; +import { BackgroundTaskSummary } from "./backgroundTasks/BackgroundTaskSummary"; +import { ExpandedTaskPanel } from "./backgroundTasks/ExpandedTaskPanel"; +import { buildDuplicateScanTask, buildFolderTasks, taskHasTerminalFailure, taskProgress } from "./backgroundTasks/taskModel"; +import type { BackgroundTask, FailedWorkerItem } from "./backgroundTasks/types"; export function BackgroundTasks() { const folders = useGalleryStore((state) => state.folders); @@ -74,31 +15,29 @@ export function BackgroundTasks() { const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); + const workerPaused = useGalleryStore((state) => state.workerPaused); + const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); + const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState>({}); const [failedEmbeddingItems, setFailedEmbeddingItems] = useState>({}); const [failedTaggingItems, setFailedTaggingItems] = useState>({}); - const workerPaused = useGalleryStore((state) => state.workerPaused); - const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); - const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); - useEffect(() => { void loadWorkerStates(); }, [folders, loadWorkerStates]); - // Fetch failed filenames whenever the expanded panel opens or failure counts change. const failedEmbeddingCounts = useMemo( () => Object.fromEntries( - Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]), + Object.entries(mediaJobProgress).map(([id, progress]) => [id, progress?.embedding_failed ?? 0]), ), [mediaJobProgress], ); const failedTaggingCounts = useMemo( () => Object.fromEntries( - Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]), + Object.entries(mediaJobProgress).map(([id, progress]) => [id, progress?.tagging_failed ?? 0]), ), [mediaJobProgress], ); @@ -125,475 +64,76 @@ export function BackgroundTasks() { } }, [expanded, failedEmbeddingCounts, failedTaggingCounts]); - const isWorkerPaused = (folderId: number, worker: WorkerKey) => { - return workerPaused[folderId]?.[worker] ?? false; - }; + const folderTasks = useMemo( + () => + buildFolderTasks({ + dismissed, + folders, + indexingProgress, + mediaJobProgress, + workerPaused, + }), + [dismissed, folders, indexingProgress, mediaJobProgress, workerPaused], + ); + + const duplicateScanTask = useMemo( + () => buildDuplicateScanTask(duplicateScanning, duplicateScanProgress), + [duplicateScanning, duplicateScanProgress], + ); + const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks; + + if (allTasks.length === 0) return null; + + const isWorkerPaused = (folderId: number, worker: WorkerKey) => workerPaused[folderId]?.[worker] ?? false; const toggleWorker = (folderId: number, worker: WorkerKey) => { setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker)); }; - const dismissTask = (id: number, snapshot: string) => { - if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed - setDismissed((prev) => ({ ...prev, [id]: snapshot })); + const dismissTask = (task: BackgroundTask) => { + if (task.id < 0) return; + setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot })); setExpanded(false); }; - const tasks = useMemo(() => { - return folders - .map((folder): Task | null => { - const index = indexingProgress[folder.id]; - const jobs = mediaJobProgress[folder.id]; - - const thumbnailPending = jobs?.thumbnail_pending ?? 0; - const metadataPending = jobs?.metadata_pending ?? 0; - const embeddingPending = jobs?.embedding_pending ?? 0; - const embeddingReady = jobs?.embedding_ready ?? 0; - const embeddingFailed = jobs?.embedding_failed ?? 0; - const taggingPending = jobs?.tagging_pending ?? 0; - const taggingReady = jobs?.tagging_ready ?? 0; - const taggingFailed = jobs?.tagging_failed ?? 0; - const captionPending = jobs?.caption_pending ?? 0; - const captionReady = jobs?.caption_ready ?? 0; - const captionFailed = jobs?.caption_failed ?? 0; - - // A folder is "actively processing" when something is genuinely moving: - // an in-progress scan, or pending work on a worker that isn't paused. - // Captions have no per-folder pause toggle, so any caption work counts. - const paused = workerPaused[folder.id]; - const isActive = - (!!index && !index.done) || - (thumbnailPending > 0 && !(paused?.thumbnail ?? false)) || - (metadataPending > 0 && !(paused?.metadata ?? false)) || - (embeddingPending > 0 && !(paused?.embedding ?? false)) || - (taggingPending > 0 && !(paused?.tagging ?? false)) || - captionPending > 0; - - const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending; - const embeddingProcessed = embeddingReady + embeddingFailed; - const embeddingTotal = embeddingProcessed + embeddingPending; - const taggingProcessed = taggingReady + taggingFailed; - const taggingTotal = taggingProcessed + taggingPending; - const captionProcessed = captionReady + captionFailed; - const captionTotal = captionProcessed + captionPending; - const hasFailedEmbeddings = embeddingFailed > 0; - const hasFailedTagging = taggingFailed > 0; - const hasFailedCaptions = captionFailed > 0; - - if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null; - - const stages: TaskStage[] = []; - - if (index && !index.done) { - const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0; - stages.push({ - label: "Scanning", - detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (thumbnailPending > 0) { - stages.push({ - label: "Thumbnails", - detail: thumbnailPending.toLocaleString(), - progress: null, - failed: false, - }); - } - - if (metadataPending > 0) { - stages.push({ - label: "Metadata", - detail: metadataPending.toLocaleString(), - progress: null, - failed: false, - }); - } - - if (embeddingPending > 0) { - const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0; - stages.push({ - label: "Embeddings", - detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (taggingPending > 0) { - const pct = taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0; - stages.push({ - label: "Tags", - detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (captionPending > 0) { - const pct = captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0; - stages.push({ - label: "Captions", - detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (hasFailedEmbeddings && pendingMediaWork === 0) { - stages.push({ - label: "Failed", - detail: `${embeddingFailed.toLocaleString()} embeddings`, - progress: null, - failed: true, - }); - } - - if (hasFailedTagging && pendingMediaWork === 0) { - stages.push({ - label: "Failed", - detail: `${taggingFailed.toLocaleString()} tags`, - progress: null, - failed: true, - }); - } - - if (hasFailedCaptions && pendingMediaWork === 0) { - stages.push({ - label: "Failed", - detail: `${captionFailed.toLocaleString()} captions`, - progress: null, - failed: true, - }); - } - - const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`; - - return { - id: folder.id, - name: folder.name, - stages, - isActive, - hasFailedEmbeddings, - hasFailedTagging, - hasFailedCaptions, - pendingMediaWork, - embeddingProcessed, - embeddingTotal, - currentFile: index && !index.done ? (index.current_file || null) : null, - snapshot, - }; - }) - .filter((t): t is Task => t !== null) - .filter((t) => dismissed[t.id] !== t.snapshot) - // Surface actively-processing folders first so a paused folder never holds - // the primary (collapsed) slot while another is genuinely working. Array - // sort is stable, so folders within each group keep their existing order. - .sort((a, b) => Number(b.isActive) - Number(a.isActive)); - }, [folders, indexingProgress, mediaJobProgress, workerPaused, dismissed]); - - // Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed - const duplicateScanTask: Task | null = duplicateScanning ? { - id: -1, - name: "Duplicate Scan", - stages: [{ - label: duplicateScanProgress?.phase === "checking" - ? "Checking" - : duplicateScanProgress?.phase === "confirming" - ? "Confirming" - : "Hashing", - detail: duplicateScanProgress - ? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` - : "Starting…", - progress: duplicateScanProgress && duplicateScanProgress.total > 0 - ? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100 - : null, - failed: false, - }], - isActive: true, - hasFailedEmbeddings: false, - hasFailedTagging: false, - hasFailedCaptions: false, - pendingMediaWork: 1, - embeddingProcessed: 0, - embeddingTotal: 0, - currentFile: null, - snapshot: "", - } : null; - - const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks; - - if (allTasks.length === 0) return null; + const retryTask = (task: BackgroundTask) => { + if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id); + if (task.hasFailedTagging) void queueTaggingJobs(task.id); + }; const primary = allTasks[0]; - const extraCount = allTasks.length - 1; - const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging || t.hasFailedCaptions) && t.pendingMediaWork === 0); - - // Best progress bar value: use embedding progress if available (most informative), - // otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate. - const embeddingStage = primary.stages.find((s) => s.label === "Embeddings"); - const taggingStage = primary.stages.find((s) => s.label === "Tags"); - const scanningStage = primary.stages.find((s) => s.label === "Scanning"); - const duplicateStage = primary.id === -1 ? primary.stages[0] : null; - const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null; + const hasFailed = folderTasks.some(taskHasTerminalFailure); + const barProgress = taskProgress(primary); return (
- {/* Slim bar */} -
setExpanded((v) => !v)} - > - {/* Pulse dot */} -
-
-
-
+ setExpanded((value) => !value)} + onToggleWorker={toggleWorker} + primary={primary} + progress={barProgress} + taskCount={allTasks.length} + /> - {/* Folder name */} - {primary.name} - - {/* Stage tags — all active stages visible simultaneously */} -
- {primary.stages.map((stage) => { - const workerKey = WORKER_FOR_STAGE[stage.label]; - const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false; - return ( - - {stage.label} - - {stage.detail} - - {workerKey && ( - - - - )} - - ); - })} -
- - {/* Progress bar — embedding or scanning progress, pulsing if indeterminate */} -
-
-
- - {/* Extra folders badge */} - {extraCount > 0 && ( - - +{extraCount} - - )} - - {primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && ( -
- {primary.hasFailedTagging ? ( - - ) : null} - -
- )} - - {/* Expand chevron (only when multiple tasks) */} - {allTasks.length > 1 && ( - - )} - - {/* Dismiss — hidden for system tasks like duplicate scan */} - {primary.id >= 0 && ( - - - - )} -
- - {/* Expanded panel — one row per folder */} - {expanded && ( -
- {allTasks.map((task) => { - const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); - const taskTaggingStage = task.stages.find((s) => s.label === "Tags"); - const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); - const taskDuplicateStage = task.id === -1 ? task.stages[0] : null; - const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? taskDuplicateStage?.progress ?? null; - const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0; - - return ( -
-
- {task.name} - -
- {task.stages.map((stage) => { - const workerKey = WORKER_FOR_STAGE[stage.label]; - const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false; - return ( - - {isPaused && ( - - - - )} - {stage.label} - - {stage.detail} - - {workerKey && ( - - - - )} - - ); - })} -
- -
-
-
- - {taskHasFailed && ( -
- {task.hasFailedTagging ? ( - - ) : null} - -
- )} - - {task.id >= 0 && ( - - - - )} -
- - {task.currentFile && ( -

- {task.currentFile} -

- )} - - {/* Failed worker file lists */} - {taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && ( -
- {failedEmbeddingItems[task.id].map((item) => ( - - ))} -
- )} - {taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && ( -
- {failedTaggingItems[task.id].map((item) => ( - - ))} -
- )} -
- ); - })} -
- )} + {expanded ? ( + + ) : null}
); } diff --git a/src/components/backgroundTasks/BackgroundTaskActions.tsx b/src/components/backgroundTasks/BackgroundTaskActions.tsx new file mode 100644 index 0000000..df9ea6b --- /dev/null +++ b/src/components/backgroundTasks/BackgroundTaskActions.tsx @@ -0,0 +1,66 @@ +import { Tooltip } from "../Tooltip"; +import { CloseIcon } from "../icons"; +import type { BackgroundTask } from "./types"; + +export function FailureActions({ + onLocate, + onRetry, + task, +}: { + onLocate: (folderId: number) => void; + onRetry: (task: BackgroundTask) => void; + task: BackgroundTask; +}) { + if (task.pendingMediaWork !== 0 || (!task.hasFailedEmbeddings && !task.hasFailedTagging)) return null; + + return ( +
+ {task.hasFailedTagging ? ( + + ) : null} + +
+ ); +} + +export function DismissTaskButton({ + onDismiss, + size = "normal", + task, +}: { + onDismiss: (task: BackgroundTask) => void; + size?: "normal" | "small"; + task: BackgroundTask; +}) { + if (task.id < 0) return null; + + return ( + + + + ); +} diff --git a/src/components/backgroundTasks/BackgroundTaskSummary.tsx b/src/components/backgroundTasks/BackgroundTaskSummary.tsx new file mode 100644 index 0000000..d43f6da --- /dev/null +++ b/src/components/backgroundTasks/BackgroundTaskSummary.tsx @@ -0,0 +1,78 @@ +import type { WorkerKey } from "../../store"; +import { ChevronDownIcon } from "../icons"; +import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions"; +import { TaskProgressBar } from "./TaskProgressBar"; +import { TaskStagePill } from "./TaskStagePill"; +import type { BackgroundTask } from "./types"; + +export function BackgroundTaskSummary({ + expanded, + extraCount, + hasFailed, + isWorkerPaused, + onDismiss, + onLocate, + onRetry, + onToggleExpanded, + onToggleWorker, + primary, + progress, + taskCount, +}: { + expanded: boolean; + extraCount: number; + hasFailed: boolean; + isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; + onDismiss: (task: BackgroundTask) => void; + onLocate: (folderId: number) => void; + onRetry: (task: BackgroundTask) => void; + onToggleExpanded: () => void; + onToggleWorker: (folderId: number, worker: WorkerKey) => void; + primary: BackgroundTask; + progress: number | null; + taskCount: number; +}) { + return ( +
+
+
+
+
+ + {primary.name} + +
+ {primary.stages.map((stage) => ( + + ))} +
+ + + + {extraCount > 0 ? ( + + +{extraCount} + + ) : null} + + + + {taskCount > 1 ? ( + + ) : null} + + +
+ ); +} diff --git a/src/components/backgroundTasks/ExpandedTaskPanel.tsx b/src/components/backgroundTasks/ExpandedTaskPanel.tsx new file mode 100644 index 0000000..cfd7195 --- /dev/null +++ b/src/components/backgroundTasks/ExpandedTaskPanel.tsx @@ -0,0 +1,84 @@ +import type { WorkerKey } from "../../store"; +import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions"; +import { FailedWorkerItemRow } from "./FailedWorkerItemRow"; +import { taskHasTerminalFailure, taskProgress } from "./taskModel"; +import { TaskProgressBar } from "./TaskProgressBar"; +import { TaskStagePill } from "./TaskStagePill"; +import type { BackgroundTask, FailedWorkerItem } from "./types"; + +export function ExpandedTaskPanel({ + failedEmbeddingItems, + failedTaggingItems, + isWorkerPaused, + onDismiss, + onLocate, + onRetry, + onToggleWorker, + tasks, +}: { + failedEmbeddingItems: Record; + failedTaggingItems: Record; + isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; + onDismiss: (task: BackgroundTask) => void; + onLocate: (folderId: number) => void; + onRetry: (task: BackgroundTask) => void; + onToggleWorker: (folderId: number, worker: WorkerKey) => void; + tasks: BackgroundTask[]; +}) { + return ( +
+ {tasks.map((task) => { + const progress = taskProgress(task); + const failed = taskHasTerminalFailure(task); + + return ( +
+
+ {task.name} + +
+ {task.stages.map((stage) => ( + + ))} +
+ + + + {failed ? : null} + + +
+ + {task.currentFile ? ( +

+ {task.currentFile} +

+ ) : null} + + {failed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 ? ( +
+ {failedEmbeddingItems[task.id].map((item) => ( + + ))} +
+ ) : null} + {failed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 ? ( +
+ {failedTaggingItems[task.id].map((item) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/src/components/backgroundTasks/FailedWorkerItemRow.tsx b/src/components/backgroundTasks/FailedWorkerItemRow.tsx new file mode 100644 index 0000000..c97d3c0 --- /dev/null +++ b/src/components/backgroundTasks/FailedWorkerItemRow.tsx @@ -0,0 +1,28 @@ +import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import { Tooltip } from "../Tooltip"; +import { WarningIcon } from "../icons"; +import type { FailedWorkerItem } from "./types"; + +export function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { + return ( +
+ +
+

{item.filename}

+ {item.error ? ( +

{item.error}

+ ) : null} +
+ + + +
+ ); +} diff --git a/src/components/backgroundTasks/TaskProgressBar.tsx b/src/components/backgroundTasks/TaskProgressBar.tsx new file mode 100644 index 0000000..6c3e559 --- /dev/null +++ b/src/components/backgroundTasks/TaskProgressBar.tsx @@ -0,0 +1,24 @@ +export function TaskProgressBar({ + failed, + progress, + widthClass = "w-20", +}: { + failed: boolean; + progress: number | null; + widthClass?: string; +}) { + return ( +
+
+
+ ); +} diff --git a/src/components/backgroundTasks/TaskStagePill.tsx b/src/components/backgroundTasks/TaskStagePill.tsx new file mode 100644 index 0000000..0d2d3ab --- /dev/null +++ b/src/components/backgroundTasks/TaskStagePill.tsx @@ -0,0 +1,65 @@ +import type { WorkerKey } from "../../store"; +import { Tooltip } from "../Tooltip"; +import { PlayIcon } from "../icons"; +import type { TaskStage } from "./types"; +import { WORKER_FOR_STAGE } from "./types"; + +export function TaskStagePill({ + folderId, + isWorkerPaused, + mutedWhenPaused = false, + onToggleWorker, + stage, +}: { + folderId: number; + isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; + mutedWhenPaused?: boolean; + onToggleWorker: (folderId: number, worker: WorkerKey) => void; + stage: TaskStage; +}) { + const workerKey = WORKER_FOR_STAGE[stage.label]; + const isPaused = workerKey ? isWorkerPaused(folderId, workerKey) : false; + + return ( + + {isPaused && mutedWhenPaused ? ( + + + + ) : null} + {stage.label} + + {stage.detail} + + {workerKey ? ( + + + + ) : null} + + ); +} diff --git a/src/components/backgroundTasks/taskModel.ts b/src/components/backgroundTasks/taskModel.ts new file mode 100644 index 0000000..e21bca6 --- /dev/null +++ b/src/components/backgroundTasks/taskModel.ts @@ -0,0 +1,178 @@ +import type { DuplicateScanProgress, Folder, FolderJobProgress, IndexProgress, WorkerKey } from "../../store"; +import type { BackgroundTask, TaskStage } from "./types"; + +export function buildFolderTasks({ + dismissed, + folders, + indexingProgress, + mediaJobProgress, + workerPaused, +}: { + dismissed: Record; + folders: Folder[]; + indexingProgress: Record; + mediaJobProgress: Record; + workerPaused: Record>; +}): BackgroundTask[] { + return folders + .map((folder): BackgroundTask | null => { + const index = indexingProgress[folder.id]; + const jobs = mediaJobProgress[folder.id]; + + const thumbnailPending = jobs?.thumbnail_pending ?? 0; + const metadataPending = jobs?.metadata_pending ?? 0; + const embeddingPending = jobs?.embedding_pending ?? 0; + const embeddingReady = jobs?.embedding_ready ?? 0; + const embeddingFailed = jobs?.embedding_failed ?? 0; + const taggingPending = jobs?.tagging_pending ?? 0; + const taggingReady = jobs?.tagging_ready ?? 0; + const taggingFailed = jobs?.tagging_failed ?? 0; + const captionPending = jobs?.caption_pending ?? 0; + const captionReady = jobs?.caption_ready ?? 0; + const captionFailed = jobs?.caption_failed ?? 0; + + const paused = workerPaused[folder.id]; + const isActive = + (!!index && !index.done) || + (thumbnailPending > 0 && !(paused?.thumbnail ?? false)) || + (metadataPending > 0 && !(paused?.metadata ?? false)) || + (embeddingPending > 0 && !(paused?.embedding ?? false)) || + (taggingPending > 0 && !(paused?.tagging ?? false)) || + captionPending > 0; + + const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending; + const embeddingProcessed = embeddingReady + embeddingFailed; + const embeddingTotal = embeddingProcessed + embeddingPending; + const taggingProcessed = taggingReady + taggingFailed; + const taggingTotal = taggingProcessed + taggingPending; + const captionProcessed = captionReady + captionFailed; + const captionTotal = captionProcessed + captionPending; + const hasFailedEmbeddings = embeddingFailed > 0; + const hasFailedTagging = taggingFailed > 0; + const hasFailedCaptions = captionFailed > 0; + + if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null; + + const stages: TaskStage[] = []; + + if (index && !index.done) { + stages.push({ + label: "Scanning", + detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, + progress: index.total > 0 ? (index.indexed / index.total) * 100 : 0, + failed: false, + }); + } + + if (thumbnailPending > 0) { + stages.push({ label: "Thumbnails", detail: thumbnailPending.toLocaleString(), progress: null, failed: false }); + } + + if (metadataPending > 0) { + stages.push({ label: "Metadata", detail: metadataPending.toLocaleString(), progress: null, failed: false }); + } + + if (embeddingPending > 0) { + stages.push({ + label: "Embeddings", + detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, + progress: embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0, + failed: false, + }); + } + + if (taggingPending > 0) { + stages.push({ + label: "Tags", + detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`, + progress: taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0, + failed: false, + }); + } + + if (captionPending > 0) { + stages.push({ + label: "Captions", + detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`, + progress: captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0, + failed: false, + }); + } + + if (hasFailedEmbeddings && pendingMediaWork === 0) { + stages.push({ label: "Failed", detail: `${embeddingFailed.toLocaleString()} embeddings`, progress: null, failed: true }); + } + + if (hasFailedTagging && pendingMediaWork === 0) { + stages.push({ label: "Failed", detail: `${taggingFailed.toLocaleString()} tags`, progress: null, failed: true }); + } + + if (hasFailedCaptions && pendingMediaWork === 0) { + stages.push({ label: "Failed", detail: `${captionFailed.toLocaleString()} captions`, progress: null, failed: true }); + } + + const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`; + + return { + id: folder.id, + name: folder.name, + stages, + isActive, + hasFailedEmbeddings, + hasFailedTagging, + hasFailedCaptions, + pendingMediaWork, + embeddingProcessed, + embeddingTotal, + currentFile: index && !index.done ? (index.current_file || null) : null, + snapshot, + }; + }) + .filter((task): task is BackgroundTask => task !== null) + .filter((task) => dismissed[task.id] !== task.snapshot) + .sort((a, b) => Number(b.isActive) - Number(a.isActive)); +} + +export function buildDuplicateScanTask(duplicateScanning: boolean, duplicateScanProgress: DuplicateScanProgress | null): BackgroundTask | null { + if (!duplicateScanning) return null; + + return { + id: -1, + name: "Duplicate Scan", + stages: [{ + label: duplicateScanProgress?.phase === "checking" + ? "Checking" + : duplicateScanProgress?.phase === "confirming" + ? "Confirming" + : "Hashing", + detail: duplicateScanProgress + ? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` + : "Starting…", + progress: duplicateScanProgress && duplicateScanProgress.total > 0 + ? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100 + : null, + failed: false, + }], + isActive: true, + hasFailedEmbeddings: false, + hasFailedTagging: false, + hasFailedCaptions: false, + pendingMediaWork: 1, + embeddingProcessed: 0, + embeddingTotal: 0, + currentFile: null, + snapshot: "", + }; +} + +export function taskProgress(task: BackgroundTask): number | null { + const embeddingStage = task.stages.find((stage) => stage.label === "Embeddings"); + const taggingStage = task.stages.find((stage) => stage.label === "Tags"); + const scanningStage = task.stages.find((stage) => stage.label === "Scanning"); + const duplicateStage = task.id === -1 ? task.stages[0] : null; + return embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null; +} + +export function taskHasTerminalFailure(task: BackgroundTask): boolean { + return (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0; +} diff --git a/src/components/backgroundTasks/types.ts b/src/components/backgroundTasks/types.ts new file mode 100644 index 0000000..f956399 --- /dev/null +++ b/src/components/backgroundTasks/types.ts @@ -0,0 +1,37 @@ +import type { WorkerKey } from "../../store"; + +export const WORKER_FOR_STAGE: Record = { + Thumbnails: "thumbnail", + Metadata: "metadata", + Embeddings: "embedding", + Tags: "tagging", +}; + +export interface TaskStage { + label: string; + detail: string; + progress: number | null; + failed: boolean; +} + +export interface BackgroundTask { + id: number; + name: string; + stages: TaskStage[]; + isActive: boolean; + hasFailedEmbeddings: boolean; + hasFailedTagging: boolean; + hasFailedCaptions: boolean; + pendingMediaWork: number; + embeddingProcessed: number; + embeddingTotal: number; + currentFile: string | null; + snapshot: string; +} + +export interface FailedWorkerItem { + image_id: number; + filename: string; + path: string; + error: string | null; +}