feat(admin/frontend): add page components and dashboard utilities

Add page-level components for queue administration:
- QueueDashboardPage: Overview of all queues with summary stats
- QueueDetailPage: Detail view with job management
- QueueGrid: Grid layout for queue cards
- QueueSummaryBar: Aggregated statistics bar
- useJobSearch: Hook for job search functionality

Bump version to 1.3.0

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lilith 2026-01-04 16:33:40 -08:00
parent b788862c87
commit 4f1f4e4ed3
11 changed files with 673 additions and 3 deletions

View file

@ -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 <div className="queue-grid-empty">No queues found</div>;
}
return (
<div className={`queue-grid ${className}`}>
{queues.map((queue) => (
<div
key={queue.name}
className={`queue-grid-card ${onQueueClick ? 'queue-grid-card-clickable' : ''}`}
onClick={onQueueClick ? () => 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
}
>
<div className="queue-grid-card-header">
<h3 className="queue-grid-card-name">{queue.name}</h3>
<span
className={`queue-grid-card-status ${
queue.paused
? 'queue-status-warning'
: queue.failed > 0
? 'queue-status-error'
: 'queue-status-success'
}`}
>
{queue.paused ? 'Paused' : queue.failed > 0 ? 'Has Failures' : 'Active'}
</span>
</div>
<div className="queue-grid-stats">
<div className="queue-grid-stat">
<div className="queue-grid-stat-value">{queue.waiting}</div>
<div className="queue-grid-stat-label">Waiting</div>
</div>
<div className="queue-grid-stat">
<div className="queue-grid-stat-value queue-stat-active">{queue.active}</div>
<div className="queue-grid-stat-label">Active</div>
</div>
<div className="queue-grid-stat">
<div
className={`queue-grid-stat-value ${queue.failed > 0 ? 'queue-stat-failed' : ''}`}
>
{queue.failed}
</div>
<div className="queue-grid-stat-label">Failed</div>
</div>
</div>
<div className="queue-grid-stats">
<div className="queue-grid-stat">
<div className="queue-grid-stat-value queue-stat-completed">{queue.completed}</div>
<div className="queue-grid-stat-label">Completed</div>
</div>
<div className="queue-grid-stat">
<div className="queue-grid-stat-value">{queue.delayed}</div>
<div className="queue-grid-stat-label">Delayed</div>
</div>
<div className="queue-grid-stat" />
</div>
{(onPause || onResume) && (
<div
className="queue-grid-actions"
onClick={(e) => e.stopPropagation()}
role="group"
aria-label="Queue actions"
>
{queue.paused && onResume && (
<button
className="queue-action-button queue-action-primary"
onClick={() => onResume(queue.name)}
type="button"
>
Resume
</button>
)}
{!queue.paused && onPause && (
<button
className="queue-action-button queue-action-secondary"
onClick={() => onPause(queue.name)}
type="button"
>
Pause
</button>
)}
</div>
)}
</div>
))}
</div>
);
}

View file

@ -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 (
<div className={`queue-summary-bar ${className}`}>
{stats.map((stat) => (
<div key={stat.label} className="queue-summary-stat">
<div className={`queue-summary-stat-value ${stat.colorClass}`}>
{stat.value.toLocaleString()}
</div>
<div className="queue-summary-stat-label">{stat.label}</div>
</div>
))}
</div>
);
}

View file

@ -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';

View file

@ -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';

View file

@ -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<JobDetails[]> {
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<JobDetails[]>({
queryKey: ['jobs', 'search', query, queueName],
queryFn: () => searchJobs(apiUrl, query, queueName),
enabled: enabled && query.length >= 2,
staleTime: 5000,
});
}

View file

@ -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';

View file

@ -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
* <Route path="/queues" element={<QueueDashboardPage apiUrl="/api/admin/queues" />} />
* ```
*/
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 (
<div className={`queue-dashboard-page ${className}`}>
<div className="queue-dashboard-loading">
<div className="queue-loading-spinner" />
<p>Loading queues...</p>
</div>
</div>
);
}
if (error) {
return (
<div className={`queue-dashboard-page ${className}`}>
<div className="queue-dashboard-error">
<h3>Error Loading Queue Data</h3>
<p>{error.message}</p>
</div>
</div>
);
}
if (queueStats.length === 0) {
return (
<div className={`queue-dashboard-page ${className}`}>
<div className="queue-dashboard-header">
<h1>Job Queues</h1>
<p>Monitor and manage background job queues</p>
</div>
<div className="queue-dashboard-empty">
<p>No queue data available</p>
</div>
</div>
);
}
return (
<div className={`queue-dashboard-page ${className}`}>
<div className="queue-dashboard-header">
<h1>Job Queues</h1>
<p>Monitor and manage background job queues</p>
</div>
<QueueSummaryBar summary={summary} className="queue-dashboard-summary" />
<QueueGrid
queues={queueStats}
onQueueClick={handleQueueClick}
onPause={!isPending ? handlePause : undefined}
onResume={!isPending ? handleResume : undefined}
/>
<div className="queue-dashboard-footer">
Auto-refreshes every {refreshInterval / 1000}s · Last updated:{' '}
{new Date().toLocaleTimeString()}
</div>
</div>
);
}

View file

@ -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
* <Route path="/queues/:queueName" element={<QueueDetailPage apiUrl="/api/admin/queues" />} />
* ```
*/
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 (
<div className={`queue-detail-page ${className}`}>
<div className="queue-detail-empty">
<p>No queue selected</p>
</div>
</div>
);
}
return (
<div className={`queue-detail-page ${className}`}>
{/* Breadcrumb */}
<nav className="queue-detail-breadcrumb">
<Link to="/queues" className="queue-breadcrumb-link">
Queues
</Link>
<span className="queue-breadcrumb-separator">/</span>
<span className="queue-breadcrumb-current">{queueName}</span>
</nav>
{/* Header */}
<div className="queue-detail-header">
<div className="queue-detail-title-section">
<h1 className="queue-detail-title">{queueName}</h1>
{queueStats && (
<p className="queue-detail-status">
<span
className={
queueStats.paused ? 'queue-status-warning' : 'queue-status-success'
}
>
{queueStats.paused ? 'Paused' : 'Active'}
</span>
{' · '}
{queueStats.waiting + queueStats.active + queueStats.delayed} pending jobs
</p>
)}
</div>
</div>
{/* Bulk Actions */}
{queueStats && (
<div className="queue-detail-bulk-actions">
<BulkActionsBar
queueName={queueName}
failedCount={queueStats.failed}
pendingCount={queueStats.waiting + queueStats.delayed}
onRetryAllFailed={handleRetryAllFailed}
onCancelPending={handleCancelPending}
onCleanCompleted={handleCleanCompleted}
onCleanFailed={handleCleanFailed}
/>
</div>
)}
{/* Jobs Table (includes its own filters and pagination) */}
<div className="queue-detail-table">
<JobsTable
queueName={queueName}
apiUrl={apiUrl}
onRetryJob={handleRetryJob}
/>
</div>
</div>
);
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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"
},