@@ -506,7 +525,7 @@ export default function Conversations() {
{/* Transcript Content - Conditionally Rendered */}
{expandedTranscripts.has(conversation.audio_uuid) && (
-
+
{conversation.transcript && conversation.transcript.length > 0 ? (
@@ -584,6 +603,7 @@ export default function Conversations() {
No transcript available
)}
+
)}
diff --git a/backends/advanced/webui/src/pages/Queue.tsx b/backends/advanced/webui/src/pages/Queue.tsx
new file mode 100644
index 00000000..c5e05aa8
--- /dev/null
+++ b/backends/advanced/webui/src/pages/Queue.tsx
@@ -0,0 +1,884 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Clock,
+ Play,
+ CheckCircle,
+ XCircle,
+ RotateCcw,
+ StopCircle,
+ Eye,
+ Filter,
+ X,
+ RefreshCw,
+ Layers,
+ Trash2,
+ AlertTriangle
+} from 'lucide-react';
+
+interface QueueJob {
+ job_id: string;
+ job_type: string;
+ user_id: string;
+ status: 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'retrying';
+ priority: 'low' | 'normal' | 'high';
+ data: any;
+ result?: any;
+ error_message?: string;
+ created_at: string;
+ started_at?: string;
+ completed_at?: string;
+ retry_count: number;
+ max_retries: number;
+ progress_percent: number;
+ progress_message: string;
+}
+
+interface QueueStats {
+ total_jobs: number;
+ queued_jobs: number;
+ processing_jobs: number;
+ completed_jobs: number;
+ failed_jobs: number;
+ cancelled_jobs: number;
+ retrying_jobs: number;
+ timestamp: string;
+}
+
+interface Filters {
+ status: string;
+ job_type: string;
+ priority: string;
+}
+
+const Queue: React.FC = () => {
+ const [jobs, setJobs] = useState
([]);
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [selectedJob, setSelectedJob] = useState(null);
+ const [filters, setFilters] = useState({
+ status: '',
+ job_type: '',
+ priority: ''
+ });
+ const [pagination, setPagination] = useState({
+ offset: 0,
+ limit: 20,
+ total: 0,
+ has_more: false
+ });
+ const [refreshing, setRefreshing] = useState(false);
+ const [showFlushModal, setShowFlushModal] = useState(false);
+ const [flushSettings, setFlushSettings] = useState({
+ older_than_hours: 24,
+ statuses: ['completed', 'failed'],
+ flush_all: false
+ });
+ const [flushing, setFlushing] = useState(false);
+
+ // Auto-refresh interval
+ useEffect(() => {
+ console.log('๐ Setting up queue auto-refresh interval');
+ const interval = setInterval(() => {
+ if (!loading) {
+ console.log('โฐ Auto-refreshing queue data');
+ fetchData();
+ }
+ }, 5000); // Refresh every 5 seconds
+
+ return () => {
+ console.log('๐งน Clearing queue auto-refresh interval');
+ clearInterval(interval);
+ };
+ }, []); // Remove dependencies to prevent interval recreation
+
+ // Initial data fetch
+ useEffect(() => {
+ fetchData();
+ }, [filters, pagination.offset]);
+
+ const fetchData = async () => {
+ console.log('๐ฅ fetchData called, refreshing:', refreshing, 'loading:', loading);
+ if (!refreshing) setRefreshing(true);
+
+ try {
+ console.log('๐ Starting Promise.all for jobs and stats');
+ await Promise.all([fetchJobs(), fetchStats()]);
+ console.log('โ
Promise.all completed successfully');
+ } catch (error) {
+ console.error('โ Error fetching queue data:', error);
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ console.log('๐ fetchData completed');
+ }
+ };
+
+ const fetchJobs = async () => {
+ try {
+ console.log('๐ fetchJobs starting...');
+ const params = new URLSearchParams({
+ limit: pagination.limit.toString(),
+ offset: pagination.offset.toString(),
+ sort: 'created_at',
+ order: 'desc'
+ });
+
+ if (filters.status) params.append('status', filters.status);
+ if (filters.job_type) params.append('job_type', filters.job_type);
+ if (filters.priority) params.append('priority', filters.priority);
+
+ console.log('๐ก Fetching jobs with params:', params.toString());
+ const response = await fetch(`/api/queue/jobs?${params}`, {
+ headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ console.log('โ
fetchJobs success, got', data.jobs?.length, 'jobs');
+ setJobs(data.jobs);
+ setPagination(prev => ({
+ ...prev,
+ total: data.pagination.total,
+ has_more: data.pagination.has_more
+ }));
+ } else if (response.status === 401) {
+ console.warn('๐ Auth error in fetchJobs, redirecting to login');
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ } else {
+ console.error('โ Error fetching jobs:', response.status, response.statusText);
+ }
+ } catch (error) {
+ console.error('โ Error fetching jobs:', error);
+ }
+ };
+
+ const fetchStats = async () => {
+ try {
+ console.log('๐ fetchStats starting...');
+ const response = await fetch('/api/queue/stats', {
+ headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ console.log('โ
fetchStats success, total jobs:', data.total_jobs);
+ setStats(data);
+ } else if (response.status === 401) {
+ console.warn('๐ Auth error in fetchStats, redirecting to login');
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ } else {
+ console.error('โ Error fetching stats:', response.status, response.statusText);
+ }
+ } catch (error) {
+ console.error('โ Error fetching stats:', error);
+ }
+ };
+
+ const retryJob = async (jobId: string) => {
+ try {
+ const response = await fetch(`/api/queue/jobs/${jobId}/retry`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ force: false })
+ });
+
+ if (response.ok) {
+ fetchJobs();
+ } else if (response.status === 401) {
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ }
+ } catch (error) {
+ console.error('Error retrying job:', error);
+ }
+ };
+
+ const cancelJob = async (jobId: string) => {
+ if (!confirm('Are you sure you want to cancel this job?')) return;
+
+ try {
+ const response = await fetch(`/api/queue/jobs/${jobId}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
+ });
+
+ if (response.ok) {
+ fetchJobs();
+ } else if (response.status === 401) {
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ }
+ } catch (error) {
+ console.error('Error cancelling job:', error);
+ }
+ };
+
+ const applyFilters = () => {
+ setPagination(prev => ({ ...prev, offset: 0 }));
+ fetchJobs();
+ };
+
+ const clearFilters = () => {
+ setFilters({ status: '', job_type: '', priority: '' });
+ setPagination(prev => ({ ...prev, offset: 0 }));
+ };
+
+ const nextPage = () => {
+ if (pagination.has_more) {
+ setPagination(prev => ({ ...prev, offset: prev.offset + prev.limit }));
+ }
+ };
+
+ const prevPage = () => {
+ if (pagination.offset > 0) {
+ setPagination(prev => ({
+ ...prev,
+ offset: Math.max(0, prev.offset - prev.limit)
+ }));
+ }
+ };
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'queued': return ;
+ case 'processing': return ;
+ case 'completed': return ;
+ case 'failed': return ;
+ case 'cancelled': return ;
+ case 'retrying': return ;
+ default: return ;
+ }
+ };
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'queued': return 'text-yellow-600 bg-yellow-100';
+ case 'processing': return 'text-blue-600 bg-blue-100';
+ case 'completed': return 'text-green-600 bg-green-100';
+ case 'failed': return 'text-red-600 bg-red-100';
+ case 'cancelled': return 'text-gray-600 bg-gray-100';
+ case 'retrying': return 'text-orange-600 bg-orange-100';
+ default: return 'text-gray-600 bg-gray-100';
+ }
+ };
+
+ const formatJobType = (type: string) => {
+ const typeMap: { [key: string]: string } = {
+ 'process_audio_files': 'Audio File Processing',
+ 'process_single_audio_file': 'Single Audio File',
+ 'reprocess_transcript': 'Reprocess Transcript',
+ 'reprocess_memory': 'Reprocess Memory'
+ };
+ return typeMap[type] || type;
+ };
+
+ const getJobTypeShort = (type: string) => {
+ const typeMap: { [key: string]: string } = {
+ 'process_audio_files': 'Process',
+ 'process_single_audio_file': 'Process',
+ 'reprocess_transcript': 'Reprocess',
+ 'reprocess_memory': 'Memory'
+ };
+ return typeMap[type] || type;
+ };
+
+ const getJobResult = (job: QueueJob) => {
+ if (job.status !== 'completed' || !job.result) {
+ return -;
+ }
+
+ const result = job.result;
+
+ // Show different results based on job type
+ if (job.job_type === 'reprocess_transcript') {
+ const segments = result.transcript_segments || 0;
+ const speakers = result.speakers_identified || 0;
+
+ return (
+
+
{segments} segments
+ {speakers > 0 && (
+
{speakers} speakers identified
+ )}
+
+ );
+ }
+
+ if (job.job_type === 'reprocess_memory') {
+ const memories = result.memory_count || 0;
+ return (
+
+ {memories} memories
+
+ );
+ }
+
+ return (
+
+ โ Success
+
+ );
+ };
+
+ const flushJobs = async () => {
+ setFlushing(true);
+ try {
+ const endpoint = flushSettings.flush_all ? '/api/queue/flush-all' : '/api/queue/flush';
+ const body = flushSettings.flush_all
+ ? { confirm: true }
+ : {
+ older_than_hours: flushSettings.older_than_hours,
+ statuses: flushSettings.statuses
+ };
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(body)
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ alert(`Successfully flushed ${result.total_removed} jobs!`);
+ setShowFlushModal(false);
+ fetchData(); // Refresh the data
+ } else if (response.status === 403) {
+ alert('Admin access required to flush jobs');
+ } else if (response.status === 401) {
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ } else {
+ const error = await response.json();
+ alert(`Error: ${error.detail || 'Failed to flush jobs'}`);
+ }
+ } catch (error) {
+ console.error('Error flushing jobs:', error);
+ alert('Failed to flush jobs');
+ } finally {
+ setFlushing(false);
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleString();
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
Queue Management
+
+
+
+
+
+
+
+ {/* Stats Cards */}
+ {stats && (
+
+
+
+
+
+
Total
+
{stats.total_jobs}
+
+
+
+
+
+
+
+
+
Queued
+
{stats.queued_jobs}
+
+
+
+
+
+
+
0 ? 'animate-pulse' : ''}`} />
+
+
Processing
+
{stats.processing_jobs}
+
+
+
+
+
+
+
+
+
Completed
+
{stats.completed_jobs}
+
+
+
+
+
+
+
+
+
Failed
+
{stats.failed_jobs}
+
+
+
+
+
+
+
+
+
Cancelled
+
{stats.cancelled_jobs}
+
+
+
+
+
+
+
+
+
Retrying
+
{stats.retrying_jobs}
+
+
+
+
+ )}
+
+ {/* Filters */}
+
+
Filters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Jobs Table */}
+
+
+
Jobs
+
+
+
+
+
+
+ | Date |
+ ID |
+ Type |
+ Status |
+ Result |
+ Actions |
+
+
+
+ {jobs.map((job) => (
+
+ |
+ {formatDate(job.created_at)}
+ |
+
+
+ #{job.job_id}
+
+ |
+
+ {getJobTypeShort(job.job_type)}
+ |
+
+
+ {getStatusIcon(job.status)}
+ {job.status.charAt(0).toUpperCase() + job.status.slice(1)}
+
+ |
+
+ {getJobResult(job)}
+ |
+
+ {job.status === 'failed' && (
+
+ )}
+
+ {(job.status === 'queued' || job.status === 'processing') && (
+
+ )}
+ |
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+ {pagination.total > pagination.limit && (
+
+
+ Showing {pagination.offset + 1} to {Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} results
+
+
+
+
+
+
+ )}
+
+
+ {/* Job Details Modal */}
+ {selectedJob && (
+
+
+
+
Job Details
+
+
+
+
+
+
+
+
{selectedJob.job_id}
+
+
+
+
+ {getStatusIcon(selectedJob.status)}
+ {selectedJob.status.charAt(0).toUpperCase() + selectedJob.status.slice(1)}
+
+
+
+
+
{formatJobType(selectedJob.job_type)}
+
+
+
+
{selectedJob.priority}
+
+
+
+
{formatDate(selectedJob.created_at)}
+
+ {selectedJob.completed_at && (
+
+
+
{formatDate(selectedJob.completed_at)}
+
+ )}
+
+
+ {selectedJob.progress_message && (
+
+
+
{selectedJob.progress_message}
+ {selectedJob.progress_percent !== undefined && (
+
+ )}
+
+ )}
+
+ {selectedJob.error_message && (
+
+
+
{selectedJob.error_message}
+
+ )}
+
+ {selectedJob.data && (
+
+
+
+ {JSON.stringify(selectedJob.data, null, 2)}
+
+
+ )}
+
+ {selectedJob.result && (
+
+
+
+ {JSON.stringify(selectedJob.result, null, 2)}
+
+
+ )}
+
+
+
+ )}
+
+ {/* Flush Jobs Modal */}
+ {showFlushModal && (
+
+
+
+
+
+ Flush Jobs
+
+
+
+
+
+
+
+
+
This will permanently remove jobs from the database
+
+
+
+
+
+
+
+ {!flushSettings.flush_all && (
+
+
+
+
+
+
+
+
+
+ {['completed', 'failed', 'cancelled'].map(status => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ {flushSettings.flush_all && (
+
+
+
+ โ ๏ธ This will remove ALL jobs including queued and processing ones, and reset the job counter!
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default Queue;
\ No newline at end of file
diff --git a/backends/advanced/webui/src/services/api.ts b/backends/advanced/webui/src/services/api.ts
index 5c9d82f0..afb174eb 100644
--- a/backends/advanced/webui/src/services/api.ts
+++ b/backends/advanced/webui/src/services/api.ts
@@ -133,6 +133,14 @@ export const systemApi = {
reloadMemoryConfig: () => api.post('/api/admin/memory/config/reload'),
}
+export const queueApi = {
+ getJobs: (params: URLSearchParams) => api.get(`/api/queue/jobs?${params}`),
+ getStats: () => api.get('/api/queue/stats'),
+ retryJob: (jobId: string, force: boolean = false) =>
+ api.post(`/api/queue/jobs/${jobId}/retry`, { force }),
+ cancelJob: (jobId: string) => api.delete(`/api/queue/jobs/${jobId}`),
+}
+
export const uploadApi = {
uploadAudioFiles: (files: FormData, onProgress?: (progress: number) => void) =>
api.post('/api/process-audio-files', files, {