Compare commits
4 Commits
fix/worker
...
feat/task-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a09691e91 | ||
|
|
01810c40a1 | ||
|
|
61e915968f | ||
|
|
a4338669a9 |
@@ -291,107 +291,6 @@ router.get('/stores/:id/summary', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/markets/stores/:id/crawl-history
|
||||
* Get crawl history for a specific store
|
||||
*/
|
||||
router.get('/stores/:id/crawl-history', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { limit = '50' } = req.query;
|
||||
const dispensaryId = parseInt(id, 10);
|
||||
const limitNum = Math.min(parseInt(limit as string, 10), 100);
|
||||
|
||||
// Get crawl history from crawl_orchestration_traces
|
||||
const { rows: historyRows } = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
run_id,
|
||||
profile_key,
|
||||
crawler_module,
|
||||
state_at_start,
|
||||
state_at_end,
|
||||
total_steps,
|
||||
duration_ms,
|
||||
success,
|
||||
error_message,
|
||||
products_found,
|
||||
started_at,
|
||||
completed_at
|
||||
FROM crawl_orchestration_traces
|
||||
WHERE dispensary_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $2
|
||||
`, [dispensaryId, limitNum]);
|
||||
|
||||
// Get next scheduled crawl if available
|
||||
const { rows: scheduleRows } = await pool.query(`
|
||||
SELECT
|
||||
js.id as schedule_id,
|
||||
js.job_name,
|
||||
js.enabled,
|
||||
js.base_interval_minutes,
|
||||
js.jitter_minutes,
|
||||
js.next_run_at,
|
||||
js.last_run_at,
|
||||
js.last_status
|
||||
FROM job_schedules js
|
||||
WHERE js.enabled = true
|
||||
AND js.job_config->>'dispensaryId' = $1::text
|
||||
ORDER BY js.next_run_at
|
||||
LIMIT 1
|
||||
`, [dispensaryId.toString()]);
|
||||
|
||||
// Get dispensary info for slug
|
||||
const { rows: dispRows } = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
dba_name,
|
||||
slug,
|
||||
state,
|
||||
city,
|
||||
menu_type,
|
||||
platform_dispensary_id,
|
||||
last_menu_scrape
|
||||
FROM dispensaries
|
||||
WHERE id = $1
|
||||
`, [dispensaryId]);
|
||||
|
||||
res.json({
|
||||
dispensary: dispRows[0] || null,
|
||||
history: historyRows.map(row => ({
|
||||
id: row.id,
|
||||
runId: row.run_id,
|
||||
profileKey: row.profile_key,
|
||||
crawlerModule: row.crawler_module,
|
||||
stateAtStart: row.state_at_start,
|
||||
stateAtEnd: row.state_at_end,
|
||||
totalSteps: row.total_steps,
|
||||
durationMs: row.duration_ms,
|
||||
success: row.success,
|
||||
errorMessage: row.error_message,
|
||||
productsFound: row.products_found,
|
||||
startedAt: row.started_at?.toISOString() || null,
|
||||
completedAt: row.completed_at?.toISOString() || null,
|
||||
})),
|
||||
nextSchedule: scheduleRows[0] ? {
|
||||
scheduleId: scheduleRows[0].schedule_id,
|
||||
jobName: scheduleRows[0].job_name,
|
||||
enabled: scheduleRows[0].enabled,
|
||||
baseIntervalMinutes: scheduleRows[0].base_interval_minutes,
|
||||
jitterMinutes: scheduleRows[0].jitter_minutes,
|
||||
nextRunAt: scheduleRows[0].next_run_at?.toISOString() || null,
|
||||
lastRunAt: scheduleRows[0].last_run_at?.toISOString() || null,
|
||||
lastStatus: scheduleRows[0].last_status,
|
||||
} : null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Markets] Error fetching crawl history:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/markets/stores/:id/products
|
||||
* Get products for a store with filtering and pagination
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
TaskFilter,
|
||||
} from '../tasks/task-service';
|
||||
import { pool } from '../db/pool';
|
||||
import {
|
||||
isTaskPoolPaused,
|
||||
pauseTaskPool,
|
||||
resumeTaskPool,
|
||||
getTaskPoolStatus,
|
||||
} from '../tasks/task-pool-state';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -592,4 +598,42 @@ router.post('/migration/full-migrate', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/pool/status
|
||||
* Check if task pool is paused
|
||||
*/
|
||||
router.get('/pool/status', async (_req: Request, res: Response) => {
|
||||
const status = getTaskPoolStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/pool/pause
|
||||
* Pause the task pool - workers won't pick up new tasks
|
||||
*/
|
||||
router.post('/pool/pause', async (_req: Request, res: Response) => {
|
||||
pauseTaskPool();
|
||||
res.json({
|
||||
success: true,
|
||||
paused: true,
|
||||
message: 'Task pool paused - workers will not pick up new tasks',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/pool/resume
|
||||
* Resume the task pool - workers will pick up tasks again
|
||||
*/
|
||||
router.post('/pool/resume', async (_req: Request, res: Response) => {
|
||||
resumeTaskPool();
|
||||
res.json({
|
||||
success: true,
|
||||
paused: false,
|
||||
message: 'Task pool resumed - workers will pick up new tasks',
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
35
backend/src/tasks/task-pool-state.ts
Normal file
35
backend/src/tasks/task-pool-state.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Task Pool State
|
||||
*
|
||||
* Shared state for task pool pause/resume functionality.
|
||||
* This is kept separate to avoid circular dependencies between
|
||||
* task-service.ts and routes/tasks.ts.
|
||||
*
|
||||
* State is in-memory and resets on server restart.
|
||||
* By default, the pool is OPEN (not paused).
|
||||
*/
|
||||
|
||||
let taskPoolPaused = false;
|
||||
|
||||
export function isTaskPoolPaused(): boolean {
|
||||
return taskPoolPaused;
|
||||
}
|
||||
|
||||
export function pauseTaskPool(): void {
|
||||
taskPoolPaused = true;
|
||||
console.log('[TaskPool] Task pool PAUSED - workers will not pick up new tasks');
|
||||
}
|
||||
|
||||
export function resumeTaskPool(): void {
|
||||
taskPoolPaused = false;
|
||||
console.log('[TaskPool] Task pool RESUMED - workers can pick up tasks');
|
||||
}
|
||||
|
||||
export function getTaskPoolStatus(): { paused: boolean; message: string } {
|
||||
return {
|
||||
paused: taskPoolPaused,
|
||||
message: taskPoolPaused
|
||||
? 'Task pool is paused - workers will not pick up new tasks'
|
||||
: 'Task pool is open - workers are picking up tasks',
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { pool } from '../db/pool';
|
||||
import { isTaskPoolPaused } from './task-pool-state';
|
||||
|
||||
// Helper to check if a table exists
|
||||
async function tableExists(tableName: string): Promise<boolean> {
|
||||
@@ -149,8 +150,14 @@ class TaskService {
|
||||
/**
|
||||
* Claim a task atomically for a worker
|
||||
* If role is null, claims ANY available task (role-agnostic worker)
|
||||
* Returns null if task pool is paused.
|
||||
*/
|
||||
async claimTask(role: TaskRole | null, workerId: string): Promise<WorkerTask | null> {
|
||||
// Check if task pool is paused - don't claim any tasks
|
||||
if (isTaskPoolPaused()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (role) {
|
||||
// Role-specific claiming - use the SQL function
|
||||
const result = await pool.query(
|
||||
|
||||
4
cannaiq/dist/index.html
vendored
4
cannaiq/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<title>CannaIQ - Cannabis Menu Intelligence Platform</title>
|
||||
<meta name="description" content="CannaIQ provides real-time cannabis dispensary menu data, product tracking, and analytics for dispensaries across Arizona." />
|
||||
<meta name="keywords" content="cannabis, dispensary, menu, products, analytics, Arizona" />
|
||||
<script type="module" crossorigin src="/assets/index-Dq9S0rVi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DhM09B-d.css">
|
||||
<script type="module" crossorigin src="/assets/index-BXmp5CSY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-4959QN4j.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ProductDetail } from './pages/ProductDetail';
|
||||
import { Stores } from './pages/Stores';
|
||||
import { Dispensaries } from './pages/Dispensaries';
|
||||
import { DispensaryDetail } from './pages/DispensaryDetail';
|
||||
import { DispensarySchedule } from './pages/DispensarySchedule';
|
||||
import { StoreDetail } from './pages/StoreDetail';
|
||||
import { StoreBrands } from './pages/StoreBrands';
|
||||
import { StoreSpecials } from './pages/StoreSpecials';
|
||||
@@ -67,7 +66,6 @@ export default function App() {
|
||||
<Route path="/stores" element={<PrivateRoute><Stores /></PrivateRoute>} />
|
||||
<Route path="/dispensaries" element={<PrivateRoute><Dispensaries /></PrivateRoute>} />
|
||||
<Route path="/dispensaries/:state/:city/:slug" element={<PrivateRoute><DispensaryDetail /></PrivateRoute>} />
|
||||
<Route path="/dispensaries/:state/:city/:slug/schedule" element={<PrivateRoute><DispensarySchedule /></PrivateRoute>} />
|
||||
<Route path="/stores/:state/:storeName/:slug/brands" element={<PrivateRoute><StoreBrands /></PrivateRoute>} />
|
||||
<Route path="/stores/:state/:storeName/:slug/specials" element={<PrivateRoute><StoreSpecials /></PrivateRoute>} />
|
||||
<Route path="/stores/:state/:storeName/:slug" element={<PrivateRoute><StoreDetail /></PrivateRoute>} />
|
||||
|
||||
@@ -983,47 +983,6 @@ class ApiClient {
|
||||
}>(`/api/markets/stores/${id}/categories`);
|
||||
}
|
||||
|
||||
async getStoreCrawlHistory(id: number, limit = 50) {
|
||||
return this.request<{
|
||||
dispensary: {
|
||||
id: number;
|
||||
name: string;
|
||||
dba_name: string | null;
|
||||
slug: string;
|
||||
state: string;
|
||||
city: string;
|
||||
menu_type: string | null;
|
||||
platform_dispensary_id: string | null;
|
||||
last_menu_scrape: string | null;
|
||||
} | null;
|
||||
history: Array<{
|
||||
id: number;
|
||||
runId: string | null;
|
||||
profileKey: string | null;
|
||||
crawlerModule: string | null;
|
||||
stateAtStart: string | null;
|
||||
stateAtEnd: string | null;
|
||||
totalSteps: number;
|
||||
durationMs: number | null;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
productsFound: number | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}>;
|
||||
nextSchedule: {
|
||||
scheduleId: number;
|
||||
jobName: string;
|
||||
enabled: boolean;
|
||||
baseIntervalMinutes: number;
|
||||
jitterMinutes: number;
|
||||
nextRunAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
} | null;
|
||||
}>(`/api/markets/stores/${id}/crawl-history?limit=${limit}`);
|
||||
}
|
||||
|
||||
// Global Brands/Categories (from v_brands/v_categories views)
|
||||
async getMarketBrands(params?: { limit?: number; offset?: number }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -2929,6 +2888,27 @@ class ApiClient {
|
||||
`/api/tasks/store/${dispensaryId}/active`
|
||||
);
|
||||
}
|
||||
|
||||
// Task Pool Control
|
||||
async getTaskPoolStatus() {
|
||||
return this.request<{ success: boolean; paused: boolean; message: string }>(
|
||||
'/api/tasks/pool/status'
|
||||
);
|
||||
}
|
||||
|
||||
async pauseTaskPool() {
|
||||
return this.request<{ success: boolean; paused: boolean; message: string }>(
|
||||
'/api/tasks/pool/pause',
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
async resumeTaskPool() {
|
||||
return this.request<{ success: boolean; paused: boolean; message: string }>(
|
||||
'/api/tasks/pool/resume',
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_URL);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { HealthPanel } from '../components/HealthPanel';
|
||||
import { api } from '../lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -41,6 +42,7 @@ export function Dashboard() {
|
||||
const [activity, setActivity] = useState<any>(null);
|
||||
const [nationalStats, setNationalStats] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [pendingChangesCount, setPendingChangesCount] = useState(0);
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [taskCounts, setTaskCounts] = useState<Record<string, number> | null>(null);
|
||||
@@ -91,7 +93,10 @@ export function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
try {
|
||||
// Fetch dashboard data (primary data source)
|
||||
const dashboard = await api.getMarketDashboard();
|
||||
@@ -153,6 +158,7 @@ export function Dashboard() {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,10 +271,23 @@ export function Dashboard() {
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadData(true)}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700 self-start sm:self-auto disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
<HealthPanel showQueues={false} refreshInterval={60000} />
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6">
|
||||
|
||||
@@ -161,6 +161,23 @@ export function Dispensaries() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Filter by Status
|
||||
</label>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => handleStatusFilter(e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
filterStatus === 'dropped' ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="dropped">Dropped (Needs Review)</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -204,6 +204,47 @@ export function DispensaryDetail() {
|
||||
Back to Dispensaries
|
||||
</button>
|
||||
|
||||
{/* Update Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUpdateDropdown(!showUpdateDropdown)}
|
||||
disabled={isUpdating}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||
{isUpdating ? 'Updating...' : 'Update'}
|
||||
{!isUpdating && <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{showUpdateDropdown && !isUpdating && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<button
|
||||
onClick={() => handleUpdate('products')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-t-lg"
|
||||
>
|
||||
Products
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdate('brands')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Brands
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdate('specials')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Specials
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdate('all')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-b-lg border-t border-gray-200"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dispensary Header */}
|
||||
@@ -225,7 +266,7 @@ export function DispensaryDetail() {
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<div>
|
||||
<span className="font-medium">Last Updated:</span>
|
||||
<span className="font-medium">Last Crawl Date:</span>
|
||||
<span className="ml-2">
|
||||
{dispensary.last_menu_scrape
|
||||
? new Date(dispensary.last_menu_scrape).toLocaleDateString('en-US', {
|
||||
@@ -290,7 +331,7 @@ export function DispensaryDetail() {
|
||||
</a>
|
||||
)}
|
||||
<Link
|
||||
to={`/dispensaries/${state}/${city}/${slug}/schedule`}
|
||||
to="/schedule"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
@@ -492,31 +533,57 @@ export function DispensaryDetail() {
|
||||
`$${product.regular_price}`
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
{product.quantity != null ? product.quantity : '-'}
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.quantity != null ? (
|
||||
<span className={`badge badge-sm ${product.quantity > 0 ? 'badge-info' : 'badge-error'}`}>
|
||||
{product.quantity}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
{product.thc_percentage ? `${product.thc_percentage}%` : '-'}
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.thc_percentage ? (
|
||||
<span className="badge badge-success badge-sm">{product.thc_percentage}%</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
{product.cbd_percentage ? `${product.cbd_percentage}%` : '-'}
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.cbd_percentage ? (
|
||||
<span className="badge badge-info badge-sm">{product.cbd_percentage}%</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
{product.strain_type || '-'}
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.strain_type ? (
|
||||
<span className="badge badge-ghost badge-sm">{product.strain_type}</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
{product.in_stock ? 'Yes' : product.in_stock === false ? 'No' : '-'}
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.in_stock ? (
|
||||
<span className="badge badge-success badge-sm">Yes</span>
|
||||
) : product.in_stock === false ? (
|
||||
<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>
|
||||
<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-ghost text-gray-500 hover:text-gray-700"
|
||||
className="btn btn-xs btn-primary"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { api } from '../lib/api';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Package,
|
||||
Timer,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CrawlHistoryItem {
|
||||
id: number;
|
||||
runId: string | null;
|
||||
profileKey: string | null;
|
||||
crawlerModule: string | null;
|
||||
stateAtStart: string | null;
|
||||
stateAtEnd: string | null;
|
||||
totalSteps: number;
|
||||
durationMs: number | null;
|
||||
success: boolean;
|
||||
errorMessage: string | null;
|
||||
productsFound: number | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
interface NextSchedule {
|
||||
scheduleId: number;
|
||||
jobName: string;
|
||||
enabled: boolean;
|
||||
baseIntervalMinutes: number;
|
||||
jitterMinutes: number;
|
||||
nextRunAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
}
|
||||
|
||||
interface Dispensary {
|
||||
id: number;
|
||||
name: string;
|
||||
dba_name: string | null;
|
||||
slug: string;
|
||||
state: string;
|
||||
city: string;
|
||||
menu_type: string | null;
|
||||
platform_dispensary_id: string | null;
|
||||
last_menu_scrape: string | null;
|
||||
}
|
||||
|
||||
export function DispensarySchedule() {
|
||||
const { state, city, slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [dispensary, setDispensary] = useState<Dispensary | null>(null);
|
||||
const [history, setHistory] = useState<CrawlHistoryItem[]>([]);
|
||||
const [nextSchedule, setNextSchedule] = useState<NextSchedule | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadScheduleData();
|
||||
}, [slug]);
|
||||
|
||||
const loadScheduleData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// First get the dispensary to get the ID
|
||||
const dispData = await api.getDispensary(slug!);
|
||||
if (dispData?.id) {
|
||||
const data = await api.getStoreCrawlHistory(dispData.id);
|
||||
setDispensary(data.dispensary);
|
||||
setHistory(data.history || []);
|
||||
setNextSchedule(data.nextSchedule);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load schedule data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatTimeUntil = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Not scheduled';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
|
||||
if (diffMs < 0) return 'Overdue';
|
||||
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
|
||||
if (diffMinutes < 60) return `in ${diffMinutes}m`;
|
||||
return `in ${diffHours}h ${diffMinutes % 60}m`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number | null) => {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 1) return `${seconds}s`;
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
};
|
||||
|
||||
const formatInterval = (baseMinutes: number, jitterMinutes: number) => {
|
||||
const hours = Math.floor(baseMinutes / 60);
|
||||
const mins = baseMinutes % 60;
|
||||
let base = hours > 0 ? `${hours}h` : '';
|
||||
if (mins > 0) base += `${mins}m`;
|
||||
return `Every ${base} (+/- ${jitterMinutes}m jitter)`;
|
||||
};
|
||||
|
||||
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-gray-400 border-t-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">Loading schedule...</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dispensary) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Dispensary not found</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// Stats from history
|
||||
const successCount = history.filter(h => h.success).length;
|
||||
const failureCount = history.filter(h => !h.success).length;
|
||||
const lastSuccess = history.find(h => h.success);
|
||||
const avgDuration = history.length > 0
|
||||
? Math.round(history.reduce((sum, h) => sum + (h.durationMs || 0), 0) / history.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<button
|
||||
onClick={() => navigate(`/dispensaries/${state}/${city}/${slug}`)}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to {dispensary.dba_name || dispensary.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dispensary Info */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<Building2 className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{dispensary.dba_name || dispensary.name}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{dispensary.city}, {dispensary.state} - Crawl Schedule & History
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>Slug: {dispensary.slug}</span>
|
||||
{dispensary.menu_type && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">
|
||||
{dispensary.menu_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Scheduled Crawl */}
|
||||
{nextSchedule && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-blue-500" />
|
||||
Upcoming Schedule
|
||||
</h2>
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Next Run</p>
|
||||
<p className="text-xl font-semibold text-blue-600">
|
||||
{formatTimeUntil(nextSchedule.nextRunAt)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatDate(nextSchedule.nextRunAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Interval</p>
|
||||
<p className="text-lg font-medium">
|
||||
{formatInterval(nextSchedule.baseIntervalMinutes, nextSchedule.jitterMinutes)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Last Run</p>
|
||||
<p className="text-lg font-medium">
|
||||
{formatTimeAgo(nextSchedule.lastRunAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Last Status</p>
|
||||
<p className={`text-lg font-medium ${
|
||||
nextSchedule.lastStatus === 'success' ? 'text-green-600' :
|
||||
nextSchedule.lastStatus === 'error' ? 'text-red-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{nextSchedule.lastStatus || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Successful Runs</p>
|
||||
<p className="text-2xl font-bold text-green-600">{successCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<XCircle className="w-8 h-8 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Failed Runs</p>
|
||||
<p className="text-2xl font-bold text-red-600">{failureCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Timer className="w-8 h-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Avg Duration</p>
|
||||
<p className="text-2xl font-bold">{formatDuration(avgDuration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-8 h-8 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Last Products Found</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{lastSuccess?.productsFound?.toLocaleString() || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crawl History Table */}
|
||||
<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 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-gray-500" />
|
||||
Crawl History
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th className="text-right">Products</th>
|
||||
<th>State</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-500">
|
||||
No crawl history available
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
history.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium ${
|
||||
item.success
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{item.success ? (
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3" />
|
||||
)}
|
||||
{item.success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm">{formatDate(item.startedAt)}</div>
|
||||
<div className="text-xs text-gray-400">{formatTimeAgo(item.startedAt)}</div>
|
||||
</td>
|
||||
<td className="font-mono text-sm">
|
||||
{formatDuration(item.durationMs)}
|
||||
</td>
|
||||
<td className="text-right font-mono text-sm">
|
||||
{item.productsFound?.toLocaleString() || '-'}
|
||||
</td>
|
||||
<td className="text-sm text-gray-600">
|
||||
{item.stateAtEnd || item.stateAtStart || '-'}
|
||||
</td>
|
||||
<td className="max-w-[200px]">
|
||||
{item.errorMessage ? (
|
||||
<span
|
||||
className="text-xs text-red-600 truncate block cursor-help"
|
||||
title={item.errorMessage}
|
||||
>
|
||||
{item.errorMessage.substring(0, 50)}...
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default DispensarySchedule;
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MapPin,
|
||||
Package,
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
Search,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
@@ -31,7 +32,7 @@ export function IntelligenceBrands() {
|
||||
const [brands, setBrands] = useState<BrandData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name' | 'states'>('stores');
|
||||
const [sortBy, setSortBy] = useState<'stores' | 'skus' | 'name'>('stores');
|
||||
|
||||
useEffect(() => {
|
||||
loadBrands();
|
||||
@@ -68,8 +69,6 @@ export function IntelligenceBrands() {
|
||||
return b.skuCount - a.skuCount;
|
||||
case 'name':
|
||||
return a.brandName.localeCompare(b.brandName);
|
||||
case 'states':
|
||||
return b.states.length - a.states.length;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -101,62 +100,37 @@ export function IntelligenceBrands() {
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Brands Intelligence</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Brand penetration and pricing analytics across markets
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* State Selector */}
|
||||
<div className="dropdown dropdown-end">
|
||||
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
||||
{stateLabel}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
|
||||
<li>
|
||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
||||
All States
|
||||
</a>
|
||||
</li>
|
||||
<div className="divider my-1"></div>
|
||||
{availableStates.map((state) => (
|
||||
<li key={state}>
|
||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
||||
{state}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
||||
onClick={() => navigate('/admin/intelligence/pricing')}
|
||||
className="btn btn-sm btn-outline gap-1"
|
||||
>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>Brands</span>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
Pricing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/intelligence/stores')}
|
||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
className="btn btn-sm btn-outline gap-1"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Stores</span>
|
||||
Stores
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/intelligence/pricing')}
|
||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
onClick={loadBrands}
|
||||
className="btn btn-sm btn-outline gap-2"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Pricing</span>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
@@ -206,32 +180,51 @@ export function IntelligenceBrands() {
|
||||
|
||||
{/* Top Brands Chart */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2 mb-4">
|
||||
<BarChart3 className="w-5 h-5 text-emerald-500" />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||
Top 10 Brands by Store Count
|
||||
</h3>
|
||||
<div className="dropdown dropdown-end">
|
||||
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
||||
{stateLabel}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
||||
<li>
|
||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
||||
All States
|
||||
</a>
|
||||
</li>
|
||||
<li className="divider"></li>
|
||||
{availableStates.map((state) => (
|
||||
<li key={state}>
|
||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
||||
{state}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topBrands.map((brand) => {
|
||||
const barWidth = Math.min((brand.storeCount / maxStoreCount) * 100, 100);
|
||||
return (
|
||||
{topBrands.map((brand, idx) => (
|
||||
<div key={brand.brandName} className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium w-28 truncate shrink-0" title={brand.brandName}>
|
||||
<span className="text-sm text-gray-500 w-6">{idx + 1}.</span>
|
||||
<span className="text-sm font-medium w-40 truncate" title={brand.brandName}>
|
||||
{brand.brandName}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-gray-100 rounded h-5 overflow-hidden">
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-4 relative">
|
||||
<div
|
||||
className="bg-gradient-to-r from-emerald-400 to-emerald-500 h-5 rounded transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
className="bg-blue-500 rounded-full h-4"
|
||||
style={{ width: `${(brand.storeCount / maxStoreCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-mono font-semibold text-emerald-600 w-16 text-right shrink-0">
|
||||
{brand.storeCount}
|
||||
<span className="text-sm text-gray-600 w-16 text-right">
|
||||
{brand.storeCount} stores
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,7 +247,6 @@ export function IntelligenceBrands() {
|
||||
>
|
||||
<option value="stores">Sort by Stores</option>
|
||||
<option value="skus">Sort by SKUs</option>
|
||||
<option value="states">Sort by States</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
</select>
|
||||
<span className="text-sm text-gray-500">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Building2,
|
||||
MapPin,
|
||||
Package,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
BarChart3,
|
||||
@@ -86,62 +87,58 @@ export function IntelligencePricing() {
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Pricing Intelligence</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Price distribution and trends by category
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* State Selector */}
|
||||
<div className="flex gap-2">
|
||||
<div className="dropdown dropdown-end">
|
||||
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
||||
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
||||
{stateLabel}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
|
||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
||||
<li>
|
||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
||||
All States
|
||||
</a>
|
||||
</li>
|
||||
<div className="divider my-1"></div>
|
||||
<li className="divider"></li>
|
||||
{availableStates.map((state) => (
|
||||
<li key={state}>
|
||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
||||
{state}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => navigate('/admin/intelligence/brands')}
|
||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
className="btn btn-sm btn-outline gap-1"
|
||||
>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>Brands</span>
|
||||
Brands
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/intelligence/stores')}
|
||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
className="btn btn-sm btn-outline gap-1"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Stores</span>
|
||||
Stores
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
||||
onClick={loadPricing}
|
||||
className="btn btn-sm btn-outline gap-2"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Pricing</span>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Stats */}
|
||||
{overall && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Building2,
|
||||
DollarSign,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Clock,
|
||||
Activity,
|
||||
@@ -33,19 +34,12 @@ export function IntelligenceStores() {
|
||||
const [stores, setStores] = useState<StoreActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [availableStates, setAvailableStates] = useState<string[]>([]);
|
||||
const [localStates, setLocalStates] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStores();
|
||||
}, [selectedState]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load available states from orchestrator API
|
||||
api.getOrchestratorStates().then(data => {
|
||||
setAvailableStates(data.states?.map((s: any) => s.state) || []);
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const loadStores = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -53,7 +47,12 @@ export function IntelligenceStores() {
|
||||
state: stateParam,
|
||||
limit: 500,
|
||||
});
|
||||
setStores(data.stores || []);
|
||||
const storeList = data.stores || [];
|
||||
setStores(storeList);
|
||||
|
||||
// Extract unique states from response for dropdown counts
|
||||
const uniqueStates = [...new Set(storeList.map((s: StoreActivity) => s.state))].filter(Boolean).sort() as string[];
|
||||
setLocalStates(uniqueStates);
|
||||
} catch (error) {
|
||||
console.error('Failed to load stores:', error);
|
||||
} finally {
|
||||
@@ -111,60 +110,35 @@ export function IntelligenceStores() {
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Store Activity</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Per-store SKU counts, snapshots, and crawl frequency
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* State Selector */}
|
||||
<div className="dropdown dropdown-end">
|
||||
<button tabIndex={0} className="btn btn-sm gap-2 bg-emerald-50 border-emerald-200 hover:bg-emerald-100">
|
||||
{stateLabel}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<ul tabIndex={0} className="dropdown-content z-50 menu p-2 shadow-lg bg-white rounded-box w-44 max-h-60 overflow-y-auto border border-gray-200">
|
||||
<li>
|
||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active bg-emerald-100' : ''}>
|
||||
All States
|
||||
</a>
|
||||
</li>
|
||||
<div className="divider my-1"></div>
|
||||
{availableStates.map((state) => (
|
||||
<li key={state}>
|
||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active bg-emerald-100' : ''}>
|
||||
{state}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/admin/intelligence/brands')}
|
||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
className="btn btn-sm btn-outline gap-1"
|
||||
>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>Brands</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm gap-1 bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Stores</span>
|
||||
Brands
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/intelligence/pricing')}
|
||||
className="btn btn-sm gap-1 bg-white border-gray-300 text-gray-700 hover:bg-gray-100"
|
||||
className="btn btn-sm btn-outline gap-1"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Pricing</span>
|
||||
Pricing
|
||||
</button>
|
||||
<button
|
||||
onClick={loadStores}
|
||||
className="btn btn-sm btn-outline gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,6 +194,26 @@ export function IntelligenceStores() {
|
||||
className="input input-bordered input-sm w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="dropdown">
|
||||
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
||||
{stateLabel}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
||||
<li>
|
||||
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
||||
All States
|
||||
</a>
|
||||
</li>
|
||||
{localStates.map(state => (
|
||||
<li key={state}>
|
||||
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
||||
{state}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
Showing {filteredStores.length} of {stores.length} stores
|
||||
</span>
|
||||
@@ -253,7 +247,7 @@ export function IntelligenceStores() {
|
||||
<tr
|
||||
key={store.id}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => navigate(`/stores/list/${store.id}`)}
|
||||
onClick={() => navigate(`/admin/orchestrator/stores?storeId=${store.id}`)}
|
||||
>
|
||||
<td>
|
||||
<span className="font-medium">{store.name}</span>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DollarSign,
|
||||
MapPin,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -203,6 +204,7 @@ export default function NationalDashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<NationalSummary | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
@@ -227,6 +229,18 @@ export default function NationalDashboard() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleRefreshMetrics = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await api.post('/api/admin/states/refresh-metrics');
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh metrics:', err);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStateClick = (stateCode: string) => {
|
||||
setSelectedState(stateCode);
|
||||
navigate(`/national/state/${stateCode}`);
|
||||
@@ -263,12 +277,24 @@ export default function NationalDashboard() {
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">National Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Multi-state cannabis market intelligence
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefreshMetrics}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh Metrics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
|
||||
@@ -153,6 +153,29 @@ export function StoreDetailPage() {
|
||||
Back to Stores
|
||||
</button>
|
||||
|
||||
{/* Update Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUpdateDropdown(!showUpdateDropdown)}
|
||||
disabled={isUpdating}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isUpdating ? 'animate-spin' : ''}`} />
|
||||
{isUpdating ? 'Crawling...' : 'Crawl Now'}
|
||||
{!isUpdating && <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{showUpdateDropdown && !isUpdating && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<button
|
||||
onClick={handleCrawl}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Start Full Crawl
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store Header */}
|
||||
@@ -177,7 +200,7 @@ export function StoreDetailPage() {
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
||||
<Clock className="w-4 h-4" />
|
||||
<div>
|
||||
<span className="font-medium">Last Updated:</span>
|
||||
<span className="font-medium">Last Crawl:</span>
|
||||
<span className="ml-2">
|
||||
{lastCrawl?.completed_at
|
||||
? new Date(lastCrawl.completed_at).toLocaleDateString('en-US', {
|
||||
@@ -189,6 +212,15 @@ export function StoreDetailPage() {
|
||||
})
|
||||
: 'Never'}
|
||||
</span>
|
||||
{lastCrawl?.status && (
|
||||
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${
|
||||
lastCrawl.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
lastCrawl.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{lastCrawl.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,8 +282,8 @@ export function StoreDetailPage() {
|
||||
setStockFilter('in_stock');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
stockFilter === 'in_stock' ? 'border-gray-400' : 'border-gray-200'
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
stockFilter === 'in_stock' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -271,8 +303,8 @@ export function StoreDetailPage() {
|
||||
setStockFilter('out_of_stock');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
stockFilter === 'out_of_stock' ? 'border-gray-400' : 'border-gray-200'
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
stockFilter === 'out_of_stock' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -288,8 +320,8 @@ export function StoreDetailPage() {
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('brands')}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
activeTab === 'brands' ? 'border-gray-400' : 'border-gray-200'
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
activeTab === 'brands' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -305,8 +337,8 @@ export function StoreDetailPage() {
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`bg-white rounded-lg border p-4 hover:border-gray-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
activeTab === 'categories' ? 'border-gray-400' : 'border-gray-200'
|
||||
className={`bg-white rounded-lg border p-4 hover:border-blue-300 hover:shadow-md transition-all cursor-pointer text-left ${
|
||||
activeTab === 'categories' ? 'border-blue-500' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -332,7 +364,7 @@ export function StoreDetailPage() {
|
||||
}}
|
||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === 'products'
|
||||
? 'border-gray-800 text-gray-900'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
@@ -342,7 +374,7 @@ export function StoreDetailPage() {
|
||||
onClick={() => setActiveTab('brands')}
|
||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === 'brands'
|
||||
? 'border-gray-800 text-gray-900'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
@@ -352,7 +384,7 @@ export function StoreDetailPage() {
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`py-4 px-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === 'categories'
|
||||
? 'border-gray-800 text-gray-900'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
@@ -401,7 +433,7 @@ export function StoreDetailPage() {
|
||||
|
||||
{productsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-4 border-gray-400 border-t-transparent"></div>
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-4 border-blue-500 border-t-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">Loading products...</p>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
@@ -453,9 +485,9 @@ export function StoreDetailPage() {
|
||||
<div className="line-clamp-2" title={product.brand || '-'}>{product.brand || '-'}</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{product.type || '-'}</span>
|
||||
<span className="badge badge-ghost badge-sm">{product.type || '-'}</span>
|
||||
{product.subcategory && (
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded ml-1">{product.subcategory}</span>
|
||||
<span className="badge badge-ghost badge-sm ml-1">{product.subcategory}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right font-semibold whitespace-nowrap">
|
||||
@@ -468,14 +500,21 @@ export function StoreDetailPage() {
|
||||
`$${product.regular_price}`
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
{product.thc_percentage ? `${product.thc_percentage}%` : '-'}
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.thc_percentage ? (
|
||||
<span className="badge badge-success badge-sm">{product.thc_percentage}%</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
{product.stock_status === 'in_stock' ? 'In Stock' :
|
||||
product.stock_status === 'out_of_stock' ? 'Out' : '-'}
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.stock_status === 'in_stock' ? (
|
||||
<span className="badge badge-success badge-sm">In Stock</span>
|
||||
) : product.stock_status === 'out_of_stock' ? (
|
||||
<span className="badge badge-error badge-sm">Out</span>
|
||||
) : (
|
||||
<span className="badge badge-warning badge-sm">Unknown</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center whitespace-nowrap text-sm text-gray-700">
|
||||
<td className="text-center whitespace-nowrap">
|
||||
{product.total_quantity != null ? product.total_quantity : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-xs text-gray-500">
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
ChevronUp,
|
||||
Gauge,
|
||||
Users,
|
||||
Calendar,
|
||||
Zap,
|
||||
Power,
|
||||
Play,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Task {
|
||||
@@ -82,6 +83,27 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
stale: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string, poolPaused: boolean): React.ReactNode => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4" />;
|
||||
case 'claimed':
|
||||
return <PlayCircle className="w-4 h-4" />;
|
||||
case 'running':
|
||||
// Don't spin when pool is paused
|
||||
return <RefreshCw className={`w-4 h-4 ${!poolPaused ? 'animate-spin' : ''}`} />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4" />;
|
||||
case 'stale':
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Static version for summary cards (always shows animation)
|
||||
const STATUS_ICONS: Record<string, React.ReactNode> = {
|
||||
pending: <Clock className="w-4 h-4" />,
|
||||
claimed: <PlayCircle className="w-4 h-4" />,
|
||||
@@ -116,6 +138,8 @@ export default function TasksDashboard() {
|
||||
const [capacity, setCapacity] = useState<CapacityMetric[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [poolPaused, setPoolPaused] = useState(false);
|
||||
const [poolLoading, setPoolLoading] = useState(false);
|
||||
|
||||
// Filters
|
||||
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||
@@ -123,13 +147,10 @@ export default function TasksDashboard() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCapacity, setShowCapacity] = useState(true);
|
||||
|
||||
// Actions
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [tasksRes, countsRes, capacityRes] = await Promise.all([
|
||||
const [tasksRes, countsRes, capacityRes, poolStatus] = await Promise.all([
|
||||
api.getTasks({
|
||||
role: roleFilter || undefined,
|
||||
status: statusFilter || undefined,
|
||||
@@ -137,11 +158,13 @@ export default function TasksDashboard() {
|
||||
}),
|
||||
api.getTaskCounts(),
|
||||
api.getTaskCapacity(),
|
||||
api.getTaskPoolStatus(),
|
||||
]);
|
||||
|
||||
setTasks(tasksRes.tasks || []);
|
||||
setCounts(countsRes);
|
||||
setCapacity(capacityRes.metrics || []);
|
||||
setPoolPaused(poolStatus.paused);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load tasks');
|
||||
@@ -150,40 +173,29 @@ export default function TasksDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const togglePool = async () => {
|
||||
setPoolLoading(true);
|
||||
try {
|
||||
if (poolPaused) {
|
||||
await api.resumeTaskPool();
|
||||
setPoolPaused(false);
|
||||
} else {
|
||||
await api.pauseTaskPool();
|
||||
setPoolPaused(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to toggle pool');
|
||||
} finally {
|
||||
setPoolLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 10000); // Refresh every 10 seconds
|
||||
const interval = setInterval(fetchData, 15000); // Auto-refresh every 15 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [roleFilter, statusFilter]);
|
||||
|
||||
const handleGenerateResync = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const result = await api.generateResyncTasks();
|
||||
setActionMessage(`Generated ${result.tasks_created} resync tasks`);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
setActionMessage(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
setTimeout(() => setActionMessage(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecoverStale = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const result = await api.recoverStaleTasks();
|
||||
setActionMessage(`Recovered ${result.tasks_recovered} stale tasks`);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
setActionMessage(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
setTimeout(() => setActionMessage(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
@@ -225,45 +237,32 @@ export default function TasksDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Pool Toggle */}
|
||||
<button
|
||||
onClick={handleGenerateResync}
|
||||
disabled={actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Generate Resync
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRecoverStale}
|
||||
disabled={actionLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Recover Stale
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Message */}
|
||||
{actionMessage && (
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
actionMessage.startsWith('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
onClick={togglePool}
|
||||
disabled={poolLoading}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
poolPaused
|
||||
? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
}`}
|
||||
>
|
||||
{actionMessage}
|
||||
</div>
|
||||
{poolPaused ? (
|
||||
<>
|
||||
<Play className={`w-5 h-5 ${poolLoading ? 'animate-pulse' : ''}`} />
|
||||
Start Pool
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className={`w-5 h-5 ${poolLoading ? 'animate-pulse' : ''}`} />
|
||||
Stop Pool
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-gray-400">Auto-refreshes every 15s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 rounded-lg">{error}</div>
|
||||
@@ -496,7 +495,7 @@ export default function TasksDashboard() {
|
||||
STATUS_COLORS[task.status]
|
||||
}`}
|
||||
>
|
||||
{STATUS_ICONS[task.status]}
|
||||
{getStatusIcon(task.status, poolPaused)}
|
||||
{task.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user