feat: Add payloads dashboard, disable snapshots, fix scheduler

Frontend:
- Add PayloadsDashboard page with search, filter, view, and diff
- Update TasksDashboard default sort: pending → claimed → completed
- Add payload API methods to api.ts

Backend:
- Disable snapshot creation in product-refresh handler
- Remove product_refresh from schedule role options
- Disable compression in payload-storage (plain JSON for debugging)
- Fix task-scheduler: map 'embedded' menu_type to 'dutchie' platform
- Fix task-scheduler: use schedule.interval_hours as skipRecentHours

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-14 11:54:25 -07:00
parent 15cb657f13
commit 60b221e7fb
7 changed files with 764 additions and 58 deletions

View File

@@ -323,9 +323,13 @@ class TaskScheduler {
}
// Group dispensaries by platform (menu_type)
// Map 'embedded' to 'dutchie' since embedded is just a menu display type, not a platform
const byPlatform: Record<string, number[]> = {};
for (const d of dispensaries) {
const platform = d.menu_type || 'dutchie';
let platform = d.menu_type || 'dutchie';
if (platform === 'embedded') {
platform = 'dutchie';
}
if (!byPlatform[platform]) {
byPlatform[platform] = [];
}
@@ -333,6 +337,7 @@ class TaskScheduler {
}
// Create tasks per platform so each task routes to the correct handler
// Use schedule.interval_hours as skipRecentHours to allow re-runs at schedule frequency
let totalCreated = 0;
for (const [platform, ids] of Object.entries(byPlatform)) {
if (ids.length > 0) {
@@ -346,6 +351,7 @@ class TaskScheduler {
{
source: 'schedule',
source_schedule_id: schedule.id,
skipRecentHours: schedule.interval_hours, // Match schedule frequency
}
);
totalCreated += created;

View File

@@ -241,19 +241,9 @@ export async function handleProductRefresh(ctx: TaskContext): Promise<TaskResult
await ctx.heartbeat();
// Create snapshots
console.log(`[ProductRefresh] Creating snapshots...`);
const snapshotsResult = await createStoreProductSnapshots(
pool,
dispensaryId,
normalizationResult.products,
normalizationResult.pricing,
normalizationResult.availability,
null // No crawl_run_id in new system
);
console.log(`[ProductRefresh] Created ${snapshotsResult.created} snapshots`);
// Snapshots disabled - logic needs review
// TODO: Re-enable when snapshot strategy is finalized
const snapshotsResult = { created: 0 };
await ctx.heartbeat();

View File

@@ -128,14 +128,15 @@ function generateStoragePath(dispensaryId: number, timestamp: Date, platform: st
const ts = timestamp.getTime();
const taskHash = taskId ? `_t${taskIdHash(taskId)}` : '';
const relativePath = `payloads/${platform}/${year}/${month}/${day}/store_${dispensaryId}${taskHash}_${ts}.json.gz`;
// Compression disabled - store as plain JSON for easier debugging
const relativePath = `payloads/${platform}/${year}/${month}/${day}/store_${dispensaryId}${taskHash}_${ts}.json`;
if (useMinIO) {
// MinIO uses forward slashes, no leading slash
return relativePath;
} else {
// Local filesystem uses OS-specific path
return path.join(PAYLOAD_BASE_PATH, platform, String(year), month, day, `store_${dispensaryId}${taskHash}_${ts}.json.gz`);
return path.join(PAYLOAD_BASE_PATH, platform, String(year), month, day, `store_${dispensaryId}${taskHash}_${ts}.json`);
}
}
@@ -178,28 +179,26 @@ export async function saveRawPayload(
const timestamp = new Date();
const storagePath = generateStoragePath(dispensaryId, timestamp, platform, taskId);
// Serialize and compress
const jsonStr = JSON.stringify(payload);
// Serialize as plain JSON (compression disabled for easier debugging)
const jsonStr = JSON.stringify(payload, null, 2);
const rawSize = Buffer.byteLength(jsonStr, 'utf8');
const compressed = await gzip(Buffer.from(jsonStr, 'utf8'));
const compressedSize = compressed.length;
const checksum = calculateChecksum(compressed);
const jsonBuffer = Buffer.from(jsonStr, 'utf8');
const checksum = calculateChecksum(jsonBuffer);
// Write to storage backend
if (useMinIO) {
// Upload to MinIO
const client = getMinioClient();
await client.putObject(MINIO_BUCKET, storagePath, compressed, compressedSize, {
'Content-Type': 'application/gzip',
'Content-Encoding': 'gzip',
await client.putObject(MINIO_BUCKET, storagePath, jsonBuffer, rawSize, {
'Content-Type': 'application/json',
});
} else {
// Write to local filesystem
await ensureDir(storagePath);
await fs.promises.writeFile(storagePath, compressed);
await fs.promises.writeFile(storagePath, jsonBuffer);
}
// Record metadata in DB
// Record metadata in DB (size_bytes = size_bytes_raw since no compression)
const result = await pool.query(`
INSERT INTO raw_crawl_payloads (
crawl_run_id,
@@ -217,7 +216,7 @@ export async function saveRawPayload(
dispensaryId,
storagePath,
productCount,
compressedSize,
rawSize, // No compression, same as raw
rawSize,
timestamp,
checksum
@@ -229,12 +228,12 @@ export async function saveRawPayload(
`, [dispensaryId, timestamp]);
const backend = useMinIO ? 'MinIO' : 'local';
console.log(`[PayloadStorage] Saved payload to ${backend} for store ${dispensaryId}: ${storagePath} (${(compressedSize / 1024).toFixed(1)}KB compressed, ${(rawSize / 1024).toFixed(1)}KB raw)`);
console.log(`[PayloadStorage] Saved payload to ${backend} for store ${dispensaryId}: ${storagePath} (${(rawSize / 1024).toFixed(1)}KB)`);
return {
id: result.rows[0].id,
storagePath,
sizeBytes: compressedSize,
sizeBytes: rawSize,
sizeBytesRaw: rawSize,
checksum
};
@@ -284,7 +283,7 @@ export async function loadRawPayloadById(
* @returns Parsed JSON payload
*/
export async function loadPayloadFromPath(storagePath: string): Promise<any> {
let compressed: Buffer;
let fileBuffer: Buffer;
// Determine if path looks like MinIO key (starts with payloads/) or local path
const isMinIOPath = storagePath.startsWith('payloads/') && useMinIO;
@@ -298,14 +297,19 @@ export async function loadPayloadFromPath(storagePath: string): Promise<any> {
for await (const chunk of stream) {
chunks.push(chunk as Buffer);
}
compressed = Buffer.concat(chunks);
fileBuffer = Buffer.concat(chunks);
} else {
// Read from local filesystem
compressed = await fs.promises.readFile(storagePath);
fileBuffer = await fs.promises.readFile(storagePath);
}
const decompressed = await gunzip(compressed);
// Handle both compressed (.gz) and uncompressed (.json) files
if (storagePath.endsWith('.gz')) {
const decompressed = await gunzip(fileBuffer);
return JSON.parse(decompressed.toString('utf8'));
} else {
return JSON.parse(fileBuffer.toString('utf8'));
}
}
/**
@@ -490,12 +494,13 @@ function generateDiscoveryStoragePath(stateCode: string, timestamp: Date): strin
const day = String(timestamp.getDate()).padStart(2, '0');
const ts = timestamp.getTime();
const relativePath = `payloads/discovery/${year}/${month}/${day}/state_${stateCode.toLowerCase()}_${ts}.json.gz`;
// Compression disabled - store as plain JSON
const relativePath = `payloads/discovery/${year}/${month}/${day}/state_${stateCode.toLowerCase()}_${ts}.json`;
if (useMinIO) {
return relativePath;
} else {
return path.join(PAYLOAD_BASE_PATH, 'discovery', String(year), month, day, `state_${stateCode.toLowerCase()}_${ts}.json.gz`);
return path.join(PAYLOAD_BASE_PATH, 'discovery', String(year), month, day, `state_${stateCode.toLowerCase()}_${ts}.json`);
}
}
@@ -517,28 +522,26 @@ export async function saveDiscoveryPayload(
const timestamp = new Date();
const storagePath = generateDiscoveryStoragePath(stateCode, timestamp);
// Serialize and compress
const jsonStr = JSON.stringify(payload);
// Serialize as plain JSON (compression disabled for easier debugging)
const jsonStr = JSON.stringify(payload, null, 2);
const rawSize = Buffer.byteLength(jsonStr, 'utf8');
const compressed = await gzip(Buffer.from(jsonStr, 'utf8'));
const compressedSize = compressed.length;
const checksum = calculateChecksum(compressed);
const jsonBuffer = Buffer.from(jsonStr, 'utf8');
const checksum = calculateChecksum(jsonBuffer);
// Write to storage backend
if (useMinIO) {
// Upload to MinIO
const client = getMinioClient();
await client.putObject(MINIO_BUCKET, storagePath, compressed, compressedSize, {
'Content-Type': 'application/gzip',
'Content-Encoding': 'gzip',
await client.putObject(MINIO_BUCKET, storagePath, jsonBuffer, rawSize, {
'Content-Type': 'application/json',
});
} else {
// Write to local filesystem
await ensureDir(storagePath);
await fs.promises.writeFile(storagePath, compressed);
await fs.promises.writeFile(storagePath, jsonBuffer);
}
// Record metadata in DB
// Record metadata in DB (size_bytes = size_bytes_raw since no compression)
const result = await pool.query(`
INSERT INTO raw_crawl_payloads (
payload_type,
@@ -556,19 +559,19 @@ export async function saveDiscoveryPayload(
stateCode.toUpperCase(),
storagePath,
storeCount,
compressedSize,
rawSize,
rawSize,
timestamp,
checksum
]);
const backend = useMinIO ? 'MinIO' : 'local';
console.log(`[PayloadStorage] Saved discovery payload to ${backend} for ${stateCode}: ${storagePath} (${storeCount} stores, ${(compressedSize / 1024).toFixed(1)}KB compressed)`);
console.log(`[PayloadStorage] Saved discovery payload to ${backend} for ${stateCode}: ${storagePath} (${storeCount} stores, ${(rawSize / 1024).toFixed(1)}KB)`);
return {
id: result.rows[0].id,
storagePath,
sizeBytes: compressedSize,
sizeBytes: rawSize,
sizeBytesRaw: rawSize,
checksum
};

View File

@@ -49,6 +49,7 @@ import { Discovery } from './pages/Discovery';
import { WorkersDashboard } from './pages/WorkersDashboard';
import { ProxyManagement } from './pages/ProxyManagement';
import TasksDashboard from './pages/TasksDashboard';
import { PayloadsDashboard } from './pages/PayloadsDashboard';
import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard';
import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator';
import { StatePage } from './pages/public/StatePage';
@@ -131,6 +132,7 @@ export default function App() {
<Route path="/proxies" element={<PrivateRoute><ProxyManagement /></PrivateRoute>} />
{/* Task Queue Dashboard */}
<Route path="/tasks" element={<PrivateRoute><TasksDashboard /></PrivateRoute>} />
<Route path="/payloads" element={<PrivateRoute><PayloadsDashboard /></PrivateRoute>} />
{/* Scraper Overview Dashboard (new primary) */}
<Route path="/scraper/overview" element={<PrivateRoute><ScraperOverviewDashboard /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />

View File

@@ -3128,6 +3128,70 @@ class ApiClient {
{ method: 'POST' }
);
}
// ============================================================
// PAYLOADS API
// ============================================================
async getPayloads(params?: { limit?: number; offset?: number; dispensary_id?: number }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', String(params.limit));
if (params?.offset) searchParams.append('offset', String(params.offset));
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
const query = searchParams.toString();
return this.request<{
success: boolean;
payloads: PayloadMetadata[];
pagination: { limit: number; offset: number };
}>(`/api/payloads${query ? '?' + query : ''}`);
}
async getPayload(id: number) {
return this.request<{
success: boolean;
payload: PayloadMetadata & { dispensary_name: string };
}>(`/api/payloads/${id}`);
}
async getPayloadData(id: number) {
return this.request<{
success: boolean;
metadata: { id: number; dispensaryId: number; productCount: number; fetchedAt: string; storagePath: string };
data: any;
}>(`/api/payloads/${id}/data`);
}
async getStorePayloads(dispensaryId: number, params?: { limit?: number; offset?: number }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', String(params.limit));
if (params?.offset) searchParams.append('offset', String(params.offset));
const query = searchParams.toString();
return this.request<{
success: boolean;
dispensaryId: number;
payloads: PayloadMetadata[];
pagination: { limit: number; offset: number };
}>(`/api/payloads/store/${dispensaryId}${query ? '?' + query : ''}`);
}
async getPayloadDiff(dispensaryId: number, fromId?: number, toId?: number) {
const searchParams = new URLSearchParams();
if (fromId) searchParams.append('from', String(fromId));
if (toId) searchParams.append('to', String(toId));
const query = searchParams.toString();
return this.request<{
success: boolean;
from: { id: number; fetchedAt: string; productCount: number };
to: { id: number; fetchedAt: string; productCount: number };
diff: { added: number; removed: number; priceChanges: number; stockChanges: number };
details: {
added: Array<{ id: string; name: string; brand?: string; price?: number }>;
removed: Array<{ id: string; name: string; brand?: string; price?: number }>;
priceChanges: Array<{ id: string; name: string; brand?: string; oldPrice: number; newPrice: number; change: number }>;
stockChanges: Array<{ id: string; name: string; brand?: string; oldStatus: string; newStatus: string }>;
};
}>(`/api/payloads/store/${dispensaryId}/diff${query ? '?' + query : ''}`);
}
}
// Type for task schedules
@@ -3155,4 +3219,17 @@ export interface TaskSchedule {
updated_at: string;
}
// Type for payload metadata
export interface PayloadMetadata {
id: number;
dispensaryId: number;
crawlRunId: number | null;
storagePath: string;
productCount: number;
sizeBytes: number;
sizeBytesRaw: number;
fetchedAt: string;
dispensary_name?: string;
}
export const api = new ApiClient(API_URL);

View File

@@ -0,0 +1,611 @@
import { useState, useEffect } from 'react';
import { api, PayloadMetadata } from '../lib/api';
import { Layout } from '../components/Layout';
import {
Database,
Search,
ChevronLeft,
ChevronRight,
FileJson,
Clock,
Store,
Package,
HardDrive,
Eye,
X,
RefreshCw,
ArrowUpDown,
ArrowUp,
ArrowDown,
GitCompare,
} from 'lucide-react';
interface Store {
id: number;
name: string;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
export function PayloadsDashboard() {
const [payloads, setPayloads] = useState<PayloadMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [limit] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [search, setSearch] = useState('');
const [stores, setStores] = useState<Store[]>([]);
const [selectedStoreId, setSelectedStoreId] = useState<number | null>(null);
const [storeSearch, setStoreSearch] = useState('');
const [showStoreDropdown, setShowStoreDropdown] = useState(false);
// View modal
const [viewingPayload, setViewingPayload] = useState<number | null>(null);
const [payloadData, setPayloadData] = useState<any>(null);
const [loadingPayload, setLoadingPayload] = useState(false);
// Diff modal
const [diffing, setDiffing] = useState(false);
const [diffResult, setDiffResult] = useState<any>(null);
const [diffStoreId, setDiffStoreId] = useState<number | null>(null);
// Sorting
const [sortField, setSortField] = useState<'fetchedAt' | 'productCount' | 'sizeBytes'>('fetchedAt');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
fetchPayloads();
}, [page, selectedStoreId]);
useEffect(() => {
fetchStores();
}, []);
const fetchPayloads = async () => {
try {
setLoading(true);
setError(null);
const result = await api.getPayloads({
limit,
offset: page * limit,
dispensary_id: selectedStoreId || undefined,
});
setPayloads(result.payloads);
// Estimate total - if we got a full page, there's probably more
if (result.payloads.length === limit) {
setTotalCount((page + 2) * limit);
} else {
setTotalCount(page * limit + result.payloads.length);
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const fetchStores = async () => {
try {
const result = await api.get<{ dispensaries: Store[] }>('/api/dispensaries', {
params: { limit: 500 }
});
setStores(result.data.dispensaries || []);
} catch (err) {
console.error('Failed to fetch stores:', err);
}
};
const viewPayload = async (id: number) => {
setViewingPayload(id);
setLoadingPayload(true);
try {
const result = await api.getPayloadData(id);
setPayloadData(result);
} catch (err: any) {
setError(err.message);
} finally {
setLoadingPayload(false);
}
};
const viewDiff = async (dispensaryId: number) => {
setDiffStoreId(dispensaryId);
setDiffing(true);
try {
const result = await api.getPayloadDiff(dispensaryId);
setDiffResult(result);
} catch (err: any) {
setError(err.message);
setDiffing(false);
}
};
const closeDiff = () => {
setDiffing(false);
setDiffResult(null);
setDiffStoreId(null);
};
const filteredStores = stores.filter(s =>
s.name.toLowerCase().includes(storeSearch.toLowerCase())
);
const sortedPayloads = [...payloads].sort((a, b) => {
let aVal: any, bVal: any;
switch (sortField) {
case 'fetchedAt':
aVal = new Date(a.fetchedAt).getTime();
bVal = new Date(b.fetchedAt).getTime();
break;
case 'productCount':
aVal = a.productCount;
bVal = b.productCount;
break;
case 'sizeBytes':
aVal = a.sizeBytes;
bVal = b.sizeBytes;
break;
}
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
});
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('desc');
}
};
const SortIcon = ({ field }: { field: typeof sortField }) => {
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 opacity-40" />;
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />;
};
return (
<Layout>
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Database className="w-8 h-8 text-purple-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">Payloads</h1>
<p className="text-sm text-gray-500">Raw crawl data stored in MinIO</p>
</div>
</div>
<button
onClick={fetchPayloads}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-wrap gap-4">
{/* Store Filter */}
<div className="relative">
<label className="block text-xs font-medium text-gray-500 mb-1">Store</label>
<div className="relative">
<button
onClick={() => setShowStoreDropdown(!showStoreDropdown)}
className="flex items-center gap-2 px-3 py-2 border rounded-lg bg-white min-w-[200px] text-left"
>
<Store className="w-4 h-4 text-gray-400" />
<span className="flex-1 truncate">
{selectedStoreId
? stores.find(s => s.id === selectedStoreId)?.name || `Store #${selectedStoreId}`
: 'All Stores'}
</span>
</button>
{showStoreDropdown && (
<div className="absolute z-50 mt-1 w-80 bg-white border rounded-lg shadow-lg max-h-80 overflow-auto">
<div className="p-2 border-b sticky top-0 bg-white">
<input
type="text"
placeholder="Search stores..."
value={storeSearch}
onChange={(e) => setStoreSearch(e.target.value)}
className="w-full px-3 py-2 border rounded text-sm"
autoFocus
/>
</div>
<div
onClick={() => {
setSelectedStoreId(null);
setShowStoreDropdown(false);
setPage(0);
}}
className="px-3 py-2 hover:bg-gray-50 cursor-pointer text-sm"
>
All Stores
</div>
{filteredStores.slice(0, 50).map(store => (
<div
key={store.id}
onClick={() => {
setSelectedStoreId(store.id);
setShowStoreDropdown(false);
setPage(0);
}}
className={`px-3 py-2 hover:bg-gray-50 cursor-pointer text-sm ${
selectedStoreId === store.id ? 'bg-purple-50 text-purple-700' : ''
}`}
>
{store.name}
</div>
))}
</div>
)}
</div>
</div>
{/* Search - future */}
<div className="flex-1 min-w-[200px]">
<label className="block text-xs font-medium text-gray-500 mb-1">Search</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search by path..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg text-sm"
/>
</div>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-red-700">
{error}
</div>
)}
{/* Table */}
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Store</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('fetchedAt')}
>
<div className="flex items-center gap-1">
Fetched <SortIcon field="fetchedAt" />
</div>
</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('productCount')}
>
<div className="flex items-center gap-1">
Products <SortIcon field="productCount" />
</div>
</th>
<th
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('sizeBytes')}
>
<div className="flex items-center gap-1">
Size <SortIcon field="sizeBytes" />
</div>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Path</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
Loading payloads...
</td>
</tr>
) : sortedPayloads.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
No payloads found
</td>
</tr>
) : (
sortedPayloads.map((payload) => (
<tr key={payload.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<span className="font-mono text-sm text-gray-600">#{payload.id}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Store className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium truncate max-w-[200px]">
{payload.dispensary_name || `Store #${payload.dispensaryId}`}
</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-600" title={payload.fetchedAt}>
{formatTimeAgo(payload.fetchedAt)}
</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium">{payload.productCount.toLocaleString()}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-600">{formatBytes(payload.sizeBytes)}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="font-mono text-xs text-gray-500 truncate max-w-[250px] block" title={payload.storagePath}>
{payload.storagePath.split('/').slice(-1)[0]}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => viewPayload(payload.id)}
className="p-1.5 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded"
title="View payload"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => viewDiff(payload.dispensaryId)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded"
title="Compare with previous"
>
<GitCompare className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
{/* Pagination */}
<div className="px-4 py-3 border-t bg-gray-50 flex items-center justify-between">
<div className="text-sm text-gray-500">
Showing {page * limit + 1} - {page * limit + payloads.length} of ~{totalCount}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-600">Page {page + 1}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={payloads.length < limit}
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* View Payload Modal */}
{viewingPayload && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-[90vw] max-w-5xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3">
<FileJson className="w-5 h-5 text-purple-600" />
<h2 className="text-lg font-semibold">Payload #{viewingPayload}</h2>
</div>
<button
onClick={() => {
setViewingPayload(null);
setPayloadData(null);
}}
className="p-2 hover:bg-gray-100 rounded"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-4">
{loadingPayload ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : payloadData ? (
<div>
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Store:</span>
<span className="ml-2 font-medium">#{payloadData.metadata.dispensaryId}</span>
</div>
<div>
<span className="text-gray-500">Products:</span>
<span className="ml-2 font-medium">{payloadData.metadata.productCount}</span>
</div>
<div>
<span className="text-gray-500">Fetched:</span>
<span className="ml-2 font-medium">{new Date(payloadData.metadata.fetchedAt).toLocaleString()}</span>
</div>
<div>
<span className="text-gray-500">Path:</span>
<span className="ml-2 font-mono text-xs">{payloadData.metadata.storagePath}</span>
</div>
</div>
</div>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-auto text-xs max-h-[60vh]">
{JSON.stringify(payloadData.data, null, 2)}
</pre>
</div>
) : (
<div className="text-center text-gray-500 py-12">Failed to load payload</div>
)}
</div>
</div>
</div>
)}
{/* Diff Modal */}
{diffing && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-[90vw] max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3">
<GitCompare className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold">Payload Diff - Store #{diffStoreId}</h2>
</div>
<button onClick={closeDiff} className="p-2 hover:bg-gray-100 rounded">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-4">
{!diffResult ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : (
<div>
{/* Summary */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-sm text-gray-500 mb-1">From (Previous)</div>
<div className="font-medium">Payload #{diffResult.from.id}</div>
<div className="text-sm text-gray-600">{new Date(diffResult.from.fetchedAt).toLocaleString()}</div>
<div className="text-sm text-gray-600">{diffResult.from.productCount} products</div>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-sm text-gray-500 mb-1">To (Latest)</div>
<div className="font-medium">Payload #{diffResult.to.id}</div>
<div className="text-sm text-gray-600">{new Date(diffResult.to.fetchedAt).toLocaleString()}</div>
<div className="text-sm text-gray-600">{diffResult.to.productCount} products</div>
</div>
</div>
{/* Diff Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg text-center">
<div className="text-2xl font-bold text-green-700">{diffResult.diff.added}</div>
<div className="text-sm text-green-600">Added</div>
</div>
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-center">
<div className="text-2xl font-bold text-red-700">{diffResult.diff.removed}</div>
<div className="text-sm text-red-600">Removed</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-center">
<div className="text-2xl font-bold text-yellow-700">{diffResult.diff.priceChanges}</div>
<div className="text-sm text-yellow-600">Price Changes</div>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-700">{diffResult.diff.stockChanges}</div>
<div className="text-sm text-blue-600">Stock Changes</div>
</div>
</div>
{/* Details */}
{diffResult.details.added.length > 0 && (
<div className="mb-4">
<h3 className="font-medium text-green-700 mb-2">Added Products ({diffResult.details.added.length})</h3>
<div className="bg-green-50 rounded-lg p-3 max-h-40 overflow-auto">
{diffResult.details.added.slice(0, 20).map((p: any, i: number) => (
<div key={i} className="text-sm py-1">
<span className="font-medium">{p.name}</span>
{p.brand && <span className="text-gray-500 ml-2">({p.brand})</span>}
{p.price && <span className="text-green-600 ml-2">${p.price}</span>}
</div>
))}
{diffResult.details.added.length > 20 && (
<div className="text-sm text-gray-500 mt-2">...and {diffResult.details.added.length - 20} more</div>
)}
</div>
</div>
)}
{diffResult.details.removed.length > 0 && (
<div className="mb-4">
<h3 className="font-medium text-red-700 mb-2">Removed Products ({diffResult.details.removed.length})</h3>
<div className="bg-red-50 rounded-lg p-3 max-h-40 overflow-auto">
{diffResult.details.removed.slice(0, 20).map((p: any, i: number) => (
<div key={i} className="text-sm py-1">
<span className="font-medium">{p.name}</span>
{p.brand && <span className="text-gray-500 ml-2">({p.brand})</span>}
</div>
))}
{diffResult.details.removed.length > 20 && (
<div className="text-sm text-gray-500 mt-2">...and {diffResult.details.removed.length - 20} more</div>
)}
</div>
</div>
)}
{diffResult.details.priceChanges.length > 0 && (
<div className="mb-4">
<h3 className="font-medium text-yellow-700 mb-2">Price Changes ({diffResult.details.priceChanges.length})</h3>
<div className="bg-yellow-50 rounded-lg p-3 max-h-40 overflow-auto">
{diffResult.details.priceChanges.slice(0, 20).map((p: any, i: number) => (
<div key={i} className="text-sm py-1 flex items-center gap-2">
<span className="font-medium">{p.name}</span>
<span className="text-gray-500">${p.oldPrice}</span>
<span className="text-gray-400"></span>
<span className={p.change > 0 ? 'text-red-600' : 'text-green-600'}>
${p.newPrice} ({p.change > 0 ? '+' : ''}{p.change?.toFixed(2)})
</span>
</div>
))}
{diffResult.details.priceChanges.length > 20 && (
<div className="text-sm text-gray-500 mt-2">...and {diffResult.details.priceChanges.length - 20} more</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
</Layout>
);
}
export default PayloadsDashboard;

View File

@@ -106,15 +106,14 @@ interface CreateTaskModalProps {
}
const TASK_ROLES = [
{ id: 'product_refresh', name: 'Product Resync', description: 'Re-crawl products for price/stock changes' },
{ id: 'product_discovery', name: 'Product Discovery', description: 'Initial crawl for new dispensaries' },
{ id: 'product_discovery', name: 'Product Discovery', description: 'Crawl products from dispensary menu' },
{ id: 'store_discovery', name: 'Store Discovery', description: 'Discover new dispensary locations' },
{ id: 'entry_point_discovery', name: 'Entry Point Discovery', description: 'Resolve platform IDs from menu URLs' },
{ id: 'analytics_refresh', name: 'Analytics Refresh', description: 'Refresh materialized views' },
];
function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProps) {
const [role, setRole] = useState('product_refresh');
const [role, setRole] = useState('product_discovery');
const [priority, setPriority] = useState(10);
const [scheduleType, setScheduleType] = useState<'now' | 'scheduled'>('now');
const [scheduledFor, setScheduledFor] = useState('');
@@ -588,7 +587,7 @@ interface ScheduleEditModalProps {
function ScheduleEditModal({ isOpen, schedule, onClose, onSave }: ScheduleEditModalProps) {
const [name, setName] = useState('');
const [role, setRole] = useState('product_refresh');
const [role, setRole] = useState('product_discovery');
const [description, setDescription] = useState('');
const [enabled, setEnabled] = useState(true);
const [intervalHours, setIntervalHours] = useState(4);
@@ -622,7 +621,7 @@ function ScheduleEditModal({ isOpen, schedule, onClose, onSave }: ScheduleEditMo
} else {
// Reset for new schedule
setName('');
setRole('product_refresh');
setRole('product_discovery');
setDescription('');
setEnabled(true);
setIntervalHours(4);
@@ -974,8 +973,8 @@ export default function TasksDashboard() {
const [showCapacity, setShowCapacity] = useState(true);
// Sorting
const [sortColumn, setSortColumn] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [sortColumn, setSortColumn] = useState<string>('status');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); // asc = pending first
// Pools for filter dropdown
const [pools, setPools] = useState<TaskPool[]>([]);
@@ -1168,7 +1167,16 @@ export default function TasksDashboard() {
return true;
});
// Sorting
// Sorting - default sort by status priority (pending, claimed/running, completed/failed)
// Within each status group, sort by newest first
const statusPriority: Record<string, number> = {
pending: 0,
claimed: 1,
running: 1,
completed: 2,
failed: 3,
};
const sortedTasks = [...filteredTasks].sort((a, b) => {
const dir = sortDirection === 'asc' ? 1 : -1;
switch (sortColumn) {
@@ -1179,7 +1187,16 @@ export default function TasksDashboard() {
case 'store':
return (a.dispensary_name || '').localeCompare(b.dispensary_name || '') * dir;
case 'status':
return a.status.localeCompare(b.status) * dir;
// Sort by status priority, then by time within each group
const aPriority = statusPriority[a.status] ?? 99;
const bPriority = statusPriority[b.status] ?? 99;
if (aPriority !== bPriority) {
return (aPriority - bPriority) * dir;
}
// Within same status, sort by newest first (created_at for pending, completed_at for completed)
const aTime = a.status === 'completed' ? new Date(a.completed_at || 0).getTime() : new Date(a.created_at).getTime();
const bTime = b.status === 'completed' ? new Date(b.completed_at || 0).getTime() : new Date(b.created_at).getTime();
return (bTime - aTime); // Newest first within group
case 'worker':
return (getWorkerName(a)).localeCompare(getWorkerName(b)) * dir;
case 'duration':