diff --git a/admin-frontend/src/api/faces.ts b/admin-frontend/src/api/faces.ts index 09d4a4d..e6f91d9 100644 --- a/admin-frontend/src/api/faces.ts +++ b/admin-frontend/src/api/faces.ts @@ -278,8 +278,15 @@ export const facesApi = { const response = await apiClient.post('/api/v1/faces/batch-unmatch', payload) return response.data }, - autoMatch: async (request: AutoMatchRequest): Promise => { - const response = await apiClient.post('/api/v1/faces/auto-match', request) + autoMatch: async ( + request: AutoMatchRequest, + options?: { signal?: AbortSignal } + ): Promise => { + const response = await apiClient.post( + '/api/v1/faces/auto-match', + request, + { signal: options?.signal } + ) return response.data }, getAutoMatchPeople: async (params?: { diff --git a/admin-frontend/src/pages/AutoMatch.tsx b/admin-frontend/src/pages/AutoMatch.tsx index 94bd3c5..3321359 100644 --- a/admin-frontend/src/pages/AutoMatch.tsx +++ b/admin-frontend/src/pages/AutoMatch.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useRef } from 'react' +import axios from 'axios' import facesApi, { AutoMatchPersonSummary, AutoMatchFaceItem @@ -33,6 +34,8 @@ export default function AutoMatch() { const [selectedFaces, setSelectedFaces] = useState>({}) const [originalSelectedFaces, setOriginalSelectedFaces] = useState>({}) const [busy, setBusy] = useState(false) + const [runElapsedSec, setRunElapsedSec] = useState(0) + const autoMatchAbortRef = useRef(null) const [saving, setSaving] = useState(false) const [hasNoResults, setHasNoResults] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) @@ -41,6 +44,25 @@ export default function AutoMatch() { const STATE_KEY = 'automatch_state' const SETTINGS_KEY = 'automatch_settings' + useEffect(() => { + if (!busy) { + setRunElapsedSec(0) + return + } + const started = Date.now() + const timer = window.setInterval(() => { + setRunElapsedSec(Math.floor((Date.now() - started) / 1000)) + }, 1000) + return () => window.clearInterval(timer) + }, [busy]) + + const cancelAutoMatch = () => { + autoMatchAbortRef.current?.abort() + autoMatchAbortRef.current = null + setBusy(false) + showToast('Auto-match cancelled', 'info') + } + // Track if initial load has happened const initialLoadRef = useRef(false) // Track if settings have been loaded from sessionStorage @@ -501,13 +523,15 @@ export default function AutoMatch() { } setBusy(true) + const controller = new AbortController() + autoMatchAbortRef.current = controller try { const response = await facesApi.autoMatch({ tolerance: RUN_AUTO_MATCH_TOLERANCE, // Use 0.5 for Run auto-match button (stricter) auto_accept: true, auto_accept_threshold: autoAcceptThreshold, use_distance_based_thresholds: true // Enable distance-based thresholds for Run auto-match button - }) + }, { signal: controller.signal }) // Show summary if auto-accept was performed if (response.auto_accepted) { @@ -533,7 +557,6 @@ export default function AutoMatch() { setPeople([]) setFilteredPeople([]) setIsActive(false) - setBusy(false) return } @@ -545,9 +568,13 @@ export default function AutoMatch() { setOriginalSelectedFaces({}) setIsActive(true) } catch (error) { + if (axios.isCancel(error)) { + return + } console.error('Auto-match failed:', error) showToast('Failed to start auto-match. Please try again.', 'error') } finally { + autoMatchAbortRef.current = null setBusy(false) } } @@ -796,6 +823,15 @@ export default function AutoMatch() { > {busy ? 'Processing...' : hasNoResults ? 'No Matches Available' : '🚀 Run Auto-Match'} + {busy && ( + + )} {isDeveloperMode && (
@@ -814,6 +850,16 @@ export default function AutoMatch() {
)} + {busy && ( +
+
+
+
+

+ Running auto-match across the library… {runElapsedSec}s elapsed +

+
+ )}
ℹ️ Auto-Match Criteria: Only faces with similarity higher than 85% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.