diff --git a/admin/frontend/src/components/QueueGrid.tsx b/admin/frontend/src/components/QueueGrid.tsx new file mode 100644 index 0000000..d36b3e9 --- /dev/null +++ b/admin/frontend/src/components/QueueGrid.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import type { QueueStats } from '../types'; + +export interface QueueGridProps { + queues: QueueStats[]; + onQueueClick?: (queue: QueueStats) => void; + onPause?: (queueName: string) => void; + onResume?: (queueName: string) => void; + className?: string; +} + +/** + * Grid display of queue cards with stats and actions. + */ +export function QueueGrid({ + queues, + onQueueClick, + onPause, + onResume, + className = '', +}: QueueGridProps) { + if (queues.length === 0) { + return
No queues found
; + } + + return ( +
+ {queues.map((queue) => ( +
onQueueClick(queue) : undefined} + role={onQueueClick ? 'button' : undefined} + tabIndex={onQueueClick ? 0 : undefined} + onKeyDown={ + onQueueClick + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onQueueClick(queue); + } + } + : undefined + } + > +
+

{queue.name}

+ 0 + ? 'queue-status-error' + : 'queue-status-success' + }`} + > + {queue.paused ? 'Paused' : queue.failed > 0 ? 'Has Failures' : 'Active'} + +
+ +
+
+
{queue.waiting}
+
Waiting
+
+
+
{queue.active}
+
Active
+
+
+
0 ? 'queue-stat-failed' : ''}`} + > + {queue.failed} +
+
Failed
+
+
+ +
+
+
{queue.completed}
+
Completed
+
+
+
{queue.delayed}
+
Delayed
+
+
+
+ + {(onPause || onResume) && ( +
e.stopPropagation()} + role="group" + aria-label="Queue actions" + > + {queue.paused && onResume && ( + + )} + {!queue.paused && onPause && ( + + )} +
+ )} +
+ ))} +
+ ); +} diff --git a/admin/frontend/src/components/QueueSummaryBar.tsx b/admin/frontend/src/components/QueueSummaryBar.tsx new file mode 100644 index 0000000..bb7b46d --- /dev/null +++ b/admin/frontend/src/components/QueueSummaryBar.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import type { QueueSummaryStats } from '../types'; + +export interface QueueSummaryBarProps { + summary: QueueSummaryStats; + className?: string; +} + +/** + * Summary bar showing aggregated queue statistics. + */ +export function QueueSummaryBar({ summary, className = '' }: QueueSummaryBarProps) { + const stats = [ + { label: 'Queues', value: summary.queues.length, colorClass: '' }, + { label: 'Waiting', value: summary.totalWaiting, colorClass: '' }, + { label: 'Active', value: summary.totalActive, colorClass: 'queue-summary-stat-active' }, + { + label: 'Completed', + value: summary.totalCompleted, + colorClass: 'queue-summary-stat-completed', + }, + { + label: 'Failed', + value: summary.totalFailed, + colorClass: summary.totalFailed > 0 ? 'queue-summary-stat-failed' : '', + }, + ]; + + return ( +
+ {stats.map((stat) => ( +
+
+ {stat.value.toLocaleString()} +
+
{stat.label}
+
+ ))} +
+ ); +} diff --git a/admin/frontend/src/components/index.ts b/admin/frontend/src/components/index.ts index 453f3fa..7cfdb04 100644 --- a/admin/frontend/src/components/index.ts +++ b/admin/frontend/src/components/index.ts @@ -34,3 +34,9 @@ export type { ServiceFilterProps, ServiceOption } from './ServiceFilter'; export { JobDetailPanel } from './JobDetailPanel'; export type { JobDetailPanelProps } from './JobDetailPanel'; + +export { QueueGrid } from './QueueGrid'; +export type { QueueGridProps } from './QueueGrid'; + +export { QueueSummaryBar } from './QueueSummaryBar'; +export type { QueueSummaryBarProps } from './QueueSummaryBar'; diff --git a/admin/frontend/src/hooks/index.ts b/admin/frontend/src/hooks/index.ts index c4dd454..83ecbcf 100644 --- a/admin/frontend/src/hooks/index.ts +++ b/admin/frontend/src/hooks/index.ts @@ -8,3 +8,4 @@ export { useJobs } from './useJobs'; export { useJobDetail } from './useJobDetail'; export { useQueueControl } from './useQueueControl'; export { useBulkJobActions } from './useBulkJobActions'; +export { useJobSearch, type UseJobSearchOptions } from './useJobSearch'; diff --git a/admin/frontend/src/hooks/useJobSearch.ts b/admin/frontend/src/hooks/useJobSearch.ts new file mode 100644 index 0000000..e8f793f --- /dev/null +++ b/admin/frontend/src/hooks/useJobSearch.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query'; +import type { JobDetails } from '../types'; + +const DEFAULT_API_URL = '/api/admin/queues'; + +export interface UseJobSearchOptions { + /** API base URL */ + apiUrl?: string; + /** Search query (minimum 2 characters) */ + query: string; + /** Optional queue name to scope search */ + queueName?: string; + /** Enable/disable the query */ + enabled?: boolean; +} + +async function searchJobs( + apiUrl: string, + query: string, + queueName?: string +): Promise { + const params = new URLSearchParams({ q: query }); + if (queueName) params.set('queue', queueName); + + const response = await fetch(`${apiUrl}/search?${params}`); + + if (!response.ok) { + throw new Error(`Search failed: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Hook to search jobs across queues. + * + * @example + * ```tsx + * const { data: results, isLoading } = useJobSearch({ + * query: searchTerm, + * queueName: 'analytics', // optional + * }); + * ``` + */ +export function useJobSearch(options: UseJobSearchOptions) { + const { + apiUrl = DEFAULT_API_URL, + query, + queueName, + enabled = true, + } = options; + + return useQuery({ + queryKey: ['jobs', 'search', query, queueName], + queryFn: () => searchJobs(apiUrl, query, queueName), + enabled: enabled && query.length >= 2, + staleTime: 5000, + }); +} diff --git a/admin/frontend/src/index.ts b/admin/frontend/src/index.ts index 7e7fc97..a319b36 100644 --- a/admin/frontend/src/index.ts +++ b/admin/frontend/src/index.ts @@ -12,8 +12,11 @@ export { useJobDetail, useQueueControl, useBulkJobActions, + useJobSearch, } from './hooks'; +export type { UseJobSearchOptions } from './hooks'; + // Export all components export { QueueDashboard, @@ -27,6 +30,8 @@ export { SearchBar, ServiceFilter, JobDetailPanel, + QueueGrid, + QueueSummaryBar, } from './components'; export type { @@ -42,8 +47,15 @@ export type { ServiceFilterProps, ServiceOption, JobDetailPanelProps, + QueueGridProps, + QueueSummaryBarProps, } from './components'; +// Export all pages +export { QueueDashboardPage, QueueDetailPage } from './pages'; + +export type { QueueDashboardPageProps, QueueDetailPageProps } from './pages'; + // Export all types export type { QueueSummary, @@ -64,4 +76,10 @@ export type { BulkOperationResult, BulkOperationOptions, CleanByAgeOptions, + // Dashboard page types + QueueStats, + QueueSummaryStats, + JobStatus, + ServiceInfo, + QueueAdminConfig, } from './types'; diff --git a/admin/frontend/src/pages/QueueDashboardPage.tsx b/admin/frontend/src/pages/QueueDashboardPage.tsx new file mode 100644 index 0000000..304411f --- /dev/null +++ b/admin/frontend/src/pages/QueueDashboardPage.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useQueueMetrics } from '../hooks/useQueueMetrics'; +import { useQueueControl } from '../hooks/useQueueControl'; +import { QueueGrid } from '../components/QueueGrid'; +import { QueueSummaryBar } from '../components/QueueSummaryBar'; +import type { QueueStats, QueueSummaryStats } from '../types'; + +export interface QueueDashboardPageProps { + /** Base API URL for queue endpoints */ + apiUrl?: string; + /** Refresh interval in milliseconds */ + refreshInterval?: number; + /** Custom class name */ + className?: string; +} + +/** + * Dashboard page showing all queues with summary statistics. + * + * Uses react-router-dom for navigation to queue details. + * + * @example + * ```tsx + * } /> + * ``` + */ +export function QueueDashboardPage({ + apiUrl = '/api/admin/queues', + refreshInterval = 5000, + className = '', +}: QueueDashboardPageProps) { + const navigate = useNavigate(); + + const { metrics, isLoading, error } = useQueueMetrics({ + apiUrl, + refreshInterval, + }); + + const { pauseQueue, resumeQueue, isPending } = useQueueControl({ + apiUrl, + }); + + // Transform metrics to QueueStats format for grid display + const queueStats: QueueStats[] = useMemo( + () => + metrics.map((m) => ({ + name: m.name, + waiting: m.counts.waiting, + active: m.counts.active, + completed: m.counts.completed, + failed: m.counts.failed, + delayed: m.counts.delayed, + paused: m.isPaused, + })), + [metrics] + ); + + // Calculate summary stats + const summary: QueueSummaryStats = useMemo( + () => ({ + queues: queueStats, + totalWaiting: queueStats.reduce((sum, q) => sum + q.waiting, 0), + totalActive: queueStats.reduce((sum, q) => sum + q.active, 0), + totalCompleted: queueStats.reduce((sum, q) => sum + q.completed, 0), + totalFailed: queueStats.reduce((sum, q) => sum + q.failed, 0), + }), + [queueStats] + ); + + const handleQueueClick = useCallback( + (queue: QueueStats) => { + navigate(`/queues/${encodeURIComponent(queue.name)}`); + }, + [navigate] + ); + + const handlePause = useCallback( + (queueName: string) => { + pauseQueue({ queueName }); + }, + [pauseQueue] + ); + + const handleResume = useCallback( + (queueName: string) => { + resumeQueue({ queueName }); + }, + [resumeQueue] + ); + + if (isLoading) { + return ( +
+
+
+

Loading queues...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error Loading Queue Data

+

{error.message}

+
+
+ ); + } + + if (queueStats.length === 0) { + return ( +
+
+

Job Queues

+

Monitor and manage background job queues

+
+
+

No queue data available

+
+
+ ); + } + + return ( +
+
+

Job Queues

+

Monitor and manage background job queues

+
+ + + + + +
+ Auto-refreshes every {refreshInterval / 1000}s · Last updated:{' '} + {new Date().toLocaleTimeString()} +
+
+ ); +} diff --git a/admin/frontend/src/pages/QueueDetailPage.tsx b/admin/frontend/src/pages/QueueDetailPage.tsx new file mode 100644 index 0000000..45ac3f1 --- /dev/null +++ b/admin/frontend/src/pages/QueueDetailPage.tsx @@ -0,0 +1,182 @@ +import React, { useCallback, useMemo } from 'react'; +import { useParams, Link } from 'react-router-dom'; + +import { useQueueMetrics } from '../hooks/useQueueMetrics'; +import { useQueueControl } from '../hooks/useQueueControl'; +import { useBulkJobActions } from '../hooks/useBulkJobActions'; + +import { JobsTable } from '../components/JobsTable'; +import { BulkActionsBar } from '../components/BulkActionsBar'; + +import type { QueueStats } from '../types'; + +export interface QueueDetailPageProps { + /** Base API URL for queue endpoints */ + apiUrl?: string; + /** Custom class name */ + className?: string; +} + +/** + * Detail page for a specific queue showing jobs with filtering. + * + * Uses react-router-dom useParams to get queueName from URL. + * + * @example + * ```tsx + * } /> + * ``` + */ +export function QueueDetailPage({ + apiUrl = '/api/admin/queues', + className = '', +}: QueueDetailPageProps) { + const { queueName } = useParams<{ queueName: string }>(); + + // Queries + const { metrics, refetch } = useQueueMetrics({ + apiUrl, + refreshInterval: 5000, + enabled: !!queueName, + }); + + // Find current queue stats from metrics + const queueStats: QueueStats | null = useMemo(() => { + const found = metrics.find((m) => m.name === queueName); + if (!found) return null; + return { + name: found.name, + waiting: found.counts.waiting, + active: found.counts.active, + completed: found.counts.completed, + failed: found.counts.failed, + delayed: found.counts.delayed, + paused: found.isPaused, + }; + }, [metrics, queueName]); + + // Mutations + const { retryJob } = useQueueControl({ + apiUrl, + onSuccess: () => { + refetch(); + }, + }); + + const { retryAllFailed: bulkRetryAllFailed, cancelPending, cleanByAge } = useBulkJobActions({ + apiUrl, + }); + + // Bulk action handlers + const handleRetryAllFailed = useCallback(async () => { + if (queueName) { + await bulkRetryAllFailed.mutateAsync({ queueName }); + } + }, [queueName, bulkRetryAllFailed]); + + const handleCancelPending = useCallback(async () => { + if (queueName) { + await cancelPending.mutateAsync({ queueName }); + } + }, [queueName, cancelPending]); + + const handleCleanCompleted = useCallback( + async (maxAgeMs: number) => { + if (queueName) { + await cleanByAge.mutateAsync({ + queueName, + options: { status: 'completed', maxAgeMs }, + }); + } + }, + [queueName, cleanByAge] + ); + + const handleCleanFailed = useCallback( + async (maxAgeMs: number) => { + if (queueName) { + await cleanByAge.mutateAsync({ + queueName, + options: { status: 'failed', maxAgeMs }, + }); + } + }, + [queueName, cleanByAge] + ); + + const handleRetryJob = useCallback( + (jobId: string) => { + if (queueName) { + retryJob({ queueName, jobId }); + } + }, + [queueName, retryJob] + ); + + if (!queueName) { + return ( +
+
+

No queue selected

+
+
+ ); + } + + return ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+

{queueName}

+ {queueStats && ( +

+ + {queueStats.paused ? 'Paused' : 'Active'} + + {' · '} + {queueStats.waiting + queueStats.active + queueStats.delayed} pending jobs +

+ )} +
+
+ + {/* Bulk Actions */} + {queueStats && ( +
+ +
+ )} + + {/* Jobs Table (includes its own filters and pagination) */} +
+ +
+
+ ); +} diff --git a/admin/frontend/src/pages/index.ts b/admin/frontend/src/pages/index.ts new file mode 100644 index 0000000..68f30ba --- /dev/null +++ b/admin/frontend/src/pages/index.ts @@ -0,0 +1,9 @@ +/** + * Queue Admin Page Components + */ + +export { QueueDashboardPage } from './QueueDashboardPage'; +export type { QueueDashboardPageProps } from './QueueDashboardPage'; + +export { QueueDetailPage } from './QueueDetailPage'; +export type { QueueDetailPageProps } from './QueueDetailPage'; diff --git a/admin/frontend/src/types/index.ts b/admin/frontend/src/types/index.ts index daaeafa..25bd136 100644 --- a/admin/frontend/src/types/index.ts +++ b/admin/frontend/src/types/index.ts @@ -216,3 +216,53 @@ export interface RetryAllFailedOptions { queueName: string; limit?: number; } + +// ============================================================ +// Queue Stats Types (for dashboard pages) +// ============================================================ + +/** + * Per-queue statistics for dashboard display. + */ +export interface QueueStats { + name: string; + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + paused: boolean; +} + +/** + * Aggregated queue summary with totals. + */ +export interface QueueSummaryStats { + queues: QueueStats[]; + totalWaiting: number; + totalActive: number; + totalCompleted: number; + totalFailed: number; +} + +/** + * Job status filter type. + */ +export type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'; + +/** + * Service configuration for filtering. + */ +export interface ServiceInfo { + value: string; + label: string; + queueName: string; +} + +/** + * Configuration for queue admin pages. + */ +export interface QueueAdminConfig { + apiUrl: string; + refreshInterval?: number; +} diff --git a/package.json b/package.json index 47d4601..d74d594 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lilith/queue", - "version": "1.2.3", + "version": "1.3.0", "description": "Job queue ecosystem: core types, NestJS integration, ML batching, reporting, and admin dashboard", "exports": { "./core": { @@ -136,6 +136,10 @@ "typeorm": "^0.3.0" }, "peerDependencies": { + "@lilith/ui-data": "^1.0.0", + "@lilith/ui-feedback": "^1.0.0", + "@lilith/ui-layout": "^1.0.0", + "@lilith/ui-primitives": "^1.0.0", "@nestjs/bullmq": "^10.0.0 || ^11.0.0", "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -145,9 +149,11 @@ "@nestjs/websockets": "^10.0.0 || ^11.0.0", "cron": "^3.0.0", "ioredis": "^5.0.0", - "react": "^18.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-router-dom": "^6.0.0 || ^7.0.0", "socket.io": "^4.0.0", - "socket.io-client": "^4.7.0" + "socket.io-client": "^4.7.0", + "styled-components": "^6.0.0" }, "peerDependenciesMeta": { "@nestjs/bullmq": { @@ -171,6 +177,18 @@ "@nestjs/websockets": { "optional": true }, + "@lilith/ui-primitives": { + "optional": true + }, + "@lilith/ui-data": { + "optional": true + }, + "@lilith/ui-feedback": { + "optional": true + }, + "@lilith/ui-layout": { + "optional": true + }, "cron": { "optional": true }, @@ -180,14 +198,24 @@ "react": { "optional": true }, + "react-router-dom": { + "optional": true + }, "socket.io": { "optional": true }, "socket.io-client": { "optional": true + }, + "styled-components": { + "optional": true } }, "devDependencies": { + "@lilith/ui-data": "^1.1.0", + "@lilith/ui-feedback": "^1.1.0", + "@lilith/ui-layout": "^1.1.0", + "@lilith/ui-primitives": "^1.1.0", "@nestjs/bullmq": "^11.0.4", "@nestjs/common": "^11.1.11", "@nestjs/core": "^11.1.11", @@ -206,10 +234,12 @@ "ioredis": "^5.0.0", "react": "^18.3.0", "react-dom": "^18.3.1", + "react-router-dom": "^7.11.0", "reflect-metadata": "^0.1.14", "rxjs": "^7.8.1", "socket.io": "^4.8.3", "socket.io-client": "^4.7.0", + "styled-components": "^6.1.0", "typescript": "^5.0.0", "vitest": "^4.0.16" },