feat(orchestrator): Add crawl_enabled filter to stores page

- Backend: Filter stores by crawl_enabled (default: enabled only)
- API: Support crawl_enabled param in getOrchestratorStores
- UI: Add Enabled/Disabled/All filter toggle buttons
- UI: Show crawl status icon in stores table

🤖 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-08 14:18:28 -07:00
parent 39aebfcb82
commit 9711d594db
3 changed files with 91 additions and 7 deletions

View File

@@ -117,12 +117,13 @@ router.get('/states', async (_req: Request, res: Response) => {
* Returns list of stores with orchestrator status info
* Query params:
* - state: Filter by state (e.g., "AZ")
* - crawl_enabled: Filter by crawl status (default: true, use "all" to show all, "false" for disabled only)
* - limit: Max results (default 100)
* - offset: Pagination offset
*/
router.get('/stores', async (req: Request, res: Response) => {
try {
const { state, limit = '100', offset = '0' } = req.query;
const { state, crawl_enabled, limit = '100', offset = '0' } = req.query;
let whereClause = 'WHERE 1=1';
const params: any[] = [];
@@ -134,6 +135,16 @@ router.get('/stores', async (req: Request, res: Response) => {
paramIndex++;
}
// Filter by crawl_enabled - defaults to showing only enabled
if (crawl_enabled === 'false' || crawl_enabled === '0') {
whereClause += ` AND (d.crawl_enabled = false OR d.crawl_enabled IS NULL)`;
} else if (crawl_enabled === 'all') {
// Show all (no filter)
} else {
// Default: show only enabled
whereClause += ` AND d.crawl_enabled = true`;
}
params.push(parseInt(limit as string, 10), parseInt(offset as string, 10));
const { rows } = await pool.query(`
@@ -145,6 +156,7 @@ router.get('/stores', async (req: Request, res: Response) => {
d.menu_type as provider,
d.platform_dispensary_id,
d.last_crawl_at,
d.crawl_enabled,
dcp.id as profile_id,
dcp.profile_key,
COALESCE(dcp.status, dcp.config->>'status', 'legacy') as crawler_status,
@@ -187,6 +199,7 @@ router.get('/stores', async (req: Request, res: Response) => {
provider_raw: r.provider || null,
provider_display: getProviderDisplayName(r.provider),
platformDispensaryId: r.platform_dispensary_id,
crawlEnabled: r.crawl_enabled ?? false,
status: r.crawler_status || (r.platform_dispensary_id ? 'legacy' : 'pending'),
profileId: r.profile_id,
profileKey: r.profile_key,

View File

@@ -1216,9 +1216,10 @@ class ApiClient {
}>('/api/admin/orchestrator/states');
}
async getOrchestratorStores(params?: { state?: string; limit?: number; offset?: number }) {
async getOrchestratorStores(params?: { state?: string; crawl_enabled?: string; limit?: number; offset?: number }) {
const searchParams = new URLSearchParams();
if (params?.state && params.state !== 'all') searchParams.append('state', params.state);
if (params?.crawl_enabled) searchParams.append('crawl_enabled', params.crawl_enabled);
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';

View File

@@ -10,6 +10,8 @@ import {
AlertTriangle,
XCircle,
RefreshCw,
Power,
PowerOff,
} from 'lucide-react';
interface StoreInfo {
@@ -27,6 +29,7 @@ interface StoreInfo {
lastSuccessAt: string | null;
lastFailureAt: string | null;
productCount: number;
crawlEnabled?: boolean;
}
const STATUS_FILTERS: Record<string, { label: string; match: (s: string) => boolean; icon: React.ReactNode; color: string }> = {
@@ -70,15 +73,19 @@ export function OrchestratorStores() {
const [totalStores, setTotalStores] = useState(0);
const statusFilter = searchParams.get('status') || 'all';
const crawlEnabledFilter = searchParams.get('crawl_enabled') || 'true'; // Default to enabled only
useEffect(() => {
loadStores();
}, []);
}, [crawlEnabledFilter]);
const loadStores = async () => {
try {
setLoading(true);
const data = await api.getOrchestratorStores({ limit: 500 });
const data = await api.getOrchestratorStores({
limit: 500,
crawl_enabled: crawlEnabledFilter,
});
setStores(data.stores || []);
setTotalStores(data.total || 0);
} catch (error) {
@@ -88,6 +95,20 @@ export function OrchestratorStores() {
}
};
const setCrawlEnabledFilter = (value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value === 'true') {
newParams.delete('crawl_enabled'); // Default is enabled
} else {
newParams.set('crawl_enabled', value);
}
// Preserve status filter if set
if (statusFilter !== 'all') {
newParams.set('status', statusFilter);
}
setSearchParams(newParams);
};
const filteredStores = stores.filter((store) => {
const filter = STATUS_FILTERS[statusFilter];
return filter ? filter.match(store.status) : true;
@@ -184,12 +205,51 @@ export function OrchestratorStores() {
</button>
</div>
{/* Crawl Enabled Filter */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-gray-600">Crawl Status:</span>
<div className="flex gap-2">
<button
onClick={() => setCrawlEnabledFilter('true')}
className={`btn btn-sm gap-2 ${
crawlEnabledFilter === 'true' ? 'btn-success' : 'btn-outline'
}`}
>
<Power className={`w-4 h-4 ${crawlEnabledFilter === 'true' ? 'text-white' : 'text-green-600'}`} />
Enabled
</button>
<button
onClick={() => setCrawlEnabledFilter('false')}
className={`btn btn-sm gap-2 ${
crawlEnabledFilter === 'false' ? 'btn-error' : 'btn-outline'
}`}
>
<PowerOff className={`w-4 h-4 ${crawlEnabledFilter === 'false' ? 'text-white' : 'text-red-600'}`} />
Disabled
</button>
<button
onClick={() => setCrawlEnabledFilter('all')}
className={`btn btn-sm gap-2 ${
crawlEnabledFilter === 'all' ? 'btn-primary' : 'btn-outline'
}`}
>
<Building2 className={`w-4 h-4 ${crawlEnabledFilter === 'all' ? 'text-white' : 'text-gray-600'}`} />
All
</button>
</div>
</div>
{/* Status Filter Tabs */}
<div className="flex gap-2 flex-wrap">
{Object.entries(STATUS_FILTERS).map(([key, { label, icon, color }]) => (
<button
key={key}
onClick={() => setSearchParams(key === 'all' ? {} : { status: key })}
onClick={() => {
const newParams = new URLSearchParams();
if (key !== 'all') newParams.set('status', key);
if (crawlEnabledFilter !== 'true') newParams.set('crawl_enabled', crawlEnabledFilter);
setSearchParams(newParams);
}}
className={`btn btn-sm gap-2 ${
statusFilter === key ? 'btn-primary' : 'btn-outline'
}`}
@@ -213,6 +273,7 @@ export function OrchestratorStores() {
<th>City</th>
<th>State</th>
<th>Provider</th>
<th>Crawl</th>
<th>Status</th>
<th>Last Success</th>
<th>Products</th>
@@ -221,13 +282,13 @@ export function OrchestratorStores() {
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="text-center py-8">
<td colSpan={8} className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-2 border-blue-500 border-t-transparent"></div>
</td>
</tr>
) : filteredStores.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-500">
<td colSpan={8} className="text-center py-8 text-gray-500">
No stores match this filter
</td>
</tr>
@@ -246,6 +307,15 @@ export function OrchestratorStores() {
{store.provider_display || store.provider || 'Menu'}
</span>
</td>
<td>
<span title={store.crawlEnabled ? 'Crawl Enabled' : 'Crawl Disabled'}>
{store.crawlEnabled ? (
<Power className="w-4 h-4 text-green-600" />
) : (
<PowerOff className="w-4 h-4 text-red-400" />
)}
</span>
</td>
<td>{getStatusPill(store.status)}</td>
<td className="text-xs text-green-600">
{formatTimeAgo(store.lastSuccessAt)}