Dr-Swopt 6 päivää sitten
vanhempi
commit
9c36cb3955

+ 117 - 56
src/app/components/history/history.component.html

@@ -3,71 +3,132 @@
     <h1>Vault & History</h1>
     <p>View previous detections and industrial reports.</p>
 
-    <div class="vault-tabs">
-      <button [class.active]="viewMode === 'local'" (click)="switchTab('local')" class="tab-btn">
-        <span class="icon">💻</span> Browser Cache
-      </button>
-      <button [class.active]="viewMode === 'remote'" (click)="switchTab('remote')" class="tab-btn">
-        <span class="icon">☁️</span> Industrial Cloud (API)
-      </button>
+    <div class="vault-tabs-row">
+      <div class="vault-tabs">
+        <button class="tab-btn" [class.active]="viewMode === 'local'" (click)="switchTab('local')">
+          <span class="icon">💻</span> Browser Cache
+        </button>
+        <button
+          class="tab-btn"
+          [class.active]="viewMode === 'remote'"
+          [class.tab-disabled]="surveillance.nestStatus() === 'OFFLINE'"
+          [disabled]="surveillance.nestStatus() === 'OFFLINE'"
+          (click)="switchTab('remote')"
+          [title]="surveillance.nestStatus() === 'OFFLINE' ? 'NestJS offline — Industrial Cloud unavailable' : ''">
+          <span class="icon">☁️</span> Industrial Cloud (API)
+          <span class="nest-dot"
+                [class.dot-live]="surveillance.nestStatus() === 'ONLINE'"
+                [class.dot-off]="surveillance.nestStatus() === 'OFFLINE'">
+          </span>
+        </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>
+      }
+      @if (viewMode === 'remote' && remoteHistoryRecords.length > 0) {
+        <button class="btn-clear-all btn-clear-all--remote" (click)="clearRemoteHistory($event)">
+          🗑 Clear All
+        </button>
+      }
     </div>
-  </div>
 
-  <div *ngIf="loading" class="loading-state glass-panel">
-    <div class="spinner"></div>
-    <p>Synchronizing vault data...</p>
+    @if (surveillance.nestStatus() === 'OFFLINE') {
+      <div class="cloud-offline-banner">
+        ⚠ NestJS Offline — Industrial Cloud (API) is unavailable. Browser Cache only.
+      </div>
+    }
   </div>
 
-  <div *ngIf="!loading && currentHistory.length === 0" class="empty-state glass-panel">
-    <div class="empty-icon">📭</div>
-    <p *ngIf="viewMode === 'local'">Your browser cache is empty. Run a local analysis to save records.</p>
-    <p *ngIf="viewMode === 'remote'">No industrial records found on the server.</p>
-  </div>
+  @if (loading) {
+    <div class="loading-state glass-panel">
+      <div class="spinner"></div>
+      <p>Synchronizing vault data...</p>
+    </div>
+  }
 
-  <div *ngIf="!loading && currentHistory.length > 0" class="history-list">
-    <div *ngFor="let record of currentHistory" class="history-card glass-panel"
-      [class.expanded]="isExpanded(record.timestamp, record.filename, record.archive_id)">
+  @if (!loading && currentHistory.length === 0) {
+    <div class="empty-state glass-panel">
+      <div class="empty-icon">📭</div>
+      @if (viewMode === 'local') {
+        <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 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">
-          <span *ngFor="let badge of getSummaryBadge(record.industrial_summary || record.summary)" class="badge">
-            {{ badge }}
-          </span>
-        </div>
-        <div class="expand-icon">{{ isExpanded(record.timestamp, record.filename, record.archive_id) ? '▾' : '▸' }}
-        </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)">
 
-      <div *ngIf="isExpanded(record.timestamp, record.filename, record.archive_id)" class="card-details">
-        <hr>
-        <div class="details-grid">
-          <div class="preview-side">
-            <div class="image-wrapper" *ngIf="record.imageData">
-              <img [src]="record.imageData" (error)="record.imageData = null">
-              <div *ngFor="let det of record.detections" class="detection-box"
-                [ngStyle]="getBoxStyles(det.box, record)">
-              </div>
+          <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 *ngIf="!record.imageData" class="no-image-preview">
-              <p>Cloud Archive: Metadata Only</p>
+            <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>
           </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>
+
+          @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>
+                    </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>
+  }
+
+</div>

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

@@ -34,6 +34,110 @@
       border-color: var(--accent-green);
       box-shadow: 0 4px 15px rgba(0, 166, 81, 0.3);
     }
+
+    &.tab-disabled {
+      opacity: 0.4;
+      cursor: not-allowed;
+      pointer-events: none;
+      transform: none;
+    }
+  }
+}
+
+// Status dot inside the Cloud tab button
+.nest-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  flex-shrink: 0;
+
+  &.dot-live { background: #00a651; box-shadow: 0 0 5px rgba(0,166,81,0.7); }
+  &.dot-off  { background: #dc3545; box-shadow: 0 0 5px rgba(220,53,69,0.6); }
+}
+
+// Offline warning banner below the tab row
+.cloud-offline-banner {
+  margin-bottom: 1.5rem;
+  padding: 10px 16px;
+  background: rgba(220, 53, 69, 0.1);
+  border: 1px solid rgba(220, 53, 69, 0.35);
+  border-radius: 8px;
+  color: #f8a8ae;
+  font-size: 0.82rem;
+  font-weight: 600;
+}
+
+// Tab row: tabs on the left, Clear All button on the right
+.vault-tabs-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+  flex-wrap: wrap;
+  margin-bottom: 0; // cloud-offline-banner provides spacing when visible
+}
+
+// "Clear All" button — destructive, muted by default
+.btn-clear-all {
+  padding: 0.6rem 1.2rem;
+  border-radius: 10px;
+  border: 1px solid rgba(220, 53, 69, 0.4);
+  background: rgba(220, 53, 69, 0.08);
+  color: #f8a8ae;
+  font-size: 0.8rem;
+  font-weight: 600;
+  cursor: pointer;
+  transition: background 0.2s ease, border-color 0.2s ease;
+  white-space: nowrap;
+
+  &:hover {
+    background: rgba(220, 53, 69, 0.2);
+    border-color: rgba(220, 53, 69, 0.7);
+  }
+
+  // Remote variant — slightly different label colour to hint at server-side impact
+  &--remote {
+    border-color: rgba(255, 152, 0, 0.4);
+    background: rgba(255, 152, 0, 0.08);
+    color: #ffcc80;
+
+    &:hover {
+      background: rgba(255, 152, 0, 0.2);
+      border-color: rgba(255, 152, 0, 0.7);
+    }
+  }
+}
+
+// Card header: summary badges + expand chevron + delete button in a row
+.card-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-left: auto;
+  flex-shrink: 0;
+}
+
+// Per-item ✕ delete button
+.btn-delete-item {
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  border: 1px solid rgba(220, 53, 69, 0.35);
+  background: rgba(220, 53, 69, 0.06);
+  color: #f8a8ae;
+  font-size: 0.7rem;
+  font-weight: 700;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
+  flex-shrink: 0;
+
+  &:hover {
+    background: rgba(220, 53, 69, 0.25);
+    border-color: rgba(220, 53, 69, 0.7);
+    transform: scale(1.1);
   }
 }
 

+ 37 - 1
src/app/components/history/history.component.ts

@@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { LocalHistoryService } from '../../services/local-history.service';
 import { RemoteInferenceService } from '../../core/services/remote-inference.service';
+import { SurveillanceService } from '../../services/surveillance.service';
 
 @Component({
   selector: 'app-history',
@@ -19,9 +20,11 @@ export class HistoryComponent implements OnInit {
 
   constructor(
     private localHistory: LocalHistoryService,
-    private remoteInference: RemoteInferenceService
+    private remoteInference: RemoteInferenceService,
+    public surveillance: SurveillanceService,
   ) {}
 
+
   ngOnInit(): void {
     this.loadLocalHistory();
   }
@@ -55,6 +58,8 @@ export class HistoryComponent implements OnInit {
   }
 
   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;
     if (mode === 'local') {
@@ -64,6 +69,37 @@ export class HistoryComponent implements OnInit {
     }
   }
 
+  // ── Delete actions ────────────────────────────────────────────────────────
+
+  deleteLocalRecord(record: any, event: Event): void {
+    event.stopPropagation();
+    this.localHistory.deleteRecord(record.timestamp, record.filename);
+    this.loadLocalHistory();
+  }
+
+  clearLocalHistory(event: Event): void {
+    event.stopPropagation();
+    if (!confirm('Clear all Browser Cache records? This cannot be undone.')) return;
+    this.localHistory.clearAll();
+    this.loadLocalHistory();
+  }
+
+  deleteRemoteRecord(record: any, event: Event): void {
+    event.stopPropagation();
+    if (!record.archive_id) return;
+    this.remoteInference.deleteRecord(record.archive_id).subscribe(() => {
+      this.loadRemoteHistory();
+    });
+  }
+
+  clearRemoteHistory(event: Event): void {
+    event.stopPropagation();
+    if (!confirm('Delete ALL industrial cloud records from the server? This also removes archived images from disk.')) return;
+    this.remoteInference.clearAll().subscribe(() => {
+      this.loadRemoteHistory();
+    });
+  }
+
   get currentHistory(): any[] {
     return this.viewMode === 'local' ? this.localHistoryRecords : this.remoteHistoryRecords;
   }

+ 22 - 0
src/app/core/services/remote-inference.service.ts

@@ -33,4 +33,26 @@ export class RemoteInferenceService {
       })
     );
   }
+
+  deleteRecord(archiveId: string): Observable<{ deleted: boolean }> {
+    return this.http.delete<{ deleted: boolean }>(
+      `${environment.apiUrl}/palm-oil/history/${archiveId}`
+    ).pipe(
+      catchError((error) => {
+        console.error('Remote delete error:', error);
+        return of({ deleted: false });
+      })
+    );
+  }
+
+  clearAll(): Observable<{ deleted: number }> {
+    return this.http.delete<{ deleted: number }>(
+      `${environment.apiUrl}/palm-oil/history`
+    ).pipe(
+      catchError((error) => {
+        console.error('Remote clear error:', error);
+        return of({ deleted: 0 });
+      })
+    );
+  }
 }

+ 12 - 0
src/app/services/local-history.service.ts

@@ -30,4 +30,16 @@ export class LocalHistoryService {
     const data = localStorage.getItem(this.STORAGE_KEY);
     return data ? JSON.parse(data) : [];
   }
+
+  /** Remove a single record by its unique timestamp+filename key */
+  deleteRecord(timestamp: string, filename: string): void {
+    const records = this.getRecords().filter(
+      r => !(r.timestamp === timestamp && r.filename === filename)
+    );
+    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(records));
+  }
+
+  clearAll(): void {
+    localStorage.removeItem(this.STORAGE_KEY);
+  }
 }