Quellcode durchsuchen

UI SCSS enhancements

Dr-Swopt vor 2 Tagen
Ursprung
Commit
a7581aeb92

+ 154 - 176
src/app/components/analyzer/analyzer.component.html

@@ -115,14 +115,14 @@
 }
 
 <!-- ─────────────────────────────────────────────────────────────────────────── -->
-<!--  SOCKET ENGINE — webcam live preview + manual Snap & Analyze               -->
+<!--  SOCKET ENGINE — Three-Column Command Center                                -->
 <!-- ─────────────────────────────────────────────────────────────────────────── -->
 @if (isSocketEngine()) {
   <div class="container main-content">
-    <div class="row">
+    <div class="dashboard-layout">
 
-      <!-- Controls column -->
-      <div class="col-left">
+      <!-- ── COL 1: Configuration Sidebar ──────────────────────────────────── -->
+      <div class="dash-sidebar">
         <div class="glass-panel backend-controls">
           <h3 class="panel-title">NestJS Socket Engine</h3>
 
@@ -160,13 +160,13 @@
                   </div>
                 </div>
                 <button class="btn btn-danger btn-sm abort-btn" (click)="abortBatch()">
-                  Abort Batch
+                  Abort
                 </button>
               </div>
             </div>
           }
 
-          <!-- Batch Complete HUD — hardware report shown after finalizeBatch() -->
+          <!-- Batch Complete HUD -->
           @if (completedReport()) {
             <div class="batch-complete-hud glass-panel">
               <div class="batch-complete-title">Batch Complete</div>
@@ -176,7 +176,7 @@
                   <span class="bc-value">{{ completedReport()!.meta.total_images }}</span>
                 </div>
                 <div class="bc-stat">
-                  <span class="bc-label">Successful</span>
+                  <span class="bc-label">OK</span>
                   <span class="bc-value ok">{{ completedReport()!.meta.successful }}</span>
                 </div>
                 <div class="bc-stat">
@@ -190,15 +190,14 @@
                   <span class="bc-value">{{ completedReport()!.meta.avg_inference_ms }} ms</span>
                 </div>
                 <div class="bc-stat">
-                  <span class="bc-label">Avg Round-Trip</span>
+                  <span class="bc-label">Avg RTT</span>
                   <span class="bc-value">{{ completedReport()!.meta.avg_round_trip_ms }} ms</span>
                 </div>
                 <div class="bc-stat">
-                  <span class="bc-label">Total Time</span>
+                  <span class="bc-label">Total</span>
                   <span class="bc-value">{{ completedReport()!.meta.total_time_ms }} ms</span>
                 </div>
               </div>
-              <!-- Ripeness Distribution -->
               @if (getBatchDistribution().length > 0) {
                 <div class="bc-distribution">
                   <div class="bc-dist-label">Ripeness Distribution</div>
@@ -221,24 +220,15 @@
 
           <!-- Input source toggle -->
           <div class="input-mode-toggle">
-            <button
-              class="input-mode-btn"
-              [class.active]="socketInputMode() === 'webcam'"
-              (click)="onSocketInputModeChange('webcam')">
-              📷 Webcam
-            </button>
-            <button
-              class="input-mode-btn"
-              [class.active]="socketInputMode() === 'gallery'"
-              (click)="onSocketInputModeChange('gallery')">
-              🖼 Gallery
-            </button>
+            <button class="input-mode-btn" [class.active]="socketInputMode() === 'webcam'"
+                    (click)="onSocketInputModeChange('webcam')">📷 Webcam</button>
+            <button class="input-mode-btn" [class.active]="socketInputMode() === 'gallery'"
+                    (click)="onSocketInputModeChange('gallery')">🖼 Gallery</button>
           </div>
 
           <div class="lego-note">
-            <strong>Lego 11:</strong> One frame per analyze. Transmitted
-            as raw uncompressed Base64 via <code>vision:analyze</code>.
-            No continuous streaming. No binary encoding.
+            <strong>Lego 11:</strong> One frame per analyze. Transmitted as raw Base64
+            via <code>vision:analyze</code>. Every snap is a Batch of 1.
           </div>
 
           <!-- Webcam controls -->
@@ -248,15 +238,12 @@
                 Start Webcam Preview
               </button>
             } @else {
-              <button
-                class="btn btn-primary snap-btn"
-                (click)="snapAndAnalyze()"
-                [disabled]="visionSocket.analyzing() || !visionSocket.connected()">
-                {{ visionSocket.analyzing() ? 'Analyzing...' : '📸 Snap & Analyze' }}
-              </button>
-              <button class="btn btn-outline" (click)="stopWebcam()">
-                Stop Webcam
+              <button class="btn btn-primary snap-btn"
+                      (click)="snapAndAnalyze()"
+                      [disabled]="isBatchActive() || !visionSocket.connected()">
+                {{ isBatchActive() ? 'Processing...' : '📸 Snap & Analyze' }}
               </button>
+              <button class="btn btn-outline" (click)="stopWebcam()">Stop Webcam</button>
             }
           }
 
@@ -273,193 +260,184 @@
                      (change)="onGalleryFileSelected($event)" style="display:none">
               <div class="upload-icon">📁</div>
               @if (!socketGalleryFile && batchQueue().length === 0) {
-                <p>Drop images here or click to start Batch Audit (API-Only)</p>
-                <p class="upload-hint">Select multiple images to run a full batch session</p>
+                <p>Drop images here or click to select</p>
+                <p class="upload-hint">Multiple images = Batch Audit</p>
               } @else if (batchQueue().length > 0) {
-                <p>{{ batchQueue().length }} image(s) queued for batch</p>
+                <p>{{ batchQueue().length }} image(s) queued</p>
               } @else {
                 <p>{{ socketGalleryFile!.name }}</p>
               }
             </div>
-            <button
-              class="btn btn-primary snap-btn"
-              (click)="analyzeGalleryImage()"
-              [disabled]="(!socketGalleryFile && batchQueue().length === 0) || visionSocket.analyzing() || !visionSocket.connected()">
-              {{ visionSocket.analyzing() ? 'Analyzing...' : (batchQueue().length > 1 ? '🚀 Start Batch Audit' : '🔍 Analyze Image') }}
+            <button class="btn btn-primary snap-btn"
+                    (click)="analyzeGalleryImage()"
+                    [disabled]="(!socketGalleryFile && batchQueue().length === 0) || isBatchActive() || !visionSocket.connected()">
+              {{ isBatchActive() ? 'Processing...' : (batchQueue().length > 1 ? '🚀 Start Batch Audit' : '🔍 Analyze Image') }}
             </button>
           }
 
           @if (visionSocket.lastError()) {
             <div class="error-note">{{ visionSocket.lastError() }}</div>
           }
-
-          @if (visionSocket.lastResult()) {
-            <div class="result-summary">
-              <div class="summary-header">Last Snap Result</div>
-              <div class="summary-stat">
-                Bunches detected: {{ visionSocket.lastResult()!.total_count }}
-              </div>
-              <div class="summary-stat">
-                Inference: {{ visionSocket.lastResult()!.inference_ms.toFixed(1) }} ms
-              </div>
-              <div class="summary-stat">
-                Processing: {{ visionSocket.lastResult()!.processing_ms.toFixed(1) }} ms
-              </div>
-            </div>
-          }
         </div>
       </div>
 
-      <!-- Camera + result column -->
-      <div class="col-right">
+      <!-- ── COL 2: Active View ─────────────────────────────────────────────── -->
+      <div class="dash-main">
         <div class="glass-panel webcam-panel">
 
-          <!-- ── Webcam mode view ── -->
+          <!-- Webcam mode -->
           @if (socketInputMode() === 'webcam') {
             @if (webcamActive) {
               <h2 class="panel-title">
-                {{ snappedFrame ? 'Snap Result' : 'Live Preview' }}
-                <span class="engine-label">
-                  {{ snappedFrame ? 'NestJS ONNX · Lego 11' : 'No inference · Lego 02' }}
-                </span>
+                Live Preview
+                <span class="engine-label">NestJS ONNX · Batch-1 · Lego 11</span>
               </h2>
-
-              <!-- Video always in DOM when webcam active; hidden (not removed) by snap -->
-              <div class="webcam-wrapper" [class.active]="!snappedFrame">
+              <div class="webcam-wrapper active">
                 <video #videoEl autoplay playsinline muted class="webcam-video"></video>
               </div>
-
-              @if (snappedFrame) {
-                <div class="snap-result-wrapper">
-                  <canvas #snapCanvas class="snap-canvas"></canvas>
-                  @if (visionSocket.analyzing()) {
-                    <div class="snap-overlay-spinner">
-                      <div class="spinner"></div>
-                      <p>Waiting for NestJS...</p>
-                    </div>
-                  }
-                </div>
-                <button class="btn btn-outline snap-retry" (click)="snappedFrame = null">
-                  ← Back to Live Preview
-                </button>
-              }
             }
-
             @if (!webcamActive) {
               <div class="no-results">
                 Click "Start Webcam Preview" to open the camera.<br>
-                Then click "Snap &amp; Analyze" to send one frame to NestJS.
+                Each "Snap &amp; Analyze" submits a Batch of 1 to NestJS.
               </div>
             }
           }
 
-          <!-- ── Gallery mode view ── -->
+          <!-- Gallery mode — show last processed image result -->
           @if (socketInputMode() === 'gallery') {
-            @if (!snappedFrame) {
+            @if (completedReport() && completedReport()!.results.length > 0) {
+              @if (selectedAuditEntry()?.localBlobUrl) {
+                <h2 class="panel-title">
+                  Evidence View
+                  <span class="engine-label">{{ selectedAuditEntry()!.image_id }}</span>
+                </h2>
+                <div class="evidence-canvas-wrapper">
+                  <canvas #evidenceCanvas class="evidence-canvas"></canvas>
+                </div>
+              } @else {
+                <div class="no-results">Select a row in the manifest to view evidence.</div>
+              }
+            } @else if (isBatchActive()) {
               <div class="no-results">
-                Choose an image from your gallery and click "Analyze Image".
+                <div class="spinner"></div>
+                <p>Processing image {{ currentBatchIndex() + 1 }} of {{ totalBatchCount }}…</p>
               </div>
-            }
-
-            @if (snappedFrame) {
-              <h2 class="panel-title">
-                {{ visionSocket.analyzing() ? 'Analyzing...' : 'Gallery Result' }}
-                <span class="engine-label">NestJS ONNX · Lego 11</span>
-              </h2>
-              <div class="snap-result-wrapper">
-                <canvas #snapCanvas class="snap-canvas"></canvas>
-                @if (visionSocket.analyzing()) {
-                  <div class="snap-overlay-spinner">
-                    <div class="spinner"></div>
-                    <p>Waiting for NestJS...</p>
-                  </div>
-                }
+            } @else {
+              <div class="no-results">
+                Select images from Gallery and click "Start Batch Audit".
               </div>
-              <button class="btn btn-outline snap-retry" (click)="snappedFrame = null; socketGalleryFile = null">
-                ← Choose Another Image
-              </button>
             }
           }
         </div>
-      </div>
 
-    </div>
-  </div>
-
-  <!-- ── Audit Manifest Table ──────────────────────────────────────────────── -->
-  @if (completedReport() && !selectedAuditEntry()) {
-    <div class="container audit-section">
-      <h3 class="audit-title">Session Manifest — {{ completedReport()!.session_id }}</h3>
-      <div class="audit-table-wrapper">
-        <table class="audit-table">
-          <thead>
-            <tr>
-              <th>ID</th>
-              <th>Status</th>
-              <th>Inference</th>
-              <th>Action</th>
-            </tr>
-          </thead>
-          <tbody>
-            @for (entry of completedReport()!.results; track entry.image_id) {
-              <tr [class.row-error]="entry.status === 'error'">
-                <td class="cell-id">{{ entry.image_id }}</td>
-                <td>
-                  <span class="status-pill" [class.pill-ok]="entry.status === 'ok'" [class.pill-err]="entry.status === 'error'">
-                    {{ entry.status === 'ok' ? '✅ OK' : '❌ ERROR' }}
-                  </span>
-                </td>
-                <td class="cell-ms">{{ entry.status === 'ok' ? (entry.performance.inference_ms | number:'1.1-1') + ' ms' : '—' }}</td>
-                <td>
-                  <button class="btn-evidence" (click)="selectedAuditEntry.set(entry)">View Evidence</button>
-                </td>
-              </tr>
-            }
-          </tbody>
-        </table>
-      </div>
-    </div>
-  }
-
-  <!-- ── Evidence Drill-Down Panel ─────────────────────────────────────────── -->
-  @if (selectedAuditEntry()) {
-    <div class="container evidence-panel">
-      <div class="evidence-header">
-        <div>
-          <span class="evidence-title">Technical Evidence</span>
-          <span class="evidence-id">{{ selectedAuditEntry()!.image_id }}</span>
-        </div>
-        <button class="btn btn-outline btn-sm" (click)="selectedAuditEntry.set(null)">← Back to Summary</button>
+        <!-- Evidence JSON blocks — shown below the canvas when an entry is selected -->
+        @if (selectedAuditEntry()) {
+          <div class="evidence-panel">
+            <div class="evidence-header">
+              <div>
+                <span class="evidence-title">Technical Evidence</span>
+                <span class="evidence-id">{{ selectedAuditEntry()!.image_id }}</span>
+              </div>
+              <button class="btn btn-outline btn-sm" (click)="selectedAuditEntry.set(null)">✕ Close</button>
+            </div>
+            <div class="evidence-grid">
+              <div class="evidence-block">
+                <div class="evidence-block-label">Raw Tensor Sample</div>
+                <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.raw_tensor_sample | json }}</pre>
+              </div>
+              <div class="evidence-block">
+                <div class="evidence-block-label">Industrial Summary</div>
+                <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.industrial_summary | json }}</pre>
+              </div>
+              <div class="evidence-block evidence-block--full">
+                <div class="evidence-block-label">Full technical_evidence</div>
+                <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence | json }}</pre>
+              </div>
+              <div class="evidence-block evidence-block--full">
+                <div class="evidence-block-label">Performance</div>
+                <pre class="json-viewer">{{ selectedAuditEntry()!.performance | json }}</pre>
+              </div>
+            </div>
+          </div>
+        }
       </div>
 
-      <!-- Evidence Canvas — image with bounding boxes drawn by drawEvidence() -->
-      @if (selectedAuditEntry()!.localBlobUrl) {
-        <div class="evidence-canvas-wrapper">
-          <canvas #evidenceCanvas class="evidence-canvas"></canvas>
-        </div>
-      }
-
-      <div class="evidence-grid">
-        <div class="evidence-block">
-          <div class="evidence-block-label">Raw Tensor Sample (pre-NMS · 5 rows · [x1, y1, x2, y2, conf, cls])</div>
-          <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.raw_tensor_sample | json }}</pre>
-        </div>
+      <!-- ── COL 3: Live Audit Manifest ────────────────────────────────────── -->
+      <div class="dash-manifest">
+        <div class="glass-panel manifest-panel">
+          <div class="manifest-header">
+            <span class="manifest-title">Audit Manifest</span>
+            @if (completedReport()) {
+              <span class="manifest-session-id">{{ completedReport()!.session_id | slice:0:8 }}…</span>
+            } @else if (isBatchActive()) {
+              <span class="manifest-live-badge">LIVE</span>
+            }
+          </div>
 
-        <div class="evidence-block">
-          <div class="evidence-block-label">Industrial Summary</div>
-          <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence.industrial_summary | json }}</pre>
-        </div>
+          @if (!completedReport() && !isBatchActive()) {
+            <div class="manifest-empty">
+              Results will appear here as each image is processed.
+            </div>
+          }
 
-        <div class="evidence-block evidence-block--full">
-          <div class="evidence-block-label">Full technical_evidence</div>
-          <pre class="json-viewer">{{ selectedAuditEntry()!.technical_evidence | json }}</pre>
-        </div>
+          <!-- Live rows during batch run -->
+          @if (isBatchActive() && sessionManifest.length > 0) {
+            <div class="manifest-table-scroll">
+              <table class="audit-table">
+                <thead>
+                  <tr><th>Image</th><th>Status</th><th>ms</th></tr>
+                </thead>
+                <tbody>
+                  @for (entry of sessionManifest; track entry.image_id) {
+                    <tr [class.row-error]="entry.status === 'error'">
+                      <td class="cell-id">{{ entry.image_id }}</td>
+                      <td>
+                        <span class="status-pill" [class.pill-ok]="entry.status === 'ok'" [class.pill-err]="entry.status === 'error'">
+                          {{ entry.status === 'ok' ? '✅' : '❌' }}
+                        </span>
+                      </td>
+                      <td class="cell-ms">{{ entry.status === 'ok' ? (entry.performance.inference_ms | number:'1.0-0') : '—' }}</td>
+                    </tr>
+                  }
+                </tbody>
+              </table>
+            </div>
+          }
 
-        <div class="evidence-block evidence-block--full">
-          <div class="evidence-block-label">Performance</div>
-          <pre class="json-viewer">{{ selectedAuditEntry()!.performance | json }}</pre>
+          <!-- Final manifest after completion -->
+          @if (completedReport()) {
+            <div class="manifest-table-scroll">
+              <table class="audit-table">
+                <thead>
+                  <tr><th>Image</th><th>Status</th><th>ms</th><th></th></tr>
+                </thead>
+                <tbody>
+                  @for (entry of completedReport()!.results; track entry.image_id) {
+                    <tr [class.row-error]="entry.status === 'error'"
+                        [class.row-selected]="selectedAuditEntry()?.image_id === entry.image_id"
+                        (click)="selectedAuditEntry.set(entry)">
+                      <td class="cell-id">{{ entry.image_id }}</td>
+                      <td>
+                        <span class="status-pill" [class.pill-ok]="entry.status === 'ok'" [class.pill-err]="entry.status === 'error'">
+                          {{ entry.status === 'ok' ? '✅' : '❌' }}
+                        </span>
+                      </td>
+                      <td class="cell-ms">{{ entry.status === 'ok' ? (entry.performance.inference_ms | number:'1.0-0') : '—' }}</td>
+                      <td>
+                        @if (entry.localBlobUrl) {
+                          <button class="btn-evidence" (click)="selectedAuditEntry.set(entry); $event.stopPropagation()">View</button>
+                        }
+                      </td>
+                    </tr>
+                  }
+                </tbody>
+              </table>
+            </div>
+          }
         </div>
       </div>
+
     </div>
-  }
+  </div>
 }

+ 108 - 0
src/app/components/analyzer/analyzer.component.scss

@@ -250,6 +250,114 @@
   &.live { color: #00a651; }
 }
 
+// ── Three-Column Command Center (socket engine) ────────────────────────────────
+.dashboard-layout {
+  display: grid;
+  grid-template-columns: 320px 1fr 400px;
+  gap: 24px;
+  align-items: start;
+
+  @media (max-width: 1200px) {
+    grid-template-columns: 280px 1fr 340px;
+    gap: 16px;
+  }
+
+  @media (max-width: 900px) {
+    grid-template-columns: 1fr;
+  }
+}
+
+.dash-sidebar {
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+}
+
+.dash-main {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  min-width: 0; // prevent overflow in grid
+}
+
+.dash-manifest {
+  position: sticky;
+  top: 20px;
+  max-height: calc(100vh - 120px);
+  display: flex;
+  flex-direction: column;
+
+  @media (max-width: 900px) {
+    position: static;
+    max-height: none;
+  }
+}
+
+// ── Live Manifest Panel ────────────────────────────────────────────────────────
+.manifest-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  max-height: calc(100vh - 140px);
+  padding: 16px;
+  overflow: hidden;
+}
+
+.manifest-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 12px;
+  flex-shrink: 0;
+}
+
+.manifest-title {
+  font-size: 0.78rem;
+  font-weight: 700;
+  color: var(--accent-gold);
+  text-transform: uppercase;
+  letter-spacing: 0.07em;
+}
+
+.manifest-session-id {
+  font-size: 0.65rem;
+  font-family: monospace;
+  color: var(--text-secondary);
+}
+
+.manifest-live-badge {
+  font-size: 0.65rem;
+  font-weight: 700;
+  color: var(--accent-green);
+  animation: pulse 1s ease-in-out infinite alternate;
+}
+
+@keyframes pulse {
+  from { opacity: 1; }
+  to   { opacity: 0.4; }
+}
+
+.manifest-empty {
+  font-size: 0.78rem;
+  color: var(--text-secondary);
+  text-align: center;
+  padding: 24px 8px;
+  line-height: 1.6;
+}
+
+.manifest-table-scroll {
+  flex: 1;
+  overflow-y: auto;
+  border: 1px solid var(--border-color);
+  border-radius: 8px;
+}
+
+.row-selected {
+  background: rgba(0, 166, 81, 0.08) !important;
+  outline: 1px solid rgba(0, 166, 81, 0.3);
+}
+
 // ── Backend socket mode ────────────────────────────────────────────────────────
 .backend-controls {
   display: flex;

+ 19 - 10
src/app/components/analyzer/analyzer.component.ts

@@ -104,6 +104,8 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   get totalBatchCount(): number { return this._totalBatchCount; }
   /** Blob URL for the file currently being processed — stored so the pong effect can attach it to the record */
   private _currentBlobUrl: string | null = null;
+  /** UUID shared across all frames in a batch session — sent to the gateway so DB rows can be grouped in the Vault */
+  private _batchId: string = '';
 
   // ── Socket-engine state ────────────────────────────────────────────────────
   /** 'webcam' = live camera snap | 'gallery' = file from device storage */
@@ -278,6 +280,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   }
 
   private startBatchProcessing(): void {
+    this._batchId = crypto.randomUUID();
     this._totalBatchCount = this.batchQueue().length;
     this._batchStartTime = performance.now();
     this.sessionManifest = [];
@@ -304,7 +307,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
       offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
       const base64 = offscreen.toDataURL('image/jpeg');
       this._pingTime = performance.now();
-      this.visionSocket.sendBase64(base64);
+      this.visionSocket.sendBase64(base64, this._batchId);
     };
     img.src = blobUrl;
   }
@@ -636,15 +639,21 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
    */
   snapAndAnalyze(): void {
     const videoEl = this.videoElRef?.nativeElement;
-    if (!videoEl) return;
-
-    const frame = this.visionSocket.snapAndSend(videoEl);
-    if (!frame) return;
-
-    this.snappedFrame = frame;
-
-    // Wait for vision:result signal, then draw bounding boxes on the snap canvas
-    this.waitForSocketResult();
+    if (!videoEl || videoEl.readyState < 2) return;
+
+    // Capture current frame to an offscreen canvas → File → batchQueue of 1
+    const offscreen = document.createElement('canvas');
+    offscreen.width = 640;
+    offscreen.height = 640;
+    offscreen.getContext('2d')!.drawImage(videoEl, 0, 0, 640, 640);
+    offscreen.toBlob((blob) => {
+      if (!blob) return;
+      const file = new File([blob], `webcam-snap-${Date.now()}.jpg`, { type: 'image/jpeg' });
+      this.batchQueue.set([file]);
+      this.sessionManifest = [];
+      this.completedReport.set(null);
+      this.startBatchProcessing();
+    }, 'image/jpeg');
   }
 
   /**

+ 123 - 75
src/app/components/history/history.component.html

@@ -23,16 +23,11 @@
         </button>
       </div>
 
-      <!-- Clear All button — context-sensitive to active tab -->
       @if (viewMode === 'local' && localHistoryRecords.length > 0) {
-        <button class="btn-clear-all" (click)="clearLocalHistory($event)">
-          🗑 Clear All
-        </button>
+        <button class="btn-clear-all" (click)="clearLocalHistory($event)">🗑 Clear All</button>
       }
       @if (viewMode === 'remote' && remoteHistoryRecords.length > 0) {
-        <button class="btn-clear-all btn-clear-all--remote" (click)="clearRemoteHistory($event)">
-          🗑 Clear All
-        </button>
+        <button class="btn-clear-all btn-clear-all--remote" (click)="clearRemoteHistory($event)">🗑 Clear All</button>
       }
     </div>
 
@@ -50,85 +45,138 @@
     </div>
   }
 
-  @if (!loading && currentHistory.length === 0) {
-    <div class="empty-state glass-panel">
-      <div class="empty-icon">📭</div>
-      @if (viewMode === 'local') {
+  <!-- ── Browser Cache tab — flat list ───────────────────────────────────── -->
+  @if (!loading && viewMode === 'local') {
+    @if (localHistoryRecords.length === 0) {
+      <div class="empty-state glass-panel">
+        <div class="empty-icon">📭</div>
         <p>Your browser cache is empty. Run a local analysis to save records.</p>
-      }
-      @if (viewMode === 'remote') {
-        <p>No industrial records found on the server.</p>
-      }
-    </div>
-  }
+      </div>
+    }
 
-  @if (!loading && currentHistory.length > 0) {
-    <div class="history-list">
-      @for (record of currentHistory; track record.archive_id ?? (record.timestamp + record.filename)) {
-        <div class="history-card glass-panel"
-             [class.expanded]="isExpanded(record.timestamp, record.filename, record.archive_id)">
+    @if (localHistoryRecords.length > 0) {
+      <div class="history-list">
+        @for (record of localHistoryRecords; track record.timestamp + record.filename) {
+          <div class="history-card glass-panel"
+               [class.expanded]="isExpanded(record.timestamp, record.filename)">
 
-          <div class="card-header" (click)="toggleExpand(record.timestamp, record.filename, record.archive_id)">
-            <div class="card-main-info">
-              <span class="timestamp">{{ record.timestamp }}</span>
-              <span class="filename">{{ record.filename }}</span>
-              <span class="engine-badge">{{ record.engine }}</span>
-            </div>
-            <div class="summary-mini">
-              @for (badge of getSummaryBadge(record.industrial_summary || record.summary); track badge) {
-                <span class="badge">{{ badge }}</span>
-              }
-            </div>
-            <div class="card-actions">
-              <span class="expand-icon">
-                {{ isExpanded(record.timestamp, record.filename, record.archive_id) ? '▾' : '▸' }}
-              </span>
-              @if (viewMode === 'local') {
-                <button class="btn-delete-item" title="Delete record"
-                        (click)="deleteLocalRecord(record, $event)">✕</button>
-              }
-              @if (viewMode === 'remote') {
-                <button class="btn-delete-item" title="Delete record"
-                        (click)="deleteRemoteRecord(record, $event)">✕</button>
-              }
+            <div class="card-header" (click)="toggleExpand(record.timestamp, record.filename)">
+              <div class="card-main-info">
+                <span class="timestamp">{{ record.timestamp }}</span>
+                <span class="filename">{{ record.filename }}</span>
+                <span class="engine-badge">{{ record.engine }}</span>
+              </div>
+              <div class="summary-mini">
+                @for (badge of getSummaryBadge(record.industrial_summary || record.summary); track badge) {
+                  <span class="badge">{{ badge }}</span>
+                }
+              </div>
+              <div class="card-actions">
+                <span class="expand-icon">{{ isExpanded(record.timestamp, record.filename) ? '▾' : '▸' }}</span>
+                <button class="btn-delete-item" (click)="deleteLocalRecord(record, $event)">✕</button>
+              </div>
             </div>
+
+            @if (isExpanded(record.timestamp, record.filename)) {
+              <div class="card-details">
+                <hr>
+                <div class="details-grid">
+                  <div class="preview-side">
+                    @if (record.imageData) {
+                      <div class="image-wrapper">
+                        <img [src]="record.imageData" (error)="record.imageData = null">
+                        @for (det of record.detections; track $index) {
+                          <div class="detection-box" [ngStyle]="getBoxStyles(det.box, record)"></div>
+                        }
+                      </div>
+                    }
+                    @if (!record.imageData) {
+                      <div class="no-image-preview"><p>No preview available</p></div>
+                    }
+                  </div>
+                  <div class="data-side">
+                    <h3>Industrial Metrics</h3>
+                    <ul>
+                      <li><strong>Total Bunches:</strong> {{ record.detections.length }}</li>
+                      <li><strong>Archive ID:</strong> {{ record.archive_id || 'LOCAL_ONLY' }}</li>
+                      <li><strong>Inference:</strong> {{ record.inference_ms }} ms</li>
+                      <li><strong>Processing:</strong> {{ (record.processing_ms || 0).toFixed(1) }} ms</li>
+                    </ul>
+                  </div>
+                </div>
+              </div>
+            }
           </div>
+        }
+      </div>
+    }
+  }
 
-          @if (isExpanded(record.timestamp, record.filename, record.archive_id)) {
-            <div class="card-details">
-              <hr>
-              <div class="details-grid">
-                <div class="preview-side">
-                  @if (record.imageData) {
-                    <div class="image-wrapper">
-                      <img [src]="record.imageData" (error)="record.imageData = null">
-                      @for (det of record.detections; track $index) {
-                        <div class="detection-box" [ngStyle]="getBoxStyles(det.box, record)"></div>
-                      }
-                    </div>
-                  }
-                  @if (!record.imageData) {
-                    <div class="no-image-preview">
-                      <p>Cloud Archive: Metadata Only</p>
+  <!-- ── Industrial Cloud tab — grouped by batch_id ──────────────────────── -->
+  @if (!loading && viewMode === 'remote') {
+    @if (batchGroups.length === 0) {
+      <div class="empty-state glass-panel">
+        <div class="empty-icon">📭</div>
+        <p>No industrial records found on the server.</p>
+      </div>
+    }
+
+    @if (batchGroups.length > 0) {
+      <div class="history-list">
+        @for (group of batchGroups; track group.batch_id) {
+          <div class="history-card glass-panel batch-session-card" [class.expanded]="group.expanded">
+
+            <!-- Session Card header -->
+            <div class="card-header" (click)="toggleBatchGroup(group)">
+              <div class="card-main-info">
+                <span class="batch-session-label">{{ group.label }}</span>
+                <span class="timestamp">{{ group.timestamp }}</span>
+              </div>
+              <div class="summary-mini">
+                @for (badge of getGroupSummary(group); track badge) {
+                  <span class="badge">{{ badge }}</span>
+                }
+              </div>
+              <div class="card-actions">
+                <span class="expand-icon">{{ group.expanded ? '▾' : '▸' }}</span>
+              </div>
+            </div>
+
+            <!-- Expanded: thumbnail grid with bounding-box overlays -->
+            @if (group.expanded) {
+              <div class="card-details batch-details">
+                <div class="thumb-grid">
+                  @for (record of group.records; track record.archive_id) {
+                    <div class="thumb-card">
+                      <div class="thumb-wrapper">
+                        @if (record.imageData) {
+                          <img class="thumb-img" [src]="record.imageData" (error)="record.imageData = null" loading="lazy">
+                          @for (det of record.detections; track $index) {
+                            <div class="detection-box thumb-box"
+                                 [ngStyle]="getBoxStyles(det.box, record)"
+                                 [style.border-color]="getDetectionColor(det)">
+                            </div>
+                          }
+                        } @else {
+                          <div class="thumb-no-img">No img</div>
+                        }
+                      </div>
+                      <div class="thumb-meta">
+                        <span class="thumb-filename">{{ record.filename }}</span>
+                        <span class="thumb-count">{{ record.total_count }} bunches</span>
+                        <button class="btn-delete-item" style="margin-left:auto"
+                                (click)="deleteRemoteRecord(record, $event)">✕</button>
+                      </div>
                     </div>
                   }
                 </div>
-                <div class="data-side">
-                  <h3>Industrial Metrics</h3>
-                  <ul>
-                    <li><strong>Total Bunches:</strong> {{ record.detections.length }}</li>
-                    <li><strong>Archive ID:</strong> {{ record.archive_id || 'LOCAL_ONLY' }}</li>
-                    <li><strong>Inference:</strong> {{ record.inference_ms }} ms</li>
-                    <li><strong>Processing:</strong> {{ (record.processing_ms || 0).toFixed(1) }} ms</li>
-                  </ul>
-                </div>
               </div>
-            </div>
-          }
+            }
 
-        </div>
-      }
-    </div>
+          </div>
+        }
+      </div>
+    }
   }
 
 </div>

+ 88 - 0
src/app/components/history/history.component.scss

@@ -326,3 +326,91 @@
   from { opacity: 0; transform: translateY(-10px); }
   to { opacity: 1; transform: translateY(0); }
 }
+
+// ── Batch Session Card ─────────────────────────────────────────────────────────
+.batch-session-label {
+  font-size: 0.9rem;
+  font-weight: 700;
+  color: var(--accent-gold);
+}
+
+.batch-details {
+  padding: 16px;
+}
+
+// ── Thumbnail Grid ─────────────────────────────────────────────────────────────
+.thumb-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+  gap: 12px;
+}
+
+.thumb-card {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  background: var(--input-bg);
+  border: 1px solid var(--border-color);
+  border-radius: 10px;
+  overflow: hidden;
+  transition: border-color 0.2s ease;
+
+  &:hover { border-color: var(--accent-green); }
+}
+
+.thumb-wrapper {
+  position: relative;
+  width: 100%;
+  aspect-ratio: 1;
+  background: #000;
+  overflow: hidden;
+}
+
+.thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.thumb-no-img {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 0.7rem;
+  color: var(--text-secondary);
+}
+
+.thumb-box {
+  position: absolute;
+  border-width: 2px;
+  border-style: solid;
+  pointer-events: none;
+}
+
+.thumb-meta {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 8px;
+  flex-wrap: wrap;
+}
+
+.thumb-filename {
+  font-size: 0.65rem;
+  color: var(--text-secondary);
+  font-family: monospace;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  max-width: 90px;
+}
+
+.thumb-count {
+  font-size: 0.65rem;
+  color: var(--accent-green);
+  font-weight: 600;
+  white-space: nowrap;
+}

+ 70 - 8
src/app/components/history/history.component.ts

@@ -4,6 +4,20 @@ import { LocalHistoryService } from '../../services/local-history.service';
 import { RemoteInferenceService } from '../../core/services/remote-inference.service';
 import { SurveillanceService } from '../../services/surveillance.service';
 
+const GRADE_COLORS: Record<string, string> = {
+  'Empty_Bunch': '#6C757D', 'Underripe': '#F9A825', 'Abnormal': '#DC3545',
+  'Ripe': '#00A651', 'Unripe': '#9E9D24', 'Overripe': '#5D4037',
+};
+
+export interface BatchSessionGroup {
+  batch_id: string;
+  label: string;         // e.g. "Batch Session · April 20"
+  count: number;
+  timestamp: string;
+  records: any[];
+  expanded: boolean;
+}
+
 @Component({
   selector: 'app-history',
   standalone: true,
@@ -14,6 +28,9 @@ import { SurveillanceService } from '../../services/surveillance.service';
 export class HistoryComponent implements OnInit {
   localHistoryRecords: any[] = [];
   remoteHistoryRecords: any[] = [];
+  /** Remote records grouped by batch_id for the Industrial Cloud tab */
+  batchGroups: BatchSessionGroup[] = [];
+
   viewMode: 'local' | 'remote' = 'local';
   loading = true;
   expandedId: string | null = null;
@@ -24,7 +41,6 @@ export class HistoryComponent implements OnInit {
     public surveillance: SurveillanceService,
   ) {}
 
-
   ngOnInit(): void {
     this.loadLocalHistory();
   }
@@ -43,11 +59,10 @@ export class HistoryComponent implements OnInit {
           ...record,
           timestamp: new Date(record.created_at).toLocaleString(),
           engine: 'API AI',
-          // CRITICAL: Set to false because NestJS now sends absolute pixels
           isNormalized: false,
-          // Sync with NestJS Streaming Endpoint
           imageData: `http://localhost:3000/palm-oil/archive/${record.archive_id}`
         }));
+        this.batchGroups = this.groupByBatch(this.remoteHistoryRecords);
         this.loading = false;
       },
       error: (err) => {
@@ -57,8 +72,33 @@ export class HistoryComponent implements OnInit {
     });
   }
 
+  /** Group remote records by batch_id. Records without a batch_id each form a singleton group. */
+  private groupByBatch(records: any[]): BatchSessionGroup[] {
+    const map = new Map<string, any[]>();
+    records.forEach(r => {
+      const key = r.batch_id || `__solo__${r.archive_id}`;
+      if (!map.has(key)) map.set(key, []);
+      map.get(key)!.push(r);
+    });
+
+    return Array.from(map.entries()).map(([key, recs]) => {
+      const first = recs[0];
+      const isSolo = key.startsWith('__solo__');
+      const date = new Date(first.created_at).toLocaleDateString('en-US', {
+        month: 'long', day: 'numeric', year: 'numeric'
+      });
+      return {
+        batch_id: key,
+        label: isSolo ? `Single Scan · ${date}` : `Batch Session · ${date} · ${recs.length} images`,
+        count: recs.length,
+        timestamp: first.timestamp,
+        records: recs,
+        expanded: false,
+      };
+    });
+  }
+
   switchTab(mode: 'local' | 'remote'): void {
-    // Block switching to remote when NestJS is offline
     if (mode === 'remote' && this.surveillance.nestStatus() === 'OFFLINE') return;
     this.viewMode = mode;
     this.expandedId = null;
@@ -69,6 +109,10 @@ export class HistoryComponent implements OnInit {
     }
   }
 
+  toggleBatchGroup(group: BatchSessionGroup): void {
+    group.expanded = !group.expanded;
+  }
+
   // ── Delete actions ────────────────────────────────────────────────────────
 
   deleteLocalRecord(record: any, event: Event): void {
@@ -130,17 +174,35 @@ export class HistoryComponent implements OnInit {
   }
 
   getBoxStyles(box: number[], record: any): any {
-    // Use absolute dimensions from the record if available
-    // We calculate percentage for CSS 'top/left' based on original image size
     const dims = record.industrial_summary ? { width: 640, height: 640 } : (record.original_dimensions || record.dimensions);
     if (!dims) return {};
-
     return {
       'left': (box[0] / dims.width * 100) + '%',
       'top': (box[1] / dims.height * 100) + '%',
       'width': ((box[2] - box[0]) / dims.width * 100) + '%',
       'height': ((box[3] - box[1]) / dims.height * 100) + '%',
-      'border-color': record.is_health_alert ? '#DC3545' : '#00A651'
+      'border-color': record.is_health_alert ? '#DC3545' : '#00A651',
     };
   }
+
+  /** CSS border color for a detection box by ripeness class */
+  getDetectionColor(det: any): string {
+    return GRADE_COLORS[det.class] ?? '#00A651';
+  }
+
+  /** Aggregate industrial_summary for a batch group into display badges */
+  getGroupSummary(group: BatchSessionGroup): string[] {
+    const totals: Record<string, number> = {};
+    group.records.forEach(r => {
+      const summary = typeof r.industrial_summary === 'string'
+        ? this.parseJSON(r.industrial_summary) : r.industrial_summary;
+      if (!summary) return;
+      Object.entries(summary).forEach(([cls, n]) => {
+        totals[cls] = (totals[cls] ?? 0) + (n as number);
+      });
+    });
+    return Object.entries(totals)
+      .filter(([, n]) => n > 0)
+      .map(([cls, n]) => `${cls}: ${n}`);
+  }
 }

+ 4 - 2
src/app/services/vision-socket.service.ts

@@ -69,7 +69,7 @@ export class VisionSocketService implements OnDestroy {
    *
    * Lego 11: raw JPEG Base64, no quality reduction, no binary encoding.
    */
-  snapAndSend(videoEl: HTMLVideoElement): string | null {
+  snapAndSend(videoEl: HTMLVideoElement, batchId?: string): string | null {
     if (!videoEl || videoEl.readyState < 2) return null;
 
     this.captureCtx.drawImage(videoEl, 0, 0, 640, 640);
@@ -84,6 +84,7 @@ export class VisionSocketService implements OnDestroy {
     this.socket.emit('vision:analyze', {
       frame: rawBase64Frame,
       sourceLabel: 'webcam-snap',
+      batchId,
     });
 
     return rawBase64Frame;
@@ -95,7 +96,7 @@ export class VisionSocketService implements OnDestroy {
    *
    * Lego 11: caller must supply a full data-URL (data:image/...;base64,...).
    */
-  sendBase64(base64DataUrl: string): void {
+  sendBase64(base64DataUrl: string, batchId?: string): void {
     this.analyzing.set(true);
     this.lastResult.set(null);
     this.lastError.set(null);
@@ -103,6 +104,7 @@ export class VisionSocketService implements OnDestroy {
     this.socket.emit('vision:analyze', {
       frame: base64DataUrl,
       sourceLabel: 'gallery-image',
+      batchId,
     });
   }