grid search
This commit is contained in:
parent
8b951ddf5c
commit
fd0ba11fa8
@ -19,7 +19,10 @@ Going beyond simple contact extraction, PAC-BOT provides structured, multi-dimen
|
||||
As part of its ongoing product roadmap, [Company Name] confirmed that PAC-BOT will soon evolve from a lead discovery engine into an automated global sales deployment. Upcoming features will enable the system to automatically generate hyper-tailored, localized outreach emails for any niche, in any language—allowing businesses to drop a pin anywhere in the world and instantly dispatch the perfect customized pitch.
|
||||
|
||||
|
||||
- [ ] gridsearch progress | pause | resume | settings : presets ( lang , overview, nearby, discover )
|
||||
### Todos
|
||||
|
||||
- [x] gridsearch progress | pause | resume
|
||||
- [ ] settings : presets ( lang , overview, nearby, discover )
|
||||
- [ ] share => noFilters | columns | filters
|
||||
- [ ] types => partial / fuzzy match | post filters => import contacts
|
||||
- [ ] report => email
|
||||
|
||||
@ -130,6 +130,16 @@ export const retryPlacesGridSearchJob = async (id: string): Promise<any> => {
|
||||
});
|
||||
};
|
||||
|
||||
export const postPlacesGridSearchControl = async (
|
||||
id: string,
|
||||
action: 'pause' | 'resume' | 'cancel',
|
||||
): Promise<{ ok: boolean }> => {
|
||||
return apiClient<{ ok: boolean }>(`/api/places/gridsearch/${id}/control`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
};
|
||||
|
||||
export const expandPlacesGridSearch = async (
|
||||
parentId: string,
|
||||
areas: { gid: string; name: string; level: number; raw?: any }[],
|
||||
|
||||
@ -1,6 +1,27 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
|
||||
/** Active = amber (in progress); finished = emerald (done). */
|
||||
function liveAreaFillColorExpr(isDark: boolean): maplibregl.ExpressionSpecification {
|
||||
return [
|
||||
'match',
|
||||
['get', 'liveAreaState'],
|
||||
'finished',
|
||||
isDark ? 'rgba(52, 211, 153, 0.26)' : 'rgba(16, 185, 129, 0.3)',
|
||||
isDark ? 'rgba(250, 204, 21, 0.22)' : 'rgba(234, 179, 8, 0.2)',
|
||||
];
|
||||
}
|
||||
|
||||
function liveAreaLineColorExpr(isDark: boolean): maplibregl.ExpressionSpecification {
|
||||
return [
|
||||
'match',
|
||||
['get', 'liveAreaState'],
|
||||
'finished',
|
||||
isDark ? '#34d399' : '#059669',
|
||||
isDark ? '#fbbf24' : '#ca8a04',
|
||||
];
|
||||
}
|
||||
|
||||
export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkStyle }: { map: maplibregl.Map, liveAreas: any[], liveRadii: any[], liveNodes: any[], isDarkStyle: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
@ -16,7 +37,7 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
|
||||
type: 'fill',
|
||||
source: 'live-areas',
|
||||
paint: {
|
||||
'fill-color': '#eab308',
|
||||
'fill-color': liveAreaFillColorExpr(isDarkStyle),
|
||||
'fill-opacity': 0.15
|
||||
}
|
||||
});
|
||||
@ -25,7 +46,7 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
|
||||
type: 'line',
|
||||
source: 'live-areas',
|
||||
paint: {
|
||||
'line-color': '#eab308',
|
||||
'line-color': liveAreaLineColorExpr(isDarkStyle),
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2]
|
||||
}
|
||||
@ -64,6 +85,17 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !map.getStyle()) return;
|
||||
if (!map.getLayer('live-areas-fill')) return;
|
||||
try {
|
||||
map.setPaintProperty('live-areas-fill', 'fill-color', liveAreaFillColorExpr(isDarkStyle));
|
||||
map.setPaintProperty('live-areas-line', 'line-color', liveAreaLineColorExpr(isDarkStyle));
|
||||
} catch {
|
||||
/* style swap race */
|
||||
}
|
||||
}, [map, isDarkStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !map.getStyle() || !map.getSource('live-areas')) return;
|
||||
|
||||
@ -73,7 +105,10 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
|
||||
if (geom) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { name: a.name },
|
||||
properties: {
|
||||
name: a.name,
|
||||
liveAreaState: a.liveAreaState === 'finished' ? 'finished' : 'active',
|
||||
},
|
||||
geometry: geom
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge } from 'lucide-react';
|
||||
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge, Pause, Play, Square } from 'lucide-react';
|
||||
|
||||
import { CompetitorsGridView } from '../PlacesGridView';
|
||||
import { PlacesMapView } from '../PlacesMapView';
|
||||
@ -8,7 +8,8 @@ import { PlacesThumbView } from '../PlacesThumbView';
|
||||
import { PlacesMetaView } from '../PlacesMetaView';
|
||||
import { PlacesReportView } from './PlacesReportView';
|
||||
import { useRestoredSearch } from './RestoredSearchContext';
|
||||
import { expandPlacesGridSearch } from '../client-gridsearch';
|
||||
import { expandPlacesGridSearch, postPlacesGridSearchControl } from '../client-gridsearch';
|
||||
import { useGridSearchStream, computeGridSearchOverallPercent } from './GridSearchStreamContext';
|
||||
import { POSTER_THEMES } from '../utils/poster-themes';
|
||||
|
||||
import { type PlaceFull } from '@polymech/shared';
|
||||
@ -65,6 +66,33 @@ import { UserPlus } from 'lucide-react';
|
||||
|
||||
|
||||
export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, onMergeSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) => {
|
||||
const { controlPaused, stats: streamStats, statusMessage: streamStatus } = useGridSearchStream();
|
||||
const overallPct = streaming ? computeGridSearchOverallPercent(streamStats) : null;
|
||||
const phaseLabel =
|
||||
streamStats.phase === 'enriching'
|
||||
? translate('Enrichment')
|
||||
: streamStats.phase === 'searching' || streamStats.phase === 'grid'
|
||||
? translate('Grid search')
|
||||
: streamStats.phase === 'complete'
|
||||
? translate('Done')
|
||||
: translate('Starting…');
|
||||
const [runControlBusy, setRunControlBusy] = useState(false);
|
||||
const controlInFlight = useRef(false);
|
||||
|
||||
const sendRunControl = useCallback(async (action: 'pause' | 'resume' | 'cancel') => {
|
||||
if (!streaming || controlInFlight.current) return;
|
||||
controlInFlight.current = true;
|
||||
setRunControlBusy(true);
|
||||
try {
|
||||
await postPlacesGridSearchControl(jobId, action);
|
||||
} catch (e) {
|
||||
console.error('Grid search control failed:', e);
|
||||
} finally {
|
||||
controlInFlight.current = false;
|
||||
setRunControlBusy(false);
|
||||
}
|
||||
}, [jobId, streaming]);
|
||||
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||
const [showImportToContactsDialog, setShowImportToContactsDialog] = useState(false);
|
||||
|
||||
@ -280,7 +308,44 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden w-full relative">
|
||||
<div className="flex justify-between items-center mb-4 mt-2 px-2">
|
||||
{streaming && (
|
||||
<div className="px-2 mt-2 mb-1 shrink-0" aria-label={translate('Overall progress')}>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">
|
||||
<span className="truncate">
|
||||
{phaseLabel}
|
||||
{streamStats.totalWaypoints > 0 && streamStats.phase !== 'enriching' && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{' '}
|
||||
· {streamStats.completedWaypoints}/{streamStats.totalWaypoints} {translate('cells')}
|
||||
</span>
|
||||
)}
|
||||
{streamStats.phase === 'enriching' && streamStats.enrichTotal > 0 && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{' '}
|
||||
· {streamStats.totalLocationsEnriched}/{streamStats.enrichTotal}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{overallPct != null && (
|
||||
<span className="tabular-nums text-gray-600 dark:text-gray-300">{overallPct}%</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ease-out ${
|
||||
overallPct == null ? 'bg-indigo-400/90 animate-pulse' : 'bg-indigo-600 dark:bg-indigo-500'
|
||||
}`}
|
||||
style={{ width: overallPct != null ? `${overallPct}%` : '36%' }}
|
||||
/>
|
||||
</div>
|
||||
{streamStatus && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 truncate mt-0.5" title={streamStatus}>
|
||||
{streamStatus}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex justify-between items-center mb-4 px-2 ${streaming ? 'mt-1' : 'mt-2'}`}>
|
||||
{/* Left: sidebar toggle + expand button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onToggleSidebar && (
|
||||
@ -292,6 +357,36 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
|
||||
{isSidebarOpen ? <PanelLeftClose className="h-4 w-4" /> : <PanelLeftOpen className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
{streaming && (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-amber-200 dark:border-amber-800/60 bg-amber-50/90 dark:bg-amber-950/40 px-1 py-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendRunControl(controlPaused ? 'resume' : 'pause')}
|
||||
disabled={runControlBusy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md text-amber-900 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/50 disabled:opacity-50"
|
||||
title={controlPaused ? translate('Resume') : translate('Pause')}
|
||||
>
|
||||
{runControlBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : controlPaused ? (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{controlPaused ? translate('Resume') : translate('Pause')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendRunControl('cancel')}
|
||||
disabled={runControlBusy}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-950/50 disabled:opacity-50"
|
||||
title={translate('Stop search')}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{translate('Stop')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isOwner && freshRegions.length > 0 && (
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
|
||||
@ -8,6 +8,8 @@ export type StreamPhase = 'idle' | 'grid' | 'searching' | 'enriching' | 'complet
|
||||
export interface GridSearchStreamState {
|
||||
streaming: boolean;
|
||||
connected: boolean;
|
||||
/** Worker acknowledged pause (UDS pause_ack). */
|
||||
controlPaused: boolean;
|
||||
statusMessage: string;
|
||||
competitors: any[];
|
||||
liveAreas: any[];
|
||||
@ -16,6 +18,8 @@ export interface GridSearchStreamState {
|
||||
stats: {
|
||||
completedWaypoints: number;
|
||||
totalWaypoints: number;
|
||||
/** From enrich-start (C++ `locationCount`). */
|
||||
enrichTotal: number;
|
||||
totalResults: number;
|
||||
apiCalls: number;
|
||||
totalLocationsEnriched: number;
|
||||
@ -27,9 +31,72 @@ export interface GridSearchStreamState {
|
||||
sseLogs: LogEntry[];
|
||||
}
|
||||
|
||||
const GS_PROGRESS_STORAGE_PREFIX = 'gs-progress-';
|
||||
|
||||
/** Overall 0–100 for the run; `null` = unknown / indeterminate (no grid yet). */
|
||||
export function computeGridSearchOverallPercent(
|
||||
stats: GridSearchStreamState['stats'],
|
||||
): number | null {
|
||||
const {
|
||||
phase,
|
||||
completedWaypoints,
|
||||
totalWaypoints,
|
||||
totalLocationsEnriched,
|
||||
enrichTotal,
|
||||
} = stats;
|
||||
|
||||
if (phase === 'complete') return 100;
|
||||
if (phase === 'failed') return null;
|
||||
|
||||
if (phase === 'enriching') {
|
||||
const et = enrichTotal > 0 ? enrichTotal : Math.max(1, totalLocationsEnriched || 1);
|
||||
const en = Math.min(totalLocationsEnriched, et);
|
||||
return Math.min(99, Math.round(60 + (en / et) * 39));
|
||||
}
|
||||
|
||||
if (totalWaypoints > 0) {
|
||||
const tw = totalWaypoints;
|
||||
const cw = Math.min(completedWaypoints, tw);
|
||||
return Math.round(5 + (cw / tw) * 55);
|
||||
}
|
||||
|
||||
if (phase === 'searching' || phase === 'grid') {
|
||||
return 5;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearStoredGridSearchProgress(jobId: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(`${GS_PROGRESS_STORAGE_PREFIX}${jobId}`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Last persisted % from an open tab (see provider sync). */
|
||||
export function readStoredGridSearchProgress(jobId: string): {
|
||||
pct: number;
|
||||
phase?: string;
|
||||
at: number;
|
||||
} | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${GS_PROGRESS_STORAGE_PREFIX}${jobId}`);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as { pct: number; phase?: string; at: number };
|
||||
if (typeof p.pct !== 'number' || typeof p.at !== 'number') return null;
|
||||
if (Date.now() - p.at > 120_000) return null;
|
||||
return p;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: GridSearchStreamState = {
|
||||
streaming: false,
|
||||
connected: false,
|
||||
controlPaused: false,
|
||||
statusMessage: 'Connecting...',
|
||||
competitors: [],
|
||||
liveAreas: [],
|
||||
@ -38,6 +105,7 @@ const initialState: GridSearchStreamState = {
|
||||
stats: {
|
||||
completedWaypoints: 0,
|
||||
totalWaypoints: 0,
|
||||
enrichTotal: 0,
|
||||
totalResults: 0,
|
||||
apiCalls: 0,
|
||||
totalLocationsEnriched: 0,
|
||||
@ -153,13 +221,20 @@ function applyEventToState(
|
||||
break;
|
||||
|
||||
case 'area-start':
|
||||
next.liveAreas = [...next.liveAreas, data];
|
||||
next.liveAreas = [...next.liveAreas, { ...data, liveAreaState: 'active' as const }];
|
||||
next.statusMessage = `Searching area: ${data?.name || '...'}`;
|
||||
break;
|
||||
|
||||
case 'area-finish':
|
||||
next.statusMessage = `Finished area: ${data?.area_gid || '...'}`;
|
||||
case 'area-finish': {
|
||||
const gid = data?.gid ?? data?.area_gid;
|
||||
if (gid) {
|
||||
next.liveAreas = next.liveAreas.map((a: any) =>
|
||||
a.gid === gid ? { ...a, liveAreaState: 'finished' as const } : a,
|
||||
);
|
||||
}
|
||||
next.statusMessage = `Finished area: ${gid || '...'}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'radius-start':
|
||||
case 'waypoint-start': {
|
||||
@ -237,8 +312,12 @@ function applyEventToState(
|
||||
}
|
||||
|
||||
case 'enrich-start':
|
||||
next.stats = { ...next.stats, phase: 'enriching' };
|
||||
next.statusMessage = `Enriching ${data?.locationCount || ''} locations...`;
|
||||
next.stats = {
|
||||
...next.stats,
|
||||
phase: 'enriching',
|
||||
enrichTotal: typeof data?.locationCount === 'number' ? data.locationCount : (next.stats.enrichTotal || 0),
|
||||
};
|
||||
next.statusMessage = `Enriching ${data?.locationCount ?? ''} locations...`;
|
||||
break;
|
||||
|
||||
case 'node-error':
|
||||
@ -254,7 +333,23 @@ function applyEventToState(
|
||||
totalResults: data?.totalResults ?? next.stats.totalResults,
|
||||
apiCalls: data?.freshApiCalls ?? next.stats.apiCalls,
|
||||
};
|
||||
next.statusMessage = 'Search Complete';
|
||||
next.statusMessage = data?.cancelled ? 'Stopped (partial results)' : 'Search Complete';
|
||||
next.controlPaused = false;
|
||||
next.liveAreas = next.liveAreas.map((a: any) => ({ ...a, liveAreaState: 'finished' as const }));
|
||||
break;
|
||||
|
||||
case 'pause_ack':
|
||||
next.controlPaused = true;
|
||||
next.statusMessage = 'Paused';
|
||||
break;
|
||||
|
||||
case 'resume_ack':
|
||||
next.controlPaused = false;
|
||||
next.statusMessage = 'Resumed';
|
||||
break;
|
||||
|
||||
case 'cancel_ack':
|
||||
next.statusMessage = 'Stopping…';
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
@ -276,7 +371,8 @@ const STREAM_EVENTS = [
|
||||
'progress', 'area-start', 'area-finish', 'waypoint-start',
|
||||
'waypoint-finish', 'radius-start', 'node-start', 'location',
|
||||
'node', 'node-error', 'nodePage', 'log', 'job_result', 'stats',
|
||||
'grid-ready', 'enrich-start', 'failed'
|
||||
'grid-ready', 'enrich-start', 'failed',
|
||||
'pause_ack', 'resume_ack', 'cancel_ack',
|
||||
] as const;
|
||||
|
||||
const GridSearchStreamContext = createContext<GridSearchStreamState>(initialState);
|
||||
@ -321,7 +417,7 @@ export function GridSearchStreamProvider({
|
||||
const url = getPlacesGridSearchStreamUrl(jobId, token);
|
||||
|
||||
es = new EventSource(url);
|
||||
setState(prev => ({ ...prev, streaming: true, connected: true }));
|
||||
setState(prev => ({ ...prev, streaming: true, connected: true, controlPaused: false }));
|
||||
|
||||
es.onopen = () => {
|
||||
if (isActive) setState(prev => ({ ...prev, connected: true, statusMessage: 'Stream connected' }));
|
||||
@ -331,12 +427,14 @@ export function GridSearchStreamProvider({
|
||||
|
||||
es.addEventListener('complete', () => {
|
||||
if (!isActive) return;
|
||||
clearStoredGridSearchProgress(jobId);
|
||||
setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: 'Search Complete', stats: { ...prev.stats, phase: 'complete' } }));
|
||||
es?.close();
|
||||
});
|
||||
|
||||
es.addEventListener('failed', (e: MessageEvent) => {
|
||||
if (!isActive) return;
|
||||
clearStoredGridSearchProgress(jobId);
|
||||
try {
|
||||
const parsed = JSON.parse(e.data);
|
||||
setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: parsed?.error || 'Job failed', stats: { ...prev.stats, phase: 'failed' } }));
|
||||
@ -357,6 +455,25 @@ export function GridSearchStreamProvider({
|
||||
return () => { isActive = false; es?.close(); };
|
||||
}, [jobId, active]);
|
||||
|
||||
// Persist coarse progress so the sidebar list can show a bar without opening SSE per row.
|
||||
useEffect(() => {
|
||||
if (!jobId || !state.streaming) return;
|
||||
const pct = computeGridSearchOverallPercent(state.stats);
|
||||
if (pct == null) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`${GS_PROGRESS_STORAGE_PREFIX}${jobId}`,
|
||||
JSON.stringify({
|
||||
pct,
|
||||
phase: state.stats.phase,
|
||||
at: Date.now(),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
}, [jobId, state.streaming, state.stats]);
|
||||
|
||||
return (
|
||||
<GridSearchStreamContext.Provider value={state}>
|
||||
{children}
|
||||
|
||||
@ -315,7 +315,7 @@ export const JobViewer = React.memo(({ jobId, isSidebarOpen, onToggleSidebar }:
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow min-h-0 bg-gray-50 dark:bg-gray-900 border-t dark:border-gray-700">
|
||||
<GridSearchStreamProvider jobId={jobId} active={isLocked} initialCompetitors={competitors}>
|
||||
<GridSearchStreamProvider key={jobId} jobId={jobId} active={isLocked} initialCompetitors={competitors}>
|
||||
<LiveGridSearchResults
|
||||
jobId={jobId}
|
||||
excludedTypes={excludedTypes}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchPlacesGridSearches, deletePlacesGridSearch, GridSearchSummary } from '../client-gridsearch';
|
||||
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock, GitBranch, MapPin, Mail } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchPlacesGridSearches, deletePlacesGridSearch, postPlacesGridSearchControl, GridSearchSummary } from '../client-gridsearch';
|
||||
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock, GitBranch, MapPin, Mail, CircleStop, Square } from 'lucide-react';
|
||||
import { readStoredGridSearchProgress } from './GridSearchStreamContext';
|
||||
import { getCurrentLang } from '@/i18n';
|
||||
|
||||
function getRelativeTime(dateString: string): string {
|
||||
@ -39,6 +40,9 @@ const StatusIcon = ({ status, isSelected }: { status: string; isSelected: boolea
|
||||
if (status === 'failed') {
|
||||
return <XCircle className="shrink-0 w-4 h-4 text-red-500" />;
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return <CircleStop className="shrink-0 w-4 h-4 text-amber-600 dark:text-amber-400" />;
|
||||
}
|
||||
return <CheckCircle className={`shrink-0 w-4 h-4 ${isSelected ? 'text-indigo-600 dark:text-indigo-400' : 'text-green-500'}`} />;
|
||||
};
|
||||
|
||||
@ -48,23 +52,30 @@ const SearchRow = ({
|
||||
isChild,
|
||||
onSelect,
|
||||
onDelete,
|
||||
storedProgressPct,
|
||||
onStopActive,
|
||||
}: {
|
||||
search: GridSearchSummary;
|
||||
isSelected: boolean;
|
||||
isChild?: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}) => (
|
||||
storedProgressPct: number | null;
|
||||
onStopActive?: (e: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const isActiveJob = search.status === 'searching' || search.status === 'enriching';
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`flex items-center justify-between cursor-pointer border-b dark:border-gray-800 transition-colors
|
||||
className={`flex flex-col cursor-pointer border-b dark:border-gray-800 transition-colors
|
||||
${isChild ? 'pl-7 pr-3 py-2' : 'p-3'}
|
||||
${isSelected
|
||||
? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-600'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-800 border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 overflow-hidden flex-1">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center space-x-3 overflow-hidden flex-1 min-w-0">
|
||||
{isChild ? (
|
||||
<GitBranch className="shrink-0 w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
|
||||
) : null}
|
||||
@ -112,6 +123,19 @@ const SearchRow = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center shrink-0 space-x-1">
|
||||
{isActiveJob && onStopActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStopActive(e);
|
||||
}}
|
||||
className="p-1.5 rounded-md transition-colors text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-950/40"
|
||||
title="Stop search"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 fill-current" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -127,11 +151,40 @@ const SearchRow = ({
|
||||
</button>
|
||||
{isSelected && <ChevronRight className="w-4 h-4 text-indigo-500 ml-1" />}
|
||||
</div>
|
||||
</div>
|
||||
{isActiveJob && (
|
||||
<div className="mt-1.5 pl-0 pr-1 flex items-center gap-2 w-full min-w-0">
|
||||
<div className="flex-1 h-1 rounded-full bg-gray-200 dark:bg-gray-600 overflow-hidden min-w-0">
|
||||
<div
|
||||
className={`h-full rounded-full bg-indigo-500 dark:bg-indigo-400 transition-all duration-500 ${
|
||||
storedProgressPct == null ? 'opacity-80 animate-pulse' : ''
|
||||
}`}
|
||||
style={{ width: storedProgressPct != null ? `${storedProgressPct}%` : '32%' }}
|
||||
/>
|
||||
</div>
|
||||
{storedProgressPct != null && (
|
||||
<span className="text-[10px] tabular-nums text-gray-500 dark:text-gray-400 shrink-0">{storedProgressPct}%</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }: { onSelectJob?: (jobId: string) => void, selectedJobId?: string | null, onSearchDeleted?: (jobId: string) => void }) => {
|
||||
const [pastSearches, setPastSearches] = useState<GridSearchSummary[]>([]);
|
||||
const [, bumpProgressRead] = useState(0);
|
||||
|
||||
const hasActiveInList = useMemo(() => {
|
||||
const active = (s: GridSearchSummary) => s.status === 'searching' || s.status === 'enriching';
|
||||
return pastSearches.some(s => active(s) || s.children?.some(active));
|
||||
}, [pastSearches]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveInList) return;
|
||||
const t = setInterval(() => bumpProgressRead(n => n + 1), 1500);
|
||||
return () => clearInterval(t);
|
||||
}, [hasActiveInList]);
|
||||
|
||||
const loadPast = async () => {
|
||||
try {
|
||||
@ -149,6 +202,16 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }:
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleStopSearch = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await postPlacesGridSearchControl(id, 'cancel');
|
||||
await loadPast();
|
||||
} catch (err) {
|
||||
console.error('Stop search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, parent?: string) => {
|
||||
if (confirm('Are you sure you want to delete this search?')) {
|
||||
try {
|
||||
@ -180,6 +243,12 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }:
|
||||
isSelected={selectedJobId === search.id}
|
||||
onSelect={() => onSelectJob && onSelectJob(search.id)}
|
||||
onDelete={() => handleDelete(search.id)}
|
||||
storedProgressPct={
|
||||
search.status === 'searching' || search.status === 'enriching'
|
||||
? readStoredGridSearchProgress(search.id)?.pct ?? null
|
||||
: null
|
||||
}
|
||||
onStopActive={(e) => handleStopSearch(e, search.id)}
|
||||
/>
|
||||
{search.children && search.children.length > 0 && search.children.map(child => (
|
||||
<SearchRow
|
||||
@ -189,6 +258,12 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }:
|
||||
isChild
|
||||
onSelect={() => onSelectJob && onSelectJob(child.id)}
|
||||
onDelete={() => handleDelete(child.id, search.id)}
|
||||
storedProgressPct={
|
||||
child.status === 'searching' || child.status === 'enriching'
|
||||
? readStoredGridSearchProgress(child.id)?.pct ?? null
|
||||
: null
|
||||
}
|
||||
onStopActive={(e) => handleStopSearch(e, child.id)}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
|
||||
@ -66,7 +66,7 @@ export function RestoredSearchProvider({
|
||||
|
||||
const status = state?.run?.status || '';
|
||||
const isLocked = status === 'searching' || status === 'enriching';
|
||||
const isComplete = status === 'complete';
|
||||
const isComplete = status === 'complete' || status === 'cancelled';
|
||||
|
||||
return (
|
||||
<RestoredSearchContext.Provider
|
||||
|
||||
Loading…
Reference in New Issue
Block a user