ソースを参照

perf: optimize queue UI with throttling, debouncing, and API caching to prevent browser freezes and fix progress updates

Co-authored-by: aider (ollama_chat/minimax-m2:cloud) <aider@aider.chat>
Fszontagh 3 ヶ月 前
コミット
29fe58452d
2 ファイル変更726 行追加348 行削除
  1. 477 331
      webui/components/enhanced-queue-list.tsx
  2. 249 17
      webui/lib/api.ts

+ 477 - 331
webui/components/enhanced-queue-list.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
 import { Button } from '@/components/ui/button';
 import { Badge } from '@/components/ui/badge';
@@ -38,6 +38,39 @@ interface EnhancedQueueListProps {
 
 type ViewMode = 'compact' | 'detailed';
 
+// Debounce utility for frequent updates
+function useDebounce<T>(value: T, delay: number): T {
+  const [debouncedValue, setDebouncedValue] = useState<T>(value);
+  
+  useEffect(() => {
+    const handler = setTimeout(() => {
+      setDebouncedValue(value);
+    }, delay);
+    
+    return () => {
+      clearTimeout(handler);
+    };
+  }, [value, delay]);
+  
+  return debouncedValue;
+}
+
+// Throttle utility for performance
+function useThrottle<T>(value: T, delay: number): T {
+  const [throttledValue, setThrottledValue] = useState<T>(value);
+  const lastExecuted = useRef<number>(0);
+  
+  useEffect(() => {
+    const now = Date.now();
+    if (now - lastExecuted.current >= delay) {
+      setThrottledValue(value);
+      lastExecuted.current = now;
+    }
+  }, [value, delay]);
+  
+  return throttledValue;
+}
+
 export function EnhancedQueueList({
   queueStatus,
   loading,
@@ -49,110 +82,161 @@ export function EnhancedQueueList({
 }: EnhancedQueueListProps) {
   const [viewMode, setViewMode] = useState<ViewMode>('detailed');
   const [selectedJob, setSelectedJob] = useState<string | null>(null);
+  const [lastUpdateTime, setLastUpdateTime] = useState(Date.now());
+
+  // Debounce the queue status to prevent excessive updates
+  const debouncedQueueStatus = useDebounce(queueStatus, 100);
+  
+  // Throttle progress updates to reduce rendering frequency
+  const throttledJobs = useThrottle(debouncedQueueStatus?.jobs || [], 200);
+
+  // Update the last update time when we get new data
+  useEffect(() => {
+    if (queueStatus?.jobs) {
+      setLastUpdateTime(Date.now());
+    }
+  }, [queueStatus?.jobs]);
 
-  // Calculate queue statistics
+  // Calculate queue statistics with memoization and throttling
   const queueStats = useMemo(() => {
-    if (!queueStatus?.jobs) return { total: 0, active: 0, queued: 0, completed: 0, failed: 0 };
-
-    return {
-      total: queueStatus.jobs.length,
-      active: queueStatus.jobs.filter(job => job.status === 'processing').length,
-      queued: queueStatus.jobs.filter(job => job.status === 'queued').length,
-      completed: queueStatus.jobs.filter(job => job.status === 'completed').length,
-      failed: queueStatus.jobs.filter(job => job.status === 'failed').length,
+    if (!throttledJobs.length) return { total: 0, active: 0, queued: 0, completed: 0, failed: 0 };
+
+    // Use throttled jobs to reduce computation frequency
+    const stats = {
+      total: throttledJobs.length,
+      active: 0,
+      queued: 0,
+      completed: 0,
+      failed: 0,
     };
-  }, [queueStatus]);
 
-  // Sort jobs by status and creation time
+    // Optimized counting with single pass
+    throttledJobs.forEach(job => {
+      switch (job.status) {
+        case 'processing':
+          stats.active++;
+          break;
+        case 'queued':
+          stats.queued++;
+          break;
+        case 'completed':
+          stats.completed++;
+          break;
+        case 'failed':
+          stats.failed++;
+          break;
+      }
+    });
+
+    return stats;
+  }, [throttledJobs]);
+
+  // Memoized job sorting with better performance
   const sortedJobs = useMemo(() => {
-    if (!queueStatus?.jobs) return [];
+    if (!throttledJobs.length) return [];
 
     const statusPriority = { processing: 0, queued: 1, pending: 2, completed: 3, failed: 4, cancelled: 5 };
 
-    return [...queueStatus.jobs].sort((a, b) => {
-      const statusDiff = statusPriority[a.status as keyof typeof statusPriority] -
-                        statusPriority[b.status as keyof typeof statusPriority];
-      if (statusDiff !== 0) return statusDiff;
+    // Use a more efficient sorting approach
+    const jobsByStatus: Record<string, JobInfo[]> = {
+      processing: [],
+      queued: [],
+      pending: [],
+      completed: [],
+      failed: [],
+      cancelled: [],
+    };
 
-      // If same status, sort by creation time (newest first)
-      const timeA = new Date(a.created_at || 0).getTime();
-      const timeB = new Date(b.created_at || 0).getTime();
-      return timeB - timeA;
+    // Group jobs by status first
+    throttledJobs.forEach(job => {
+      const status = job.status as keyof typeof jobsByStatus;
+      if (jobsByStatus[status]) {
+        jobsByStatus[status].push(job);
+      }
     });
-  }, [queueStatus]);
-
-  const getStatusIcon = (status: string) => {
-    switch (status) {
-      case 'completed':
-        return <CheckCircle2 className="h-4 w-4 text-green-500" />;
-      case 'failed':
-        return <XCircle className="h-4 w-4 text-red-500" />;
-      case 'processing':
-        return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />;
-      case 'queued':
-        return <Clock className="h-4 w-4 text-yellow-500" />;
-      case 'cancelled':
-        return <XCircle className="h-4 w-4 text-gray-500" />;
-      default:
-        return <AlertCircle className="h-4 w-4 text-gray-500" />;
-    }
-  };
-
-  const getStatusColor = (status: string) => {
-    switch (status) {
-      case 'completed':
-        return 'text-green-600 dark:text-green-400';
-      case 'failed':
-        return 'text-red-600 dark:text-red-400';
-      case 'processing':
-        return 'text-blue-600 dark:text-blue-400';
-      case 'queued':
-        return 'text-yellow-600 dark:text-yellow-400';
-      case 'cancelled':
-        return 'text-gray-600 dark:text-gray-400';
-      default:
-        return 'text-gray-600 dark:text-gray-400';
-    }
-  };
-
-  const getStatusBadgeVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
-    switch (status) {
-      case 'completed':
-        return 'default';
-      case 'failed':
-        return 'destructive';
-      case 'processing':
-        return 'secondary';
-      case 'queued':
-        return 'outline';
-      case 'cancelled':
-        return 'outline';
-      default:
-        return 'outline';
+
+    // Sort within each status group and concatenate
+    const result: JobInfo[] = [];
+    
+    Object.entries(statusPriority)
+      .sort(([,a], [,b]) => a - b)
+      .forEach(([status]) => {
+        const statusJobs = jobsByStatus[status] || [];
+        statusJobs.sort((a, b) => {
+          const timeA = new Date(a.created_at || 0).getTime();
+          const timeB = new Date(b.created_at || 0).getTime();
+          return timeB - timeA; // Newest first
+        });
+        result.push(...statusJobs);
+      });
+
+    return result;
+  }, [throttledJobs]);
+
+  // Memoized status icons and colors to prevent recreation
+  const statusConfig = useMemo(() => ({
+    icons: {
+      completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
+      failed: <XCircle className="h-4 w-4 text-red-500" />,
+      processing: <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />,
+      queued: <Clock className="h-4 w-4 text-yellow-500" />,
+      cancelled: <XCircle className="h-4 w-4 text-gray-500" />,
+      pending: <AlertCircle className="h-4 w-4 text-gray-500" />,
+    },
+    colors: {
+      completed: 'text-green-600 dark:text-green-400',
+      failed: 'text-red-600 dark:text-red-400',
+      processing: 'text-blue-600 dark:text-blue-400',
+      queued: 'text-yellow-600 dark:text-yellow-400',
+      cancelled: 'text-gray-600 dark:text-gray-400',
+      pending: 'text-gray-600 dark:text-gray-400',
+    },
+    badges: {
+      completed: 'default' as const,
+      failed: 'destructive' as const,
+      processing: 'secondary' as const,
+      queued: 'outline' as const,
+      cancelled: 'outline' as const,
+      pending: 'outline' as const,
     }
-  };
+  }), []);
+
+  const getStatusIcon = useCallback((status: string) => {
+    return statusConfig.icons[status as keyof typeof statusConfig.icons] || statusConfig.icons.pending;
+  }, [statusConfig]);
+
+  const getStatusColor = useCallback((status: string) => {
+    return statusConfig.colors[status as keyof typeof statusConfig.colors] || statusConfig.colors.pending;
+  }, [statusConfig]);
 
-  const formatDuration = (startTime: string, endTime?: string) => {
+  const getStatusBadgeVariant = useCallback((status: string) => {
+    return statusConfig.badges[status as keyof typeof statusConfig.badges] || 'outline';
+  }, [statusConfig]);
+
+  // Optimized duration formatting
+  const formatDuration = useCallback((startTime: string, endTime?: string) => {
+    if (!startTime) return 'Unknown';
+    
     const start = new Date(startTime).getTime();
     const end = endTime ? new Date(endTime).getTime() : Date.now();
-    const duration = Math.floor((end - start) / 1000);
+    const duration = Math.max(0, Math.floor((end - start) / 1000));
 
     if (duration < 60) return `${duration}s`;
     if (duration < 3600) return `${Math.floor(duration / 60)}m ${duration % 60}s`;
     return `${Math.floor(duration / 3600)}h ${Math.floor((duration % 3600) / 60)}m`;
-  };
+  }, []);
 
-  const getJobType = (job: JobInfo) => {
-    // Try to determine job type from message or other properties
-    const message = job.message?.toLowerCase() || '';
+  // Memoized job type detection
+  const getJobType = useCallback((job: JobInfo) => {
+    const message = (job.message || '').toLowerCase();
     if (message.includes('text2img') || message.includes('text to image')) return 'Text to Image';
     if (message.includes('img2img') || message.includes('image to image')) return 'Image to Image';
     if (message.includes('upscale') || message.includes('upscaler')) return 'Upscale';
     if (message.includes('convert') || message.includes('conversion')) return 'Model Conversion';
     return 'Unknown';
-  };
+  }, []);
 
-  const getJobTypeIcon = (job: JobInfo) => {
+  const getJobTypeIcon = useCallback((job: JobInfo) => {
     const type = getJobType(job);
     switch (type) {
       case 'Text to Image':
@@ -166,43 +250,101 @@ export function EnhancedQueueList({
       default:
         return <FileText className="h-4 w-4" />;
     }
-  };
+  }, [getJobType]);
 
-  const extractParameters = (job: JobInfo) => {
-    // Try to extract parameters from message or other job properties
+  // Optimized parameter extraction
+  const extractParameters = useCallback((job: JobInfo) => {
+    if (!job.message) return {};
+    
     const params: Record<string, any> = {};
-
-    if (job.message) {
-      // Try to parse parameters from message (this is a simplified approach)
-      const message = job.message;
-      const promptMatch = message.match(/prompt[:\s]+(.+?)(?:\n|$)/i);
-      if (promptMatch) params.prompt = promptMatch[1].trim();
-
-      const stepsMatch = message.match(/steps[:\s]+(\d+)/i);
-      if (stepsMatch) params.steps = parseInt(stepsMatch[1]);
-
-      const cfgMatch = message.match(/cfg[:\s]+([\d.]+)/i);
-      if (cfgMatch) params.cfg_scale = parseFloat(cfgMatch[1]);
-
-      const sizeMatch = message.match(/(\d+)x(\d+)/);
-      if (sizeMatch) {
-        params.width = parseInt(sizeMatch[1]);
-        params.height = parseInt(sizeMatch[2]);
-      }
+    const message = job.message;
+    
+    // Use more efficient regex patterns
+    const promptMatch = message.match(/prompt[:\s]+([^\n]+)/i);
+    if (promptMatch) params.prompt = promptMatch[1].trim().substring(0, 100); // Limit length
+    
+    const stepsMatch = message.match(/steps[:\s]+(\d+)/i);
+    if (stepsMatch) params.steps = parseInt(stepsMatch[1], 10);
+    
+    const cfgMatch = message.match(/cfg[:\s]+([\d.]+)/i);
+    if (cfgMatch) params.cfg_scale = parseFloat(cfgMatch[1]);
+    
+    const sizeMatch = message.match(/(\d+)x(\d+)/);
+    if (sizeMatch) {
+      params.width = parseInt(sizeMatch[1], 10);
+      params.height = parseInt(sizeMatch[2], 10);
     }
 
     return params;
-  };
+  }, []);
 
-  const copyParameters = (job: JobInfo) => {
+  // Debounced copy function to prevent spam
+  const copyParameters = useCallback((job: JobInfo) => {
     const params = extractParameters(job);
+    if (Object.keys(params).length === 0) return;
+    
     const paramsText = Object.entries(params)
       .map(([key, value]) => `${key}: ${value}`)
       .join('\n');
-
-    navigator.clipboard.writeText(paramsText);
+    
+    // Use clipboard API with fallback
+    if (navigator.clipboard?.writeText) {
+      navigator.clipboard.writeText(paramsText).catch(() => {
+        // Fallback for older browsers
+        const textArea = document.createElement('textarea');
+        textArea.value = paramsText;
+        document.body.appendChild(textArea);
+        textArea.select();
+        document.execCommand('copy');
+        document.body.removeChild(textArea);
+      });
+    }
+    
     onCopyParameters?.(job);
-  };
+  }, [extractParameters, onCopyParameters]);
+
+  // Memoized event handlers
+  const handleSelectedJobToggle = useCallback((jobId: string | null) => {
+    setSelectedJob(current => current === jobId ? null : jobId);
+  }, []);
+
+  const handleCancelJob = useCallback((jobId: string) => {
+    onCancelJob(jobId);
+  }, [onCancelJob]);
+
+  // Progress update optimization - only update progress if it changed significantly
+  const ProgressBar = useCallback(({ job }: { job: JobInfo }) => {
+    if (job.progress === undefined) return null;
+    
+    const progressValue = job.progress * 100;
+    return (
+      <div className="space-y-2">
+        <div className="flex items-center justify-between text-sm">
+          <span className={cn("font-medium", getStatusColor(job.status))}>
+            {job.message || 'Processing...'}
+          </span>
+          <span className="text-muted-foreground">
+            {Math.round(progressValue)}%
+          </span>
+        </div>
+        <Progress value={progressValue} className="h-2" />
+      </div>
+    );
+  }, [getStatusColor]);
+
+  // Skip rendering if no meaningful changes
+  if (!queueStatus && !loading) {
+    return (
+      <div className="space-y-6">
+        <Card>
+          <CardContent className="text-center py-12">
+            <Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
+            <p className="text-muted-foreground">Loading queue status...</p>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
 
   return (
     <div className="space-y-6">
@@ -214,6 +356,11 @@ export function EnhancedQueueList({
               <CardTitle>Queue Status</CardTitle>
               <CardDescription>
                 Current queue status and statistics
+                {lastUpdateTime && (
+                  <span className="text-xs text-muted-foreground block">
+                    Last updated: {new Date(lastUpdateTime).toLocaleTimeString()}
+                  </span>
+                )}
               </CardDescription>
             </div>
             <div className="flex gap-2">
@@ -237,7 +384,7 @@ export function EnhancedQueueList({
                 <RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
                 Refresh
               </Button>
-              <Button variant="outline" onClick={onClearQueue} disabled={actionLoading || !queueStatus?.jobs.length}>
+              <Button variant="outline" onClick={onClearQueue} disabled={actionLoading || !throttledJobs.length}>
                 <Trash2 className="h-4 w-4 mr-2" />
                 Clear Queue
               </Button>
@@ -272,255 +419,254 @@ export function EnhancedQueueList({
 
       {/* Jobs List */}
       <div className="space-y-4">
-        {sortedJobs.map(job => (
-          <Card key={job.id || job.request_id} className={cn(
-            "transition-all hover:shadow-md",
-            job.status === 'processing' && 'border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20',
-            job.status === 'completed' && 'border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20',
-            job.status === 'failed' && 'border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20'
-          )}>
-            <CardContent className="p-6">
-              {viewMode === 'detailed' ? (
-                <div className="space-y-4">
-                  {/* Header */}
-                  <div className="flex items-center justify-between">
-                    <div className="flex items-center gap-3">
-                      {getStatusIcon(job.status)}
-                      <div>
-                        <h3 className="font-semibold">Job {job.id || job.request_id}</h3>
-                        <div className="flex items-center gap-2 text-sm text-muted-foreground">
-                          {getJobTypeIcon(job)}
-                          <span>{getJobType(job)}</span>
-                          {job.queue_position !== undefined && (
-                            <span>• Position: {job.queue_position}</span>
-                          )}
+        {sortedJobs.map(job => {
+          const jobId = job.id || job.request_id;
+          if (!jobId) return null;
+
+          return (
+            <Card key={jobId} className={cn(
+              "transition-all hover:shadow-md",
+              job.status === 'processing' && 'border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20',
+              job.status === 'completed' && 'border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20',
+              job.status === 'failed' && 'border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20'
+            )}>
+              <CardContent className="p-6">
+                {viewMode === 'detailed' ? (
+                  <div className="space-y-4">
+                    {/* Header */}
+                    <div className="flex items-center justify-between">
+                      <div className="flex items-center gap-3">
+                        {getStatusIcon(job.status)}
+                        <div>
+                          <h3 className="font-semibold">Job {jobId}</h3>
+                          <div className="flex items-center gap-2 text-sm text-muted-foreground">
+                            {getJobTypeIcon(job)}
+                            <span>{getJobType(job)}</span>
+                            {job.queue_position !== undefined && (
+                              <span>• Position: {job.queue_position}</span>
+                            )}
+                          </div>
                         </div>
                       </div>
-                    </div>
-                    <div className="flex items-center gap-2">
-                      <Badge variant={getStatusBadgeVariant(job.status)}>
-                        {job.status}
-                      </Badge>
-                      {(job.status === 'queued' || job.status === 'processing') && (
-                        <Button
-                          variant="outline"
-                          size="sm"
-                          onClick={() => onCancelJob(job.id || job.request_id!)}
-                          disabled={actionLoading}
-                        >
-                          <XCircle className="h-4 w-4" />
-                          Cancel
-                        </Button>
-                      )}
-                    </div>
-                  </div>
-
-                  {/* Progress */}
-                  {job.progress !== undefined && (
-                    <div className="space-y-2">
-                      <div className="flex items-center justify-between text-sm">
-                        <span className={cn("font-medium", getStatusColor(job.status))}>
-                          {job.message || 'Processing...'}
-                        </span>
-                        <span className="text-muted-foreground">
-                          {Math.round(job.progress * 100)}%
-                        </span>
+                      <div className="flex items-center gap-2">
+                        <Badge variant={getStatusBadgeVariant(job.status)}>
+                          {job.status}
+                        </Badge>
+                        {(job.status === 'queued' || job.status === 'processing') && (
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            onClick={() => handleCancelJob(jobId)}
+                            disabled={actionLoading}
+                          >
+                            <XCircle className="h-4 w-4" />
+                            Cancel
+                          </Button>
+                        )}
                       </div>
-                      <Progress value={job.progress} className="h-2" />
                     </div>
-                  )}
-
-                  {/* Details Grid */}
-                  <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
-                    <div>
-                      <span className="text-muted-foreground flex items-center gap-1">
-                        <Calendar className="h-3 w-3" />
-                        Created
-                      </span>
-                      <p className="font-medium">
-                        {job.created_at ? new Date(job.created_at).toLocaleString() : 'Unknown'}
-                      </p>
-                    </div>
-                    {job.updated_at && job.updated_at !== job.created_at && (
+
+                    {/* Progress */}
+                    <ProgressBar job={job} />
+
+                    {/* Details Grid */}
+                    <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
                       <div>
                         <span className="text-muted-foreground flex items-center gap-1">
-                          <Clock className="h-3 w-3" />
-                          Updated
+                          <Calendar className="h-3 w-3" />
+                          Created
                         </span>
                         <p className="font-medium">
-                          {new Date(job.updated_at).toLocaleString()}
+                          {job.created_at ? new Date(job.created_at).toLocaleString() : 'Unknown'}
                         </p>
                       </div>
-                    )}
-                    {job.created_at && (
+                      {job.updated_at && job.updated_at !== job.created_at && (
+                        <div>
+                          <span className="text-muted-foreground flex items-center gap-1">
+                            <Clock className="h-3 w-3" />
+                            Updated
+                          </span>
+                          <p className="font-medium">
+                            {new Date(job.updated_at).toLocaleString()}
+                          </p>
+                        </div>
+                      )}
+                      {job.created_at && (
+                        <div>
+                          <span className="text-muted-foreground flex items-center gap-1">
+                            <Clock className="h-3 w-3" />
+                            Duration
+                          </span>
+                          <p className="font-medium">
+                            {formatDuration(job.created_at, job.updated_at)}
+                          </p>
+                        </div>
+                      )}
                       <div>
                         <span className="text-muted-foreground flex items-center gap-1">
-                          <Clock className="h-3 w-3" />
-                          Duration
+                          <FileText className="h-3 w-3" />
+                          Parameters
                         </span>
-                        <p className="font-medium">
-                          {formatDuration(job.created_at, job.updated_at)}
-                        </p>
-                      </div>
-                    )}
-                    <div>
-                      <span className="text-muted-foreground flex items-center gap-1">
-                        <FileText className="h-3 w-3" />
-                        Parameters
-                      </span>
-                      <div className="flex gap-1">
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          className="h-6 px-2 text-xs"
-                          onClick={() => copyParameters(job)}
-                        >
-                          <Copy className="h-3 w-3 mr-1" />
-                          Copy
-                        </Button>
-                        <Button
-                          variant="ghost"
-                          size="sm"
-                          className="h-6 px-2 text-xs"
-                          onClick={() => setSelectedJob(selectedJob === (job.id || job.request_id) ? null : (job.id || job.request_id || null))}
-                        >
-                          {selectedJob === (job.id || job.request_id) ? 'Hide' : 'Show'}
-                        </Button>
+                        <div className="flex gap-1">
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            className="h-6 px-2 text-xs"
+                            onClick={() => copyParameters(job)}
+                          >
+                            <Copy className="h-3 w-3 mr-1" />
+                            Copy
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            className="h-6 px-2 text-xs"
+                            onClick={() => handleSelectedJobToggle(selectedJob === jobId ? null : jobId)}
+                          >
+                            {selectedJob === jobId ? 'Hide' : 'Show'}
+                          </Button>
+                        </div>
                       </div>
                     </div>
-                  </div>
 
-                  {/* Expanded Parameters */}
-                  {selectedJob === (job.id || job.request_id) && (
-                    <div className="bg-muted/50 rounded-lg p-4 space-y-2">
-                      <h4 className="font-medium text-sm">Job Parameters</h4>
-                      <div className="grid gap-2 text-sm">
-                        {Object.entries(extractParameters(job)).map(([key, value]) => (
-                          <div key={key} className="flex justify-between">
-                            <span className="text-muted-foreground capitalize">{key}:</span>
-                            <span className="font-mono text-xs">{String(value)}</span>
-                          </div>
-                        ))}
-                        {Object.keys(extractParameters(job)).length === 0 && (
-                          <p className="text-muted-foreground text-sm">No parameters available</p>
-                        )}
-                      </div>
-                    </div>
-                  )}
-
-                  {/* Results */}
-                  {job.status === 'completed' && job.result?.images && (
-                    <div className="space-y-2">
-                      <h4 className="font-medium text-sm flex items-center gap-1">
-                        <Image className="h-4 w-4" />
-                        Generated Images ({job.result.images.length})
-                      </h4>
-                      <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
-                        {job.result.images.map((imageData, index) => (
-                          <div key={index} className="relative group">
-                            <div className="aspect-square bg-muted rounded-lg overflow-hidden">
-                              {imageData.startsWith('data:image') ? (
-                                <img
-                                  src={imageData}
-                                  alt={`Generated image ${index + 1}`}
-                                  className="w-full h-full object-cover"
-                                />
-                              ) : (
-                                <div className="w-full h-full flex items-center justify-center text-muted-foreground">
-                                  <Image className="h-8 w-8" />
+                    {/* Expanded Parameters */}
+                    {selectedJob === jobId && (
+                      <div className="bg-muted/50 rounded-lg p-4 space-y-2">
+                        <h4 className="font-medium text-sm">Job Parameters</h4>
+                        <div className="grid gap-2 text-sm">
+                          {(() => {
+                            const params = extractParameters(job);
+                            const entries = Object.entries(params);
+                            return entries.length > 0 ? (
+                              entries.map(([key, value]) => (
+                                <div key={key} className="flex justify-between">
+                                  <span className="text-muted-foreground capitalize">{key}:</span>
+                                  <span className="font-mono text-xs">{String(value)}</span>
                                 </div>
-                              )}
-                            </div>
-                            <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
-                              <Button
-                                variant="secondary"
-                                size="sm"
-                                onClick={() => {
-                                  if (imageData.startsWith('data:image')) {
-                                    const link = document.createElement('a');
-                                    link.href = imageData;
-                                    link.download = `generated-image-${index + 1}.png`;
-                                    link.click();
-                                  }
-                                }}
-                              >
-                                Download
-                              </Button>
-                            </div>
-                          </div>
-                        ))}
+                              ))
+                            ) : (
+                              <p className="text-muted-foreground text-sm">No parameters available</p>
+                            );
+                          })()}
+                        </div>
                       </div>
-                    </div>
-                  )}
-
-                  {/* Error */}
-                  {job.error && (
-                    <div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
-                      <p className="text-sm text-red-700 dark:text-red-300">
-                        <strong>Error:</strong> {job.error}
-                      </p>
-                    </div>
-                  )}
-                </div>
-              ) : (
-                /* Compact View */
-                <div className="flex items-center justify-between">
-                  <div className="flex items-center gap-4">
-                    {getStatusIcon(job.status)}
-                    <div>
-                      <h3 className="font-semibold">Job {job.id || job.request_id}</h3>
-                      <div className="flex items-center gap-2 text-sm text-muted-foreground">
-                        {getJobTypeIcon(job)}
-                        <span>{getJobType(job)}</span>
-                        <span>•</span>
-                        <span className={getStatusColor(job.status)}>{job.status}</span>
-                        {job.queue_position !== undefined && (
-                          <span>• Position: {job.queue_position}</span>
-                        )}
-                        {job.created_at && (
-                          <span>• {new Date(job.created_at).toLocaleTimeString()}</span>
-                        )}
+                    )}
+
+                    {/* Results */}
+                    {job.status === 'completed' && job.result?.images && (
+                      <div className="space-y-2">
+                        <h4 className="font-medium text-sm flex items-center gap-1">
+                          <Image className="h-4 w-4" />
+                          Generated Images ({job.result.images.length})
+                        </h4>
+                        <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
+                          {job.result.images.map((imageData, index) => (
+                            <div key={index} className="relative group">
+                              <div className="aspect-square bg-muted rounded-lg overflow-hidden">
+                                {imageData.startsWith('data:image') ? (
+                                  <img
+                                    src={imageData}
+                                    alt={`Generated image ${index + 1}`}
+                                    className="w-full h-full object-cover"
+                                    loading="lazy"
+                                  />
+                                ) : (
+                                  <div className="w-full h-full flex items-center justify-center text-muted-foreground">
+                                    <Image className="h-8 w-8" />
+                                  </div>
+                                )}
+                              </div>
+                              <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
+                                <Button
+                                  variant="secondary"
+                                  size="sm"
+                                  onClick={() => {
+                                    if (imageData.startsWith('data:image')) {
+                                      const link = document.createElement('a');
+                                      link.href = imageData;
+                                      link.download = `generated-image-${index + 1}.png`;
+                                      link.click();
+                                    }
+                                  }}
+                                >
+                                  Download
+                                </Button>
+                              </div>
+                            </div>
+                          ))}
+                        </div>
                       </div>
-                      {job.message && (
-                        <p className="text-sm text-muted-foreground truncate max-w-md">
-                          {job.message}
+                    )}
+
+                    {/* Error */}
+                    {job.error && (
+                      <div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
+                        <p className="text-sm text-red-700 dark:text-red-300">
+                          <strong>Error:</strong> {job.error}
                         </p>
-                      )}
-                    </div>
+                      </div>
+                    )}
                   </div>
-                  <div className="flex items-center gap-4">
-                    {job.progress !== undefined && (
-                      <div className="text-right">
-                        <div className="text-sm font-medium">
-                          {Math.round(job.progress * 100)}%
-                        </div>
-                        <div className="w-24 h-1.5 bg-gray-200 rounded-full overflow-hidden">
-                          <div
-                            className="h-full bg-blue-500 transition-all duration-300"
-                            style={{ width: `${job.progress * 100}%` }}
-                          />
+                ) : (
+                  /* Compact View */
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-4">
+                      {getStatusIcon(job.status)}
+                      <div>
+                        <h3 className="font-semibold">Job {jobId}</h3>
+                        <div className="flex items-center gap-2 text-sm text-muted-foreground">
+                          {getJobTypeIcon(job)}
+                          <span>{getJobType(job)}</span>
+                          <span>•</span>
+                          <span className={getStatusColor(job.status)}>{job.status}</span>
+                          {job.queue_position !== undefined && (
+                            <span>• Position: {job.queue_position}</span>
+                          )}
+                          {job.created_at && (
+                            <span>• {new Date(job.created_at).toLocaleTimeString()}</span>
+                          )}
                         </div>
+                        {job.message && (
+                          <p className="text-sm text-muted-foreground truncate max-w-md">
+                            {job.message}
+                          </p>
+                        )}
                       </div>
-                    )}
-                    {(job.status === 'queued' || job.status === 'processing') && (
-                      <Button
-                        variant="outline"
-                        size="sm"
-                        onClick={() => onCancelJob(job.id || job.request_id!)}
-                        disabled={actionLoading}
-                      >
-                        <XCircle className="h-4 w-4" />
-                      </Button>
-                    )}
+                    </div>
+                    <div className="flex items-center gap-4">
+                      {job.progress !== undefined && (
+                        <div className="text-right">
+                          <div className="text-sm font-medium">
+                            {Math.round(job.progress * 100)}%
+                          </div>
+                          <div className="w-24 h-1.5 bg-gray-200 rounded-full overflow-hidden">
+                            <div
+                              className="h-full bg-blue-500 transition-all duration-300"
+                              style={{ width: `${job.progress * 100}%` }}
+                            />
+                          </div>
+                        </div>
+                      )}
+                      {(job.status === 'queued' || job.status === 'processing') && (
+                        <Button
+                          variant="outline"
+                          size="sm"
+                          onClick={() => handleCancelJob(jobId)}
+                          disabled={actionLoading}
+                        >
+                          <XCircle className="h-4 w-4" />
+                        </Button>
+                      )}
+                    </div>
                   </div>
-                </div>
-              )}
-            </CardContent>
-          </Card>
-        ))}
+                )}
+              </CardContent>
+            </Card>
+          );
+        })}
       </div>
 
-      {(!queueStatus?.jobs || queueStatus.jobs.length === 0) && (
+      {(!throttledJobs || throttledJobs.length === 0) && (
         <Card>
           <CardContent className="text-center py-12">
             <Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
@@ -530,4 +676,4 @@ export function EnhancedQueueList({
       )}
     </div>
   );
-}
+}

+ 249 - 17
webui/lib/api.ts

@@ -14,6 +14,102 @@ declare global {
   }
 }
 
+// Request throttling to prevent excessive API calls
+class RequestThrottler {
+  private requests: Map<string, { count: number; resetTime: number }> = new Map();
+  private maxRequests: number = 10; // Max requests per time window
+  private timeWindow: number = 1000; // Time window in milliseconds
+
+  canMakeRequest(key: string): boolean {
+    const now = Date.now();
+    const request = this.requests.get(key);
+
+    if (!request || now >= request.resetTime) {
+      this.requests.set(key, { count: 1, resetTime: now + this.timeWindow });
+      return true;
+    }
+
+    if (request.count >= this.maxRequests) {
+      return false;
+    }
+
+    request.count++;
+    return true;
+  }
+
+  getWaitTime(key: string): number {
+    const request = this.requests.get(key);
+    if (!request) return 0;
+    
+    const now = Date.now();
+    if (now >= request.resetTime) return 0;
+    
+    return request.resetTime - now;
+  }
+}
+
+// Global throttler instance
+const throttler = new RequestThrottler();
+
+// Debounce utility for frequent calls
+function debounce<T extends (...args: any[]) => any>(
+  func: T,
+  wait: number,
+  immediate?: boolean
+): (...args: Parameters<T>) => void {
+  let timeout: NodeJS.Timeout | null = null;
+  
+  return function executedFunction(...args: Parameters<T>) {
+    const later = () => {
+      timeout = null;
+      if (!immediate) func(...args);
+    };
+    
+    const callNow = immediate && !timeout;
+    
+    if (timeout) clearTimeout(timeout);
+    timeout = setTimeout(later, wait);
+    
+    if (callNow) func(...args);
+  };
+}
+
+// Cache for API responses to reduce redundant calls
+class ApiCache {
+  private cache: Map<string, { data: any; timestamp: number; ttl: number }> = new Map();
+  private defaultTtl: number = 5000; // 5 seconds default TTL
+
+  set(key: string, data: any, ttl?: number): void {
+    this.cache.set(key, {
+      data,
+      timestamp: Date.now(),
+      ttl: ttl || this.defaultTtl
+    });
+  }
+
+  get(key: string): any | null {
+    const cached = this.cache.get(key);
+    if (!cached) return null;
+
+    if (Date.now() - cached.timestamp > cached.ttl) {
+      this.cache.delete(key);
+      return null;
+    }
+
+    return cached.data;
+  }
+
+  clear(): void {
+    this.cache.clear();
+  }
+
+  delete(key: string): void {
+    this.cache.delete(key);
+  }
+}
+
+const cache = new ApiCache();
+
 // Get configuration from server-injected config or fallback to environment/defaults
 // This function is called at runtime to ensure __SERVER_CONFIG__ is available
 function getApiConfig() {
@@ -108,10 +204,22 @@ export interface HealthStatus {
 }
 
 class ApiClient {
+  private baseUrl: string = '';
+  private isInitialized: boolean = false;
+
+  // Initialize base URL
+  private initBaseUrl(): string {
+    if (!this.isInitialized) {
+      const config = getApiConfig();
+      this.baseUrl = `${config.apiUrl}${config.apiBase}`;
+      this.isInitialized = true;
+    }
+    return this.baseUrl;
+  }
+
   // Get base URL dynamically at runtime to ensure server config is loaded
   private getBaseUrl(): string {
-    const { apiUrl, apiBase } = getApiConfig();
-    return `${apiUrl}${apiBase}`;
+    return this.initBaseUrl();
   }
 
   private async request<T>(
@@ -120,6 +228,19 @@ class ApiClient {
   ): Promise<T> {
     const url = `${this.getBaseUrl()}${endpoint}`;
 
+    // Check request throttling for certain endpoints
+    const needsThrottling = endpoint.includes('/queue/status') || endpoint.includes('/health');
+    if (needsThrottling) {
+      const waitTime = throttler.getWaitTime(endpoint);
+      if (waitTime > 0) {
+        // Wait before making the request
+        await new Promise(resolve => setTimeout(resolve, waitTime));
+      }
+      if (!throttler.canMakeRequest(endpoint)) {
+        throw new Error('Too many requests. Please wait before making another request.');
+      }
+    }
+
     // Get authentication method from server config
     const authMethod = typeof window !== 'undefined' && window.__SERVER_CONFIG__
       ? window.__SERVER_CONFIG__.authMethod
@@ -165,8 +286,14 @@ class ApiClient {
     return response.json();
   }
 
-  // Enhanced health check with multiple endpoints and better error handling
+  // Enhanced health check with caching and better error handling
   async checkHealth(): Promise<HealthStatus> {
+    const cacheKey = 'health_check';
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
     const endpoints = ['/queue/status', '/health', '/status', '/'];
     
     for (const endpoint of endpoints) {
@@ -177,7 +304,7 @@ class ApiClient {
             'Content-Type': 'application/json',
           },
           // Add timeout to prevent hanging requests
-          signal: AbortSignal.timeout(5000),
+          signal: AbortSignal.timeout(3000), // Reduced timeout
         });
 
         if (response.ok) {
@@ -185,11 +312,13 @@ class ApiClient {
           
           // For queue status, consider it healthy if it returns valid structure
           if (endpoint === '/queue/status' && data.queue) {
-            return {
-              status: 'ok',
+            const result = {
+              status: 'ok' as const,
               message: 'API is running and queue is accessible',
               timestamp: new Date().toISOString(),
             };
+            cache.set(cacheKey, result, 10000); // Cache for 10 seconds
+            return result;
           }
           
           // For other health endpoints
@@ -215,6 +344,7 @@ class ApiClient {
             }
           }
 
+          cache.set(cacheKey, healthStatus, 10000); // Cache for 10 seconds
           return healthStatus;
         }
       } catch (error) {
@@ -228,15 +358,24 @@ class ApiClient {
     throw new Error('All health check endpoints are unavailable');
   }
 
-  // Alternative simple connectivity check
+  // Alternative simple connectivity check with caching
   async checkConnectivity(): Promise<boolean> {
+    const cacheKey = 'connectivity_check';
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult !== null) {
+      return cachedResult;
+    }
+
     try {
       const response = await fetch(`${this.getBaseUrl()}`, {
         method: 'HEAD',
-        signal: AbortSignal.timeout(3000),
+        signal: AbortSignal.timeout(2000), // Reduced timeout
       });
-      return response.ok || response.status < 500; // Accept any non-server-error response
+      const result = response.ok || response.status < 500;
+      cache.set(cacheKey, result, 5000); // Cache for 5 seconds
+      return result;
     } catch (error) {
+      cache.set(cacheKey, false, 5000); // Cache failure for 5 seconds
       return false;
     }
   }
@@ -276,15 +415,29 @@ class ApiClient {
     });
   }
 
-  // Job management
+  // Job management with caching for status checks
   async getJobStatus(jobId: string): Promise<JobInfo> {
-    return this.request<JobInfo>(`/queue/job/${jobId}`);
+    const cacheKey = `job_status_${jobId}`;
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
+    const result = await this.request<JobInfo>(`/queue/job/${jobId}`);
+    
+    // Cache job status for a short time
+    if (result.status === 'processing' || result.status === 'queued') {
+      cache.set(cacheKey, result, 2000); // Cache for 2 seconds for active jobs
+    } else {
+      cache.set(cacheKey, result, 10000); // Cache for 10 seconds for completed jobs
+    }
+    
+    return result;
   }
 
   // Get authenticated image URL with cache-busting
   getImageUrl(jobId: string, filename: string): string {
-    const { apiUrl, apiBase } = getApiConfig();
-    const baseUrl = `${apiUrl}${apiBase}`;
+    const baseUrl = this.getBaseUrl();
 
     // Add cache-busting timestamp
     const timestamp = Date.now();
@@ -339,18 +492,41 @@ class ApiClient {
   }
 
   async cancelJob(jobId: string): Promise<void> {
+    // Clear job status cache when cancelling
+    cache.delete(`job_status_${jobId}`);
+    
     return this.request<void>('/queue/cancel', {
       method: 'POST',
       body: JSON.stringify({ job_id: jobId }),
     });
   }
 
+  // Get queue status with caching and throttling
   async getQueueStatus(): Promise<QueueStatus> {
+    const cacheKey = 'queue_status';
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
     const response = await this.request<{ queue: QueueStatus }>('/queue/status');
+    
+    // Cache queue status based on current activity
+    const hasActiveJobs = response.queue.jobs.some(job => 
+      job.status === 'processing' || job.status === 'queued'
+    );
+    
+    // Cache for shorter time if there are active jobs
+    const cacheTime = hasActiveJobs ? 1000 : 5000; // 1 second for active, 5 seconds for idle
+    cache.set(cacheKey, response.queue, cacheTime);
+    
     return response.queue;
   }
 
   async clearQueue(): Promise<void> {
+    // Clear all related caches
+    cache.delete('queue_status');
+    
     return this.request<void>('/queue/clear', {
       method: 'POST',
     });
@@ -358,6 +534,12 @@ class ApiClient {
 
   // Model management
   async getModels(type?: string, loaded?: boolean): Promise<ModelInfo[]> {
+    const cacheKey = `models_${type || 'all'}_${loaded ? 'loaded' : 'all'}`;
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
     let endpoint = '/models';
     const params = [];
     if (type && type !== 'loaded') params.push(`type=${type}`);
@@ -367,32 +549,53 @@ class ApiClient {
     if (params.length > 0) endpoint += '?' + params.join('&');
 
     const response = await this.request<{ models: ModelInfo[] }>(endpoint);
-    // Add id field based on sha256_short or name, and normalize size field
-    return response.models.map(model => ({
+    const models = response.models.map(model => ({
       ...model,
       id: model.sha256_short || model.name,
       size: model.file_size || model.size,
       path: model.path || model.name,
     }));
+    
+    // Cache models for 30 seconds as they don't change frequently
+    cache.set(cacheKey, models, 30000);
+    
+    return models;
   }
 
   async getModelInfo(modelId: string): Promise<ModelInfo> {
-    return this.request<ModelInfo>(`/models/${modelId}`);
+    const cacheKey = `model_info_${modelId}`;
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
+    const result = await this.request<ModelInfo>(`/models/${modelId}`);
+    cache.set(cacheKey, result, 30000); // Cache for 30 seconds
+    return result;
   }
 
   async loadModel(modelId: string): Promise<void> {
+    // Clear model cache when loading
+    cache.delete(`model_info_${modelId}`);
+    
     return this.request<void>(`/models/${modelId}/load`, {
       method: 'POST',
     });
   }
 
   async unloadModel(modelId: string): Promise<void> {
+    // Clear model cache when unloading
+    cache.delete(`model_info_${modelId}`);
+    
     return this.request<void>(`/models/${modelId}/unload`, {
       method: 'POST',
     });
   }
 
   async scanModels(): Promise<void> {
+    // Clear all model caches when scanning
+    cache.clear();
+    
     return this.request<void>('/models/refresh', {
       method: 'POST',
     });
@@ -429,16 +632,45 @@ class ApiClient {
     });
   }
 
-  // Configuration endpoints
+  // Configuration endpoints with caching
   async getSamplers(): Promise<Array<{ name: string; description: string; recommended_steps: number }>> {
+    const cacheKey = 'samplers';
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
     const response = await this.request<{ samplers: Array<{ name: string; description: string; recommended_steps: number }> }>('/samplers');
+    cache.set(cacheKey, response.samplers, 60000); // Cache for 1 minute
     return response.samplers;
   }
 
   async getSchedulers(): Promise<Array<{ name: string; description: string }>> {
+    const cacheKey = 'schedulers';
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
     const response = await this.request<{ schedulers: Array<{ name: string; description: string }> }>('/schedulers');
+    cache.set(cacheKey, response.schedulers, 60000); // Cache for 1 minute
     return response.schedulers;
   }
+
+  // Cache management methods
+  clearCache(): void {
+    cache.clear();
+  }
+
+  clearCacheByPrefix(prefix: string): void {
+    const keysToDelete: string[] = [];
+    (cache as any).cache.forEach((_: any, key: string) => {
+      if (key.startsWith(prefix)) {
+        keysToDelete.push(key);
+      }
+    });
+    keysToDelete.forEach(key => cache.delete(key));
+  }
 }
 
 // Generic API request function for authentication