fix(monitor): remove non-existent worker columns from job_run_logs query
The job_run_logs table tracks scheduled job orchestration, not individual worker jobs. Worker info (worker_id, worker_hostname) belongs on dispensary_crawl_jobs, not job_run_logs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import { ApiPermissions } from './pages/ApiPermissions';
|
||||
import { DutchieAZSchedule } from './pages/DutchieAZSchedule';
|
||||
import { DutchieAZStores } from './pages/DutchieAZStores';
|
||||
import { DutchieAZStoreDetail } from './pages/DutchieAZStoreDetail';
|
||||
import { WholesaleAnalytics } from './pages/WholesaleAnalytics';
|
||||
import { PrivateRoute } from './components/PrivateRoute';
|
||||
|
||||
export default function App() {
|
||||
@@ -53,6 +54,7 @@ export default function App() {
|
||||
<Route path="/az" element={<PrivateRoute><DutchieAZStores /></PrivateRoute>} />
|
||||
<Route path="/az/stores/:id" element={<PrivateRoute><DutchieAZStoreDetail /></PrivateRoute>} />
|
||||
<Route path="/api-permissions" element={<PrivateRoute><ApiPermissions /></PrivateRoute>} />
|
||||
<Route path="/wholesale-analytics" element={<PrivateRoute><WholesaleAnalytics /></PrivateRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -164,6 +164,12 @@ export function Layout({ children }: LayoutProps) {
|
||||
</NavSection>
|
||||
|
||||
<NavSection title="AZ Data">
|
||||
<NavLink
|
||||
to="/wholesale-analytics"
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
label="Wholesale Analytics"
|
||||
isActive={isActive('/wholesale-analytics')}
|
||||
/>
|
||||
<NavLink
|
||||
to="/az"
|
||||
icon={<Store className="w-4 h-4" />}
|
||||
|
||||
@@ -344,6 +344,56 @@ class ApiClient {
|
||||
return this.request<any>(`/api/scraper-monitor/jobs/workers${params}`);
|
||||
}
|
||||
|
||||
// AZ Monitor (Dutchie AZ live crawler status)
|
||||
async getAZMonitorActiveJobs() {
|
||||
return this.request<{
|
||||
scheduledJobs: any[];
|
||||
crawlJobs: any[];
|
||||
inMemoryScrapers: any[];
|
||||
totalActive: number;
|
||||
}>('/api/az/monitor/active-jobs');
|
||||
}
|
||||
|
||||
async getAZMonitorRecentJobs(limit?: number) {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return this.request<{
|
||||
jobLogs: any[];
|
||||
crawlJobs: any[];
|
||||
}>(`/api/az/monitor/recent-jobs${params}`);
|
||||
}
|
||||
|
||||
async getAZMonitorErrors(params?: { limit?: number; hours?: number }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
||||
if (params?.hours) searchParams.append('hours', params.hours.toString());
|
||||
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||
return this.request<{ errors: any[] }>(`/api/az/monitor/errors${queryString}`);
|
||||
}
|
||||
|
||||
async getAZMonitorSummary() {
|
||||
return this.request<{
|
||||
running_scheduled_jobs: number;
|
||||
running_crawl_jobs: number;
|
||||
successful_jobs_24h: number;
|
||||
failed_jobs_24h: number;
|
||||
successful_crawls_24h: number;
|
||||
failed_crawls_24h: number;
|
||||
products_found_24h: number;
|
||||
snapshots_created_24h: number;
|
||||
last_job_started: string | null;
|
||||
last_job_completed: string | null;
|
||||
nextRuns: Array<{
|
||||
id: number;
|
||||
job_name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
next_run_at: string;
|
||||
last_status: string;
|
||||
last_run_at: string;
|
||||
}>;
|
||||
}>('/api/az/monitor/summary');
|
||||
}
|
||||
|
||||
// Change Approval
|
||||
async getChanges(status?: 'pending' | 'approved' | 'rejected') {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
@@ -402,7 +452,7 @@ class ApiClient {
|
||||
return this.request<{ dispensaries: Array<{ id: number; name: string }> }>('/api/api-permissions/dispensaries');
|
||||
}
|
||||
|
||||
async createApiPermission(data: { user_name: string; dispensary_id: number; allowed_ips?: string; allowed_domains?: string }) {
|
||||
async createApiPermission(data: { user_name: string; store_id: number; allowed_ips?: string; allowed_domains?: string }) {
|
||||
return this.request<{ permission: any; message: string }>('/api/api-permissions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
@@ -828,6 +878,39 @@ class ApiClient {
|
||||
}>(`/api/az/stores/${id}/categories`);
|
||||
}
|
||||
|
||||
// Dutchie AZ Global Brands/Categories (from v_brands/v_categories views)
|
||||
async getDutchieAZBrands(params?: { limit?: number; offset?: number }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
||||
if (params?.offset) searchParams.append('offset', params.offset.toString());
|
||||
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||
return this.request<{
|
||||
brands: Array<{
|
||||
brand_name: string;
|
||||
brand_id: string | null;
|
||||
brand_logo_url: string | null;
|
||||
product_count: number;
|
||||
dispensary_count: number;
|
||||
product_types: string[];
|
||||
}>;
|
||||
}>(`/api/az/brands${queryString}`);
|
||||
}
|
||||
|
||||
async getDutchieAZCategories() {
|
||||
return this.request<{
|
||||
categories: Array<{
|
||||
type: string;
|
||||
subcategory: string | null;
|
||||
product_count: number;
|
||||
dispensary_count: number;
|
||||
brand_count: number;
|
||||
avg_thc: number | null;
|
||||
min_thc: number | null;
|
||||
max_thc: number | null;
|
||||
}>;
|
||||
}>('/api/az/categories');
|
||||
}
|
||||
|
||||
// Dutchie AZ Debug
|
||||
async getDutchieAZDebugSummary() {
|
||||
return this.request<{
|
||||
@@ -893,6 +976,65 @@ class ApiClient {
|
||||
body: JSON.stringify(options || {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Dutchie AZ Menu Detection
|
||||
async getDetectionStats() {
|
||||
return this.request<{
|
||||
totalDispensaries: number;
|
||||
withMenuType: number;
|
||||
withPlatformId: number;
|
||||
needsDetection: number;
|
||||
byProvider: Record<string, number>;
|
||||
}>('/api/az/admin/detection/stats');
|
||||
}
|
||||
|
||||
async getDispensariesNeedingDetection(params?: { state?: string; limit?: number }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.state) searchParams.append('state', params.state);
|
||||
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
||||
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||
return this.request<{ dispensaries: any[]; total: number }>(`/api/az/admin/detection/pending${queryString}`);
|
||||
}
|
||||
|
||||
async detectDispensary(id: number) {
|
||||
return this.request<{
|
||||
dispensaryId: number;
|
||||
dispensaryName: string;
|
||||
previousMenuType: string | null;
|
||||
detectedProvider: string;
|
||||
cName: string | null;
|
||||
platformDispensaryId: string | null;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>(`/api/az/admin/detection/detect/${id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async detectAllDispensaries(options?: {
|
||||
state?: string;
|
||||
onlyUnknown?: boolean;
|
||||
onlyMissingPlatformId?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
return this.request<{
|
||||
totalProcessed: number;
|
||||
totalSucceeded: number;
|
||||
totalFailed: number;
|
||||
totalSkipped: number;
|
||||
results: any[];
|
||||
errors: string[];
|
||||
}>('/api/az/admin/detection/detect-all', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options || {}),
|
||||
});
|
||||
}
|
||||
|
||||
async triggerMenuDetectionJob() {
|
||||
return this.request<{ success: boolean; message: string }>('/api/az/admin/detection/trigger', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_URL);
|
||||
|
||||
@@ -12,8 +12,8 @@ interface ApiPermission {
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
dispensary_id: number | null;
|
||||
dispensary_name: string | null;
|
||||
store_id: number | null;
|
||||
store_name: string | null;
|
||||
}
|
||||
|
||||
interface Dispensary {
|
||||
@@ -28,7 +28,7 @@ export function ApiPermissions() {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newPermission, setNewPermission] = useState({
|
||||
user_name: '',
|
||||
dispensary_id: '',
|
||||
store_id: '',
|
||||
allowed_ips: '',
|
||||
allowed_domains: '',
|
||||
});
|
||||
@@ -68,18 +68,18 @@ export function ApiPermissions() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPermission.dispensary_id) {
|
||||
setNotification({ message: 'Dispensary is required', type: 'error' });
|
||||
if (!newPermission.store_id) {
|
||||
setNotification({ message: 'Store is required', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.createApiPermission({
|
||||
...newPermission,
|
||||
dispensary_id: parseInt(newPermission.dispensary_id),
|
||||
store_id: parseInt(newPermission.store_id),
|
||||
});
|
||||
setNotification({ message: result.message, type: 'success' });
|
||||
setNewPermission({ user_name: '', dispensary_id: '', allowed_ips: '', allowed_domains: '' });
|
||||
setNewPermission({ user_name: '', store_id: '', allowed_ips: '', allowed_domains: '' });
|
||||
setShowAddForm(false);
|
||||
loadPermissions();
|
||||
} catch (error: any) {
|
||||
@@ -182,22 +182,22 @@ export function ApiPermissions() {
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dispensary *
|
||||
Store *
|
||||
</label>
|
||||
<select
|
||||
value={newPermission.dispensary_id}
|
||||
onChange={(e) => setNewPermission({ ...newPermission, dispensary_id: e.target.value })}
|
||||
value={newPermission.store_id}
|
||||
onChange={(e) => setNewPermission({ ...newPermission, store_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select a dispensary...</option>
|
||||
<option value="">Select a store...</option>
|
||||
{dispensaries.map((dispensary) => (
|
||||
<option key={dispensary.id} value={dispensary.id}>
|
||||
{dispensary.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-sm text-gray-600 mt-1">The dispensary this API token can access</p>
|
||||
<p className="text-sm text-gray-600 mt-1">The store this API token can access</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -261,7 +261,7 @@ export function ApiPermissions() {
|
||||
User Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Dispensary
|
||||
Store
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
API Key
|
||||
@@ -290,7 +290,7 @@ export function ApiPermissions() {
|
||||
<div className="font-medium text-gray-900">{perm.user_name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{perm.dispensary_name || <span className="text-gray-400 italic">No dispensary</span>}</div>
|
||||
<div className="text-sm text-gray-900">{perm.store_name || <span className="text-gray-400 italic">No store</span>}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -35,15 +35,26 @@ interface RunLog {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface DetectionStats {
|
||||
totalDispensaries: number;
|
||||
withMenuType: number;
|
||||
withPlatformId: number;
|
||||
needsDetection: number;
|
||||
byProvider: Record<string, number>;
|
||||
}
|
||||
|
||||
export function DutchieAZSchedule() {
|
||||
const [schedules, setSchedules] = useState<JobSchedule[]>([]);
|
||||
const [runLogs, setRunLogs] = useState<RunLog[]>([]);
|
||||
const [schedulerStatus, setSchedulerStatus] = useState<{ running: boolean; pollIntervalMs: number } | null>(null);
|
||||
const [detectionStats, setDetectionStats] = useState<DetectionStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'schedules' | 'logs'>('schedules');
|
||||
const [activeTab, setActiveTab] = useState<'schedules' | 'logs' | 'detection'>('schedules');
|
||||
const [editingSchedule, setEditingSchedule] = useState<JobSchedule | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [detectingAll, setDetectingAll] = useState(false);
|
||||
const [detectionResults, setDetectionResults] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -56,15 +67,17 @@ export function DutchieAZSchedule() {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [schedulesData, logsData, statusData] = await Promise.all([
|
||||
const [schedulesData, logsData, statusData, detectionData] = await Promise.all([
|
||||
api.getDutchieAZSchedules(),
|
||||
api.getDutchieAZRunLogs({ limit: 50 }),
|
||||
api.getDutchieAZSchedulerStatus(),
|
||||
api.getDetectionStats().catch(() => null),
|
||||
]);
|
||||
|
||||
setSchedules(schedulesData.schedules || []);
|
||||
setRunLogs(logsData.logs || []);
|
||||
setSchedulerStatus(statusData);
|
||||
setDetectionStats(detectionData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load schedule data:', error);
|
||||
} finally {
|
||||
@@ -139,6 +152,36 @@ export function DutchieAZSchedule() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetectAll = async () => {
|
||||
if (!confirm('Run menu detection on all dispensaries with unknown/missing menu_type?')) return;
|
||||
setDetectingAll(true);
|
||||
setDetectionResults(null);
|
||||
try {
|
||||
const result = await api.detectAllDispensaries({ state: 'AZ', onlyUnknown: true });
|
||||
setDetectionResults(result);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to run bulk detection:', error);
|
||||
} finally {
|
||||
setDetectingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetectMissingIds = async () => {
|
||||
if (!confirm('Resolve platform IDs for all Dutchie dispensaries missing them?')) return;
|
||||
setDetectingAll(true);
|
||||
setDetectionResults(null);
|
||||
try {
|
||||
const result = await api.detectAllDispensaries({ state: 'AZ', onlyMissingPlatformId: true, onlyUnknown: false });
|
||||
setDetectionResults(result);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve platform IDs:', error);
|
||||
} finally {
|
||||
setDetectingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
@@ -334,6 +377,22 @@ export function DutchieAZSchedule() {
|
||||
>
|
||||
Run Logs ({runLogs.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('detection')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: activeTab === 'detection' ? 'white' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'detection' ? '3px solid #2563eb' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: activeTab === 'detection' ? '600' : '400',
|
||||
color: activeTab === 'detection' ? '#2563eb' : '#666',
|
||||
marginBottom: '-2px'
|
||||
}}
|
||||
>
|
||||
Menu Detection {detectionStats?.needsDetection ? `(${detectionStats.needsDetection} pending)` : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'schedules' && (
|
||||
@@ -574,6 +633,150 @@ export function DutchieAZSchedule() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'detection' && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
padding: '30px'
|
||||
}}>
|
||||
{/* Detection Stats */}
|
||||
{detectionStats && (
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{ margin: '0 0 20px 0' }}>Detection Statistics</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '20px' }}>
|
||||
<div style={{ padding: '20px', background: '#f8f8f8', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#2563eb' }}>{detectionStats.totalDispensaries}</div>
|
||||
<div style={{ color: '#666', marginTop: '4px' }}>Total Dispensaries</div>
|
||||
</div>
|
||||
<div style={{ padding: '20px', background: '#f8f8f8', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#10b981' }}>{detectionStats.withMenuType}</div>
|
||||
<div style={{ color: '#666', marginTop: '4px' }}>With Menu Type</div>
|
||||
</div>
|
||||
<div style={{ padding: '20px', background: '#f8f8f8', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#10b981' }}>{detectionStats.withPlatformId}</div>
|
||||
<div style={{ color: '#666', marginTop: '4px' }}>With Platform ID</div>
|
||||
</div>
|
||||
<div style={{ padding: '20px', background: '#fef3c7', borderRadius: '8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e' }}>{detectionStats.needsDetection}</div>
|
||||
<div style={{ color: '#666', marginTop: '4px' }}>Needs Detection</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Breakdown */}
|
||||
{Object.keys(detectionStats.byProvider).length > 0 && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<h4 style={{ margin: '0 0 10px 0' }}>By Provider</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px' }}>
|
||||
{Object.entries(detectionStats.byProvider).map(([provider, count]) => (
|
||||
<span key={provider} style={{
|
||||
padding: '6px 14px',
|
||||
background: provider === 'dutchie' ? '#dbeafe' : '#f3f4f6',
|
||||
borderRadius: '16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{provider}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ marginBottom: '30px', display: 'flex', gap: '15px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleDetectAll}
|
||||
disabled={detectingAll || !detectionStats?.needsDetection}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: detectingAll ? '#94a3b8' : '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: detectingAll ? 'not-allowed' : 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{detectingAll ? 'Detecting...' : 'Detect All Unknown'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDetectMissingIds}
|
||||
disabled={detectingAll}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: detectingAll ? '#94a3b8' : '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: detectingAll ? 'not-allowed' : 'pointer',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{detectingAll ? 'Resolving...' : 'Resolve Missing Platform IDs'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Detection Results */}
|
||||
{detectionResults && (
|
||||
<div style={{ marginBottom: '30px', padding: '20px', background: '#f8f8f8', borderRadius: '8px' }}>
|
||||
<h4 style={{ margin: '0 0 15px 0' }}>Detection Results</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '15px', marginBottom: '15px' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', fontSize: '24px' }}>{detectionResults.totalProcessed}</div>
|
||||
<div style={{ color: '#666', fontSize: '13px' }}>Processed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', fontSize: '24px', color: '#10b981' }}>{detectionResults.totalSucceeded}</div>
|
||||
<div style={{ color: '#666', fontSize: '13px' }}>Succeeded</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', fontSize: '24px', color: '#ef4444' }}>{detectionResults.totalFailed}</div>
|
||||
<div style={{ color: '#666', fontSize: '13px' }}>Failed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', fontSize: '24px', color: '#666' }}>{detectionResults.totalSkipped}</div>
|
||||
<div style={{ color: '#666', fontSize: '13px' }}>Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
{detectionResults.errors && detectionResults.errors.length > 0 && (
|
||||
<div style={{ marginTop: '15px' }}>
|
||||
<div style={{ fontWeight: '600', marginBottom: '8px', color: '#991b1b' }}>Errors:</div>
|
||||
<div style={{ maxHeight: '150px', overflow: 'auto', background: '#fee2e2', padding: '10px', borderRadius: '4px', fontSize: '12px' }}>
|
||||
{detectionResults.errors.slice(0, 10).map((error: string, i: number) => (
|
||||
<div key={i} style={{ marginBottom: '4px' }}>{error}</div>
|
||||
))}
|
||||
{detectionResults.errors.length > 10 && (
|
||||
<div style={{ fontStyle: 'italic', marginTop: '8px' }}>...and {detectionResults.errors.length - 10} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ padding: '20px', background: '#f0f9ff', borderRadius: '8px', fontSize: '14px' }}>
|
||||
<h4 style={{ margin: '0 0 10px 0', color: '#1e40af' }}>About Menu Detection</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#1e40af' }}>
|
||||
<li style={{ marginBottom: '8px' }}>
|
||||
<strong>Detect All Unknown:</strong> Scans dispensaries with no menu_type set and detects the provider (dutchie, treez, jane, etc.) from their menu_url.
|
||||
</li>
|
||||
<li style={{ marginBottom: '8px' }}>
|
||||
<strong>Resolve Missing Platform IDs:</strong> For dispensaries already detected as "dutchie", extracts the cName from menu_url and resolves the platform_dispensary_id via GraphQL.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Automatic scheduling:</strong> A "Menu Detection" job runs daily (24h +/- 1h jitter) to detect new dispensaries.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingSchedule && (
|
||||
<div style={{
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
export function DutchieAZStores() {
|
||||
const navigate = useNavigate();
|
||||
const [stores, setStores] = useState<any[]>([]);
|
||||
const [totalStores, setTotalStores] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dashboard, setDashboard] = useState<any>(null);
|
||||
|
||||
@@ -25,10 +26,11 @@ export function DutchieAZStores() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [storesData, dashboardData] = await Promise.all([
|
||||
api.getDutchieAZStores({ limit: 100 }),
|
||||
api.getDutchieAZStores({ limit: 200 }),
|
||||
api.getDutchieAZDashboard(),
|
||||
]);
|
||||
setStores(storesData.stores);
|
||||
setTotalStores(storesData.total);
|
||||
setDashboard(dashboardData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
@@ -124,7 +126,7 @@ export function DutchieAZStores() {
|
||||
{/* Stores List */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">All Stores ({stores.length})</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">All Stores ({totalStores})</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full">
|
||||
|
||||
@@ -11,10 +11,16 @@ export function ScraperMonitor() {
|
||||
const [recentJobs, setRecentJobs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'scrapers' | 'jobs'>('jobs');
|
||||
const [activeTab, setActiveTab] = useState<'az-live' | 'jobs' | 'scrapers'>('az-live');
|
||||
const [selectedWorker, setSelectedWorker] = useState<string | null>(null);
|
||||
const [workerLogs, setWorkerLogs] = useState<string>('');
|
||||
|
||||
// AZ Crawler state
|
||||
const [azSummary, setAzSummary] = useState<any>(null);
|
||||
const [azActiveJobs, setAzActiveJobs] = useState<any>({ scheduledJobs: [], crawlJobs: [], inMemoryScrapers: [], totalActive: 0 });
|
||||
const [azRecentJobs, setAzRecentJobs] = useState<any>({ jobLogs: [], crawlJobs: [] });
|
||||
const [azErrors, setAzErrors] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
@@ -41,6 +47,19 @@ export function ScraperMonitor() {
|
||||
setActiveJobs(jobsData.jobs || []);
|
||||
setWorkers(workersData.workers || []);
|
||||
setRecentJobs(recentJobsData.jobs || []);
|
||||
|
||||
// Load AZ monitor data
|
||||
const [azSummaryData, azActiveData, azRecentData, azErrorsData] = await Promise.all([
|
||||
api.getAZMonitorSummary().catch(() => null),
|
||||
api.getAZMonitorActiveJobs().catch(() => ({ scheduledJobs: [], crawlJobs: [], inMemoryScrapers: [], totalActive: 0 })),
|
||||
api.getAZMonitorRecentJobs(30).catch(() => ({ jobLogs: [], crawlJobs: [] })),
|
||||
api.getAZMonitorErrors({ limit: 10, hours: 24 }).catch(() => ({ errors: [] })),
|
||||
]);
|
||||
|
||||
setAzSummary(azSummaryData);
|
||||
setAzActiveJobs(azActiveData);
|
||||
setAzRecentJobs(azRecentData);
|
||||
setAzErrors(azErrorsData?.errors || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load scraper data:', error);
|
||||
} finally {
|
||||
@@ -79,6 +98,22 @@ export function ScraperMonitor() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ marginBottom: '30px', display: 'flex', gap: '10px', borderBottom: '2px solid #eee' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('az-live')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: activeTab === 'az-live' ? 'white' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'az-live' ? '3px solid #10b981' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: activeTab === 'az-live' ? '600' : '400',
|
||||
color: activeTab === 'az-live' ? '#10b981' : '#666',
|
||||
marginBottom: '-2px'
|
||||
}}
|
||||
>
|
||||
AZ Live {azActiveJobs.totalActive > 0 && <span style={{ marginLeft: '8px', padding: '2px 8px', background: '#10b981', color: 'white', borderRadius: '10px', fontSize: '12px' }}>{azActiveJobs.totalActive}</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
style={{
|
||||
@@ -113,7 +148,329 @@ export function ScraperMonitor() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'jobs' ? (
|
||||
{activeTab === 'az-live' && (
|
||||
<>
|
||||
{/* AZ Summary Stats */}
|
||||
{azSummary && (
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 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' }}>Running Jobs</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '600', color: azActiveJobs.totalActive > 0 ? '#10b981' : '#666' }}>
|
||||
{azActiveJobs.totalActive}
|
||||
</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' }}>Successful (24h)</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '600', color: '#10b981' }}>
|
||||
{(azSummary.successful_jobs_24h || 0) + (azSummary.successful_crawls_24h || 0)}
|
||||
</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 (24h)</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '600', color: (azSummary.failed_jobs_24h || 0) + (azSummary.failed_crawls_24h || 0) > 0 ? '#ef4444' : '#666' }}>
|
||||
{(azSummary.failed_jobs_24h || 0) + (azSummary.failed_crawls_24h || 0)}
|
||||
</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' }}>Products (24h)</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '600', color: '#8b5cf6' }}>
|
||||
{azSummary.products_found_24h || 0}
|
||||
</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' }}>Snapshots (24h)</div>
|
||||
<div style={{ fontSize: '32px', fontWeight: '600', color: '#06b6d4' }}>
|
||||
{azSummary.snapshots_created_24h || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Jobs Section */}
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2 style={{ fontSize: '24px', marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
Active Jobs
|
||||
{azActiveJobs.totalActive > 0 && (
|
||||
<span style={{ padding: '4px 12px', background: '#d1fae5', color: '#065f46', borderRadius: '12px', fontSize: '14px', fontWeight: '600' }}>
|
||||
{azActiveJobs.totalActive} running
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{azActiveJobs.totalActive === 0 ? (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
padding: '60px 40px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '20px' }}>😴</div>
|
||||
<div style={{ fontSize: '18px', color: '#666' }}>No jobs currently running</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '15px' }}>
|
||||
{/* Scheduled Jobs */}
|
||||
{azActiveJobs.scheduledJobs.map((job: any) => (
|
||||
<div key={`sched-${job.id}`} style={{
|
||||
background: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
borderLeft: '4px solid #10b981'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
{job.job_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '12px' }}>
|
||||
{job.job_description || 'Scheduled job'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Processed</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600' }}>{job.items_processed || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Succeeded</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#10b981' }}>{job.items_succeeded || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Failed</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: job.items_failed > 0 ? '#ef4444' : '#666' }}>{job.items_failed || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Duration</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600' }}>
|
||||
{Math.floor((job.duration_seconds || 0) / 60)}m {Math.floor((job.duration_seconds || 0) % 60)}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
background: '#d1fae5',
|
||||
color: '#065f46'
|
||||
}}>
|
||||
RUNNING
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Individual Crawl Jobs */}
|
||||
{azActiveJobs.crawlJobs.map((job: any) => (
|
||||
<div key={`crawl-${job.id}`} style={{
|
||||
background: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
borderLeft: '4px solid #3b82f6'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
|
||||
{job.dispensary_name || 'Unknown Store'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '12px' }}>
|
||||
{job.city} | {job.job_type || 'crawl'}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Products Found</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#8b5cf6' }}>{job.products_found || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Snapshots</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#06b6d4' }}>{job.snapshots_created || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Duration</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600' }}>
|
||||
{Math.floor((job.duration_seconds || 0) / 60)}m {Math.floor((job.duration_seconds || 0) % 60)}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
background: '#dbeafe',
|
||||
color: '#1e40af'
|
||||
}}>
|
||||
CRAWLING
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next Scheduled Runs */}
|
||||
{azSummary?.nextRuns && azSummary.nextRuns.length > 0 && (
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2 style={{ fontSize: '24px', marginBottom: '20px' }}>Next Scheduled Runs</h2>
|
||||
<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' }}>Job</th>
|
||||
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Next Run</th>
|
||||
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Last Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{azSummary.nextRuns.map((run: any) => (
|
||||
<tr key={run.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '15px' }}>
|
||||
<div style={{ fontWeight: '600' }}>{run.job_name}</div>
|
||||
<div style={{ fontSize: '13px', color: '#666' }}>{run.description}</div>
|
||||
</td>
|
||||
<td style={{ padding: '15px' }}>
|
||||
<div style={{ fontWeight: '600', color: '#2563eb' }}>
|
||||
{run.next_run_at ? new Date(run.next_run_at).toLocaleString() : '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '15px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
background: run.last_status === 'success' ? '#d1fae5' : run.last_status === 'error' ? '#fee2e2' : '#fef3c7',
|
||||
color: run.last_status === 'success' ? '#065f46' : run.last_status === 'error' ? '#991b1b' : '#92400e'
|
||||
}}>
|
||||
{run.last_status || 'never'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Errors */}
|
||||
{azErrors.length > 0 && (
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2 style={{ fontSize: '24px', marginBottom: '20px', color: '#ef4444' }}>Recent Errors (24h)</h2>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{azErrors.map((error: any, i: number) => (
|
||||
<div key={i} style={{ padding: '15px', borderBottom: i < azErrors.length - 1 ? '1px solid #eee' : 'none' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '8px' }}>
|
||||
<div style={{ fontWeight: '600' }}>{error.job_name || error.dispensary_name}</div>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
background: '#fee2e2',
|
||||
color: '#991b1b'
|
||||
}}>
|
||||
{error.status}
|
||||
</span>
|
||||
</div>
|
||||
{error.error_message && (
|
||||
<div style={{ fontSize: '13px', color: '#991b1b', background: '#fef2f2', padding: '8px', borderRadius: '4px' }}>
|
||||
{error.error_message}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '8px' }}>
|
||||
{error.started_at ? new Date(error.started_at).toLocaleString() : '-'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Jobs */}
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', marginBottom: '20px' }}>Recent Job Runs</h2>
|
||||
<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' }}>Job</th>
|
||||
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Status</th>
|
||||
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Processed</th>
|
||||
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Duration</th>
|
||||
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{azRecentJobs.jobLogs.slice(0, 20).map((job: any) => (
|
||||
<tr key={`log-${job.id}`} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '15px' }}>
|
||||
<div style={{ fontWeight: '600' }}>{job.job_name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>Log #{job.id}</div>
|
||||
</td>
|
||||
<td style={{ padding: '15px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
background:
|
||||
job.status === 'success' ? '#d1fae5' :
|
||||
job.status === 'running' ? '#dbeafe' :
|
||||
job.status === 'error' ? '#fee2e2' :
|
||||
'#fef3c7',
|
||||
color:
|
||||
job.status === 'success' ? '#065f46' :
|
||||
job.status === 'running' ? '#1e40af' :
|
||||
job.status === 'error' ? '#991b1b' :
|
||||
'#92400e'
|
||||
}}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '15px', textAlign: 'right' }}>
|
||||
<span style={{ color: '#10b981' }}>{job.items_succeeded || 0}</span>
|
||||
{' / '}
|
||||
<span>{job.items_processed || 0}</span>
|
||||
</td>
|
||||
<td style={{ padding: '15px', textAlign: 'right' }}>
|
||||
{job.duration_ms ? `${Math.floor(job.duration_ms / 60000)}m ${Math.floor((job.duration_ms % 60000) / 1000)}s` : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '15px', color: '#666' }}>
|
||||
{job.completed_at ? new Date(job.completed_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'jobs' && (
|
||||
<>
|
||||
{/* Job Stats */}
|
||||
{jobStats && (
|
||||
@@ -343,7 +700,9 @@ export function ScraperMonitor() {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{activeTab === 'scrapers' && (
|
||||
<>
|
||||
{/* Active Scrapers */}
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
|
||||
470
frontend/src/pages/WholesaleAnalytics.tsx
Normal file
470
frontend/src/pages/WholesaleAnalytics.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import {
|
||||
Building2,
|
||||
Package,
|
||||
Tag,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
BarChart3,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DashboardStats {
|
||||
dispensaryCount: number;
|
||||
productCount: number;
|
||||
snapshotCount24h: number;
|
||||
lastCrawlTime: string | null;
|
||||
failedJobCount: number;
|
||||
brandCount: number;
|
||||
categoryCount: number;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
id: number;
|
||||
name: string;
|
||||
dba_name?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
platform_dispensary_id?: string;
|
||||
last_crawl_at?: string;
|
||||
product_count?: number;
|
||||
}
|
||||
|
||||
interface Brand {
|
||||
brand_name: string;
|
||||
product_count: number;
|
||||
dispensary_count: number;
|
||||
product_types?: string[];
|
||||
}
|
||||
|
||||
interface Category {
|
||||
type: string;
|
||||
subcategory: string | null;
|
||||
product_count: number;
|
||||
dispensary_count: number;
|
||||
brand_count: number;
|
||||
avg_thc?: number | null;
|
||||
}
|
||||
|
||||
export function WholesaleAnalytics() {
|
||||
const navigate = useNavigate();
|
||||
const [dashboard, setDashboard] = useState<DashboardStats | null>(null);
|
||||
const [stores, setStores] = useState<Store[]>([]);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'stores' | 'brands' | 'categories'>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dashboardData, storesData, brandsData, categoriesData] = await Promise.all([
|
||||
api.getDutchieAZDashboard(),
|
||||
api.getDutchieAZStores({ limit: 200 }),
|
||||
api.getDutchieAZBrands ? api.getDutchieAZBrands({ limit: 100 }) : Promise.resolve({ brands: [] }),
|
||||
api.getDutchieAZCategories ? api.getDutchieAZCategories() : Promise.resolve({ categories: [] }),
|
||||
]);
|
||||
setDashboard(dashboardData);
|
||||
setStores(storesData.stores || []);
|
||||
setBrands(brandsData.brands || []);
|
||||
setCategories(categoriesData.categories || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffHours < 1) return 'Just now';
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">Loading analytics...</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Wholesale & Inventory Analytics</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Arizona Dutchie dispensaries data overview
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Top Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||
<StatCard
|
||||
title="Dispensaries"
|
||||
value={dashboard?.dispensaryCount || 0}
|
||||
icon={<Building2 className="w-5 h-5 text-blue-600" />}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Products"
|
||||
value={dashboard?.productCount || 0}
|
||||
icon={<Package className="w-5 h-5 text-green-600" />}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Brands"
|
||||
value={dashboard?.brandCount || 0}
|
||||
icon={<Tag className="w-5 h-5 text-purple-600" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Categories"
|
||||
value={dashboard?.categoryCount || 0}
|
||||
icon={<Layers className="w-5 h-5 text-orange-600" />}
|
||||
color="orange"
|
||||
/>
|
||||
<StatCard
|
||||
title="Snapshots (24h)"
|
||||
value={dashboard?.snapshotCount24h || 0}
|
||||
icon={<TrendingUp className="w-5 h-5 text-cyan-600" />}
|
||||
color="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
title="Failed Jobs (24h)"
|
||||
value={dashboard?.failedJobCount || 0}
|
||||
icon={<AlertCircle className="w-5 h-5 text-red-600" />}
|
||||
color="red"
|
||||
/>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-2 text-gray-600 mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-xs">Last Crawl</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{formatDate(dashboard?.lastCrawlTime || null)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex gap-4 px-6">
|
||||
<TabButton
|
||||
active={activeTab === 'overview'}
|
||||
onClick={() => setActiveTab('overview')}
|
||||
icon={<BarChart3 className="w-4 h-4" />}
|
||||
label="Overview"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'stores'}
|
||||
onClick={() => setActiveTab('stores')}
|
||||
icon={<Building2 className="w-4 h-4" />}
|
||||
label={`Stores (${stores.length})`}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'brands'}
|
||||
onClick={() => setActiveTab('brands')}
|
||||
icon={<Tag className="w-4 h-4" />}
|
||||
label={`Brands (${brands.length})`}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'categories'}
|
||||
onClick={() => setActiveTab('categories')}
|
||||
icon={<Layers className="w-4 h-4" />}
|
||||
label={`Categories (${categories.length})`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Top Stores */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Stores by Products</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{stores.slice(0, 6).map((store) => (
|
||||
<button
|
||||
key={store.id}
|
||||
onClick={() => navigate(`/az/stores/${store.id}`)}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors text-left"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{store.dba_name || store.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{store.city}, {store.state}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Last crawl: {formatDate(store.last_crawl_at || null)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Brands */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Brands</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{brands.slice(0, 12).map((brand) => (
|
||||
<div
|
||||
key={brand.brand_name}
|
||||
className="p-4 bg-gray-50 rounded-lg text-center"
|
||||
>
|
||||
<p className="font-medium text-gray-900 text-sm line-clamp-2">
|
||||
{brand.brand_name}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-purple-600 mt-1">
|
||||
{brand.product_count}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">products</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Categories */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Product Categories</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{categories.slice(0, 12).map((cat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 bg-gray-50 rounded-lg text-center"
|
||||
>
|
||||
<p className="font-medium text-gray-900">{cat.type}</p>
|
||||
{cat.subcategory && (
|
||||
<p className="text-xs text-gray-600">{cat.subcategory}</p>
|
||||
)}
|
||||
<p className="text-lg font-bold text-orange-600 mt-1">
|
||||
{cat.product_count}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">products</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stores Tab */}
|
||||
{activeTab === 'stores' && (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Store Name</th>
|
||||
<th>City</th>
|
||||
<th className="text-center">Platform ID</th>
|
||||
<th className="text-center">Last Crawl</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stores.map((store) => (
|
||||
<tr key={store.id} className="hover">
|
||||
<td className="font-medium">
|
||||
{store.dba_name || store.name}
|
||||
</td>
|
||||
<td>{store.city}, {store.state}</td>
|
||||
<td className="text-center">
|
||||
{store.platform_dispensary_id ? (
|
||||
<span className="badge badge-success badge-sm">Resolved</span>
|
||||
) : (
|
||||
<span className="badge badge-warning badge-sm">Pending</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center text-sm text-gray-600">
|
||||
{formatDate(store.last_crawl_at || null)}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => navigate(`/az/stores/${store.id}`)}
|
||||
className="btn btn-xs btn-ghost"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brands Tab */}
|
||||
{activeTab === 'brands' && (
|
||||
<div className="space-y-4">
|
||||
{brands.length === 0 ? (
|
||||
<p className="text-center py-8 text-gray-500">
|
||||
No brands found. Run a crawl to populate brand data.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{brands.map((brand) => (
|
||||
<div
|
||||
key={brand.brand_name}
|
||||
className="border border-gray-200 rounded-lg p-4 text-center hover:border-purple-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<p className="font-medium text-gray-900 text-sm line-clamp-2 h-10">
|
||||
{brand.brand_name}
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-500">Products:</span>
|
||||
<span className="font-semibold">{brand.product_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-500">Stores:</span>
|
||||
<span className="font-semibold">{brand.dispensary_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories Tab */}
|
||||
{activeTab === 'categories' && (
|
||||
<div className="space-y-4">
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-center py-8 text-gray-500">
|
||||
No categories found. Run a crawl to populate category data.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{categories.map((cat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:border-orange-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<p className="font-medium text-gray-900">{cat.type}</p>
|
||||
{cat.subcategory && (
|
||||
<p className="text-sm text-gray-600">{cat.subcategory}</p>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="bg-gray-50 rounded p-2 text-center">
|
||||
<p className="font-bold text-lg text-orange-600">{cat.product_count}</p>
|
||||
<p className="text-gray-500">products</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded p-2 text-center">
|
||||
<p className="font-bold text-lg text-blue-600">{cat.brand_count}</p>
|
||||
<p className="text-gray-500">brands</p>
|
||||
</div>
|
||||
</div>
|
||||
{cat.avg_thc != null && (
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||
Avg THC: {cat.avg_thc.toFixed(1)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color }: StatCardProps) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
blue: 'bg-blue-50',
|
||||
green: 'bg-green-50',
|
||||
purple: 'bg-purple-50',
|
||||
orange: 'bg-orange-50',
|
||||
cyan: 'bg-cyan-50',
|
||||
red: 'bg-red-50',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 ${colorClasses[color] || 'bg-gray-50'} rounded-lg`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">{title}</p>
|
||||
<p className="text-xl font-bold text-gray-900">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, icon, label }: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 py-4 px-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
active
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user