Add crawler scheduler, orchestrator, and multi-category intelligence

- Add scheduler UI with store schedules, job queue, and global settings
- Add store crawl orchestrator for intelligent crawl workflow
- Add multi-category intelligence detection (product, specials, brands, metadata)
- Add CrawlerLogger for structured JSON logging
- Add migrations for scheduler tables and dispensary linking
- Add dispensary → scheduler navigation link
- Support production/sandbox crawler modes per provider

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-11-30 09:29:15 -07:00
parent 8b4292fbb2
commit 3861a31a3b
25 changed files with 8874 additions and 13 deletions

View File

@@ -16,6 +16,7 @@ import { Settings } from './pages/Settings';
import { Proxies } from './pages/Proxies';
import { Logs } from './pages/Logs';
import { ScraperMonitor } from './pages/ScraperMonitor';
import { ScraperSchedule } from './pages/ScraperSchedule';
import { ScraperTools } from './pages/ScraperTools';
import { ChangeApproval } from './pages/ChangeApproval';
import { ApiPermissions } from './pages/ApiPermissions';
@@ -44,6 +45,7 @@ export default function App() {
<Route path="/logs" element={<PrivateRoute><Logs /></PrivateRoute>} />
<Route path="/scraper-tools" element={<PrivateRoute><ScraperTools /></PrivateRoute>} />
<Route path="/scraper-monitor" element={<PrivateRoute><ScraperMonitor /></PrivateRoute>} />
<Route path="/scraper-schedule" element={<PrivateRoute><ScraperSchedule /></PrivateRoute>} />
<Route path="/api-permissions" element={<PrivateRoute><ApiPermissions /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -11,6 +11,7 @@ import {
TrendingUp,
Wrench,
Activity,
Clock,
Shield,
FileText,
Settings,
@@ -147,6 +148,12 @@ export function Layout({ children }: LayoutProps) {
label="Tools"
isActive={isActive('/scraper-tools')}
/>
<NavLink
to="/scraper-schedule"
icon={<Clock className="w-4 h-4" />}
label="Schedule"
isActive={isActive('/scraper-schedule')}
/>
<NavLink
to="/scraper-monitor"
icon={<Activity className="w-4 h-4" />}

View File

@@ -423,6 +423,67 @@ class ApiClient {
method: 'DELETE',
});
}
// Crawler Schedule
async getGlobalSchedule() {
return this.request<{ schedules: any[] }>('/api/schedule/global');
}
async updateGlobalSchedule(type: string, data: { enabled?: boolean; interval_hours?: number; run_time?: string }) {
return this.request<{ schedule: any; message: string }>(`/api/schedule/global/${type}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async getStoreSchedules() {
return this.request<{ stores: any[] }>('/api/schedule/stores');
}
async getStoreSchedule(storeId: number) {
return this.request<{ schedule: any }>(`/api/schedule/stores/${storeId}`);
}
async updateStoreSchedule(storeId: number, data: any) {
return this.request<{ schedule: any }>(`/api/schedule/stores/${storeId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async getCrawlJobs(limit?: number) {
const params = limit ? `?limit=${limit}` : '';
return this.request<{ jobs: any[] }>(`/api/schedule/jobs${params}`);
}
async getStoreCrawlJobs(storeId: number, limit?: number) {
const params = limit ? `?limit=${limit}` : '';
return this.request<{ jobs: any[] }>(`/api/schedule/jobs/store/${storeId}${params}`);
}
async cancelCrawlJob(jobId: number) {
return this.request<{ success: boolean; message: string }>(`/api/schedule/jobs/${jobId}/cancel`, {
method: 'POST',
});
}
async triggerStoreCrawl(storeId: number) {
return this.request<{ job: any; message: string }>(`/api/schedule/trigger/store/${storeId}`, {
method: 'POST',
});
}
async triggerAllCrawls() {
return this.request<{ jobs_created: number; message: string }>('/api/schedule/trigger/all', {
method: 'POST',
});
}
async restartScheduler() {
return this.request<{ message: string }>('/api/schedule/restart', {
method: 'POST',
});
}
}
export const api = new ApiClient(API_URL);

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import {
@@ -15,7 +15,8 @@ import {
DollarSign,
Calendar,
RefreshCw,
ChevronDown
ChevronDown,
Clock
} from 'lucide-react';
export function DispensaryDetail() {
@@ -33,6 +34,19 @@ export function DispensaryDetail() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(25);
const formatDate = (dateStr: string) => {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
};
useEffect(() => {
loadDispensary();
}, [slug]);
@@ -274,6 +288,13 @@ export function DispensaryDetail() {
<span>AZDHS Profile</span>
</a>
)}
<Link
to="/schedule"
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
>
<Clock className="w-4 h-4" />
<span>View Schedule</span>
</Link>
</div>
</div>
@@ -424,7 +445,8 @@ export function DispensaryDetail() {
<th className="text-center">CBD %</th>
<th className="text-center">Strain Type</th>
<th className="text-center">In Stock</th>
<th>Link</th>
<th>Last Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -490,17 +512,28 @@ export function DispensaryDetail() {
<span className="badge badge-error badge-sm">No</span>
) : '-'}
</td>
<td className="whitespace-nowrap text-xs text-gray-500">
{product.updated_at ? formatDate(product.updated_at) : '-'}
</td>
<td>
{product.dutchie_url ? (
<a
href={product.dutchie_url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-xs btn-outline"
<div className="flex gap-1">
{product.dutchie_url && (
<a
href={product.dutchie_url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-xs btn-outline"
>
Dutchie
</a>
)}
<button
onClick={() => navigate(`/products/${product.id}`)}
className="btn btn-xs btn-primary"
>
View
</a>
) : '-'}
Details
</button>
</div>
</td>
</tr>
))}

View File

@@ -0,0 +1,723 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
interface GlobalSchedule {
id: number;
schedule_type: string;
enabled: boolean;
interval_hours?: number;
run_time?: string;
description?: string;
}
interface StoreSchedule {
store_id: number;
store_name: string;
store_slug: string;
timezone: string;
active: boolean;
scrape_enabled: boolean;
last_scraped_at: string | null;
schedule_enabled: boolean;
interval_hours: number;
daily_special_enabled: boolean;
daily_special_time: string;
priority: number;
next_scheduled_run: string;
latest_job_id: number | null;
latest_job_status: string | null;
latest_job_type: string | null;
latest_job_trigger: string | null;
latest_job_started: string | null;
latest_job_completed: string | null;
latest_products_found: number | null;
latest_products_new: number | null;
latest_products_updated: number | null;
latest_job_error: string | null;
// Dispensary info (from master AZDHS directory)
dispensary_id: number | null;
dispensary_name: string | null;
dispensary_company: string | null;
dispensary_city: string | null;
// Provider intelligence (from dispensary)
product_provider: string | null;
product_confidence: number | null;
product_crawler_mode: string | null;
// Orchestrator status
last_status: string | null;
last_summary: string | null;
schedule_last_run: string | null;
last_error: string | null;
}
interface CrawlJob {
id: number;
store_id: number;
store_name: string;
job_type: string;
trigger_type: string;
status: string;
priority: number;
scheduled_at: string;
started_at: string | null;
completed_at: string | null;
products_found: number | null;
products_new: number | null;
products_updated: number | null;
error_message: string | null;
}
export function ScraperSchedule() {
const [globalSchedules, setGlobalSchedules] = useState<GlobalSchedule[]>([]);
const [storeSchedules, setStoreSchedules] = useState<StoreSchedule[]>([]);
const [jobs, setJobs] = useState<CrawlJob[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<'stores' | 'jobs' | 'global'>('stores');
const [triggeringStore, setTriggeringStore] = useState<number | null>(null);
useEffect(() => {
loadData();
if (autoRefresh) {
const interval = setInterval(loadData, 5000);
return () => clearInterval(interval);
}
}, [autoRefresh]);
const loadData = async () => {
try {
const [globalData, storesData, jobsData] = await Promise.all([
api.getGlobalSchedule(),
api.getStoreSchedules(),
api.getCrawlJobs(100)
]);
setGlobalSchedules(globalData.schedules || []);
setStoreSchedules(storesData.stores || []);
setJobs(jobsData.jobs || []);
} catch (error) {
console.error('Failed to load schedule data:', error);
} finally {
setLoading(false);
}
};
const handleTriggerCrawl = async (storeId: number) => {
setTriggeringStore(storeId);
try {
await api.triggerStoreCrawl(storeId);
await loadData();
} catch (error) {
console.error('Failed to trigger crawl:', error);
} finally {
setTriggeringStore(null);
}
};
const handleTriggerAll = async () => {
if (!confirm('This will create crawl jobs for ALL active stores. Continue?')) return;
try {
const result = await api.triggerAllCrawls();
alert(`Created ${result.jobs_created} crawl jobs`);
await loadData();
} catch (error) {
console.error('Failed to trigger all crawls:', error);
}
};
const handleCancelJob = async (jobId: number) => {
try {
await api.cancelCrawlJob(jobId);
await loadData();
} catch (error) {
console.error('Failed to cancel job:', error);
}
};
const handleUpdateGlobalSchedule = async (type: string, data: any) => {
try {
await api.updateGlobalSchedule(type, data);
await loadData();
} catch (error) {
console.error('Failed to update global schedule:', error);
}
};
const formatTimeAgo = (dateString: string | null) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
const formatTimeUntil = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (diffMs < 0) return 'Overdue';
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
if (diffMins < 60) return `${diffMins}m`;
return `${diffHours}h ${diffMins % 60}m`;
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
case 'success': return { bg: '#d1fae5', color: '#065f46' };
case 'running': return { bg: '#dbeafe', color: '#1e40af' };
case 'failed':
case 'error': return { bg: '#fee2e2', color: '#991b1b' };
case 'cancelled': return { bg: '#f3f4f6', color: '#374151' };
case 'pending': return { bg: '#fef3c7', color: '#92400e' };
case 'sandbox_only': return { bg: '#e0e7ff', color: '#3730a3' };
case 'detection_only': return { bg: '#fce7f3', color: '#9d174d' };
default: return { bg: '#f3f4f6', color: '#374151' };
}
};
const getProviderBadge = (provider: string | null, mode: string | null) => {
if (!provider) return null;
const isProduction = mode === 'production';
return {
label: provider,
bg: isProduction ? '#d1fae5' : '#fef3c7',
color: isProduction ? '#065f46' : '#92400e',
suffix: isProduction ? '' : ' (sandbox)'
};
};
const globalIntervalSchedule = globalSchedules.find(s => s.schedule_type === 'global_interval');
const dailySpecialSchedule = globalSchedules.find(s => s.schedule_type === 'daily_special');
return (
<Layout>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '30px' }}>
<h1 style={{ fontSize: '32px', margin: 0 }}>Crawler Schedule</h1>
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>Auto-refresh (5s)</span>
</label>
<button
onClick={handleTriggerAll}
style={{
padding: '10px 20px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Crawl All Stores
</button>
</div>
</div>
{/* Tabs */}
<div style={{ marginBottom: '30px', display: 'flex', gap: '10px', borderBottom: '2px solid #eee' }}>
<button
onClick={() => setActiveTab('stores')}
style={{
padding: '12px 24px',
background: activeTab === 'stores' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'stores' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'stores' ? '600' : '400',
color: activeTab === 'stores' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Store Schedules
</button>
<button
onClick={() => setActiveTab('jobs')}
style={{
padding: '12px 24px',
background: activeTab === 'jobs' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'jobs' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'jobs' ? '600' : '400',
color: activeTab === 'jobs' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Job Queue ({jobs.filter(j => j.status === 'pending' || j.status === 'running').length})
</button>
<button
onClick={() => setActiveTab('global')}
style={{
padding: '12px 24px',
background: activeTab === 'global' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'global' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'global' ? '600' : '400',
color: activeTab === 'global' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Global Settings
</button>
</div>
{activeTab === 'global' && (
<div style={{ display: 'grid', gap: '20px' }}>
{/* Global Interval Schedule */}
<div style={{
background: 'white',
padding: '24px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '20px' }}>
<div>
<h2 style={{ fontSize: '20px', margin: 0, marginBottom: '8px' }}>Interval Crawl Schedule</h2>
<p style={{ color: '#666', margin: 0 }}>Crawl all stores periodically</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<span style={{ color: '#666' }}>Enabled</span>
<input
type="checkbox"
checked={globalIntervalSchedule?.enabled ?? true}
onChange={(e) => handleUpdateGlobalSchedule('global_interval', { enabled: e.target.checked })}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span>Crawl every</span>
<select
value={globalIntervalSchedule?.interval_hours ?? 4}
onChange={(e) => handleUpdateGlobalSchedule('global_interval', { interval_hours: parseInt(e.target.value) })}
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '16px'
}}
>
<option value={1}>1 hour</option>
<option value={2}>2 hours</option>
<option value={4}>4 hours</option>
<option value={6}>6 hours</option>
<option value={8}>8 hours</option>
<option value={12}>12 hours</option>
<option value={24}>24 hours</option>
</select>
</label>
</div>
</div>
{/* Daily Special Schedule */}
<div style={{
background: 'white',
padding: '24px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '20px' }}>
<div>
<h2 style={{ fontSize: '20px', margin: 0, marginBottom: '8px' }}>Daily Special Crawl</h2>
<p style={{ color: '#666', margin: 0 }}>Crawl stores at local midnight to capture daily specials</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<span style={{ color: '#666' }}>Enabled</span>
<input
type="checkbox"
checked={dailySpecialSchedule?.enabled ?? true}
onChange={(e) => handleUpdateGlobalSchedule('daily_special', { enabled: e.target.checked })}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span>Run at</span>
<input
type="time"
value={dailySpecialSchedule?.run_time?.slice(0, 5) ?? '00:01'}
onChange={(e) => handleUpdateGlobalSchedule('daily_special', { run_time: e.target.value })}
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '16px'
}}
/>
<span style={{ color: '#666' }}>(store local time)</span>
</label>
</div>
</div>
</div>
)}
{activeTab === 'stores' && (
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Dispensary / Store</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Provider</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Schedule</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Last Run</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Next Run</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Last Result</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Actions</th>
</tr>
</thead>
<tbody>
{storeSchedules.map((store) => (
<tr key={store.store_id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '15px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{store.dispensary_id ? (
<Link
to={`/dispensaries/${store.dispensary_id}`}
style={{
fontWeight: '600',
color: '#2563eb',
textDecoration: 'none'
}}
>
{store.dispensary_name || store.store_name}
</Link>
) : (
<span style={{ fontWeight: '600' }}>{store.store_name}</span>
)}
{!store.dispensary_id && (
<span style={{
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
fontWeight: '600',
background: '#fef3c7',
color: '#92400e'
}}>
Unmapped
</span>
)}
</div>
<div style={{ fontSize: '13px', color: '#666' }}>
{store.dispensary_city ? `${store.dispensary_city} | ${store.timezone}` : store.timezone}
</div>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
{store.product_provider ? (
<div>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
background: store.product_crawler_mode === 'production' ? '#d1fae5' : '#fef3c7',
color: store.product_crawler_mode === 'production' ? '#065f46' : '#92400e'
}}>
{store.product_provider}
</span>
{store.product_crawler_mode !== 'production' && (
<div style={{ fontSize: '10px', color: '#92400e', marginTop: '2px' }}>sandbox</div>
)}
</div>
) : (
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
background: '#f3f4f6',
color: '#666'
}}>
Unknown
</span>
)}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
background: store.schedule_enabled && store.scrape_enabled ? '#d1fae5' : '#fee2e2',
color: store.schedule_enabled && store.scrape_enabled ? '#065f46' : '#991b1b'
}}>
{store.schedule_enabled && store.scrape_enabled ? 'Active' : 'Disabled'}
</span>
<span style={{ fontSize: '12px', color: '#666' }}>
Every {store.interval_hours}h
</span>
</div>
</td>
<td style={{ padding: '15px' }}>
<div>{formatTimeAgo(store.last_scraped_at)}</div>
{store.last_scraped_at && (
<div style={{ fontSize: '12px', color: '#999' }}>
{new Date(store.last_scraped_at).toLocaleString()}
</div>
)}
</td>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600', color: '#2563eb' }}>
{formatTimeUntil(store.next_scheduled_run)}
</div>
</td>
<td style={{ padding: '15px' }}>
{store.last_status || store.latest_job_status ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
...getStatusColor(store.last_status || store.latest_job_status || 'pending')
}}>
{store.last_status || store.latest_job_status}
</span>
{store.last_error && (
<button
onClick={() => alert(store.last_error)}
style={{
padding: '2px 6px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '10px'
}}
>
Error
</button>
)}
</div>
{store.last_summary ? (
<div style={{ fontSize: '12px', color: '#666', maxWidth: '250px' }}>
{store.last_summary}
</div>
) : store.latest_products_found !== null ? (
<div style={{ fontSize: '12px', color: '#666' }}>
{store.latest_products_found} products
{store.latest_products_new !== null && ` (+${store.latest_products_new} new)`}
</div>
) : null}
</div>
) : (
<span style={{ color: '#999', fontSize: '13px' }}>No runs yet</span>
)}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<button
onClick={() => handleTriggerCrawl(store.store_id)}
disabled={triggeringStore === store.store_id}
style={{
padding: '6px 12px',
background: triggeringStore === store.store_id ? '#94a3b8' : '#2563eb',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: triggeringStore === store.store_id ? 'wait' : 'pointer',
fontSize: '13px'
}}
>
{triggeringStore === store.store_id ? 'Starting...' : 'Run Now'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === 'jobs' && (
<>
{/* Job Stats */}
<div style={{ marginBottom: '30px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '15px' }}>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Pending</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#f59e0b' }}>
{jobs.filter(j => j.status === 'pending').length}
</div>
</div>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Running</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#3b82f6' }}>
{jobs.filter(j => j.status === 'running').length}
</div>
</div>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Completed</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#10b981' }}>
{jobs.filter(j => j.status === 'completed').length}
</div>
</div>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Failed</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#ef4444' }}>
{jobs.filter(j => j.status === 'failed').length}
</div>
</div>
</div>
</div>
{/* Jobs Table */}
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Store</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Type</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Trigger</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Status</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Products</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Started</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Completed</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Actions</th>
</tr>
</thead>
<tbody>
{jobs.length === 0 ? (
<tr>
<td colSpan={8} style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
No crawl jobs found
</td>
</tr>
) : (
jobs.map((job) => (
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600' }}>{job.store_name}</div>
<div style={{ fontSize: '12px', color: '#999' }}>Job #{job.id}</div>
</td>
<td style={{ padding: '15px', textAlign: 'center', fontSize: '13px' }}>
{job.job_type}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{
padding: '3px 8px',
borderRadius: '4px',
fontSize: '12px',
background: job.trigger_type === 'manual' ? '#e0e7ff' :
job.trigger_type === 'daily_special' ? '#fce7f3' : '#f3f4f6',
color: job.trigger_type === 'manual' ? '#3730a3' :
job.trigger_type === 'daily_special' ? '#9d174d' : '#374151'
}}>
{job.trigger_type}
</span>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
...getStatusColor(job.status)
}}>
{job.status}
</span>
</td>
<td style={{ padding: '15px', textAlign: 'right' }}>
{job.products_found !== null ? (
<div>
<div style={{ fontWeight: '600' }}>{job.products_found}</div>
{job.products_new !== null && job.products_updated !== null && (
<div style={{ fontSize: '12px', color: '#666' }}>
+{job.products_new} / ~{job.products_updated}
</div>
)}
</div>
) : '-'}
</td>
<td style={{ padding: '15px', fontSize: '13px' }}>
{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}
</td>
<td style={{ padding: '15px', fontSize: '13px' }}>
{job.completed_at ? new Date(job.completed_at).toLocaleString() : '-'}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
{job.status === 'pending' && (
<button
onClick={() => handleCancelJob(job.id)}
style={{
padding: '4px 10px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
)}
{job.error_message && (
<button
onClick={() => alert(job.error_message)}
style={{
padding: '4px 10px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
View Error
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
)}
</div>
</Layout>
);
}