diff --git a/.gitignore b/.gitignore
index 1f4c108c4..bfde1ab1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,3 @@
-data/rover_samples/**
-!data/rover_samples/.gitkeep
-
-# Ignore data payloads
-/data/
-# --- Secrets and Certificates ---
-*.env
*.crt
*/certs/
**/certs/
@@ -12,91 +5,4 @@ data/rover_samples/**
storage_with_mqtt/secrets/
storage_with_mqtt/mqtt_images/secrets/
MQTT_IMAGES/secrets/
-services/sounds/sounds_classifier/src/classification/data/
-services/sounds/sounds_classifier/src/classification/models/panns_data/
-
-# Ignore environment and IDE files
-.env
-
-# --- Python ---
-__pycache__/
-*.py[cod]
-*.pyc
-*.pyo
-*.pyd
-*.pytest_cache/
-.pytest_cache/
-<<<<<<< HEAD
-=======
-
-
->>>>>>> 4bb2e60fc0fd9a846955fa89533d661a56b1645a
-.venv/
-venv/
-.coverage
-
-# --- VSCode / Editor ---
-.vscode/
-.idea/
-
-# --- Docker / Build ---
-*.log
-*.pid
-*.bak
-*.tmp
-*.swp
-.env.local
-.env.*
-!.env.example
-
-# --- OS files ---
-.DS_Store
-Thumbs.db
-
-
-# ==== Training/experiment outputs (never version) ====
-# Any top-level or nested "runs" folders created by Ultralytics or notebooks
-runs*/
-**/runs*/
-
-# ==== Model weights from training (PyTorch checkpoints) ====
-# Keep weights out of Git; publish via Releases/Artifacts instead.
-<<<<<<< HEAD
-!services/inference_http/models/fence_hole_detector/weights/
-!services/inference_http/models/fence_hole_detector/weights/best.onnx
-
-# ==== Model weights from training (PyTorch checkpoints) ====
-# Keep weights out of Git; publish via Releases/Artifacts instead.
-=======
-!services/fence_hole_detector/weights/
-!services/fence_hole_detector/weights/best.onnx
-
->>>>>>> 4bb2e60fc0fd9a846955fa89533d661a56b1645a
-runs_fence/**/weights/*.pt
-
-# ==== Prediction artifacts (images + txt) ====
-# Generic preds folders created by `yolo predict`
-runs_fence/**/preds/**
-runs_fence/*_preds/**
-
-# Specific experiment outputs you listed (safe to ignore entirely)
-runs_fence/y8n_baseline_no_roi/**
-runs_fence/y8n_baseline_vote_soft/**
-runs_fence/y8n_realtime_preds_no_roi/**
-runs_fence/y8s_cpu_v1_preds/**
-
-# ==== Logs / plots (reproducible – don’t store) ====
-**/results.png
-**/confusion_matrix.png
-**/*.log
-
-# ==== Optional: large exported models (keep if you plan to ship them) ====
-# Uncomment to ignore ONNX as well; otherwise keep the single runtime ONNX in repo.
-runs_fence/**/weights/*.onnx
-
-models/*.pt
-<<<<<<< HEAD
-=======
-.coverage
->>>>>>> 4bb2e60fc0fd9a846955fa89533d661a56b1645a
diff --git a/GUI/grafana/dashboards/ultrasonic-dashboard.json b/GUI/grafana/dashboards/ultrasonic-dashboard.json
new file mode 100644
index 000000000..15c98c4b3
--- /dev/null
+++ b/GUI/grafana/dashboards/ultrasonic-dashboard.json
@@ -0,0 +1,857 @@
+{
+ "id": null,
+ "uid": "ultrasonic-plant-dashboard-bw-01",
+ "title": "Plant Health Monitoring - Professional Dashboard",
+ "tags": [
+ "postgres",
+ "ultrasonic",
+ "plants",
+ "agriculture"
+ ],
+ "timezone": "browser",
+ "schemaVersion": 36,
+ "version": 6,
+ "refresh": "10s",
+ "time": {
+ "from": "now-30d",
+ "to": "now"
+ },
+ "panels": [
+ {
+ "id": 1,
+ "type": "stat",
+ "title": "Total Predictions",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 0,
+ "y": 0
+ },
+ "targets": [
+ {
+ "expr": "last_over_time(ultrasonic_predictions_total_total[5m])",
+ "refId": "A",
+ "legendFormat": "Total"
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "orientation": "auto",
+ "textMode": "auto"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "#000000"
+ },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "#000000",
+ "value": null
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": 2,
+ "type": "stat",
+ "title": "Success Rate",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 6,
+ "y": 0
+ },
+ "targets": [
+ {
+ "expr": "avg(last_over_time(ultrasonic_success_rate_by_sensor_success_rate[5m]))",
+ "refId": "A",
+ "legendFormat": "Success Rate"
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "orientation": "auto",
+ "textMode": "auto"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "unit": "percent",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "#DC2626",
+ "value": null
+ },
+ {
+ "color": "#000000",
+ "value": 60
+ },
+ {
+ "color": "#15803d",
+ "value": 90
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": 3,
+ "type": "stat",
+ "title": "Healthy Status",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 12,
+ "y": 0
+ },
+ "targets": [
+ {
+ "expr": "last_over_time(ultrasonic_class_distribution_healthy_healthy_percentage[5m])",
+ "refId": "A",
+ "legendFormat": "Healthy %"
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "orientation": "auto",
+ "textMode": "auto"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "#000000"
+ },
+ "unit": "percent",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "#000000",
+ "value": null
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": 4,
+ "type": "stat",
+ "title": "Stress Status",
+ "gridPos": {
+ "h": 8,
+ "w": 6,
+ "x": 18,
+ "y": 0
+ },
+ "targets": [
+ {
+ "expr": "last_over_time(ultrasonic_class_distribution_stress_stress_percentage[5m])",
+ "refId": "A",
+ "legendFormat": "Stress %"
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "orientation": "auto",
+ "textMode": "auto"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "#000000"
+ },
+ "unit": "percent",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "#000000",
+ "value": null
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": 5,
+ "type": "piechart",
+ "title": "Plant Health Classification",
+ "gridPos": {
+ "h": 9,
+ "w": 12,
+ "x": 0,
+ "y": 8
+ },
+ "targets": [
+ {
+ "expr": "ultrasonic_predictions_by_class_count",
+ "refId": "A",
+ "legendFormat": "{{predicted_class}}",
+ "instant": true
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi"
+ },
+ "pieType": "pie",
+ "displayLabels": [
+ "name",
+ "percent"
+ ]
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Drought_Plant"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#FBBF24"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Pest_Plant"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#EF4444"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Control_Greenhouse"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#10B981"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Control_Empty"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#3B82F6"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": 6,
+ "type": "gauge",
+ "title": "Average Confidence Score",
+ "gridPos": {
+ "h": 9,
+ "w": 6,
+ "x": 12,
+ "y": 8
+ },
+ "targets": [
+ {
+ "expr": "last_over_time(ultrasonic_predictions_avg_confidence_avg_confidence[5m])",
+ "refId": "A",
+ "legendFormat": "Avg Confidence"
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "showThresholdLabels": true,
+ "showThresholdMarkers": true
+ },
+ "fieldConfig": {
+ "defaults": {
+ "min": 0,
+ "max": 1,
+ "unit": "percentunit",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "#DC2626",
+ "value": null
+ },
+ {
+ "color": "#6B7280",
+ "value": 0.5
+ },
+ {
+ "color": "#15803d",
+ "value": 0.85
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": 7,
+ "type": "barchart",
+ "title": "Sensor Confidence by ID",
+ "gridPos": {
+ "h": 9,
+ "w": 6,
+ "x": 18,
+ "y": 8
+ },
+ "targets": [
+ {
+ "expr": "ultrasonic_confidence_by_sensor_avg_confidence",
+ "refId": "A",
+ "legendFormat": "{{sensor_id}}",
+ "instant": true
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "legend": {
+ "displayMode": "hidden",
+ "placement": "bottom",
+ "showLegend": false
+ },
+ "orientation": "horizontal",
+ "xTickLabelRotation": 0,
+ "xTickLabelSpacing": 0,
+ "showValue": "always"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 90,
+ "lineWidth": 1
+ },
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "#374151"
+ },
+ "unit": "percentunit",
+ "decimals": 2
+ }
+ },
+ "transformations": [
+ {
+ "id": "reduce",
+ "options": {
+ "calcs": [
+ "last"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "id": 8,
+ "type": "timeseries",
+ "title": "Predictions per Hour (Last 24h)",
+ "gridPos": {
+ "h": 9,
+ "w": 12,
+ "x": 0,
+ "y": 17
+ },
+ "timeFrom": "now-24h",
+ "targets": [
+ {
+ "expr": "ultrasonic_predictions_per_hour_count",
+ "refId": "A",
+ "legendFormat": "Predictions"
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": false
+ },
+ "tooltip": {
+ "mode": "multi"
+ }
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "bars",
+ "lineInterpolation": "linear",
+ "fillOpacity": 80,
+ "lineWidth": 0
+ },
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "#6B7280"
+ }
+ }
+ }
+ },
+ {
+ "id": 9,
+ "type": "barchart",
+ "title": "Daily Stress Events",
+ "gridPos": {
+ "h": 9,
+ "w": 12,
+ "x": 12,
+ "y": 17
+ },
+ "targets": [
+ {
+ "expr": "ultrasonic_daily_stress_count_event_count",
+ "refId": "A",
+ "legendFormat": "{{event_date}}",
+ "format": "time_series"
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "legend": {
+ "displayMode": "hidden",
+ "placement": "bottom",
+ "showLegend": false
+ },
+ "orientation": "auto",
+ "xTickLabelRotation": -45,
+ "xTickLabelSpacing": 50,
+ "showValue": "auto"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 85,
+ "lineWidth": 1
+ },
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "#6B7280"
+ }
+ }
+ }
+ },
+ {
+ "id": 10,
+ "type": "piechart",
+ "title": "Reading Status Distribution",
+ "gridPos": {
+ "h": 9,
+ "w": 8,
+ "x": 0,
+ "y": 26
+ },
+ "targets": [
+ {
+ "expr": "ultrasonic_predictions_by_status_count",
+ "refId": "A",
+ "legendFormat": "{{status}}",
+ "instant": true
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "showLegend": true
+ },
+ "displayLabels": [
+ "name",
+ "percent"
+ ]
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Success"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#9CA3AF"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Error"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#DC2626"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": 11,
+ "type": "barchart",
+ "title": "Sensor Success Rates",
+ "gridPos": {
+ "h": 9,
+ "w": 8,
+ "x": 8,
+ "y": 26
+ },
+ "targets": [
+ {
+ "expr": "ultrasonic_success_rate_by_sensor_success_rate",
+ "refId": "A",
+ "legendFormat": "{{sensor_id}}",
+ "instant": true
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "legend": {
+ "displayMode": "hidden",
+ "showLegend": false
+ },
+ "orientation": "auto",
+ "xTickLabelRotation": -45,
+ "showValue": "always"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "fillOpacity": 90
+ },
+ "color": {
+ "mode": "thresholds"
+ },
+ "unit": "percent",
+ "decimals": 1,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "#DC2626",
+ "value": 0
+ },
+ {
+ "color": "#6B7280",
+ "value": 70
+ },
+ {
+ "color": "#15803d",
+ "value": 95
+ }
+ ]
+ }
+ }
+ },
+ "transformations": [
+ {
+ "id": "reduce",
+ "options": {
+ "calcs": [
+ "last"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "id": 12,
+ "type": "piechart",
+ "title": "Confidence Distribution",
+ "gridPos": {
+ "h": 9,
+ "w": 8,
+ "x": 16,
+ "y": 26
+ },
+ "targets": [
+ {
+ "expr": "ultrasonic_confidence_distribution_count",
+ "refId": "A",
+ "legendFormat": "{{confidence_range}}",
+ "instant": true
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "displayLabels": [
+ "name",
+ "percent"
+ ]
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "90-100% (High)"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#34D399"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "75-90% (Good)"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#60A5FA"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "60-75% (Medium)"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#FCD34D"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "< 60% (Low)"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "mode": "fixed",
+ "fixedColor": "#F87171"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "id": 13,
+ "type": "table",
+ "title": "Sensor Activity Summary",
+ "gridPos": {
+ "h": 9,
+ "w": 24,
+ "x": 0,
+ "y": 35
+ },
+ "targets": [
+ {
+ "expr": "ultrasonic_confidence_by_sensor_avg_confidence",
+ "refId": "A",
+ "format": "table",
+ "instant": true
+ }
+ ],
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "options": {
+ "showHeader": true,
+ "sortBy": [
+ {
+ "displayName": "avg_confidence",
+ "desc": true
+ }
+ ]
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "align": "auto",
+ "width": "auto"
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "avg_confidence"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "percentunit"
+ },
+ {
+ "id": "decimals",
+ "value": 2
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "min_confidence"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "percentunit"
+ },
+ {
+ "id": "decimals",
+ "value": 2
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "max_confidence"
+ },
+ "properties": [
+ {
+ "id": "unit",
+ "value": "percentunit"
+ },
+ {
+ "id": "decimals",
+ "value": 2
+ }
+ ]
+ }
+ ]
+ },
+ "transformations": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/GUI/mock_db_api.py b/GUI/mock_db_api.py
new file mode 100644
index 000000000..c643b4414
--- /dev/null
+++ b/GUI/mock_db_api.py
@@ -0,0 +1,24 @@
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import json
+
+class H(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/health":
+ body = {"ok": True}
+ elif self.path.startswith("/api/tables/"):
+ body = {"data": []}
+ else:
+ body = {"data": []}
+ b = json.dumps(body).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(b)))
+ self.end_headers()
+ self.wfile.write(b)
+
+ def log_message(self, *a):
+ pass
+
+if __name__ == "__main__":
+ print("[mock-db-api] listening on 127.0.0.1:8001")
+ HTTPServer(("127.0.0.1", 8001), H).serve_forever()
diff --git a/GUI/requirements.txt b/GUI/requirements.txt
index 779d432a9..694273dd7 100644
--- a/GUI/requirements.txt
+++ b/GUI/requirements.txt
@@ -5,6 +5,7 @@ PyQt6-Charts==6.9.0
PyQt6-Qt6==6.9.1
PyQt6-sip>=13.6
python-vlc
+pyqtgraph
# ───── Web/API ─────
@@ -48,5 +49,11 @@ python-dotenv>=1.0.0
jsonschema>=4.0.0
psycopg2-binary>=2.9.0
matplotlib>=3.7.0
+minio==7.1.17
+Pillow
+boto3
+botocore
+
+psycopg2-binary
diff --git a/GUI/src/vast/dashboard_api.py b/GUI/src/vast/dashboard_api.py
index 845ff093b..6d301bb38 100644
--- a/GUI/src/vast/dashboard_api.py
+++ b/GUI/src/vast/dashboard_api.py
@@ -1,97 +1,82 @@
-# -*- coding: utf-8 -*-
-from __future__ import annotations
-
-import os
import json
import time
-import base64
import pathlib
from typing import Dict, List, Optional, Tuple, Union
+import os
import requests
+from urllib.parse import quote
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
-# ---- Optional deps (do not crash if missing) ----
-try:
- from minio import Minio
- from minio.error import S3Error
-except Exception: # pragma: no cover
- Minio = None # type: ignore
- S3Error = Exception # type: ignore
-
-try:
- from vast.rel_db import RelDB
-except Exception: # pragma: no cover
- RelDB = None # type: ignore
-
-
-# =========================
-# CONFIG
-# =========================
-# --- HTTP API ---
-DB_API_BASE = os.getenv("DB_API_BASE", "http://db_api_service:8001")
-DB_API_AUTH_MODE = os.getenv("DB_API_AUTH_MODE", "service") # "service" | "bearer"
+import psycopg2
+import psycopg2.extras
+
+from minio import Minio
+from minio.error import S3Error
+
+
+# =====================================================
+# ================ CONFIG & CONSTANTS =================
+# =====================================================
+
+DB_API_BASE = os.getenv("DB_API_BASE", "http://62.219.106.75:8001")
+
+DB_API_AUTH_MODE = os.getenv("DB_API_AUTH_MODE", "service")
DB_API_TOKEN_FILE = os.getenv("DB_API_TOKEN_FILE", "/app/secrets/db_api_token")
DB_API_TOKEN = os.getenv("DB_API_TOKEN", "auto")
DB_API_SERVICE_NAME = os.getenv("DB_API_SERVICE_NAME", "GUI_H")
-# --- RelDB (used inside RelDB class; here only for reference/env) ---
-DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
-DB_PORT = int(os.getenv("DB_PORT", "5432"))
-DB_USER = os.getenv("DB_USER", "missions_user")
-DB_PASS = os.getenv("DB_PASS", "pg123")
-DB_NAME = os.getenv("DB_NAME", "missions_db")
-
-# --- MinIO ---
-MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "127.0.0.1:9001") # host:exposed_port
-MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
-MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
-MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
-DEFAULT_GROUND_BUCKET = os.getenv("GROUND_BUCKET", "ground")
-DEFAULT_GROUND_PREFIX = os.getenv("GROUND_PREFIX", "")
+# =====================================================
+# ================= TOKEN BOOTSTRAP ===================
+# =====================================================
-
-# =========================
-# TOKEN BOOTSTRAP HELPERS
-# =========================
def _safe_join_url(base: str, path: str) -> str:
return f"{base.rstrip('/')}/{path.lstrip('/')}"
-def _read_token_from_file(path: str) -> Optional[str]:
+
+def _read_token_from_file(path: str) -> str | None:
p = pathlib.Path(path)
if p.exists():
token = p.read_text(encoding="utf-8").strip()
return token or None
return None
-def _fetch_token_via_dev_bootstrap(base: str, retries: int = 3, backoff: float = 0.8) -> Optional[str]:
+
+def _fetch_token_via_dev_bootstrap(base: str, retries: int = 3, backoff: float = 0.8) -> str | None:
"""
- Calls /auth/_dev_bootstrap to mint/rotate a service token for this client.
+ Try to obtain a service token via /auth/_dev_bootstrap.
+ Only for dev / demo environments.
"""
url = _safe_join_url(base, "/auth/_dev_bootstrap")
payload = {"service_name": DB_API_SERVICE_NAME, "rotate_if_exists": True}
- last_exc: Optional[Exception] = None
+
for attempt in range(1, retries + 1):
try:
r = requests.post(url, json=payload, timeout=10)
if r.status_code in (200, 201):
- data = r.json() if r.content else {}
+ data = r.json()
raw = (data.get("service_account", {}) or {}).get("raw_token") \
- or (data.get("service_account", {}) or {}).get("token")
+ or (data.get("service_account", {}) or {}).get("token")
if raw and isinstance(raw, str) and "***" not in raw:
return raw.strip()
- except Exception as e:
- last_exc = e
- time.sleep(backoff * attempt)
- if last_exc:
- print(f"[BOOTSTRAP][WARN] last error: {last_exc}")
+ except Exception:
+ time.sleep(backoff * attempt)
return None
-def get_or_bootstrap_token() -> Optional[str]:
+
+def get_or_bootstrap_token() -> str | None:
+ """
+ Resolve service token according to config:
+ 1. If DB_API_TOKEN != "auto" → use it.
+ 2. Else try to read DB_API_TOKEN_FILE.
+ 3. Else try /auth/_dev_bootstrap and cache in DB_API_TOKEN_FILE.
+ """
+ print(f"[DEBUG] Checking for existing token file at: {DB_API_TOKEN_FILE}", flush=True)
+
if DB_API_TOKEN and DB_API_TOKEN.lower() != "auto":
- print("[DEBUG] Using static token from DB_API_TOKEN", flush=True)
+ print(f"[DEBUG] Using static token from config", flush=True)
return DB_API_TOKEN
token = _read_token_from_file(DB_API_TOKEN_FILE)
@@ -99,12 +84,11 @@ def get_or_bootstrap_token() -> Optional[str]:
print(f"[DEBUG] Loaded token from {DB_API_TOKEN_FILE}", flush=True)
return token
- print(f"[DEBUG] No token found, bootstrapping via {DB_API_BASE}/auth/_dev_bootstrap", flush=True)
+ print(f"[DEBUG] No existing token found, bootstrapping via {DB_API_BASE}/auth/_dev_bootstrap", flush=True)
token = _fetch_token_via_dev_bootstrap(DB_API_BASE)
if token:
- p = pathlib.Path(DB_API_TOKEN_FILE)
- p.parent.mkdir(parents=True, exist_ok=True)
- p.write_text(token, encoding="utf-8")
+ pathlib.Path(DB_API_TOKEN_FILE).parent.mkdir(parents=True, exist_ok=True)
+ pathlib.Path(DB_API_TOKEN_FILE).write_text(token, encoding="utf-8")
print(f"[BOOTSTRAP] wrote token to {DB_API_TOKEN_FILE}", flush=True)
return token
@@ -112,479 +96,513 @@ def get_or_bootstrap_token() -> Optional[str]:
return None
-# =========================
-# UTILITIES
-# =========================
-def _image_id_from_object_key(object_key: str) -> str:
- """
- 'some/prefix/image (3).jpg' -> 'image (3)'
- """
- base = os.path.basename(object_key or "")
- return base.rsplit(".", 1)[0] if "." in base else base
-
+# =====================================================
+# ==================== DashboardApi ===================
+# =====================================================
-# =========================
-# DASHBOARD API
-# =========================
class DashboardApi:
"""
- Unified client:
- - REST to DB-API (with token bootstrap/refresh)
- - Optional MinIO helper
- - Optional RelDB helper
+ Central API client used by the GUI:
+ * HTTP access to db_api_service (REST tables / helpers).
+ * Direct PostgreSQL queries (for heavy / ad-hoc analytics).
+ * MinIO access (images, masks, listing for gallery).
"""
+ # -------------------------------------------------
+ # ctor & connection helpers
+ # -------------------------------------------------
def __init__(self) -> None:
- # ---- HTTP session ----
+ # HTTP client + auth
self.base = DB_API_BASE.rstrip("/")
self.http = requests.Session()
- # Attach robust retries
- retry = Retry(
+ token = get_or_bootstrap_token()
+ if token:
+ if DB_API_AUTH_MODE == "service":
+ self.http.headers.update({"X-Service-Token": token})
+ else:
+ self.http.headers.update({"Authorization": f"Bearer {token}"})
+
+ self.http.headers.update({"Content-Type": "application/json"})
+
+ retry_cfg = Retry(
total=5,
backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504],
- allowed_methods=frozenset(["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"])
)
- self.http.mount("http://", HTTPAdapter(max_retries=retry))
- self.http.mount("https://", HTTPAdapter(max_retries=retry))
- self.http.headers.update({"Content-Type": "application/json"})
+ self.http.mount("http://", HTTPAdapter(max_retries=retry_cfg))
+ self.http.mount("https://", HTTPAdapter(max_retries=retry_cfg))
- # ---- Auth ----
- token = get_or_bootstrap_token()
- self.token: Optional[str] = token
- self.token_type = "service" if DB_API_AUTH_MODE == "service" else "bearer"
- self._apply_auth_header(token)
+ # DB connection params
+ self.conn_params = {
+ "host": os.getenv("PGHOST", "62.219.106.75"),
+ "port": int(os.getenv("PGPORT", 5432)),
+ "database": os.getenv("PGDATABASE", "missions_db"),
+ "user": os.getenv("PGUSER", "missions_user"),
+ "password": os.getenv("PGPASSWORD", "pg123"),
+ }
+ print(
+ f"[DashboardApi] Initialized with DB host={self.conn_params['host']}, "
+ f"db={self.conn_params['database']}",
+ flush=True,
+ )
- # ---- MinIO (optional) ----
- self.minio: Optional[Minio] = None
- if Minio is not None:
- try:
- self.minio = Minio(
- MINIO_ENDPOINT,
- access_key=MINIO_ACCESS_KEY,
- secret_key=MINIO_SECRET_KEY,
- secure=MINIO_SECURE,
- )
- except Exception as e: # pragma: no cover
- print(f"[MINIO][INIT][WARN] {e}")
+ def _get_connection(self):
+ """Create and return a new database connection."""
+ return psycopg2.connect(**self.conn_params)
- # ---- RelDB (optional) ----
- self.rdb: Optional[RelDB] = None
- if RelDB is not None:
- try:
- self.rdb = RelDB()
- except Exception as e: # pragma: no cover
- print(f"[RelDB][INIT][WARN] {e}")
-
- # ---------------------------
- # Auth helpers
- # ---------------------------
- def _apply_auth_header(self, token: Optional[str]) -> None:
- # Clean previous header variants
- for h in ["X-Service-Token", "Authorization"]:
- if h in self.http.headers:
- del self.http.headers[h]
- if token:
- if DB_API_AUTH_MODE == "service":
- self.http.headers.update({"X-Service-Token": token})
+ # -------------------------------------------------
+ # Generic SQL helper
+ # -------------------------------------------------
+ def run_query(self, query: str, params: Tuple | None = None) -> List[Dict]:
+ """Execute a raw SQL query and return results as list[dict]."""
+ conn = None
+ cursor = None
+ try:
+ conn = self._get_connection()
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+
+ if params:
+ cursor.execute(query, params)
else:
- self.http.headers.update({"Authorization": f"Bearer {token}"})
+ cursor.execute(query)
+
+ results = cursor.fetchall()
+ return [dict(row) for row in results]
- def get_token_info(self) -> dict:
+ except Exception as e:
+ print(f"[DashboardApi] Query error: {e}", flush=True)
+ print(f"[DashboardApi] Query was: {query[:200]}...", flush=True)
+ return []
+ finally:
+ if cursor:
+ cursor.close()
+ if conn:
+ conn.close()
+
+ # -------------------------------------------------
+ # HTTP helpers for tables (DB API)
+ # -------------------------------------------------
+ def _get_table_rows_paged(self, table_name: str, page_size: int = 200) -> List[dict]:
"""
- Tries to decode JWT payload. If not a JWT, returns basic info.
+ Fetch ALL rows from a table using pagination against db_api_service.
"""
- t = self.token
- if not t:
- return {"type": self.token_type, "status": "missing"}
+ all_rows: List[dict] = []
+ offset = 0
- if "." in t:
- try:
- payload_b64 = t.split(".")[1]
- padded = payload_b64 + "=" * (-len(payload_b64) % 4)
- data = json.loads(base64.urlsafe_b64decode(padded))
- exp = data.get("exp")
- secs_left = exp - int(time.time()) if exp else None
- return {"type": "jwt", "exp": exp, "secs_left": secs_left, "payload": data}
- except Exception:
- pass
- return {"type": self.token_type, "token_length": len(t)}
-
- def refresh_token(self) -> bool:
- """
- Fetches a new service token via dev bootstrap and updates headers + file.
- """
- new_token = _fetch_token_via_dev_bootstrap(self.base)
- if new_token:
- try:
- pathlib.Path(DB_API_TOKEN_FILE).parent.mkdir(parents=True, exist_ok=True)
- pathlib.Path(DB_API_TOKEN_FILE).write_text(new_token, encoding="utf-8")
- except Exception as e:
- print(f"[TOKEN][WARN] Could not persist new token: {e}")
- self.token = new_token
- self._apply_auth_header(new_token)
- print("[TOKEN] refreshed", flush=True)
- return True
- print("[TOKEN][ERROR] refresh failed", flush=True)
- return False
-
- # ---------------------------
- # REST: examples / utilities
- # ---------------------------
- def list_devices(self, model: Optional[str] = None) -> List[dict]:
- """
- Tries modern path /api/devices; falls back to /api/tables/devices for older servers.
- """
- paths = ["/api/devices", "/api/tables/devices"]
- last_err: Optional[str] = None
- for path in paths:
- url = f"{self.base}{path}"
- if model:
- sep = "&" if "?" in url else "?"
- url = f"{url}{sep}model={model}"
+ while True:
+ params = {"limit": page_size, "offset": offset}
+ url = f"{self.base}/api/tables/{table_name}"
try:
- r = self.http.get(url, timeout=10)
- if r.status_code == 200:
- try:
- return r.json()
- except Exception:
- print("[API WARN] devices response is not JSON", flush=True)
- return []
- if r.status_code in (404, 405):
- last_err = f"http-{r.status_code}"
- continue
- print(f"[API ERROR] {r.status_code}: {r.text[:200]}")
- return []
+ r = self.http.get(url, params=params, timeout=10)
+ if r.status_code != 200:
+ print(f"[API ERROR] GET {url} → {r.status_code}: {r.text[:200]}")
+ break
+ batch = r.json().get("rows", [])
+ if not batch:
+ break
+ all_rows.extend(batch)
+ offset += page_size
except Exception as e:
- last_err = str(e)
- continue
- if last_err:
- print(f"[API FAIL] list_devices: {last_err}")
+ print(f"[API FAIL PAGED] {e}")
+ break
+
+ return all_rows
+
+ # =====================================================
+ # =============== Devices & thresholds =================
+ # =====================================================
+ def list_devices(self, model: str | None = None) -> List[dict]:
+ """Get list of devices from the db_api_service."""
+ url = f"{self.base}/api/devices"
+ if model:
+ url += f"?model={quote(model)}"
+
+ try:
+ r = self.http.get(url, timeout=10)
+ if r.status_code == 200:
+ return r.json()
+ print(f"[API ERROR] {r.status_code}: {r.text[:100]}")
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+
return []
def bulk_set_task_thresholds_labeled(
self,
- mapping: Dict[Tuple[str, str], float] | List[dict],
+ mapping: dict[tuple[str, str], float] | List[dict],
updated_by: str = "gui",
) -> dict:
"""
- Unified + fallback:
- 1) POST /api/task_thresholds/batch
- 2) if 404/405 -> POST /api/thresholds/batch
- Body shape is normalized to: {"task": str, "label": str, "threshold": float, "updated_by": str}
+ Bulk update task thresholds via /api/task_thresholds/batch.
+ mapping can be:
+ * dict[(task, label)] -> threshold
+ * list[{"task":..., "label":..., "threshold":..., "updated_by":...}]
"""
- items = (
- [
+ if isinstance(mapping, dict):
+ items = [
{"task": t, "label": l or "", "threshold": thr, "updated_by": updated_by}
for (t, l), thr in mapping.items()
]
- if isinstance(mapping, dict) else mapping
- )
+ else:
+ items = mapping
- paths = ["/api/task_thresholds/batch", "/api/thresholds/batch"]
- last_err: Optional[str] = None
- for path in paths:
- url = f"{self.base}{path}"
- try:
- r = self.http.post(url, json=items, timeout=20)
- if r.status_code in (200, 201):
- data = r.json() if r.content else {}
- return {"ok": list(data.get("ok", [])), "fail": list(data.get("fail", []))}
- if r.status_code in (404, 405):
- last_err = f"http-{r.status_code}"
- continue
+ url = f"{self.base}/api/task_thresholds/batch"
+ try:
+ r = self.http.post(url, json=items, timeout=20)
+ if r.status_code in (200, 201):
+ data = r.json()
return {
- "ok": [],
- "fail": [[[i.get("task"), i.get("label","")], f"http-{r.status_code} {r.text[:200]}"] for i in items],
+ "ok": list(data.get("ok", [])),
+ "fail": list(data.get("fail", [])),
}
- except Exception as e:
- last_err = str(e)
- continue
- return {"ok": [], "fail": [[[i.get("task"), i.get("label","")], last_err or "unknown"] for i in items]}
-
- # ---------------------------
- # MinIO helpers (optional)
- # ---------------------------
- def list_minio_objects(self, bucket: str, prefix: str = "", limit: int = 100) -> List[dict]:
- """
- Returns: [{'key': 'path/file.jpg', 'size': int, 'last_modified': iso}, ...]
- """
- if not self.minio:
- print("[MINIO][WARN] MinIO client not available")
- return []
- out: List[dict] = []
- try:
- for i, obj in enumerate(self.minio.list_objects(bucket, prefix=prefix, recursive=True)):
- if i >= limit:
- break
- lm = getattr(obj, "last_modified", None)
- out.append({
- "key": getattr(obj, "object_name", None) or getattr(obj, "name", None),
- "size": getattr(obj, "size", None),
- "last_modified": lm.isoformat() if lm else None,
- })
- except Exception as e:
- print(f"[MINIO LIST FAIL] {e}")
- return out
-
- def get_latest_minio_key(self, bucket: str, prefix: str = "") -> Optional[str]:
- objs = self.list_minio_objects(bucket, prefix=prefix, limit=200)
- if not objs:
- return None
- objs_sorted = sorted(objs, key=lambda o: o.get("last_modified") or "", reverse=True)
- key = objs_sorted[0].get("key")
- return key if isinstance(key, str) and key.strip() else None
-
- def get_image_bytes_from_minio(self, key: str, bucket: Optional[str] = None) -> Optional[bytes]:
- if not self.minio:
- print("[MINIO][WARN] MinIO client not available")
- return None
- bucket_name = bucket or DEFAULT_GROUND_BUCKET
- try:
- response = self.minio.get_object(bucket_name, key)
- data = response.read()
- response.close()
- response.release_conn()
- print(f"[DEBUG] Got {len(data)} bytes from {bucket_name}/{key}")
- return data
+ return {
+ "ok": [],
+ "fail": [
+ [[i.get("task"), i.get("label", "")], f"http-{r.status_code} {r.text[:200]}"]
+ for i in items
+ ],
+ }
except Exception as e:
- print(f"[MINIO GET FAIL] {e}")
- return None
+ return {
+ "ok": [],
+ "fail": [
+ [[i.get("task"), i.get("label", "")], str(e)]
+ for i in items
+ ],
+ }
- # ---------------------------
- # RelDB delegates (optional)
- # ---------------------------
- def _rdb_guard(self) -> bool:
- if not self.rdb:
- print("[RelDB][WARN] RelDB client not available")
- return False
- return True
+ # =====================================================
+ # =============== Audio analytics section =============
+ # =====================================================
- def get_weekly_phi(self) -> dict:
- if not self._rdb_guard(): return {}
- return self.rdb.get_weekly_phi()
+ def _audio_time_filter(self, time_range: str, column: str) -> str:
+ mapping = {
+ "all": "",
+ "hour": f"AND {column} > NOW() - INTERVAL '1 hour'",
+ "day": f"AND {column} > NOW() - INTERVAL '24 hours'",
+ "week": f"AND {column} > NOW() - INTERVAL '7 days'",
+ "month": f"AND {column} > NOW() - INTERVAL '30 days'",
+ }
+ return mapping.get(time_range, "")
- def get_latest_rows(self, limit: int = 20) -> List[dict]:
- if not self._rdb_guard(): return []
- return self.rdb.get_latest_anomalies(limit=limit)
+ def _audio_sound_filter(self, sound_types: List[str] | None, column: str) -> str:
+ if sound_types:
+ sound_list = "'" + "','".join(sound_types) + "'"
+ return f"AND {column} IN ({sound_list})"
+ return ""
- def get_latest_detections(self, limit: int = 20) -> List[dict]:
- if not self._rdb_guard(): return []
- return self.rdb.get_latest_anomalies(limit=limit)
+ def get_audio_stats(self, time_range: str = "all", sound_types: List[str] | None = None) -> Dict:
+ """Get aggregated audio classification statistics."""
+ time_filter = self._audio_time_filter(time_range, "r.started_at")
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
- def get_rows_by_image(self, image_name: str, limit: int = 50) -> List[dict]:
- """
- image_name is image_id without extension.
+ query = f"""
+ SELECT
+ COUNT(*) as total_files,
+ SUM(CASE WHEN head_is_another = true THEN 1 ELSE 0 END) as unknown_count,
+ AVG(head_pred_prob) as avg_confidence,
+ AVG(processing_ms) as avg_processing_ms
+ FROM agcloud_audio.file_aggregates fa
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
+ WHERE 1=1 {time_filter} {sound_filter}
"""
- if not self._rdb_guard(): return []
- return self.rdb.get_anomalies_by_image(image_name, limit=limit)
-
- def get_last_row_by_image(self, image_name: str) -> Optional[dict]:
- if not self._rdb_guard(): return None
- return self.rdb.get_last_anomaly_by_image(image_name)
+ results = self.run_query(query)
+ return results[0] if results else {}
- def get_rows_by_day(self, date_iso: str, limit: int = 1000) -> List[dict]:
- if not self._rdb_guard(): return []
- return self.rdb.get_anomalies_by_day(date_iso, limit=limit)
+ def get_audio_distribution(
+ self,
+ time_range: str = "all",
+ limit: int = 10,
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
+ """Get distribution of audio classifications."""
+ time_filter = self._audio_time_filter(time_range, "r.started_at")
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
- # ---------------------------
- # Image-centric (MinIO→image_id→RelDB)
- # ---------------------------
- def get_latest_image_key(self) -> Optional[str]:
- """
- Prefer the newest in MinIO; if none—fallback to DB (if available).
+ query = f"""
+ SELECT
+ head_pred_label,
+ COUNT(*) as count
+ FROM agcloud_audio.file_aggregates fa
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
+ WHERE head_pred_label IS NOT NULL {time_filter} {sound_filter}
+ GROUP BY head_pred_label
+ ORDER BY count DESC
+ LIMIT {limit}
"""
- key = None
- if self.minio:
- key = self.get_latest_minio_key(DEFAULT_GROUND_BUCKET, DEFAULT_GROUND_PREFIX)
- if key:
- return key
- if self.rdb:
- try:
- return self.rdb.get_latest_image_key()
- except Exception as e:
- print(f"[RelDB][WARN] get_latest_image_key fallback failed: {e}")
- return None
-
- def get_anomalies_for_image_key(self, object_key: str, limit: int = 50) -> List[dict]:
- if not self._rdb_guard(): return []
- image_id = _image_id_from_object_key(object_key)
- return self.rdb.get_anomalies_by_image(image_id, limit=limit)
+ return self.run_query(query)
- def get_anomalies_for_current_image(self, limit: int = 100) -> List[dict]:
- if not self._rdb_guard(): return []
- key = self.get_latest_image_key()
- if not key:
- return []
- image_id = _image_id_from_object_key(key)
- return self.rdb.get_anomalies_by_image(image_id, limit=limit)
+ def get_audio_confidence_by_class(
+ self,
+ time_range: str = "all",
+ limit: int = 10,
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
+ """Get average confidence levels by classification."""
+ time_filter = self._audio_time_filter(time_range, "r.started_at")
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
- def get_last_anomaly_for_current_image(self) -> Optional[dict]:
- if not self._rdb_guard(): return None
- key = self.get_latest_image_key()
- if not key:
- return None
- image_id = _image_id_from_object_key(key)
- return self.rdb.get_last_anomaly_by_image(image_id)
+ query = f"""
+ SELECT
+ head_pred_label,
+ AVG(head_pred_prob) as avg_confidence
+ FROM agcloud_audio.file_aggregates fa
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
+ WHERE head_pred_label IS NOT NULL
+ AND head_pred_prob IS NOT NULL
+ {time_filter}
+ {sound_filter}
+ GROUP BY head_pred_label
+ ORDER BY avg_confidence DESC
+ LIMIT {limit}
+ """
+ return self.run_query(query)
- def get_phi_for_image(self, image_name_or_key: str) -> dict:
- if not self._rdb_guard():
- return {"phi": None, "severity_avg": None, "density": None, "coverage": None, "trend": None}
- image_id = _image_id_from_object_key(image_name_or_key)
- return self.rdb.get_phi_for_image(image_id)
+ def get_audio_detailed_table(
+ self,
+ time_range: str = "all",
+ limit: int = 20,
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
+ """Get detailed table data with class probabilities."""
+ time_filter = self._audio_time_filter(time_range, "r.started_at")
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
- def get_phi_for_current_image(self) -> dict:
- if not self._rdb_guard():
- return {"phi": None, "severity_avg": None, "density": None, "coverage": None, "trend": None}
- key = self.get_latest_image_key()
- if not key:
- return {"phi": None, "severity_avg": None, "density": None, "coverage": None, "trend": None}
- image_id = _image_id_from_object_key(key)
- return self.rdb.get_phi_for_image(image_id)
+ query = f"""
+ SELECT
+ head_pred_label,
+ COUNT(*) as count,
+ AVG(head_pred_prob) as avg_prob,
+ AVG((head_probs_json->>'predatory_animals')::float) as p_predatory,
+ AVG((head_probs_json->>'birds')::float) as p_birds,
+ AVG((head_probs_json->>'fire')::float) as p_fire,
+ AVG((head_probs_json->>'screaming')::float) as p_screaming,
+ AVG((head_probs_json->>'shotgun')::float) as p_shotgun
+ FROM agcloud_audio.file_aggregates fa
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
+ WHERE head_pred_label IS NOT NULL {time_filter} {sound_filter}
+ GROUP BY head_pred_label
+ ORDER BY count DESC
+ LIMIT {limit}
+ """
+ return self.run_query(query)
+ def get_audio_critical_events(
+ self,
+ time_range: str = "day",
+ limit: int = 100,
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
+ """Get critical sound events for alerting."""
+ # default filter column: r.started_at
+ time_filter_map = {
+ "hour": "AND r.started_at > NOW() - INTERVAL '1 hour'",
+ "day": "AND r.started_at > NOW() - INTERVAL '24 hours'",
+ "week": "AND r.started_at > NOW() - INTERVAL '7 days'",
+ "month": "AND r.started_at > NOW() - INTERVAL '30 days'",
+ }
+ time_filter = time_filter_map.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'")
- # =====================================================
- # ===== ADDED: AUDIO ANALYTICS METHODS =====
- # =====================================================
- def get_audio_stats(self, time_range: str = 'all') -> Dict:
- """
- Get aggregated audio classification statistics.
- """
- time_filter = {
- 'all': '',
- 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'",
- 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'",
- 'week': "AND r.started_at > NOW() - INTERVAL '7 days'"
- }.get(time_range, '')
+ if sound_types:
+ sound_list = "'" + "','".join(sound_types) + "'"
+ sound_filter = f"AND fa.head_pred_label IN ({sound_list})"
+ else:
+ sound_filter = "AND fa.head_pred_label IN ('fire', 'screaming', 'shotgun', 'predatory_animals')"
query = f"""
SELECT
- COUNT(*) AS total_files,
- SUM(CASE WHEN fa.head_is_another = true THEN 1 ELSE 0 END) AS unknown_count,
- AVG(fa.head_pred_prob) AS avg_confidence,
- AVG(fa.processing_ms) AS avg_processing_ms
+ r.run_id,
+ r.started_at,
+ f.path as file_path,
+ fa.head_pred_label as event_type,
+ fa.head_pred_prob as confidence,
+ fa.head_probs_json
FROM agcloud_audio.file_aggregates fa
- JOIN agcloud_audio.runs r
- ON fa.run_id = r.run_id
- WHERE 1=1 {time_filter}
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
+ JOIN public.files f ON fa.file_id = f.file_id
+ WHERE 1=1
+ {time_filter}
+ {sound_filter}
+ ORDER BY r.started_at DESC, fa.head_pred_prob DESC
+ LIMIT {limit}
"""
- results = self.run_query(query)
- return results[0] if results else {}
+ return self.run_query(query)
- def get_audio_distribution(self, time_range: str = 'all', limit: int = 10) -> List[Dict]:
- """
- Get distribution of audio classifications (for pie chart).
- """
- time_filter = {
- 'all': '',
- 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'",
- 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'",
- 'week': "AND r.started_at > NOW() - INTERVAL '7 days'"
- }.get(time_range, '')
+ def get_audio_timeline(
+ self,
+ time_range: str = "day",
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
+ """Get audio alert timeline data grouped by time buckets."""
+ bucket_interval = {
+ "day": 1,
+ "week": 6,
+ "month": 24,
+ }.get(time_range, 1)
+
+ time_filter_map = {
+ "day": "AND r.started_at > NOW() - INTERVAL '24 hours'",
+ "week": "AND r.started_at > NOW() - INTERVAL '7 days'",
+ "month": "AND r.started_at > NOW() - INTERVAL '30 days'",
+ }
+ time_filter = time_filter_map.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'")
+
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
query = f"""
SELECT
+ date_trunc('hour', r.started_at) +
+ INTERVAL '{bucket_interval} hours' *
+ (EXTRACT(hour FROM r.started_at)::int / {bucket_interval}) as time_bucket,
fa.head_pred_label,
- COUNT(*) AS count
+ COUNT(*) as count
FROM agcloud_audio.file_aggregates fa
- JOIN agcloud_audio.runs r
- ON fa.run_id = r.run_id
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
WHERE fa.head_pred_label IS NOT NULL
- {time_filter}
- GROUP BY fa.head_pred_label
- ORDER BY count DESC
- LIMIT {limit}
+ {time_filter}
+ {sound_filter}
+ GROUP BY time_bucket, fa.head_pred_label
+ ORDER BY time_bucket ASC, count DESC
"""
return self.run_query(query)
- def get_audio_confidence_by_class(self, time_range: str = 'all', limit: int = 10) -> List[Dict]:
- """
- Get average confidence levels by classification (for bar chart).
- """
- time_filter = {
- 'all': '',
- 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'",
- 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'",
- 'week': "AND r.started_at > NOW() - INTERVAL '7 days'"
- }.get(time_range, '')
+ def get_audio_heatmap(
+ self,
+ time_range: str = "week",
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
+ """Get audio detection heatmap data - hour of day vs day of week."""
+ time_filter_map = {
+ "day": "AND r.started_at > NOW() - INTERVAL '24 hours'",
+ "week": "AND r.started_at > NOW() - INTERVAL '7 days'",
+ "month": "AND r.started_at > NOW() - INTERVAL '30 days'",
+ }
+ time_filter = time_filter_map.get(time_range, "AND r.started_at > NOW() - INTERVAL '7 days'")
+
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
query = f"""
SELECT
- fa.head_pred_label,
- AVG(fa.head_pred_prob) AS avg_confidence
+ EXTRACT(HOUR FROM r.started_at) as hour_of_day,
+ EXTRACT(DOW FROM r.started_at) as day_of_week,
+ fa.head_pred_label as sound_type,
+ COUNT(*) as count
FROM agcloud_audio.file_aggregates fa
- JOIN agcloud_audio.runs r
- ON fa.run_id = r.run_id
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
WHERE fa.head_pred_label IS NOT NULL
- AND fa.head_pred_prob IS NOT NULL
{time_filter}
- GROUP BY fa.head_pred_label
- ORDER BY avg_confidence DESC
- LIMIT {limit}
+ {sound_filter}
+ GROUP BY hour_of_day, day_of_week, fa.head_pred_label
+ ORDER BY day_of_week, hour_of_day
"""
return self.run_query(query)
- def get_audio_critical_events(self, time_range: str = 'day', limit: int = 100) -> List[Dict]:
+ def get_audio_correlations(
+ self,
+ time_range: str = "day",
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
"""
- Get critical sound events (fire, screaming, shotgun, predatory animals).
+ Get sound detection data for correlation analysis
+ using linked_time from sound_new_sounds_connections.
"""
- time_filter = {
- 'hour': "AND r.started_at > NOW() - INTERVAL '1 hour'",
- 'day': "AND r.started_at > NOW() - INTERVAL '24 hours'",
- 'week': "AND r.started_at > NOW() - INTERVAL '7 days'"
- }.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'")
+ bucket_interval = {
+ "day": 1,
+ "week": 6,
+ "month": 24,
+ }.get(time_range, 1)
+
+ time_filter_map = {
+ "day": "AND c.linked_time > NOW() - INTERVAL '24 hours'",
+ "week": "AND c.linked_time > NOW() - INTERVAL '7 days'",
+ "month": "AND c.linked_time > NOW() - INTERVAL '30 days'",
+ }
+ time_filter = time_filter_map.get(time_range, "AND c.linked_time > NOW() - INTERVAL '24 hours'")
+
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
query = f"""
SELECT
- r.run_id,
- r.started_at,
- snsc.file_name,
- snsc.key AS s3_key,
- sm.device_id,
- sm.capture_time,
- fa.head_pred_label AS event_type,
- fa.head_pred_prob AS confidence,
- fa.head_probs_json
+ (date_trunc('hour', c.linked_time)
+ - (INTERVAL '1 hour' * (EXTRACT(hour FROM c.linked_time)::int % {bucket_interval}))
+ ) AS time_bucket,
+ fa.head_pred_label AS sound_type,
+ COUNT(*) AS detection_count
FROM agcloud_audio.file_aggregates fa
- JOIN agcloud_audio.runs r
- ON fa.run_id = r.run_id
- JOIN public.sound_new_sounds_connections snsc
- ON fa.file_id = snsc.id
- LEFT JOIN public.sounds_metadata sm
- ON snsc.file_name = sm.file_name
- WHERE fa.head_pred_label IN ('fire', 'screaming', 'shotgun', 'predatory_animals')
+ JOIN public.sound_new_sounds_connections c
+ ON c.id = fa.file_id
+ WHERE fa.head_pred_label IS NOT NULL
{time_filter}
- ORDER BY r.started_at DESC, fa.head_pred_prob DESC
- LIMIT {limit}
+ {sound_filter}
+ GROUP BY time_bucket, fa.head_pred_label
+ ORDER BY time_bucket ASC
"""
return self.run_query(query)
+ def get_model_health_metrics(
+ self,
+ time_range: str = "day",
+ sound_types: List[str] | None = None,
+ ) -> List[Dict]:
+ """Get model health metrics over time."""
+ bucket_interval = {
+ "day": 1,
+ "week": 6,
+ "month": 24,
+ }.get(time_range, 1)
+ time_filter_map = {
+ "day": "AND r.started_at > NOW() - INTERVAL '24 hours'",
+ "week": "AND r.started_at > NOW() - INTERVAL '7 days'",
+ "month": "AND r.started_at > NOW() - INTERVAL '30 days'",
+ }
+ time_filter = time_filter_map.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'")
+
+ sound_filter = self._audio_sound_filter(sound_types, "fa.head_pred_label")
+
+ query = f"""
+ SELECT
+ date_trunc('hour', r.started_at) +
+ INTERVAL '{bucket_interval} hours' *
+ (EXTRACT(hour FROM r.started_at)::int / {bucket_interval}) as time_bucket,
+ AVG(fa.head_pred_prob) as avg_confidence,
+ AVG(fa.processing_ms) as avg_processing_ms,
+ COUNT(*) as total_predictions,
+ SUM(CASE WHEN fa.head_is_another = true THEN 1 ELSE 0 END) as unknown_count,
+ (SUM(CASE WHEN fa.head_is_another = true THEN 1 ELSE 0 END)::float /
+ NULLIF(COUNT(*), 0)) * 100 as error_rate_pct
+ FROM agcloud_audio.file_aggregates fa
+ JOIN agcloud_audio.runs r ON fa.run_id = r.run_id
+ WHERE fa.head_pred_label IS NOT NULL
+ {time_filter}
+ {sound_filter}
+ GROUP BY time_bucket
+ ORDER BY time_bucket ASC
+ """
+ return self.run_query(query)
# =====================================================
- # ===== ADDED: HELPER METHODS FOR OTHER VIEWS =====
+ # ================ Sensors / Alerts / QA ==============
# =====================================================
def get_sensors(self) -> List[Dict]:
- """Get all sensors from the sensors table"""
+ """Get all sensors."""
query = "SELECT * FROM sensors ORDER BY sensor_name"
return self.run_query(query)
+
def get_sensor_status(self, sensor_name: str) -> Dict:
- """Get status of a specific sensor"""
+ """Get status of specific sensor."""
query = "SELECT * FROM sensors WHERE sensor_name = %s"
results = self.run_query(query, (sensor_name,))
return results[0] if results else {}
+
def get_alerts(self, limit: int = 50) -> List[Dict]:
- """Get recent alerts"""
- query = """
- SELECT * FROM alerts
- ORDER BY started_at DESC
- LIMIT %s
- """
+ """Get recent alerts."""
+ query = "SELECT * FROM alerts ORDER BY started_at DESC LIMIT %s"
return self.run_query(query, (limit,))
-
+
def acknowledge_alert(self, alert_id: str) -> bool:
- """Mark an alert as acknowledged"""
+ """Mark alert as acknowledged in DB."""
conn = None
cursor = None
try:
@@ -603,8 +621,9 @@ def acknowledge_alert(self, alert_id: str) -> bool:
cursor.close()
if conn:
conn.close()
+
def get_ripeness_stats(self) -> Dict:
- """Get ripeness prediction statistics"""
+ """Get ripeness prediction statistics."""
query = """
SELECT
COUNT(*) as total_predictions,
@@ -614,4 +633,471 @@ def get_ripeness_stats(self) -> Dict:
FROM ripeness_predictions
"""
results = self.run_query(query)
- return results[0] if results else {}
\ No newline at end of file
+ return results[0] if results else {}
+
+ # =====================================================
+ # ============ Aerial imagery & detections ============
+ # =====================================================
+
+ def list_aerial_metadata(self) -> List[dict]:
+ """Images metadata – time, drone, geom (aerial_images_metadata)."""
+ return self._get_table_rows_paged("aerial_images_metadata", page_size=200)
+
+ def list_aerial_complete_metadata(self) -> List[dict]:
+ """Complete metadata including img_key mapping (aerial_images_complete_metadata)."""
+ return self._get_table_rows_paged("aerial_images_complete_metadata", page_size=200)
+
+ def list_object_detections(self) -> List[dict]:
+ """Detected objects from aerial images (aerial_image_object_detections)."""
+ return self._get_table_rows_paged("aerial_image_object_detections", page_size=200)
+
+ def list_anomaly_detections(self) -> List[dict]:
+ """Detected anomalies from aerial images (aerial_image_anomaly_detections)."""
+ return self._get_table_rows_paged("aerial_image_anomaly_detections", page_size=200)
+
+ def list_aerial_connections(self) -> List[dict]:
+ """New aerial image connections over time (image_new_aerial_connections)."""
+ return self._get_table_rows_paged("image_new_aerial_connections", page_size=200)
+
+ # --- Latest image per GIS ---
+ def list_latest_images_per_gis(self) -> List[dict]:
+ """Get the most recent image for each GIS."""
+ url = f"{self.base}/api/tables/aerial_images_complete_metadata"
+ try:
+ r = self.http.get(url, timeout=10)
+ if r.status_code != 200:
+ print(f"[API ERROR] {r.status_code}: {r.text[:200]}")
+ return []
+ data = r.json()
+ rows = data.get("rows", data)
+ if not rows:
+ return []
+
+ rows.sort(
+ key=lambda x: x.get("timestamp_utc")
+ or x.get("created_at")
+ or "",
+ reverse=True,
+ )
+
+ latest: Dict[str, dict] = {}
+ for row in rows:
+ gis = row.get("gis")
+ if gis and gis not in latest:
+ latest[gis] = row
+ return list(latest.values())
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+ return []
+
+ # --- All dates for a given GIS ---
+ def list_dates_for_gis(self, gis_point: str) -> List[str]:
+ """Return all available dates (YYYY-MM-DD) for same GIS location."""
+ url = f"{self.base}/api/tables/aerial_images_complete_metadata"
+ try:
+ r = self.http.get(url, timeout=10)
+ if r.status_code == 200:
+ rows = r.json().get("rows", [])
+ gis_rows = [x for x in rows if x.get("gis") == gis_point]
+ return sorted(
+ {
+ x["timestamp_utc"][:10]
+ for x in gis_rows
+ if x.get("timestamp_utc")
+ }
+ )
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+ return []
+
+ # --- All images for a specific GIS ---
+ def list_all_images_for_gis(self, gis_point: str) -> List[dict]:
+ """Return all images metadata for a specific GIS, newest first."""
+ url = f"{self.base}/api/tables/aerial_images_complete_metadata"
+ try:
+ r = self.http.get(url, timeout=10)
+ if r.status_code == 200:
+ rows = r.json().get("rows", [])
+ return sorted(
+ [x for x in rows if x.get("gis") == gis_point],
+ key=lambda x: x.get("timestamp_utc"),
+ reverse=True,
+ )
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+ return []
+
+ def list_anomalies(self) -> List[dict]:
+ url = f"{self.base}/api/tables/aerial_image_anomaly_detections"
+ try:
+ r = self.http.get(url, timeout=10)
+ if r.status_code == 200:
+ return r.json().get("rows", [])
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+ return []
+
+ def list_objects(self, key):
+ url = f"{self.base}/api/tables/aerial_image_object_detections"
+ try:
+ r = self.http.get(url, params={"img_key": key}, timeout=10)
+ if r.status_code == 200:
+ return r.json().get("rows", [])
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+ return []
+
+ def list_polygons(self) -> List[dict]:
+ url = f"{self.base}/api/tables/field_polygons"
+ try:
+ r = self.http.get(url, timeout=10)
+ if r.status_code == 200:
+ return r.json().get("rows", [])
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+ return []
+
+ # --- All fields images bytes helper (for some pages) ---
+ def get_all_fields_images(self) -> List[dict]:
+ """
+ Helper that returns a list of:
+ { "field": GIS, "timestamp": ..., "image_bytes": ..., "key": img_key }
+ For use by aerial imagery overview UI.
+ """
+ rows = self.list_latest_images_per_gis()
+ results: List[dict] = []
+
+ for row in rows:
+ img_key = row.get("img_key") or row.get("image_key") or row.get("file_key")
+ if not img_key:
+ print("[WARN] missing img_key in row:", row)
+ continue
+
+ img_bytes = self.get_image_bytes_from_minio(img_key)
+ if not img_bytes:
+ print("[WARN] failed to load image:", img_key)
+ continue
+
+ results.append(
+ {
+ "field": row.get("gis", "Unknown"),
+ "timestamp": row.get("timestamp_utc", ""),
+ "image_bytes": img_bytes,
+ "key": img_key,
+ }
+ )
+
+ return results
+
+ # =====================================================
+ # ================= MinIO integration =================
+ # =====================================================
+
+ def _minio_client(self) -> Minio:
+ """
+ Internal helper to construct MinIO client.
+
+ NOTE: If in future you want to parametrize endpoint/keys,
+ read from env here.
+ """
+ return Minio(
+ endpoint=os.getenv("MINIO_ENDPOINT", "62.219.106.75:9001"),
+ access_key=os.getenv("MINIO_ACCESS_KEY", "minioadmin"),
+ secret_key=os.getenv("MINIO_SECRET_KEY", "minioadmin123"),
+ secure=os.getenv("MINIO_SECURE", "false") == "true",
+ )
+
+
+
+
+
+ def get_image_bytes_from_minio(self, key: str) -> bytes | None:
+ """Fetch an image from MinIO bucket 'imagery' by key (or imagery/key)."""
+ try:
+ bucket = "imagery"
+ if key.startswith(f"{bucket}/"):
+ key = key[len(f"{bucket}/") :]
+
+ print("key to minio", key)
+
+ client = self._minio_client()
+ response = client.get_object(bucket, key)
+ data = response.read()
+ response.close()
+ response.release_conn()
+ return data
+ except S3Error as e:
+ print(f"[MINIO ERROR] {e}")
+ except Exception as e:
+ print("Aerial ERROR")
+ print(f"[GENERAL ERROR] {e}")
+ return None
+ def get_latest_ground_rows(self, limit: int = 10) -> List[Dict]:
+ """
+ Return last N anomaly rows from RelDB for Ground PHI fallback.
+
+ Each row will have 'severity_avg' (and optional coverage/density),
+ so GroundView can derive PHI from it.
+ """
+ query = """
+ SELECT
+ ts,
+ -- use severity as severity_avg (0..1)
+ severity AS severity_avg,
+ NULLIF(details->>'coverage','')::float AS coverage,
+ NULLIF(details->>'density','')::float AS density
+ FROM anomalies
+ ORDER BY ts DESC
+ LIMIT %s
+ """
+ return self.run_query(query, (limit,))
+
+ def get_mask_bytes_from_minio(self, mask_path: str) -> bytes | None:
+ """Fetch segmentation mask from MinIO bucket 'imagery'."""
+ try:
+ bucket = "imagery"
+ if mask_path.startswith(f"{bucket}/"):
+ mask_path = mask_path[len(f"{bucket}/") :]
+
+ client = self._minio_client()
+ response = client.get_object(bucket, mask_path)
+ data = response.read()
+ response.close()
+ response.release_conn()
+ return data
+ except Exception as e:
+ print(f"[MASK ERROR] {e}")
+ return None
+
+ def list_minio_objects(
+ self,
+ bucket: str,
+ prefix: str = "",
+ limit: int = 1000,
+ ) -> List[dict]:
+ """
+ List objects from MinIO for the gallery (used by GroundView).
+
+ Returns list of dicts:
+ { "key": ..., "last_modified": "...", "size": int }
+ """
+ try:
+ client = self._minio_client()
+ results: List[dict] = []
+
+ for obj in client.list_objects(
+ bucket_name=bucket,
+ prefix=prefix,
+ recursive=True,
+ ):
+ item: Dict[str, object] = {"key": obj.object_name}
+
+ lm = getattr(obj, "last_modified", None)
+ if lm is not None:
+ try:
+ item["last_modified"] = lm.isoformat()
+ except Exception:
+ item["last_modified"] = str(lm)
+
+ sz = getattr(obj, "size", None)
+ if sz is not None:
+ item["size"] = sz
+
+ results.append(item)
+ if limit and len(results) >= limit:
+ break
+
+ return results
+
+ except Exception as e:
+ print(f"[MINIO LIST ERROR] {e}", flush=True)
+ return []
+
+ # =====================================================
+ # ================= DB API write helpers ==============
+ # =====================================================
+
+ def write_to_db_api(self, table: str, payload: dict) -> bool:
+ """Insert a row via db_api_service /api/tables/
."""
+ url = f"{self.base}/api/tables/{table}"
+
+ print("\n=== DEBUG INSERT ===")
+ print("URL:", url)
+ print("Payload:", json.dumps(payload, indent=4))
+ print("====================\n")
+
+ try:
+ r = self.http.post(url, json=payload, timeout=10)
+ if 200 <= r.status_code < 300:
+ print(f"[DB] INSERT OK → {table}")
+ return True
+
+ print(f"[DB] INSERT FAILED ({table}): {r.status_code} {r.text[:300]}")
+ return False
+
+ except Exception as e:
+ print("[DB][ERROR]", e)
+ return False
+ def get_phi_for_image(self, key: str) -> dict | None:
+ """
+ Compute PHI for a given ground image key based on anomalies table.
+
+ Logic:
+ * Extract filename from MinIO key (e.g. ground_weed/DRN-...Y.jpg -> DRN-...Y)
+ * Try to match details->>'image_id' exactly.
+ * If no rows, try same base with E/P/U/O suffixes.
+ * PHI ≈ 100 * (1 - severity_avg) (clamped to [0, 100])
+ """
+ try:
+ # 1) extract stem from key: "ground_weed/XXX.jpg" -> "XXX"
+ fname = key.split("/")[-1]
+ stem = fname.rsplit(".", 1)[0]
+
+ candidates: list[str] = [stem]
+ # 2) also try base with typical suffixes (E/P/U/O) – כי ה-anomalies על E
+ if len(stem) >= 1:
+ base = stem[:-1]
+ for sfx in ["E", "P", "U", "O"]:
+ candidates.append(base + sfx)
+
+ # הסר כפילויות תוך שמירה על הסדר
+ seen = set()
+ uniq_candidates = []
+ for c in candidates:
+ if c not in seen:
+ seen.add(c)
+ uniq_candidates.append(c)
+
+ for cid in uniq_candidates:
+ rows = self.run_query(
+ """
+ SELECT
+ details->>'image_id' AS image_id,
+ AVG(severity) AS severity_avg,
+ COUNT(*) AS spots,
+ MIN(ts) AS ts_min,
+ MAX(ts) AS ts_max
+ FROM anomalies
+ WHERE details->>'image_id' = %s
+ GROUP BY details->>'image_id'
+ ORDER BY ts_max DESC
+ LIMIT 1;
+ """,
+ (cid,),
+ )
+
+ if not rows:
+ continue
+
+ row = rows[0]
+ sev = row.get("severity_avg")
+
+ phi_val = None
+ if sev is not None:
+ try:
+ s = float(sev)
+ except Exception:
+ s = 0.0
+ # clamp severity ל-[0,1]
+ if s > 1.0:
+ s = min(s, 10.0) / 10.0
+ phi_val = max(0.0, min(100.0, 100.0 * (1.0 - s)))
+
+ return {
+ "phi": phi_val,
+ "severity_avg": sev,
+ "coverage": None,
+ "density": None,
+ "trend": None,
+ "week_start": None,
+ }
+
+ # no match
+ return None
+
+ except Exception as e:
+ print(f"[DashboardApi] get_phi_for_image error: {e}", flush=True)
+ return None
+
+ def update_db_api(self, table: str, row_id: int, payload: dict) -> bool:
+ """
+ Update a row via db_api_service /api/tables/.
+ Assumes primary key column is 'id'.
+ """
+ url = f"{self.base}/api/tables/{table}"
+
+ request_body = {"keys": {"id": row_id}, "data": payload}
+
+ print("\n=== DEBUG UPDATE ===")
+ print("URL:", url)
+ print("Payload:", json.dumps(request_body, indent=4))
+ print("====================\n")
+
+ try:
+ r = self.http.put(url, json=request_body, timeout=10)
+ if 200 <= r.status_code < 300:
+ print(f"[DB] UPDATE OK → row {row_id}")
+ return True
+
+ print(f"[DB] UPDATE FAILED ({table}): {r.status_code} {r.text[:300]}")
+ return False
+
+ except Exception as e:
+ print("[DB][ERROR]", e)
+ return False
+
+ # =====================================================
+ # ============= Segmentation & PHI helpers ============
+ # =====================================================
+
+ def get_segmentation_record(self, img_key: str) -> dict | None:
+ """Get segmentation record from aerial_image_segmentation for given img_key."""
+ url = f"{self.base}/api/tables/aerial_image_segmentation"
+ try:
+ r = self.http.get(url, timeout=10)
+ if r.status_code != 200:
+ print(f"[API ERROR] {r.status_code}: {r.text[:200]}")
+ return None
+
+ data = r.json()
+ rows = data.get("rows", data)
+ print("DEBUG segmentation API result:", rows)
+
+ for row in rows:
+ if row.get("img_key") == img_key:
+ return row
+ except Exception as e:
+ print(f"[API FAIL] {e}")
+ return None
+
+ def get_anomalies_map(self) -> Dict[str, int]:
+ """
+ Build a map: img_key -> count of anomalies
+ based on aerial_image_anomaly_detections table.
+ """
+ all_anomalies = self.list_anomalies()
+ anomaly_map: Dict[str, int] = {}
+ for anomaly in all_anomalies:
+ field_id = anomaly.get("img_key")
+ if field_id:
+ anomaly_map[field_id] = anomaly_map.get(field_id, 0) + 1
+ return anomaly_map
+
+ def get_daily_activity(self, time_range, sound_types=None):
+ query = """
+ SELECT DATE(recorded_at) AS date, COUNT(*) AS count
+ FROM sounds
+ WHERE recorded_at >= NOW() - INTERVAL '30 DAY'
+ GROUP BY date
+ ORDER BY date;
+ """
+ return self.run_query(query)
+
+ def get_recent_sound_files(self):
+ query = """
+ SELECT object_key
+ FROM files
+ WHERE created_at >= NOW() - INTERVAL '30 days'
+ AND object_key LIKE 'sounds/%'
+ """
+ return self.run_query(query)
\ No newline at end of file
diff --git a/GUI/src/vast/desktop/Dockerfile b/GUI/src/vast/desktop/Dockerfile
index 56a4c5d04..43fb74e2c 100644
--- a/GUI/src/vast/desktop/Dockerfile
+++ b/GUI/src/vast/desktop/Dockerfile
@@ -2,7 +2,7 @@ FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
-# ───────── system dependencies ─────────
+# ───────────────────── system deps ─────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libegl1 libx11-6 libxcomposite1 libxext6 libxi6 libxtst6 libsm6 \
libxkbcommon0 libxkbcommon-x11-0 libxkbfile1 libxrender1 libxrandr2 \
@@ -13,27 +13,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libnspr4 libdbus-1-3 libkrb5-3 libgssapi-krb5-2 libasound2 libpulse0 \
fluxbox x11vnc xvfb wget net-tools python3-tk ca-certificates \
procps iproute2 xauth git vlc libvlc5 libvlccore9 \
- fonts-dejavu-core fonts-noto-core fonts-noto-color-emoji\
- && rm -rf /var/lib/apt/lists/*
+ fonts-dejavu-core fonts-noto-core fonts-noto-color-emoji && \
+ rm -rf /var/lib/apt/lists/*
-# (optional) minimal extra XCB deps for PyQt
+# Extra XCB deps for PyQt
RUN apt-get update && apt-get install -y --no-install-recommends \
libxcb-xinerama0 libxcb-cursor0 libxcb-keysyms1 libxcb-render-util0 \
libxcb-randr0 && rm -rf /var/lib/apt/lists/*
-# ───────── optional CA certs ─────────
-COPY certs /app/certs
-RUN if [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \
- echo "Configuring NetFree certificates..."; \
- cp ./certs/*.crt /usr/local/share/ca-certificates/; \
- update-ca-certificates; \
- fi
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt
-# ───────── noVNC for remote GUI ─────────
+# ───────────────────── noVNC ─────────────────────
RUN mkdir -p /opt && \
wget --tries=3 --timeout=30 -O /tmp/novnc.tar.gz https://github.com/novnc/noVNC/archive/refs/tags/v1.4.0.tar.gz && \
tar xzf /tmp/novnc.tar.gz -C /opt && \
@@ -41,26 +34,35 @@ RUN mkdir -p /opt && \
rm /tmp/novnc.tar.gz && \
git clone --depth 1 https://github.com/novnc/websockify /opt/noVNC/utils/websockify
-# ───────── Python deps ─────────
+# ───────────────────── PulseAudio FIX ─────────────────────
+RUN apt-get update && apt-get install -y --no-install-recommends pulseaudio && \
+ mkdir -p /etc/pulse && \
+ echo "load-module module-native-protocol-unix" >> /etc/pulse/default.pa && \
+ echo "load-module module-always-sink" >> /etc/pulse/default.pa && \
+ echo "set-default-sink default" >> /etc/pulse/default.pa
+
+RUN mkdir -p /run/user/1000 && chmod -R 777 /run/user/1000
+
+# ───────────────────── Python deps ─────────────────────
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
-RUN pip install --no-cache-dir --upgrade pip \
- && pip install --no-cache-dir \
- "PyQt6==6.9.0" \
- "PyQt6-WebEngine==6.9.0" \
- "argon2-cffi" \
- "requests" \
- "numpy" \
- --extra-index-url https://pypi.org/simple \
- --prefer-binary \
- --break-system-packages \
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir \
+ "PyQt6==6.9.0" \
+ "PyQt6-WebEngine==6.9.0" \
+ "argon2-cffi" \
+ "requests" \
+ "numpy" \
+ argon2-cffi requests numpy \
+ --extra-index-url https://pypi.org/simple \
+ --prefer-binary \
+ --break-system-packages \
&& pip show PyQt6 PyQt6-WebEngine argon2-cffi
-RUN pip install plotly
-RUN pip install PyJWT
-# ───────── app setup ─────────
-RUN useradd -m -s /bin/bash appuser \
- && mkdir -p /app /tmp/.X11-unix \
- && chown -R appuser:appuser /app /tmp /opt/noVNC /var/tmp
+RUN pip install plotly PyJWT
+
+# ───────────────────── App setup ─────────────────────
+RUN useradd -m -s /bin/bash appuser && \
+ mkdir -p /app /tmp/.X11-unix && chown -R appuser:appuser /app /tmp /opt/noVNC /var/tmp
RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
@@ -70,8 +72,11 @@ RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh && chown -R appuser
RUN mkdir -p /app/secrets && chmod -R 777 /app/secrets
+RUN mkdir -p /run/user/1000 && chmod -R 777 /run/user/1000
+
USER appuser
-EXPOSE 5900 6080
+
+EXPOSE 5900 6080
ENV PYTHONPATH=/app/src:/app
ENV DISPLAY=:0
ENV NO_VNC_PORT=6080
@@ -79,6 +84,3 @@ ENV PORT=19100
ENV MEDIA_BASE=http://media-proxy:8080
CMD ["/app/start.sh"]
-
-
-
diff --git a/GUI/src/vast/desktop/start.sh b/GUI/src/vast/desktop/start.sh
index 460dfcafe..82fa7b64d 100644
--- a/GUI/src/vast/desktop/start.sh
+++ b/GUI/src/vast/desktop/start.sh
@@ -5,6 +5,10 @@ set -x
export DISPLAY=:0
rm -f /tmp/.X0-lock
+echo "[INFO] Starting PulseAudio..."
+pulseaudio --start --exit-idle-time=-1 --log-target=stderr
+sleep 1
+
echo "[INFO] Starting Xvfb..."
Xvfb :0 -screen 0 1920x1080x24 &
sleep 3
diff --git a/GUI/src/vast/dsl/builder.py b/GUI/src/vast/dsl/builder.py
index 8636ddf6c..f62c9f140 100644
--- a/GUI/src/vast/dsl/builder.py
+++ b/GUI/src/vast/dsl/builder.py
@@ -28,11 +28,7 @@ class SQLState:
"select", "from", "where", "group_by", "having", "order_by", "limit", "offset"
])
- # Helper methods to avoid importing Clause classes in ops
- def add_select(self, columns: List[str]) -> None:
- self.clauses["select"].append(SelectClause(columns))
- def add_where(self, cond) -> None:
- self.clauses["where"].append(WhereClause(cond))
+
def add_clause(self, clause: Clause) -> None:
self.clauses[clause.phase].append(clause)
diff --git a/GUI/src/vast/dsl/clauses.py b/GUI/src/vast/dsl/clauses.py
index cc7cfddc9..d2f2ecf48 100644
--- a/GUI/src/vast/dsl/clauses.py
+++ b/GUI/src/vast/dsl/clauses.py
@@ -94,10 +94,8 @@ def joiner(self): return ", "
def fragment(self, ctx):
def quote_or_passthrough(col: str) -> str:
# Skip quoting if it's clearly an SQL expression or aggregate
- if any(token in col.upper() for token in (
- "(", ")", " AS ", "COUNT", "AVG", "SUM", "MAX", "MIN"
- )):
- return col # leave expressions as-is
+ if "(" in col or ")" in col:
+ return col
return ctx.dialect.quote_ident(col)
cols = [quote_or_passthrough(c) for c in self.columns]
diff --git a/GUI/src/vast/dsl/dialects.py b/GUI/src/vast/dsl/dialects.py
index 36bcce02b..b83ce6c9f 100644
--- a/GUI/src/vast/dsl/dialects.py
+++ b/GUI/src/vast/dsl/dialects.py
@@ -34,24 +34,6 @@ def normalize_bool(self, v: Any) -> Any:
def placeholder(self, idx: int) -> str:
return "?" # qmark style
-# class PostgresDialect(Dialect):
-# def __init__(self, style: str = "psycopg"):
-# """style:
-# - 'psycopg' → %s style placeholders (psycopg2/3)
-# - 'numeric' → $1, $2, ... style placeholders (asyncpg)
-# """
-
-# if style not in ("psycopg", "numeric"):
-# raise ValueError("PostgresDialect.style must be 'psycopg' or 'numeric'")
-# self.style = style
-# def quote_ident(self, name: str) -> str:
-# parts = name.split(".")
-# return ".".join('"' + p.replace('"', '""') + '"' for p in parts)
-# def normalize_bool(self, v: Any) -> Any:
-# return v # PostgreSQL has a real boolean type
-# def placeholder(self, idx: int) -> str:
-# return "%s" if self.style == "psycopg" else f"${idx}"
-# dialects.py
class PostgresDialect(Dialect):
def __init__(self, style: str = "named"):
"""
diff --git a/GUI/src/vast/dsl/expr.py b/GUI/src/vast/dsl/expr.py
index 4e9cffc70..a9bc1a837 100644
--- a/GUI/src/vast/dsl/expr.py
+++ b/GUI/src/vast/dsl/expr.py
@@ -62,6 +62,22 @@ def compile(self, ctx: CompileCtx) -> str:
def to_ir(self) -> Dict[str, Any]:
return {"literal": self.value}
+@dataclass
+class Func(Expr):
+ name: str
+ args: list[Expr]
+
+ def compile(self, ctx: CompileCtx) -> str:
+ compiled = ", ".join(arg.compile(ctx) for arg in self.args)
+ return f"{self.name.upper()}({compiled})"
+
+ def to_ir(self) -> Dict[str, Any]:
+ return {
+ "func": self.name,
+ "args": [arg.to_ir() for arg in self.args]
+ }
+
+
def ensure_expr(x: Any) -> Expr:
"""Coerce Python values to Literal, leave Expr as-is."""
@@ -85,7 +101,7 @@ class Predicate(Cond):
op: BinOp
right: Expr
def __post_init__(self):
- if not isinstance(self.left, (Col, Literal)) or not isinstance(self.right, (Col, Literal)):
+ if not isinstance(self.left, (Col, Literal,Func)) or not isinstance(self.right, (Col, Literal,Func)):
raise TypeError("Predicate must compare columns and/or literals only")
def compile(self, ctx: CompileCtx) -> str:
return f"({self.left.compile(ctx)} {self.op.value} {self.right.compile(ctx)})"
@@ -109,12 +125,25 @@ def to_ir(self) -> Dict[str, Any]: return {"any": [p.to_ir() for p in self.parts
# ---- Strict IR decoding ----
def expr_from_ir(d: Dict[str, Any]) -> Expr:
- """Decode a strict Expr IR object into Expr."""
- if not isinstance(d, dict): raise TypeError("Expr leaf must be an object")
+ if not isinstance(d, dict):
+ raise TypeError("Expr leaf must be an object")
+
keys = set(d.keys())
- if keys == {"col"}: return Col(d["col"])
- if keys == {"literal"}: return Literal(d["literal"])
- raise ValueError("Expr leaf must be either {\"col\": name} or {\"literal\": value}")
+
+ if keys == {"col"}:
+ return Col(d["col"])
+
+ if keys == {"literal"}:
+ return Literal(d["literal"])
+
+ if keys == {"func", "args"}:
+ return Func(
+ d["func"],
+ [expr_from_ir(arg) for arg in d["args"]]
+ )
+
+ raise ValueError(f"Invalid Expr IR: {d}")
+
def cond_from_ir(d: Dict[str, Any]) -> Cond:
diff --git a/GUI/src/vast/dsl/ops.py b/GUI/src/vast/dsl/ops.py
index f92ca0528..cc66239c0 100644
--- a/GUI/src/vast/dsl/ops.py
+++ b/GUI/src/vast/dsl/ops.py
@@ -28,18 +28,19 @@ def apply(self, st: "SQLState") -> None:
class SelectOp(Op):
op_type = "select"
def apply(self, st: "SQLState") -> None:
- cols = self.payload.get("columns")
- st.add_select(cols or [])
+ cols = self.payload.get("columns", [])
+ st.add_clause(SelectClause(cols))
+
class WhereOp(Op):
op_type = "where"
def apply(self, st: "SQLState") -> None:
cond_ir = self.payload.get("cond")
if not isinstance(cond_ir, dict):
- raise TypeError(
- f"Invalid WHERE condition: expected dict, got {type(cond_ir).__name__} → {cond_ir}"
- )
- st.add_where(cond_from_ir(cond_ir))
+ raise TypeError("Invalid WHERE condition")
+ expr = cond_from_ir(cond_ir)
+ st.add_clause(WhereClause(expr))
+
class HavingOp(Op):
diff --git a/GUI/src/vast/fruit_defect_token.py b/GUI/src/vast/fruit_defect_token.py
new file mode 100644
index 000000000..1c8afa41d
--- /dev/null
+++ b/GUI/src/vast/fruit_defect_token.py
@@ -0,0 +1,76 @@
+# GUI/src/vast/fruit_defect_token.py
+from __future__ import annotations
+import os
+import pathlib
+import requests
+
+# בסיס ל-DB API
+DB_API_BASE = os.getenv("DB_API_BASE", "http://db_api_service:8001").strip()
+
+# איפה נשמור את הטוקן בתוך הקונטיינר של ה-GUI
+DB_API_TOKEN_FILE = os.getenv("DB_API_TOKEN_FILE", "/app/secret/db_api_token")
+
+# שם השירות לצורך ה-bootstrap (לא באמת קריטי, רק שיהיה מזהה)
+DB_API_SERVICE_NAME = os.getenv("DB_API_SERVICE_NAME", "fruit_defect_gui").strip() or "fruit_defect_gui"
+
+
+def _safe_join_url(base: str, path: str) -> str:
+ return f"{base.rstrip('/')}/{path.lstrip('/')}"
+
+
+def _read_token(path: str) -> str | None:
+ p = pathlib.Path(path)
+ if p.exists():
+ t = p.read_text(encoding="utf-8").strip()
+ if t and "***" not in t:
+ return t
+ return None
+
+
+def _write_token(path: str, token: str) -> None:
+ p = pathlib.Path(path)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(token, encoding="utf-8")
+
+
+def _try_dev_bootstrap() -> str | None:
+ """Try to get token using /auth/_dev_bootstrap (new API)."""
+ url = _safe_join_url(DB_API_BASE, "/auth/_dev_bootstrap")
+ payload = {"service_name": DB_API_SERVICE_NAME, "rotate_if_exists": True}
+ try:
+ r = requests.post(url, json=payload, timeout=10)
+ if r.status_code in (200, 201):
+ data = r.json()
+ sa = data.get("service_account") or {}
+ token = sa.get("raw_token") or sa.get("token")
+ if token and "***" not in token:
+ print("[BOOTSTRAP] obtained token via /auth/_dev_bootstrap")
+ return token.strip()
+ print(f"[BOOTSTRAP][WARN] _dev_bootstrap returned {r.status_code}: {r.text[:100]}")
+ except Exception as e:
+ print(f"[BOOTSTRAP][ERROR] {e}")
+ return None
+
+
+def get_service_token() -> str | None:
+ """Get or create a service token automatically."""
+ if not DB_API_BASE:
+ print("[BOOTSTRAP][WARN] DB_API_BASE not set")
+ return None
+
+ # קודם מנסה לקרוא מטוקן קיים
+ token = _read_token(DB_API_TOKEN_FILE)
+ if token:
+ print(f"[BOOTSTRAP] using existing token from {DB_API_TOKEN_FILE}")
+ return token
+
+ # אם אין, מנסה bootstrap
+ print(f"[BOOTSTRAP] fetching new service token from {DB_API_BASE}")
+ token = _try_dev_bootstrap()
+ if token:
+ _write_token(DB_API_TOKEN_FILE, token)
+ print(f"[BOOTSTRAP] wrote token to {DB_API_TOKEN_FILE}")
+ return token
+
+ print("[BOOTSTRAP][ERROR] Could not obtain service token.")
+ return None
diff --git a/GUI/src/vast/gateway/Dockerfile b/GUI/src/vast/gateway/Dockerfile
index 29549ecba..5dd9255b6 100644
--- a/GUI/src/vast/gateway/Dockerfile
+++ b/GUI/src/vast/gateway/Dockerfile
@@ -20,7 +20,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
# Optional extra CA certs (e.g. NetFree) from ./certs
-COPY certs/ /tmp/certs
+#COPY certs/ /tmp/certs
RUN set -eux; \
if [ "${USE_NETFREE}" = "true" ] \
diff --git a/GUI/src/vast/main_window.py b/GUI/src/vast/main_window.py
index 34310e234..e7232eca2 100644
--- a/GUI/src/vast/main_window.py
+++ b/GUI/src/vast/main_window.py
@@ -1,915 +1,3 @@
-# # from PyQt6.QtCore import Qt, pyqtSignal, QSize
-# # from PyQt6.QtWidgets import (
-# # QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar,
-# # QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout,
-# # QGraphicsDropShadowEffect, QPushButton,
-# # QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar,
-# # QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout,
-# # QGraphicsDropShadowEffect, QPushButton
-# # )
-# # from PyQt6.QtGui import QAction, QIcon, QFont, QColor
-# # import os
-
-# # from PyQt6.QtGui import QAction, QIcon, QFont, QColor
-# # import os
-
-# # from home_view import HomeView
-# # from views.sensors_view import SensorsView
-# # from views.alerts_panel import AlertsPanel
-# # from views.notification_view import NotificationView
-# # from views.fruits_view import FruitsView
-# # from views.sound.sound_view import SoundView
-# # from views.ground_view import GroundView
-# # from views.auth_status_view import AuthStatusView
-# # from views.ground_view import GroundView
-# # from views.auth_status_view import AuthStatusView
-# # from dashboard_api import DashboardApi
-# # from vast.alerts.alert_service import AlertService
-# # from views.leaf_diseases import LeafDiseaseView
-
-
-# # # === New Sensors GUI imports ===
-# # from views.sensorsMainView import SensorsMainView
-# # from views.sensorsMapView import SensorsMapView
-# # from views.sensorDetailsTab import SensorDetailsTab
-# # from views.sensors_status_summary import SensorsStatusSummary
-
-# # from views.security.incident_player_vlc import IncidentPlayerVLC
-# # # === New Sensors GUI imports ===
-# # from views.sensorsMainView import SensorsMainView
-# # from views.sensorsMapView import SensorsMapView
-# # from views.sensorDetailsTab import SensorDetailsTab
-# # from views.sensors_status_summary import SensorsStatusSummary
-
-
-# # class MainWindow(QMainWindow):
-# # logoutRequested = pyqtSignal()
-
-# # def __init__(self, api: DashboardApi, parent=None):
-# # super().__init__(parent)
-# # self.setWindowTitle("AgCloud – Dashboard")
-# # self.resize(1280, 760)
-# # self.setWindowTitle("AgCloud – Dashboard")
-# # self.resize(1280, 760)
-# # self.api = api
-
-# # # ───────────────────────────────
-# # # GLOBAL STYLE
-# # # ───────────────────────────────
-# # self.setStyleSheet("""
-# # QMainWindow { background-color: #f9fafb; }
-# # QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; }
-# # QToolBar {
-# # background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6);
-# # border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px;
-# # }
-# # QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; }
-# # QToolButton:hover { background-color: #e5e7eb; }
-# # QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; }
-# # QListWidget::item { padding: 10px; border-radius: 6px; }
-# # QListWidget::item:selected { background-color: #10b981; color: white; }
-# # QStatusBar { background-color: #f3f4f6; font-size: 10pt; }
-# # """)
-
-# # # ───────────────────────────────
-# # # MENU
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # GLOBAL STYLE
-# # # ───────────────────────────────
-# # self.setStyleSheet("""
-# # QMainWindow { background-color: #f9fafb; }
-# # QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; }
-# # QToolBar {
-# # background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6);
-# # border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px;
-# # }
-# # QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; }
-# # QToolButton:hover { background-color: #e5e7eb; }
-# # QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; }
-# # QListWidget::item { padding: 10px; border-radius: 6px; }
-# # QListWidget::item:selected { background-color: #10b981; color: white; }
-# # QStatusBar { background-color: #f3f4f6; font-size: 10pt; }
-# # """)
-
-# # # ───────────────────────────────
-# # # MENU
-# # # ───────────────────────────────
-# # file_menu = self.menuBar().addMenu("&File")
-# # self.back_action = QAction(QIcon.fromTheme("go-previous"), "Back", self)
-# # self.back_action.setShortcut("Alt+Left")
-# # self.back_action.triggered.connect(self.go_back)
-# # file_menu.addAction(self.back_action)
-# # self.logout_action = QAction("Log out", self)
-# # self.logout_action = QAction("Log out", self)
-# # self.logout_action.triggered.connect(self._logout)
-# # file_menu.addAction(self.logout_action)
-
-# # # ───────────────────────────────
-# # # TOP BAR (toolbar)
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # TOP BAR (toolbar)
-# # # ───────────────────────────────
-# # toolbar = self.addToolBar("Main Toolbar")
-# # toolbar.setMovable(False)
-# # toolbar.setFloatable(False)
-# # toolbar.setIconSize(QSize(32, 32))
-
-# # top_bar = QWidget()
-# # top_bar_layout = QHBoxLayout(top_bar)
-# # top_bar_layout.setContentsMargins(8, 0, 8, 0)
-# # top_bar_layout.setSpacing(10)
-
-# # # Logout button
-# # logout_btn = QPushButton("Logout")
-# # logout_btn.setToolTip("Log out")
-# # logout_btn.setCursor(Qt.CursorShape.PointingHandCursor)
-# # logout_btn.setStyleSheet("""
-# # QPushButton {
-# # background-color: #10b981;
-# # color: white;
-# # border: none;
-# # border-radius: 8px;
-# # padding: 6px 16px;
-# # font-size: 11pt;
-# # font-weight: 600;
-# # }
-# # QPushButton:hover { background-color: #059669; }
-# # QPushButton:pressed { background-color: #047857; }
-# # """)
-# # logout_btn.clicked.connect(self._logout)
-
-# # # Alert bell
-# # toolbar.setIconSize(QSize(32, 32))
-
-# # top_bar = QWidget()
-# # top_bar_layout = QHBoxLayout(top_bar)
-# # top_bar_layout.setContentsMargins(8, 0, 8, 0)
-# # top_bar_layout.setSpacing(10)
-
-# # # Logout button
-# # logout_btn = QPushButton("Logout")
-# # logout_btn.setToolTip("Log out")
-# # logout_btn.setCursor(Qt.CursorShape.PointingHandCursor)
-# # logout_btn.setStyleSheet("""
-# # QPushButton {
-# # background-color: #10b981;
-# # color: white;
-# # border: none;
-# # border-radius: 8px;
-# # padding: 6px 16px;
-# # font-size: 11pt;
-# # font-weight: 600;
-# # }
-# # QPushButton:hover { background-color: #059669; }
-# # QPushButton:pressed { background-color: #047857; }
-# # """)
-# # logout_btn.clicked.connect(self._logout)
-
-# # # Alert bell
-# # self.alert_button = QToolButton()
-# # self.alert_button.setToolTip("Show alerts")
-# # self.alert_button.setText("🔔")
-# # self.alert_button.setIconSize(QSize(40, 40))
-# # self.alert_button.setIconSize(QSize(40, 40))
-# # self.alert_button.setStyleSheet("""
-# # QToolButton {
-# # font-size: 30px;
-# # font-size: 30px;
-# # border: none;
-# # background: transparent;
-# # padding: 4px;
-# # border-radius: 8px;
-# # border-radius: 8px;
-# # }
-# # QToolButton:hover { background-color: #e5e7eb; }
-# # QToolButton:hover { background-color: #e5e7eb; }
-# # """)
-
-# # # Alert badge
-# # # Alert badge
-# # self.alert_badge = QLabel("0", self.alert_button)
-# # self.alert_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# # self.alert_badge.setFixedSize(24, 24)
-# # self.alert_badge.setFixedSize(24, 24)
-# # self.alert_badge.setStyleSheet("""
-# # QLabel {
-# # background-color: #3b82f6;
-# # background-color: #3b82f6;
-# # color: white;
-# # font-size: 10pt;
-# # font-size: 10pt;
-# # font-weight: bold;
-# # border-radius: 12px;
-# # border: 2px solid white;
-# # border-radius: 12px;
-# # border: 2px solid white;
-# # }
-# # """)
-# # self.alert_badge.hide()
-
-# # def reposition_badge():
-# # btn_w = self.alert_button.width()
-# # self.alert_badge.move(btn_w - 22, 2)
-# # self.alert_badge.move(btn_w - 22, 2)
-# # self.alert_badge.raise_()
-
-# # self.alert_button.resizeEvent = lambda e: (
-# # QToolButton.resizeEvent(self.alert_button, e),
-# # reposition_badge()
-# # )
-# # reposition_badge()
-
-# # # ───────────────────────────────
-# # # TITLE AREA (Updated)
-# # # ───────────────────────────────
-# # title_container = QWidget()
-# # title_layout = QVBoxLayout(title_container)
-# # title_layout.setContentsMargins(0, 0, 0, 0)
-# # title_layout.setSpacing(0)
-
-# # main_title = QLabel("AgCloud")
-# # main_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# # main_title.setStyleSheet("""
-# # QLabel {
-# # font-size: 22pt;
-# # font-weight: 700;
-# # color: #047857;
-# # letter-spacing: 1px;
-# # }
-# # """)
-
-# # subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field")
-# # subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# # subtitle.setStyleSheet("""
-# # QLabel {
-# # font-size: 11pt;
-# # font-weight: 500;
-# # color: #374151;
-# # margin-top: 2px;
-# # }
-# # """)
-
-# # title_layout.addWidget(main_title)
-# # title_layout.addWidget(subtitle)
-
-# # shadow = QGraphicsDropShadowEffect()
-# # shadow.setBlurRadius(8)
-# # shadow.setColor(QColor(0, 0, 0, 35))
-# # shadow.setOffset(0, 2)
-# # top_bar.setGraphicsEffect(shadow)
-
-# # top_bar_layout.addWidget(logout_btn)
-# # top_bar_layout.addWidget(self.alert_button)
-# # top_bar_layout.addStretch()
-# # top_bar_layout.addWidget(title_container)
-# # top_bar_layout.addStretch()
-# # toolbar.addWidget(top_bar)
-
-# # # ───────────────────────────────
-# # # NAVIGATION
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # TITLE AREA (Updated)
-# # # ───────────────────────────────
-# # title_container = QWidget()
-# # title_layout = QVBoxLayout(title_container)
-# # title_layout.setContentsMargins(0, 0, 0, 0)
-# # title_layout.setSpacing(0)
-
-# # main_title = QLabel("AgCloud")
-# # main_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# # main_title.setStyleSheet("""
-# # QLabel {
-# # font-size: 22pt;
-# # font-weight: 700;
-# # color: #047857;
-# # letter-spacing: 1px;
-# # }
-# # """)
-
-# # subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field")
-# # subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# # subtitle.setStyleSheet("""
-# # QLabel {
-# # font-size: 11pt;
-# # font-weight: 500;
-# # color: #374151;
-# # margin-top: 2px;
-# # }
-# # """)
-
-# # title_layout.addWidget(main_title)
-# # title_layout.addWidget(subtitle)
-
-# # shadow = QGraphicsDropShadowEffect()
-# # shadow.setBlurRadius(8)
-# # shadow.setColor(QColor(0, 0, 0, 35))
-# # shadow.setOffset(0, 2)
-# # top_bar.setGraphicsEffect(shadow)
-
-# # top_bar_layout.addWidget(logout_btn)
-# # top_bar_layout.addWidget(self.alert_button)
-# # top_bar_layout.addStretch()
-# # top_bar_layout.addWidget(title_container)
-# # top_bar_layout.addStretch()
-# # toolbar.addWidget(top_bar)
-
-# # # ───────────────────────────────
-# # # NAVIGATION
-# # # ───────────────────────────────
-# # self.nav_dock = QDockWidget("Navigation", self)
-# # self.nav_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
-# # self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.nav_dock)
-# # self.nav_list = QListWidget(self.nav_dock)
-# # self.nav_dock.setWidget(self.nav_list)
-# # self.nav_dock.setMinimumWidth(220)
-# # self.nav_dock.setMinimumWidth(220)
-
-# # font = QFont(); font.setPointSize(12)
-# # self.nav_list.setFont(font)
-
-# # for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth", "Leaf Diseases"]:
-# # item.setData(Qt.ItemDataRole.UserRole, {"type": "main"})
-# # self.nav_list.addItem(item)
-# # if main_item == "Sensors":
-# # for sub in ["Live Data", "Sensor Health", "Location Map"]:
-# # sub_item = QListWidgetItem(f" ↳ {sub}")
-# # sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub})
-# # sub_item.setHidden(True)
-# # self.nav_list.addItem(sub_item)
-
-# # font = QFont(); font.setPointSize(12)
-# # self.nav_list.setFont(font)
-# # for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth", "Leaf Diseases"]:
-# # item = QListWidgetItem(main_item)
-# # item.setData(Qt.ItemDataRole.UserRole, {"type": "main"})
-# # self.nav_list.addItem(item)
-# # if main_item == "Sensors":
-# # for sub in ["Live Data", "Sensor Health", "Location Map"]:
-# # sub_item = QListWidgetItem(f" ↳ {sub}")
-# # sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub})
-# # sub_item.setHidden(True)
-# # self.nav_list.addItem(sub_item)
-
-# # self.nav_list.currentRowChanged.connect(self._on_nav_change)
-# # self.nav_list.itemClicked.connect(self._on_nav_click)
-# # self.nav_list.itemClicked.connect(self._on_nav_click)
-
-# # # ───────────────────────────────
-# # # ALERT SERVICE + PANEL
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # ALERT SERVICE + PANEL
-# # # ───────────────────────────────
-# # ws_url = os.getenv("ALERTS_WS", "ws://alerts-gateway:8000/ws/alerts")
-# # self.alert_service = AlertService(ws_url, api)
-# # self.alert_service.alertsUpdated.connect(self.update_alert_badge)
-# # self.alert_service.alertAdded.connect(lambda _: self.update_alert_badge())
-
-# # self.alerts_panel = AlertsPanel(self.alert_service)
-# # self.alerts_panel.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool)
-# # self.alerts_panel.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
-# # self.alerts_panel.setStyleSheet("""
-# # QWidget {
-# # background-color: #ffffff;
-# # border: 1px solid #d1d5db;
-# # border: 1px solid #d1d5db;
-# # border-radius: 10px;
-# # }
-# # """)
-# # self.alerts_panel.hide()
-# # self.alert_button.clicked.connect(self.toggle_alert_panel)
-
-# # # ───────────────────────────────
-# # # CENTRAL STACKED VIEWS
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # CENTRAL STACKED VIEWS
-# # # ───────────────────────────────
-# # self.home = HomeView(api, self.alert_service, self)
-# # self.sensors_view = SensorsView(api, self)
-# # self.notification_view = NotificationView(self)
-# # self.fruits_view = FruitsView(api, self)
-# # self.sound_view = SoundView(api, self)
-# # self.ground_view = GroundView(api, self)
-# # self.auth_status = AuthStatusView(api, self)
-# # self.leaf_diseases_view = LeafDiseaseView(api, self)
-# # self.sensors_status_summary = SensorsStatusSummary(api, self)
-# # self.sensors_health = SensorsView(api, self)
-# # self.sensors_main = SensorsMainView(api, self)
-# # self.security_view = IncidentPlayerVLC(api, self.alert_service, self)
-# # self.ground_view = GroundView(api, self)
-# # self.auth_status = AuthStatusView(api, self)
-
-# # self.sensors_status_summary = SensorsStatusSummary(api, self)
-# # self.sensors_health = SensorsView(api, self)
-# # self.sensors_main = SensorsMainView(api, self)
-
-# # self.stack = QStackedWidget()
-# # self.setCentralWidget(self.stack)
-# # self.views = {
-# # "Home": self.home,
-# # "Sensors": self.sensors_view,
-# # "Sound": self.sound_view,
-# # "Sensors - Live Data": self.sensors_status_summary,
-# # "Sensors - Sensor Health": self.sensors_health,
-# # "Sensors - Location Map": self.sensors_main,
-# # "Notifications": self.notification_view,
-# # "Leaf Diseases": self.leaf_diseases_view,
-# # "Fruits": self.fruits_view,
-# # "Ground Image": self.ground_view,
-# # "Auth": self.auth_status,
-# # "Security": self.security_view,
-# # }
-
-# # for view in self.views.values():
-# # self.stack.addWidget(view)
-# # self.stack.setCurrentWidget(self.home)
-# # self.history = []
-# # self.history = []
-
-# # # ───────────────────────────────
-# # # STATUS BAR
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # STATUS BAR
-# # # ───────────────────────────────
-# # sb = QStatusBar(self)
-# # sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }")
-# # sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }")
-# # self.setStatusBar(sb)
-# # sb.showMessage("Ready")
-
-# # # ───────────────────────────────
-# # # ALERT BADGE
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # ALERT BADGE
-# # # ───────────────────────────────
-# # def update_alert_badge(self):
-# # unacked = sum(1 for a in self.alert_service.alerts if not a.get("ack", False))
-# # if unacked > 0:
-# # self.alert_badge.setText(str(unacked))
-# # self.alert_badge.show()
-# # else:
-# # self.alert_badge.hide()
-
-# # def toggle_alert_panel(self):
-# # if self.alerts_panel.isVisible():
-# # self.alerts_panel.hide()
-# # return
-
-# # panel_width, panel_height = 420, 540
-# # panel_width, panel_height = 420, 540
-# # self.alerts_panel.resize(panel_width, panel_height)
-# # rect = self.alert_button.geometry()
-# # bottom_left = self.alert_button.mapToGlobal(rect.bottomLeft())
-# # bottom_right = self.alert_button.mapToGlobal(rect.bottomRight())
-# # center_x = (bottom_left.x() + bottom_right.x()) // 2 - (panel_width // 2)
-# # pos_y = bottom_left.y() + 8
-# # pos_y = bottom_left.y() + 8
-# # self.alerts_panel.move(center_x, pos_y)
-# # self.alerts_panel.show()
-# # self.alerts_panel.raise_()
-
-# # if hasattr(self.alert_service, "mark_all_acknowledged"):
-# # self.alert_service.mark_all_acknowledged()
-# # self.update_alert_badge()
-
-# # # ───────────────────────────────
-# # # NAVIGATION
-# # # ───────────────────────────────
-# # # ───────────────────────────────
-# # # NAVIGATION
-# # # ───────────────────────────────
-# # def _on_nav_change(self, row: int) -> None:
-# # name = self.nav_list.item(row).text().strip()
-# # name = self.nav_list.item(row).text().strip()
-# # if name in self.views:
-# # self.navigate_to(self.views[name])
-# # else:
-# # self.statusBar().showMessage(f"Section '{name}' not implemented yet.")
-
-# # def _on_nav_click(self, item):
-# # data = item.data(Qt.ItemDataRole.UserRole)
-# # if data and data.get("type") == "main":
-# # parent = item.text()
-# # expanded = False
-# # for i in range(self.nav_list.count()):
-# # sub_item = self.nav_list.item(i)
-# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
-# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
-# # expanded = sub_item.isHidden()
-# # break
-# # for i in range(self.nav_list.count()):
-# # sub_item = self.nav_list.item(i)
-# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
-# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
-# # sub_item.setHidden(not expanded)
-# # elif data and data.get("type") == "sub":
-# # parent = data.get("parent")
-# # sub_name = data.get("name")
-# # key = f"{parent} - {sub_name}"
-# # if key in self.views:
-# # self.stack.setCurrentWidget(self.views[key])
-
-# # def _on_nav_click(self, item):
-# # data = item.data(Qt.ItemDataRole.UserRole)
-# # if data and data.get("type") == "main":
-# # parent = item.text()
-# # expanded = False
-# # for i in range(self.nav_list.count()):
-# # sub_item = self.nav_list.item(i)
-# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
-# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
-# # expanded = sub_item.isHidden()
-# # break
-# # for i in range(self.nav_list.count()):
-# # sub_item = self.nav_list.item(i)
-# # sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
-# # if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
-# # sub_item.setHidden(not expanded)
-# # elif data and data.get("type") == "sub":
-# # parent = data.get("parent")
-# # sub_name = data.get("name")
-# # key = f"{parent} - {sub_name}"
-# # if key in self.views:
-# # self.stack.setCurrentWidget(self.views[key])
-
-# # def navigate_to(self, widget):
-# # current = self.stack.currentWidget()
-# # if current not in self.history:
-# # self.history.append(current)
-# # self.stack.setCurrentWidget(widget)
-
-# # def go_back(self):
-# # if self.history:
-# # last = self.history.pop()
-# # self.stack.setCurrentWidget(last)
-# # else:
-# # self.statusBar().showMessage("No previous view to go back to.")
-
-# # def _logout(self) -> None:
-# # self.statusBar().showMessage("Logged out (demo)")
-# # self.logoutRequested.emit()
-
-# from PyQt6.QtCore import Qt, pyqtSignal, QSize
-# from PyQt6.QtWidgets import (
-# QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar,
-# QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout,
-# QGraphicsDropShadowEffect, QPushButton
-# )
-# from PyQt6.QtGui import QAction, QIcon, QFont, QColor
-# import os
-
-# from home_view import HomeView
-# from views.sensors_view import SensorsView
-# from views.alerts_panel import AlertsPanel
-# from views.notification_view import NotificationView
-# from views.fruits_view import FruitsView
-# from views.ground_view import GroundView
-# from views.auth_status_view import AuthStatusView
-# from dashboard_api import DashboardApi
-# from vast.alerts.alert_service import AlertService
-
-# # === New Sensors GUI imports ===
-# from views.sensorsMainView import SensorsMainView
-# from views.sensorsMapView import SensorsMapView
-# from views.sensorDetailsTab import SensorDetailsTab
-# from views.sensors_status_summary import SensorsStatusSummary
-
-
-# class MainWindow(QMainWindow):
-# logoutRequested = pyqtSignal()
-
-# def __init__(self, api: DashboardApi, parent=None):
-# super().__init__(parent)
-# self.setWindowTitle("AgCloud – Dashboard")
-# self.resize(1280, 760)
-# self.api = api
-
-# # ───────────────────────────────
-# # GLOBAL STYLE
-# # ───────────────────────────────
-# self.setStyleSheet("""
-# QMainWindow { background-color: #f9fafb; }
-# QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; }
-# QToolBar {
-# background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6);
-# border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px;
-# }
-# QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; }
-# QToolButton:hover { background-color: #e5e7eb; }
-# QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; }
-# QListWidget::item { padding: 10px; border-radius: 6px; }
-# QListWidget::item:selected { background-color: #10b981; color: white; }
-# QStatusBar { background-color: #f3f4f6; font-size: 10pt; }
-# """)
-
-# # ───────────────────────────────
-# # MENU
-# # ───────────────────────────────
-# file_menu = self.menuBar().addMenu("&File")
-# self.back_action = QAction(QIcon.fromTheme("go-previous"), "Back", self)
-# self.back_action.setShortcut("Alt+Left")
-# self.back_action.triggered.connect(self.go_back)
-# file_menu.addAction(self.back_action)
-# self.logout_action = QAction("Log out", self)
-# self.logout_action.triggered.connect(self._logout)
-# file_menu.addAction(self.logout_action)
-
-# # ───────────────────────────────
-# # TOP BAR (toolbar)
-# # ───────────────────────────────
-# toolbar = self.addToolBar("Main Toolbar")
-# toolbar.setMovable(False)
-# toolbar.setFloatable(False)
-# toolbar.setIconSize(QSize(32, 32))
-
-# top_bar = QWidget()
-# top_bar_layout = QHBoxLayout(top_bar)
-# top_bar_layout.setContentsMargins(8, 0, 8, 0)
-# top_bar_layout.setSpacing(10)
-
-# # Logout button
-# logout_btn = QPushButton("Logout")
-# logout_btn.setToolTip("Log out")
-# logout_btn.setCursor(Qt.CursorShape.PointingHandCursor)
-# logout_btn.setStyleSheet("""
-# QPushButton {
-# background-color: #10b981;
-# color: white;
-# border: none;
-# border-radius: 8px;
-# padding: 6px 16px;
-# font-size: 11pt;
-# font-weight: 600;
-# }
-# QPushButton:hover { background-color: #059669; }
-# QPushButton:pressed { background-color: #047857; }
-# """)
-# logout_btn.clicked.connect(self._logout)
-
-# # Alert bell
-# self.alert_button = QToolButton()
-# self.alert_button.setToolTip("Show alerts")
-# self.alert_button.setText("🔔")
-# self.alert_button.setIconSize(QSize(40, 40))
-# self.alert_button.setStyleSheet("""
-# QToolButton {
-# font-size: 30px;
-# border: none;
-# background: transparent;
-# padding: 4px;
-# border-radius: 8px;
-# }
-# QToolButton:hover { background-color: #e5e7eb; }
-# """)
-
-# # Alert badge
-# self.alert_badge = QLabel("0", self.alert_button)
-# self.alert_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# self.alert_badge.setFixedSize(24, 24)
-# self.alert_badge.setStyleSheet("""
-# QLabel {
-# background-color: #3b82f6;
-# color: white;
-# font-size: 10pt;
-# font-weight: bold;
-# border-radius: 12px;
-# border: 2px solid white;
-# }
-# """)
-# self.alert_badge.hide()
-
-# def reposition_badge():
-# btn_w = self.alert_button.width()
-# self.alert_badge.move(btn_w - 22, 2)
-# self.alert_badge.raise_()
-
-# self.alert_button.resizeEvent = lambda e: (
-# QToolButton.resizeEvent(self.alert_button, e),
-# reposition_badge()
-# )
-# reposition_badge()
-
-# # ───────────────────────────────
-# # TITLE AREA (Updated)
-# # ───────────────────────────────
-# title_container = QWidget()
-# title_layout = QVBoxLayout(title_container)
-# title_layout.setContentsMargins(0, 0, 0, 0)
-# title_layout.setSpacing(0)
-
-# main_title = QLabel("AgCloud")
-# main_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# main_title.setStyleSheet("""
-# QLabel {
-# font-size: 22pt;
-# font-weight: 700;
-# color: #047857;
-# letter-spacing: 1px;
-# }
-# """)
-
-# subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field")
-# subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
-# subtitle.setStyleSheet("""
-# QLabel {
-# font-size: 11pt;
-# font-weight: 500;
-# color: #374151;
-# margin-top: 2px;
-# }
-# """)
-
-# title_layout.addWidget(main_title)
-# title_layout.addWidget(subtitle)
-
-# shadow = QGraphicsDropShadowEffect()
-# shadow.setBlurRadius(8)
-# shadow.setColor(QColor(0, 0, 0, 35))
-# shadow.setOffset(0, 2)
-# top_bar.setGraphicsEffect(shadow)
-
-# top_bar_layout.addWidget(logout_btn)
-# top_bar_layout.addWidget(self.alert_button)
-# top_bar_layout.addStretch()
-# top_bar_layout.addWidget(title_container)
-# top_bar_layout.addStretch()
-# toolbar.addWidget(top_bar)
-
-# # ───────────────────────────────
-# # NAVIGATION
-# # ───────────────────────────────
-# self.nav_dock = QDockWidget("Navigation", self)
-# self.nav_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
-# self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.nav_dock)
-# self.nav_list = QListWidget(self.nav_dock)
-# self.nav_dock.setWidget(self.nav_list)
-# self.nav_dock.setMinimumWidth(220)
-
-# font = QFont(); font.setPointSize(12)
-# self.nav_list.setFont(font)
-
-# for main_item in ["Home", "Sensors", "Sound", "Ground Image", "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Auth"]:
-# item = QListWidgetItem(main_item)
-# item.setData(Qt.ItemDataRole.UserRole, {"type": "main"})
-# self.nav_list.addItem(item)
-# if main_item == "Sensors":
-# for sub in ["Live Data", "Sensor Health", "Location Map"]:
-# sub_item = QListWidgetItem(f" ↳ {sub}")
-# sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub})
-# sub_item.setHidden(True)
-# self.nav_list.addItem(sub_item)
-
-# self.nav_list.currentRowChanged.connect(self._on_nav_change)
-# self.nav_list.itemClicked.connect(self._on_nav_click)
-
-# # ───────────────────────────────
-# # ALERT SERVICE + PANEL
-# # ───────────────────────────────
-# ws_url = os.getenv("ALERTS_WS", "ws://alerts-gateway:8000/ws/alerts")
-# self.alert_service = AlertService(ws_url, api)
-# self.alert_service.alertsUpdated.connect(self.update_alert_badge)
-# self.alert_service.alertAdded.connect(lambda _: self.update_alert_badge())
-
-# self.alerts_panel = AlertsPanel(self.alert_service)
-# self.alerts_panel.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool)
-# self.alerts_panel.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
-# self.alerts_panel.setStyleSheet("""
-# QWidget {
-# background-color: #ffffff;
-# border: 1px solid #d1d5db;
-# border-radius: 10px;
-# }
-# """)
-# self.alerts_panel.hide()
-# self.alert_button.clicked.connect(self.toggle_alert_panel)
-
-# # ───────────────────────────────
-# # CENTRAL STACKED VIEWS
-# # ───────────────────────────────
-# self.home = HomeView(api, self.alert_service, self)
-# self.sensors_view = SensorsView(api, self)
-# self.notification_view = NotificationView(self)
-# self.fruits_view = FruitsView(api, self)
-# self.ground_view = GroundView(api, self)
-# self.auth_status = AuthStatusView(api, self)
-
-# self.sensors_status_summary = SensorsStatusSummary(api, self)
-# self.sensors_health = SensorsView(api, self)
-# self.sensors_main = SensorsMainView(api, self)
-
-# self.stack = QStackedWidget()
-# self.setCentralWidget(self.stack)
-# self.views = {
-# "Home": self.home,
-# "Sensors": self.sensors_view,
-# "Sensors - Live Data": self.sensors_status_summary,
-# "Sensors - Sensor Health": self.sensors_health,
-# "Sensors - Location Map": self.sensors_main,
-# "Notifications": self.notification_view,
-# "Fruits": self.fruits_view,
-# "Ground Image": self.ground_view,
-# "Auth": self.auth_status
-# }
-
-# for view in self.views.values():
-# self.stack.addWidget(view)
-# self.stack.setCurrentWidget(self.home)
-# self.history = []
-
-# # ───────────────────────────────
-# # STATUS BAR
-# # ───────────────────────────────
-# sb = QStatusBar(self)
-# sb.setStyleSheet("QStatusBar { background-color: #f3f4f6; color: #374151; font-size: 10.5pt; }")
-# self.setStatusBar(sb)
-# sb.showMessage("Ready")
-
-# # ───────────────────────────────
-# # ALERT BADGE
-# # ───────────────────────────────
-# def update_alert_badge(self):
-# unacked = sum(1 for a in self.alert_service.alerts if not a.get("ack", False))
-# if unacked > 0:
-# self.alert_badge.setText(str(unacked))
-# self.alert_badge.show()
-# else:
-# self.alert_badge.hide()
-
-# def toggle_alert_panel(self):
-# if self.alerts_panel.isVisible():
-# self.alerts_panel.hide()
-# return
-
-# panel_width, panel_height = 420, 540
-# self.alerts_panel.resize(panel_width, panel_height)
-# rect = self.alert_button.geometry()
-# bottom_left = self.alert_button.mapToGlobal(rect.bottomLeft())
-# bottom_right = self.alert_button.mapToGlobal(rect.bottomRight())
-# center_x = (bottom_left.x() + bottom_right.x()) // 2 - (panel_width // 2)
-# pos_y = bottom_left.y() + 8
-# self.alerts_panel.move(center_x, pos_y)
-# self.alerts_panel.show()
-# self.alerts_panel.raise_()
-
-# if hasattr(self.alert_service, "mark_all_acknowledged"):
-# self.alert_service.mark_all_acknowledged()
-# self.update_alert_badge()
-
-# # ───────────────────────────────
-# # NAVIGATION
-# # ───────────────────────────────
-# def _on_nav_change(self, row: int) -> None:
-# name = self.nav_list.item(row).text().strip()
-# if name in self.views:
-# self.navigate_to(self.views[name])
-# else:
-# self.statusBar().showMessage(f"Section '{name}' not implemented yet.")
-
-# def _on_nav_click(self, item):
-# data = item.data(Qt.ItemDataRole.UserRole)
-# if data and data.get("type") == "main":
-# parent = item.text()
-# expanded = False
-# for i in range(self.nav_list.count()):
-# sub_item = self.nav_list.item(i)
-# sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
-# if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
-# expanded = sub_item.isHidden()
-# break
-# for i in range(self.nav_list.count()):
-# sub_item = self.nav_list.item(i)
-# sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
-# if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
-# sub_item.setHidden(not expanded)
-# elif data and data.get("type") == "sub":
-# parent = data.get("parent")
-# sub_name = data.get("name")
-# key = f"{parent} - {sub_name}"
-# if key in self.views:
-# self.stack.setCurrentWidget(self.views[key])
-
-# def navigate_to(self, widget):
-# current = self.stack.currentWidget()
-# if current not in self.history:
-# self.history.append(current)
-# self.stack.setCurrentWidget(widget)
-
-# def go_back(self):
-# if self.history:
-# last = self.history.pop()
-# self.stack.setCurrentWidget(last)
-# else:
-# self.statusBar().showMessage("No previous view to go back to.")
-
-# def _logout(self) -> None:
-# self.statusBar().showMessage("Logged out (demo)")
-# self.logoutRequested.emit()
from PyQt6.QtCore import Qt, pyqtSignal, QSize
from PyQt6.QtWidgets import (
@@ -920,84 +8,93 @@
from PyQt6.QtGui import QAction, QIcon, QFont, QColor
import os
+# ============================
+# VIEWS IMPORTS
+# ============================
from home_view import HomeView
from views.sensors_view import SensorsView
-from views.security.incident_player_vlc import IncidentPlayerVLC
from views.alerts_panel import AlertsPanel
from views.notification_view import NotificationView
from views.fruits_view import FruitsView
+#from views.sound.sound_view import SoundView
+from views.ground_view import GroundView
+from views.auth_status_view import AuthStatusView
+#Leaves GUI
+from views.leaves_dashboard import LeafDiseaseView
+from views.leaves_view import LeafView
+
+# Sensors GUI
+from views.SimilarPeriodsSensors import SimilarPeriodsTab
+from views.sensorsMainView import SensorsMainView
+from views.sensorsMapView import SensorsMapView
+from views.sensorDetailsTab import SensorDetailsTab
+from views.sensors_status_summary import SensorsStatusSummary
+from views.irrigation.irrigation_view import IrrigationView
+
+# Security
+from views.security.incident_player_vlc import IncidentPlayerVLC
+from vast.views.security.events_history_page import EventsHistoryPage
+from src.vast.views.security.analytics.analytics_page import GeoAnalyticsView
+
from dashboard_api import DashboardApi
from vast.alerts.alert_service import AlertService
+from views.fruit.fruit_defect_metrics_view import FruitDefectMetricsView
+from views.fruit.fruit_defect_grafana_view import FruitDefectGrafanaView
+from views.fruit.fruit_alert_defect import FruitAlertDefectView
+from views.fruit.fruit_alert_ripness import FruitAlertRipenessView
+from views.fruit.tree_overlay_view import TreeOverlayView
+
+# aerial
+from views.aerial_img_galery import FieldsGridView
+from views.graphs_aerial_view import AerialGraphsView
+from views.aerial_main_view import AerialView
+# Sound
+from views.sound.recordings_tab import RecordingsTab
+from views.sound.sound_graphic import SoundGraphic
+from views.sound.sound_analytics_view import SoundAnalyticsView
+# =====================================================================
+# MAIN WINDOW
+# =====================================================================
+
class MainWindow(QMainWindow):
logoutRequested = pyqtSignal()
def __init__(self, api: DashboardApi, parent=None):
super().__init__(parent)
- self.setWindowTitle("VAST – Dashboard")
+
+ # Basic window setup
+ self.setWindowTitle("AgCloud – Dashboard")
self.resize(1280, 760)
self.api = api
- # ───────────────────────────────
- # GLOBAL STYLE
- # ───────────────────────────────
+ # =====================================================================
+ # GLOBAL STYLE (יחיד, לא כפול)
+ # =====================================================================
self.setStyleSheet("""
- QMainWindow {
- background-color: #f9fafb;
- }
- QMenuBar {
- background-color: #e5e7eb;
- font-size: 11.5pt;
- padding: 4px 10px;
- }
+ QMainWindow { background-color: #f9fafb; }
+ QMenuBar { background-color: #e5e7eb; font-size: 11.5pt; padding: 4px 10px; }
QToolBar {
- background: qlineargradient(
- x1:0, y1:0, x2:0, y2:1,
- stop:0 #ffffff,
- stop:1 #f3f4f6
- );
- border-bottom: 1px solid #d1d5db;
- padding: 2px 10px;
- min-height: 42px;
- }
- QToolButton {
- background-color: transparent;
- border: none;
- padding: 4px;
- border-radius: 8px;
- font-size: 20px;
- }
- QToolButton:hover {
- background-color: #e5e7eb;
- }
- QListWidget {
- background-color: #ffffff;
- border: none;
- font-size: 12pt;
- color: #111827;
- }
- QListWidget::item {
- padding: 10px;
- border-radius: 6px;
- }
- QListWidget::item:selected {
- background-color: #10b981;
- color: white;
- }
- QStatusBar {
- background-color: #f3f4f6;
- font-size: 10pt;
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #ffffff, stop:1 #f3f4f6);
+ border-bottom: 1px solid #d1d5db; padding: 2px 10px; min-height: 42px;
}
+ QToolButton { background-color: transparent; border: none; padding: 4px; border-radius: 8px; font-size: 20px; }
+ QToolButton:hover { background-color: #e5e7eb; }
+ QListWidget { background-color: #ffffff; border: none; font-size: 12pt; color: #111827; }
+ QListWidget::item { padding: 10px; border-radius: 6px; }
+ QListWidget::item:selected { background-color: #10b981; color: white; }
+ QStatusBar { background-color: #f3f4f6; font-size: 10pt; }
""")
- # ───────────────────────────────
+ # =====================================================================
# MENU
- # ───────────────────────────────
+ # =====================================================================
file_menu = self.menuBar().addMenu("&File")
- self.back_action = QAction(QIcon.fromTheme("go-previous"), "Back", self)
+
+ self.back_action = QAction("Back", self)
self.back_action.setShortcut("Alt+Left")
self.back_action.triggered.connect(self.go_back)
file_menu.addAction(self.back_action)
@@ -1006,211 +103,407 @@ def __init__(self, api: DashboardApi, parent=None):
self.logout_action.triggered.connect(self._logout)
file_menu.addAction(self.logout_action)
- # ───────────────────────────────
- # TOP BAR
- # ───────────────────────────────
+ # =====================================================================
+ # TOP TOOLBAR (נקי, יחיד)
+ # =====================================================================
toolbar = self.addToolBar("Main Toolbar")
toolbar.setMovable(False)
toolbar.setFloatable(False)
toolbar.setIconSize(QSize(32, 32))
top_bar = QWidget()
- top_bar_layout = QHBoxLayout(top_bar)
- top_bar_layout.setContentsMargins(8, 0, 8, 0)
- top_bar_layout.setSpacing(10)
-
- # Back button
- back_btn = QToolButton()
- back_btn.setIcon(QIcon.fromTheme("go-previous"))
- back_btn.setIconSize(QSize(28, 28))
- back_btn.setToolTip("Go back")
- back_btn.clicked.connect(self.go_back)
-
- # Logout button
+ top_layout = QHBoxLayout(top_bar)
+ top_layout.setContentsMargins(8, 0, 8, 0)
+ top_layout.setSpacing(10)
+
+ # Logout boton
logout_btn = QPushButton("Logout")
- logout_btn.setToolTip("Log out")
- logout_btn.setCursor(Qt.CursorShape.PointingHandCursor)
logout_btn.setStyleSheet("""
QPushButton {
background-color: #10b981;
color: white;
- border: none;
border-radius: 8px;
padding: 6px 16px;
font-size: 11pt;
font-weight: 600;
}
- QPushButton:hover {
- background-color: #059669;
- }
- QPushButton:pressed {
- background-color: #047857;
- }
+ QPushButton:hover { background-color: #059669; }
""")
logout_btn.clicked.connect(self._logout)
- # Bell button
+ # Alert bell
self.alert_button = QToolButton()
- self.alert_button.setToolTip("Show alerts")
self.alert_button.setText("🔔")
self.alert_button.setIconSize(QSize(40, 40))
- self.alert_button.setStyleSheet("""
- QToolButton {
- font-size: 30px;
- border: none;
- background: transparent;
- padding: 4px;
- border-radius: 8px;
- }
- QToolButton:hover {
- background-color: #e5e7eb;
- }
- """)
+ self.alert_button.setStyleSheet("font-size: 30px; border: none; padding: 4px;")
- # Larger blue badge
+ # Alert counter badge
self.alert_badge = QLabel("0", self.alert_button)
self.alert_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.alert_badge.setFixedSize(24, 24)
+ self.alert_badge.setFixedSize(22, 22)
self.alert_badge.setStyleSheet("""
QLabel {
- background-color: #3b82f6; /* blue */
+ background-color: #3b82f6;
color: white;
- font-size: 10pt;
font-weight: bold;
- border-radius: 12px;
+ border-radius: 11px;
border: 2px solid white;
}
""")
self.alert_badge.hide()
- # Position badge dynamically
- def reposition_badge():
- btn_w = self.alert_button.width()
- self.alert_badge.move(btn_w - 22, 2)
- self.alert_badge.raise_()
-
- self.alert_button.resizeEvent = lambda e: (
- QToolButton.resizeEvent(self.alert_button, e),
- reposition_badge()
- )
- reposition_badge()
-
- # Title
- title_label = QLabel("VAST Dashboard")
- title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- title_label.setStyleSheet("""
- QLabel {
- font-size: 17pt;
- font-weight: 600;
- color: #111827;
- }
- """)
+ # Center title (AgCloud)
+ title_container = QWidget()
+ title_layout = QVBoxLayout(title_container)
+ title_layout.setContentsMargins(0, 0, 0, 0)
+
+ main_title = QLabel("AgCloud")
+ main_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ main_title.setStyleSheet("font-size: 22pt; font-weight: 700; color: #047857;")
- # Shadow
- shadow = QGraphicsDropShadowEffect()
- shadow.setBlurRadius(8)
- shadow.setColor(QColor(0, 0, 0, 35))
- shadow.setOffset(0, 2)
- top_bar.setGraphicsEffect(shadow)
+ subtitle = QLabel("The Smart Platform that Protects and Optimizes Your Field")
+ subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ subtitle.setStyleSheet("font-size: 11pt; color: #374151;")
- top_bar_layout.addWidget(logout_btn)
- top_bar_layout.addWidget(self.alert_button)
- top_bar_layout.addStretch()
- top_bar_layout.addWidget(title_label)
- top_bar_layout.addStretch()
+ title_layout.addWidget(main_title)
+ title_layout.addWidget(subtitle)
+ # Compose toolbar
+ top_layout.addWidget(logout_btn)
+ top_layout.addWidget(self.alert_button)
+ top_layout.addStretch()
+ top_layout.addWidget(title_container)
+ top_layout.addStretch()
toolbar.addWidget(top_bar)
- # ───────────────────────────────
- # NAVIGATION DOCK
- # ───────────────────────────────
+ # =====================================================================
+ # ALERT SERVICE
+ # =====================================================================
+ ws_url = os.getenv("ALERTS_WS", "ws://alerts-gateway:8000/ws/alerts")
+ self.alert_service = AlertService(ws_url, api)
+ self.alert_service.alertsUpdated.connect(self.update_alert_badge)
+ self.alert_service.alertAdded.connect(lambda _: self.update_alert_badge())
+
+ self.alert_button.clicked.connect(self.toggle_alert_panel)
+ self.alerts_panel = AlertsPanel(self.alert_service)
+ self.alerts_panel.hide()
+
+ # =====================================================================
+ # LEFT NAVIGATION DOCK
+ # =====================================================================
self.nav_dock = QDockWidget("Navigation", self)
self.nav_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.nav_dock)
- self.nav_list = QListWidget(self.nav_dock)
+ self.nav_list = QListWidget()
+ self.nav_list.setFont(QFont("Segoe UI", 12))
self.nav_dock.setWidget(self.nav_list)
self.nav_dock.setMinimumWidth(220)
- font = QFont()
- font.setPointSize(12)
- self.nav_list.setFont(font)
-
- for name in [
- "Home", "Sensors", "Sound", "Ground Image",
- "Aerial Image", "Fruits", "Security", "Settings", "Notifications"
- ]:
- QListWidgetItem(f" {name}", self.nav_list)
+ self._build_navigation()
- self.nav_list.setCurrentRow(0)
self.nav_list.currentRowChanged.connect(self._on_nav_change)
+ self.nav_list.itemClicked.connect(self._on_nav_click)
- # ───────────────────────────────
- # ALERT SERVICE
- # ───────────────────────────────
- ws_url = os.getenv("ALERTS_WS", "ws://alerts-gateway:8000/ws/alerts")
- self.alert_service = AlertService(ws_url, api)
- self.alert_service.alertsUpdated.connect(self.update_alert_badge)
- self.alert_service.alertAdded.connect(lambda _: self.update_alert_badge())
+ # =====================================================================
+ # STACKED VIEWS
+ # =====================================================================
+ self.stack = QStackedWidget()
+ self.setCentralWidget(self.stack)
- # Alerts panel
- self.alerts_panel = AlertsPanel(self.alert_service)
- self.alerts_panel.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool)
- self.alerts_panel.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
- self.alerts_panel.setStyleSheet("""
- QWidget {
- background-color: #ffffff;
- border: 1px solid #d1d5db;
- border-radius: 10px;
- }
- """)
- self.alerts_panel.hide()
- self.alert_button.clicked.connect(self.toggle_alert_panel)
+ self._register_views()
- # ───────────────────────────────
- # CENTRAL STACKED VIEWS
- # ───────────────────────────────
- self.home = HomeView(api, self.alert_service, self)
- self.sensors_view = SensorsView(api, self)
- self.notification_view = NotificationView(self)
- self.security_view = IncidentPlayerVLC(api, self.alert_service, self)
- self.fruits_view = FruitsView(api, self)
+ # Default view
+ self.stack.setCurrentWidget(self.views["Home"])
- self.stack = QStackedWidget()
- self.setCentralWidget(self.stack)
+ # Navigation history
+ self.history = []
+
+ # =====================================================================
+ # STATUS BAR
+ # =====================================================================
+ sb = QStatusBar(self)
+ sb.showMessage("Ready")
+ self.setStatusBar(sb)
+
+ # =====================================================================
+ # BUILD NAVIGATION CLEAN
+ # =====================================================================
+ def _build_navigation(self):
+ menu = [
+ "Home",
+ "Security",
+ "Sensors",
+ "Sound",
+ "Leaves",
+ "Ground Image",
+ "Ground",
+ "Aerial Image",
+ "Fruits",
+ "Settings",
+ "Notifications",
+ "Auth",
+ ]
+
+ for main_item in menu:
+ item = QListWidgetItem(main_item)
+ item.setData(Qt.ItemDataRole.UserRole, {"type": "main"})
+ self.nav_list.addItem(item)
+
+ if main_item == "Sensors":
+ for sub in ["Live Data", "Sensor Health", "Location Map", "Similar Periods"]:
+ sub_item = QListWidgetItem(f" ↳ {sub}")
+ sub_item.setHidden(True)
+ sub_item.setData(
+ Qt.ItemDataRole.UserRole,
+ {"type": "sub", "parent": main_item, "name": sub}
+ )
+ self.nav_list.addItem(sub_item)
+ elif main_item == "Leaves":
+ for sub in ["Leaves View", "Leaves Dashboard"]:
+ sub_item = QListWidgetItem(f" ↳ {sub}")
+ sub_item.setHidden(True)
+ sub_item.setData(
+ Qt.ItemDataRole.UserRole,
+ {"type": "sub", "parent": main_item, "name": sub}
+ )
+ self.nav_list.addItem(sub_item)
+ elif main_item == "Sound":
+ for sub in ["Environment Sounds", "Plant Ultrasounds", "Ultrasonic Dashboard", "Sound Analytics"]:
+ sub_item = QListWidgetItem(f" ↳ {sub}")
+ sub_item.setData(Qt.ItemDataRole.UserRole, {
+ "type": "sub",
+ "parent": main_item,
+ "name": sub,
+ })
+ sub_item.setHidden(True)
+ self.nav_list.addItem(sub_item)
+
+ elif main_item == "Fruits":
+ for sub in ["Defect Metrics", "Defect Dashboard", "Alert Defect Dashboard","Alert Ripness Dashboard", "Tree View"]:
+ sub_item = QListWidgetItem(f" ↳ {sub}")
+ sub_item.setData(Qt.ItemDataRole.UserRole, {
+ "type": "sub",
+ "parent": main_item,
+ "name": sub,
+ })
+ sub_item.setHidden(True)
+ self.nav_list.addItem(sub_item)
+
+ elif main_item == "Aerial Image":
+ for sub in ["Galery", "Graph"]:
+ sub_item = QListWidgetItem(f" ↳ {sub}")
+ sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub})
+ sub_item.setHidden(True)
+ self.nav_list.addItem(sub_item)
+
+
+ elif main_item == "Ground":
+ for sub in ["Irrigation"]:
+ sub_item = QListWidgetItem(f" ↳ {sub}")
+ sub_item.setData(Qt.ItemDataRole.UserRole, {"type": "sub", "parent": main_item, "name": sub})
+
+ elif main_item == "Security":
+ for sub in ["Live", "History", "Analytics"]:
+ sub_item = QListWidgetItem(f" ↳ {sub}")
+ sub_item.setData(Qt.ItemDataRole.UserRole, {
+ "type": "sub",
+ "parent": main_item,
+ "name": sub,
+ })
+
+ sub_item.setHidden(True)
+ self.nav_list.addItem(sub_item)
+
+
+ # =====================================================================
+ # REGISTER ALL VIEWS
+ # =====================================================================
+ def _register_views(self):
+ self.home = HomeView(self.api, self.alert_service, self)
+ self.sensors_view = SensorsView(self.api, self)
+ self.notification_view = NotificationView(self)
+ self.fruits_view = FruitsView(self.api, self)
+ self.fruits_metrics = FruitDefectMetricsView(self.api, self)
+ self.fruit_defect_grafana_view = FruitDefectGrafanaView(self.api, parent=self)
+ self.fruit_alert_defect = FruitAlertDefectView(self.api, parent=self)
+ self.fruit_alert_ripness = FruitAlertRipenessView(self.api, parent=self)
+ self.tree_overlay_view = TreeOverlayView(self.api, self)
+# self.sound_view = SoundView(self.api, self)
+ self.sound_env_tab = RecordingsTab(recording_type="audio", api=self.api)
+ self.sound_plant_tab = RecordingsTab(recording_type="ultrasound", api=self.api)
+ self.sound_dashboard_tab = SoundGraphic(api=self.api)
+ self.sound_analytics_tab = SoundAnalyticsView(api=self.api)
+ self.ground_view = GroundView(self.api, self)
+ self.auth_status = AuthStatusView(self.api, self)
+ self.leaves_view = LeafView(self.api, self)
+ self.leaves_dashboard =LeafDiseaseView(self.api, self)
+ self.sensors_status_summary = SensorsStatusSummary(self.api, self)
+ self.sensors_health = SensorsView(self.api, self)
+ self.sensors_main = SensorsMainView(self.api, self)
+ self.similar_periods_view = SimilarPeriodsTab(self.api, self)
+ # self.security_view = IncidentPlayerVLC(self.api, self.alert_service, self)
+
+ self.security_live = IncidentPlayerVLC(self.api, self.alert_service, self)
+ self.security_history = EventsHistoryPage(api=self.api, parent=self)
+ self.security_analytics = GeoAnalyticsView(parent=self)
+
+ self.aerial_view = AerialView(self.api,self)
+ self.aerial_galery = AerialView(self.api,self)
+ self.aerial_graph = AerialGraphsView(self.api,self)
+ self.irrigation_view = IrrigationView(self.api, self)
self.views = {
"Home": self.home,
"Sensors": self.sensors_view,
+ "Sensors - Live Data": self.sensors_status_summary,
+ "Sensors - Sensor Health": self.sensors_health,
+ "Sensors - Location Map": self.sensors_main,
+ "Sensors - Similar Periods": self.similar_periods_view,
+# "Sound": self.sound_view,
+ "Sound - Environment Sounds": self.sound_env_tab,
+ "Sound - Plant Ultrasounds": self.sound_plant_tab,
+ "Sound - Ultrasonic Dashboard": self.sound_dashboard_tab,
+ "Sound - Sound Analytics": self.sound_analytics_tab,
"Notifications": self.notification_view,
- "Security": self.security_view,
+ "Leaves - Leaves View": self.leaves_view,
+ "Leaves - Leaves Dashboard": self.leaves_dashboard,
+ "Ground - Irrigation": self.irrigation_view,
"Fruits": self.fruits_view,
-
+ "Fruits - Defect Metrics": self.fruits_metrics,
+ "Fruits - Defect Dashboard": self.fruit_defect_grafana_view,
+ "Fruits - Alert Defect Dashboard": self.fruit_alert_defect,
+ "Fruits - Alert Ripness Dashboard": self.fruit_alert_ripness,
+ "Fruits - Tree View": self.tree_overlay_view,
+ "Ground Image": self.ground_view,
+ "Aerial Image": self.aerial_view,
+ "Aerial Image - Galery": self.aerial_galery,
+ "Aerial Image - Graph": self.aerial_graph,
+ "Auth": self.auth_status,
+ "Security": self.security_live,
+ "Security - Live": self.security_live,
+ "Security - History": self.security_history,
+ "Security - Analytics": self.security_analytics,
+
}
+
+
for view in self.views.values():
self.stack.addWidget(view)
- self.stack.setCurrentWidget(self.home)
- self.history: list = []
- # ───────────────────────────────
- # STATUS BAR
- # ───────────────────────────────
- sb = QStatusBar(self)
- sb.setStyleSheet("""
- QStatusBar {
- background-color: #f3f4f6;
- color: #374151;
- font-size: 10.5pt;
- }
- """)
- self.setStatusBar(sb)
- sb.showMessage("Ready")
+ # =====================================================================
+ # NAVIGATION LOGIC
+ # =====================================================================
- # ───────────────────────────────
- # ALERT BADGE
- # ───────────────────────────────
+ def _on_nav_change(self, row: int) -> None:
+ item = self.nav_list.item(row)
+ if not item:
+ return
+
+ name = item.text().strip()
+ data = item.data(Qt.ItemDataRole.UserRole)
+
+ target_name = name
+
+ if data and data.get("type") == "main" and name == "Aerial Image":
+ target_name = "Aerial Image - Galery"
+
+ if data and data.get("type") == "main" and name == "Sound":
+ target_name = "Sound - Environment Sounds"
+
+ if target_name in self.views:
+ self.navigate_to(self.views[target_name])
+
+
+ # def _on_nav_click(self, item):
+ # data = item.data(Qt.ItemDataRole.UserRole)
+ # if not data:
+ # return
+
+ # # Toggle expand/collapse for main menu
+ # if data.get("type") == "main":
+ # parent = item.text()
+ # expand = any(
+ # self.nav_list.item(i).isHidden()
+ # for i in range(self.nav_list.count())
+ # if self._is_sub_of(i, parent)
+ # )
+
+ # for i in range(self.nav_list.count()):
+ # if self._is_sub_of(i, parent):
+ # self.nav_list.item(i).setHidden(not expand)
+
+ # # Submenu click → navigate
+ # elif data.get("type") == "sub":
+ # key = f"{data['parent']} - {data['name']}"
+ # if key in self.views:
+ # self.stack.setCurrentWidget(self.views[key])
+
+
+ def _on_nav_click(self, item):
+ data = item.data(Qt.ItemDataRole.UserRole)
+
+ if data and data.get("type") == "main":
+ parent = item.text()
+
+ expanded = False
+ for i in range(self.nav_list.count()):
+ sub_item = self.nav_list.item(i)
+ sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
+ if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
+ expanded = sub_item.isHidden()
+ break
+
+
+ for i in range(self.nav_list.count()):
+ sub_item = self.nav_list.item(i)
+ sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
+ if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent:
+ sub_item.setHidden(not expanded)
+
+
+ if expanded and parent == "Aerial Image":
+ for i in range(self.nav_list.count()):
+ sub_item = self.nav_list.item(i)
+ sub_data = sub_item.data(Qt.ItemDataRole.UserRole)
+ if sub_data and sub_data.get("type") == "sub" and sub_data.get("parent") == parent and sub_data.get("name") == "Galery":
+ self.nav_list.setCurrentItem(sub_item)
+ break
+
+
+ elif data and data.get("type") == "sub":
+ parent = data.get("parent")
+ sub_name = data.get("name")
+ key = f"{parent} - {sub_name}"
+ if key in self.views:
+ self.navigate_to(self.views[key])
+ else:
+ self.statusBar().showMessage(f"Sub-section '{key}' not implemented yet.")
+
+ def _is_sub_of(self, index, parent):
+ item = self.nav_list.item(index)
+ data = item.data(Qt.ItemDataRole.UserRole)
+ return data and data.get("type") == "sub" and data.get("parent") == parent
+
+ # =====================================================================
+ # NAVIGATE
+ # =====================================================================
+ def navigate_to(self, widget):
+ current = self.stack.currentWidget()
+ if current not in self.history:
+ self.history.append(current)
+
+ self.stack.setCurrentWidget(widget)
+
+ def go_back(self):
+ if self.history:
+ self.stack.setCurrentWidget(self.history.pop())
+
+ # =====================================================================
+ # ALERT BADGE + PANEL
+ # =====================================================================
def update_alert_badge(self):
- unacked = sum(1 for a in self.alert_service.alerts if not a.get("ack", False))
+ unacked = sum(1 for a in self.alert_service.alerts if not a.get("ack"))
if unacked > 0:
self.alert_badge.setText(str(unacked))
self.alert_badge.show()
@@ -1222,44 +515,24 @@ def toggle_alert_panel(self):
self.alerts_panel.hide()
return
- panel_width, panel_height = 420, 540
- self.alerts_panel.resize(panel_width, panel_height)
+ panel_w, panel_h = 420, 540
+ self.alerts_panel.resize(panel_w, panel_h)
+
rect = self.alert_button.geometry()
- bottom_left = self.alert_button.mapToGlobal(rect.bottomLeft())
- bottom_right = self.alert_button.mapToGlobal(rect.bottomRight())
- center_x = (bottom_left.x() + bottom_right.x()) // 2 - (panel_width // 2)
- pos_y = bottom_left.y() + 8
- self.alerts_panel.move(center_x, pos_y)
+ pos = self.alert_button.mapToGlobal(rect.bottomLeft())
+ self.alerts_panel.move(pos.x() - panel_w // 2 + 20, pos.y() + 8)
+
self.alerts_panel.show()
self.alerts_panel.raise_()
if hasattr(self.alert_service, "mark_all_acknowledged"):
self.alert_service.mark_all_acknowledged()
- self.update_alert_badge()
- # ───────────────────────────────
- # NAVIGATION
- # ───────────────────────────────
- def _on_nav_change(self, row: int) -> None:
- name = self.nav_list.item(row).text().strip()
- if name in self.views:
- self.navigate_to(self.views[name])
- else:
- self.statusBar().showMessage(f"Section '{name}' not implemented yet.")
-
- def navigate_to(self, widget):
- current = self.stack.currentWidget()
- if current not in self.history:
- self.history.append(current)
- self.stack.setCurrentWidget(widget)
-
- def go_back(self):
- if self.history:
- last = self.history.pop()
- self.stack.setCurrentWidget(last)
- else:
- self.statusBar().showMessage("No previous view to go back to.")
+ self.update_alert_badge()
- def _logout(self) -> None:
- self.statusBar().showMessage("Logged out (demo)")
+ # =====================================================================
+ # LOGOUT
+ # =====================================================================
+ def _logout(self):
+ self.statusBar().showMessage("Logged out")
self.logoutRequested.emit()
\ No newline at end of file
diff --git a/GUI/src/vast/orthophoto_canvas/ui/fields.png b/GUI/src/vast/orthophoto_canvas/ui/fields.png
new file mode 100644
index 000000000..a279cc08d
Binary files /dev/null and b/GUI/src/vast/orthophoto_canvas/ui/fields.png differ
diff --git a/GUI/src/vast/orthophoto_canvas/ui/viewer.py b/GUI/src/vast/orthophoto_canvas/ui/viewer.py
index 34f0fc4a2..911080f24 100644
--- a/GUI/src/vast/orthophoto_canvas/ui/viewer.py
+++ b/GUI/src/vast/orthophoto_canvas/ui/viewer.py
@@ -12,7 +12,7 @@
from PyQt6.QtWidgets import (
QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsRectItem
)
-
+from PyQt6.QtGui import QPixmap, QTransform
# ==== Tunables ====
TILE_SIZE = 512
TARGET_TILE_PX_FOR_LOD = 512.0
@@ -125,8 +125,58 @@ def __init__(self, tiles: Union[TileStore, str, Path]) -> None:
# ─────────────────────────────
# Initial tile rendering
# ─────────────────────────────
+ self._custom_bg_item: Optional[QGraphicsPixmapItem] = None
+ self._tiles_visible: bool = True
self.update_tiles()
-
+
+ def _apply_tile_visibility(self):
+ """Apply visibility/opacity preference to existing tile items."""
+ for item in self.tile_items.values():
+ # You can choose to hide or fade; here we just hide/show them.
+ item.setVisible(self._tiles_visible)
+ # If you prefer fading:
+ # item.setOpacity(0.2 if not self._tiles_visible else 1.0)
+
+
+
+ def set_custom_background_image(self, path: str, hide_tiles: bool = False):
+ """
+ Place a single static image as the map background, scaled to the scene extents.
+ It will zoom & pan together with all other items.
+ """
+ pix = QPixmap(path)
+ p = Path(path)
+ print("[OrthophotoViewer] Exists?", p.exists())
+ if pix.isNull():
+ print(f"[OrthophotoViewer] ❌ Failed to load background image: {path}")
+ return
+
+ # Remove previous bg if exists
+ if self._custom_bg_item is not None:
+ self.scene.removeItem(self._custom_bg_item)
+ self._custom_bg_item = None
+
+ scene_rect = self.scene.sceneRect()
+ width = scene_rect.width()
+ height = scene_rect.height()
+
+ item = QGraphicsPixmapItem(pix)
+ item.setZValue(-1000) # behind tiles, regions, sensors
+
+ # Scale to fill the entire scene rect
+ sx = width / pix.width() if pix.width() > 0 else 1.0
+ sy = height / pix.height() if pix.height() > 0 else 1.0
+ item.setTransform(QTransform().scale(sx, sy))
+
+ # Position at the scene rect origin (you use a small margin, so respect that)
+ item.setPos(scene_rect.left(), scene_rect.top())
+
+ self.scene.addItem(item)
+ self._custom_bg_item = item
+
+ if hide_tiles:
+ self._tiles_visible = False
+ self._apply_tile_visibility()
# ─────────────────────────────
# Scene geometry
@@ -228,6 +278,9 @@ def update_tiles(self) -> None:
if key not in want:
self.scene.removeItem(self.tile_items.pop(key))
+ # 🔹 Ensure visibility style is applied to all tiles (including new ones)
+ self._apply_tile_visibility()
+
# ─────────────────────────────
# Tile placement / upgrade
# ─────────────────────────────
diff --git a/GUI/src/vast/runner/Dockerfile b/GUI/src/vast/runner/Dockerfile
index 1307a1d59..48487b1cd 100644
--- a/GUI/src/vast/runner/Dockerfile
+++ b/GUI/src/vast/runner/Dockerfile
@@ -6,7 +6,7 @@ WORKDIR /app
ARG USE_NETFREE=true
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
-COPY certs /app/certs
+#COPY certs /app/certs
# System CA + add NetFree certs
RUN if [ "$USE_NETFREE" = "true" ] && [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \
echo "Configuring NetFree certificates..."; \
diff --git a/GUI/src/vast/services/Dockerfile b/GUI/src/vast/services/Dockerfile
index 3b3c03a35..494f1a073 100644
--- a/GUI/src/vast/services/Dockerfile
+++ b/GUI/src/vast/services/Dockerfile
@@ -3,7 +3,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# # System CA + NetFree
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
-COPY certs/*.crt /usr/local/share/ca-certificates/
+#COPY certs/*.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates || true
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
diff --git a/GUI/src/vast/views/SimilarPeriodsSensors.py b/GUI/src/vast/views/SimilarPeriodsSensors.py
new file mode 100644
index 000000000..e195603af
--- /dev/null
+++ b/GUI/src/vast/views/SimilarPeriodsSensors.py
@@ -0,0 +1,345 @@
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QComboBox,
+ QPushButton, QGridLayout
+)
+from PyQt6.QtWebEngineWidgets import QWebEngineView
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QColor
+import traceback
+
+
+class SimilarPeriodsTab(QWidget):
+ def __init__(self, api, parent=None):
+ super().__init__(parent)
+ self.api = api
+
+ # vector service internal Docker endpoint
+ self.vector_base = "http://vector_service:8000"
+
+ # ======= MAIN LAYOUT (compact) =======
+ main = QVBoxLayout(self)
+ main.setContentsMargins(20, 20, 20, 20)
+ main.setSpacing(12)
+
+ # ================================
+ # HEADER
+ # ================================
+ title = QLabel("🌾 Similar Sensors Search")
+ title.setStyleSheet("""
+ QLabel {
+ font-family: 'Inter';
+ font-size: 26px;
+ font-weight: 800;
+ color: #1a1a1a;
+ margin-bottom: 2px;
+ }
+ """)
+
+ subtitle = QLabel("Find sensors with similar characteristics")
+ subtitle.setStyleSheet("""
+ QLabel {
+ font-family: 'Inter';
+ font-size: 13px;
+ font-weight: 400;
+ color: #6B7280;
+ margin-bottom: 8px;
+ }
+ """)
+
+ header_layout = QVBoxLayout()
+ header_layout.setSpacing(3)
+ header_layout.addWidget(title)
+ header_layout.addWidget(subtitle)
+ main.addLayout(header_layout)
+
+ # ================================
+ # SENSOR SELECTOR
+ # ================================
+ sensor_row = QHBoxLayout()
+ sensor_row.setSpacing(8)
+
+ lbl = QLabel("Sensor:")
+ lbl.setStyleSheet("font-size: 14px; font-weight: 600; color:#374151;")
+
+ self.sensor_dropdown = QComboBox()
+ self.sensor_dropdown.setMinimumWidth(230)
+ self.sensor_dropdown.setStyleSheet("""
+ QComboBox {
+ background: #ffffff;
+ border: 1px solid #d1d5db;
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 13px;
+ font-family: 'Inter';
+ }
+ """)
+
+ self._load_sensor_list()
+
+ sensor_row.addWidget(lbl)
+ sensor_row.addWidget(self.sensor_dropdown)
+ sensor_row.addStretch()
+ main.addLayout(sensor_row)
+
+ # ================================
+ # COMPACT FILTER CARDS (GREEN)
+ # ================================
+ cards_row = QHBoxLayout()
+ cards_row.setSpacing(8)
+
+ self.card_same_status = self._create_filter_card("Same Status", "●")
+ self.card_same_type = self._create_filter_card("Same Type", "●")
+ self.card_same_day = self._create_filter_card("Same Install Day", "●")
+
+ cards_row.addWidget(self.card_same_status)
+ cards_row.addWidget(self.card_same_type)
+ cards_row.addWidget(self.card_same_day)
+ main.addLayout(cards_row)
+
+ # ================================
+ # DATE FILTER (Compact card)
+ # ================================
+ time_box = QFrame()
+ time_box.setStyleSheet("""
+ QFrame {
+ background: #ffffff;
+ border-radius: 10px;
+ border: 1px solid #e5e7eb;
+ padding: 10px;
+ }
+ """)
+
+ time_layout = QVBoxLayout(time_box)
+ time_layout.setSpacing(4)
+
+ time_label = QLabel("Date Filter:")
+ time_label.setStyleSheet("font-size: 14px; font-weight:600; color:#374151;")
+
+ self.date_dropdown = QComboBox()
+ self.date_dropdown.setStyleSheet("""
+ QComboBox {
+ background: white;
+ border: 1px solid #d1d5db;
+ padding: 6px 10px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-family: 'Inter';
+ }
+ """)
+
+ self.date_dropdown.addItem("— None —", None)
+ self.date_dropdown.addItem("Today", "today")
+ self.date_dropdown.addItem("Yesterday", "yesterday")
+
+ weekdays = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
+ for w in weekdays:
+ self.date_dropdown.addItem(w, w.lower())
+ for w in weekdays:
+ self.date_dropdown.addItem("Last " + w, "last_" + w.lower())
+
+ time_layout.addWidget(time_label)
+ time_layout.addWidget(self.date_dropdown)
+
+ main.addWidget(time_box)
+
+ # ================================
+ # SEARCH BUTTON (compact)
+ # ================================
+ self.btn = QPushButton("🔍 Search")
+ self.btn.setFixedHeight(40)
+ self.btn.setStyleSheet("""
+ QPushButton {
+ background: #3B82F6;
+ color: white;
+ font-size: 15px;
+ font-weight: 600;
+ border-radius: 10px;
+ font-family: 'Inter';
+ }
+ QPushButton:hover { background:#2563EB; }
+ """)
+ self.btn.clicked.connect(self._on_search)
+ main.addWidget(self.btn)
+
+ # ================================
+ # RESULTS VIEW — FULL HEIGHT
+ # ================================
+ self.web = QWebEngineView()
+ self.web.setMinimumHeight(350) # pushes table to full size
+ main.addWidget(self.web)
+
+ self._placeholder()
+
+ # Background like dashboard
+ self.setStyleSheet("""
+ QWidget {
+ background: qlineargradient(x1:0,y1:0,x2:0,y2:1,
+ stop:0 #F8FAFC, stop:1 #F1F5F9);
+ }
+ """)
+
+ # ========= FILTER CARD (compact) =========
+ def _create_filter_card(self, title, icon):
+ card = QFrame()
+ card.setProperty("active", False)
+
+ card.setStyleSheet("""
+ QFrame {
+ background: #F0FDF4;
+ border-radius: 12px;
+ border: 1px solid #D1FAE5;
+ }
+ QFrame[active="true"] {
+ border: 2px solid #10B981;
+ }
+ """)
+
+ layout = QHBoxLayout(card)
+ layout.setContentsMargins(12, 8, 12, 8)
+ layout.setSpacing(8)
+
+ icon_label = QLabel(icon)
+ icon_label.setStyleSheet("""
+ QLabel {
+ font-size: 22px;
+ color: #10B981;
+ font-weight: 900;
+ }
+ """)
+
+ text_label = QLabel(title)
+ text_label.setStyleSheet("""
+ QLabel {
+ font-size: 13px;
+ font-weight: 600;
+ color: #374151;
+ font-family:'Inter';
+ }
+ """)
+
+ layout.addWidget(icon_label)
+ layout.addWidget(text_label)
+
+ card.mousePressEvent = lambda e: self._toggle_card(card)
+ return card
+
+ def _toggle_card(self, card):
+ state = not card.property("active")
+ card.setProperty("active", state)
+ card.style().unpolish(card)
+ card.style().polish(card)
+ card.update()
+
+ # ========= PLACEHOLDER =========
+ def _placeholder(self):
+ self.web.setHtml("""
+
+ Select filters and search for similar sensors.
+
+ """)
+
+ # ========= LOAD SENSOR LIST =========
+ def _load_sensor_list(self):
+ try:
+ r = self.api.http.get(f"{self.api.base}/api/tables/sensors")
+ sensors = r.json().get("rows", [])
+ self.sensor_dropdown.addItem("-- Select Sensor --", None)
+
+ for s in sensors:
+ sid = s.get("sensor_id")
+ name = s.get("sensor_name", "")
+ self.sensor_dropdown.addItem(f"{sid} – {name}", sid)
+
+ except Exception as e:
+ print("Failed loading sensors:", e)
+
+ # ========= BUILD PARAMS =========
+ def _build_query_params(self):
+ params = {}
+ if self.card_same_status.property("active"):
+ params["same_status"] = "true"
+ if self.card_same_type.property("active"):
+ params["same_type"] = "true"
+ if self.card_same_day.property("active"):
+ params["same_day"] = "true"
+
+ selected = self.date_dropdown.currentData()
+ if selected:
+ params["date_filter"] = selected
+
+ return params
+
+ # ========= SEARCH =========
+ def _on_search(self):
+ sid = self.sensor_dropdown.currentData()
+ if not sid:
+ self.web.setHtml("Select a sensor.
")
+ return
+
+ params = self._build_query_params()
+
+ if not params:
+ url = f"{self.vector_base}/similar_sensors/{sid}"
+ else:
+ q = "&".join([f"{k}={v}" for k, v in params.items()])
+ url = f"{self.vector_base}/similar_sensors_advanced?sensor_id={sid}&{q}"
+
+ try:
+ data = self.api.http.get(url).json()
+ self._render_results(data)
+ except Exception as e:
+ traceback.print_exc()
+ self.web.setHtml(f"Error: {e}
")
+
+ # ========= RENDER RESULTS (full-size table) =========
+ def _render_results(self, data):
+ sims = data.get("similar_sensors", [])
+
+ if not sims:
+ self.web.setHtml("No results found.
")
+ return
+
+ rows = ""
+ for s in sims:
+ status = s.get("status", "").lower()
+ active = (status == "active")
+ status_display = (
+ "● ONLINE"
+ if active else
+ "● OFFLINE"
+ )
+
+ rows += f"""
+
+ | {s.get('sensor_id','—')} |
+ {s.get('sensor_name','—')} |
+ {s.get('sensor_type','—')} |
+ {s.get('install_date','—')} |
+ {status_display} |
+ {round(s.get('distance',0),3)} |
+
+ """
+
+ html = f"""
+
+
+
+
+
+ | ID |
+ Name |
+ Type |
+ Install Date |
+ Active |
+ Distance |
+
+
+
+ {rows}
+
+
+
+
+ """
+
+ self.web.setHtml(html)
diff --git a/GUI/src/vast/views/aerial_img_galery.py b/GUI/src/vast/views/aerial_img_galery.py
new file mode 100644
index 000000000..865d692b4
--- /dev/null
+++ b/GUI/src/vast/views/aerial_img_galery.py
@@ -0,0 +1,478 @@
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QScrollArea, QComboBox, QFrame, QGridLayout,
+ QSpacerItem, QSizePolicy, QToolTip
+)
+from PyQt6.QtGui import QPixmap
+from PyQt6.QtCore import Qt, QPoint, QPointF, QSize, QDateTime
+from datetime import datetime
+
+
+from PyQt6.QtWebSockets import QWebSocket
+from PyQt6.QtCore import QUrl
+import json
+
+class FieldsGridView(QWidget):
+ def __init__(self, api, open_field_callback, parent=None):
+ super().__init__(parent)
+ self.api = api
+ self.open_field_callback = open_field_callback
+ self.all_fields_data = []
+ self.anomalies_map = {}
+
+ self.setStyleSheet("""
+ QWidget {
+ background-color: #F8F9FA;
+ font-family: 'Segoe UI', 'Heebo', sans-serif;
+ color: #343A40;
+ }
+ QFrame#card {
+ background-color: #FFFFFF;
+ border: 1px solid #E0E0E0;
+ border-radius: 20px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+ transition: transform 0.2s, box-shadow 0.2s;
+ }
+ QFrame#card:hover {
+ border: 2px solid #00897B;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
+ }
+ QPushButton {
+ background-color: #00897B;
+ color: white;
+ font-weight: 600;
+ font-size: 15px;
+ padding: 10px 18px;
+ border-radius: 12px;
+ border: none;
+ min-width: 120px;
+ }
+ QPushButton:hover { background-color: #00695C; }
+ QPushButton:pressed { background-color: #004D40; }
+
+ QLabel { padding: 0; }
+ """)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self.header_layout = self._create_header_and_tools()
+ layout.addLayout(self.header_layout)
+
+ layout.addWidget(self._create_separator())
+
+ self.scroll = QScrollArea()
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.scroll.setStyleSheet("QScrollArea { border: none; }")
+ layout.addWidget(self.scroll)
+
+ self.container = QWidget()
+ self.grid = QGridLayout(self.container)
+ self.grid.setSpacing(40)
+ self.grid.setContentsMargins(40, 30, 40, 40)
+
+ self.scroll.setWidget(self.container)
+
+ self.load_grid_images()
+
+ self.ws = QWebSocket()
+ self.ws.textMessageReceived.connect(self._on_ws_message)
+ self.ws.connected.connect(lambda: print("WS CONNECTED"))
+ self.ws.disconnected.connect(lambda: print("WS CLOSED"))
+ self.ws.errorOccurred.connect(lambda e: print("WS ERROR:", e))
+ self.ws.open(QUrl("ws://host.docker.internal:8001/ws/aerial-updates"))
+
+ def _on_ws_message(self, message):
+ try:
+ data = json.loads(message)
+ if data.get("type") == "new_image_metadata":
+ print(f"[AerialImagesView] 🛰️ New image added: {data['img_key']}")
+ self.load_grid_images()
+ except Exception as e:
+ print("[AerialImagesView] WebSocket error:", e)
+
+
+ def _create_separator(self):
+ sep = QFrame()
+ sep.setFrameShape(QFrame.Shape.HLine)
+ sep.setFrameShadow(QFrame.Shadow.Sunken)
+ sep.setFixedHeight(1)
+ sep.setStyleSheet("QFrame { background-color: #E0E0E0; border: none; margin: 0 0px; }")
+ return sep
+
+
+ def _create_header_and_tools(self):
+ h_layout = QHBoxLayout()
+ h_layout.setContentsMargins(30, 20, 30, 10)
+
+ header_label = QLabel("🌱 Fields Maps")
+ header_label.setStyleSheet("""
+ QLabel {
+ font-size: 28px;
+ font-weight: 700;
+ color: #00897B;
+ }
+ """)
+ h_layout.addWidget(header_label)
+
+ h_layout.addStretch(1)
+
+ sort_label = QLabel("Sort by:")
+ sort_label.setStyleSheet("QLabel { font-size: 16px; font-weight: 500; color: #343A40; }")
+ h_layout.addWidget(sort_label)
+
+ self.sort_combo = QComboBox()
+ self.sort_combo.addItems([
+ "Last Update (New > Old)",
+ "Anomaly Count (High > Low)",
+ "Field Name (A-Z)"
+ ])
+ self.sort_combo.setMinimumWidth(200)
+ self.sort_combo.setStyleSheet("""
+ QComboBox {
+ border: 1px solid #CED4DA;
+ border-radius: 8px;
+ padding: 5px 10px;
+ font-size: 15px;
+ background-color: white;
+ }
+ QComboBox::drop-down {
+ border: none;
+ }
+ """)
+
+ self.sort_combo.currentIndexChanged.connect(self.sort_and_display_grid)
+
+ h_layout.addWidget(self.sort_combo)
+
+ return h_layout
+
+
+ def sort_and_display_grid(self):
+ sort_option = self.sort_combo.currentText()
+
+ if "Last Update" in sort_option:
+ key_func = lambda item: self.safe_parse_datetime(item.get("timestamp", ""))
+ reverse_sort = True
+
+ elif "Anomaly Count" in sort_option:
+ key_func = lambda item: item.get("anomaly_count", 0)
+ reverse_sort = True
+
+ elif "Field Name" in sort_option:
+ key_func = lambda item: item.get("field", "")
+ reverse_sort = False
+
+ else:
+ return
+
+ try:
+ self.all_fields_data.sort(key=key_func, reverse=reverse_sort)
+ self._render_grid(self.all_fields_data)
+
+ except Exception as e:
+ print("Sorting error:", e)
+
+ def load_grid_images(self):
+ try:
+ fields = self.api.get_all_fields_images()
+ self.anomalies_map = self.api.get_anomalies_map()
+ print("Anomalies Map:", self.anomalies_map)
+
+ for item in fields:
+ gis_value = item.get("key")
+ if not gis_value:
+ gis_value = item.get("gis")
+ print("ITEM KEYS:", item.get("key"), item.get("field"))
+
+ item["anomaly_count"] = self.anomalies_map.get(gis_value, 0)
+ item["gis"] = gis_value if gis_value else item.get("gis", "Unknown")
+
+ self.all_fields_data = fields
+
+ except Exception as e:
+ print("API Error:", e)
+ self.all_fields_data = []
+
+ self.sort_and_display_grid()
+
+
+ def _render_grid(self, fields_to_display):
+ for i in reversed(range(self.grid.count())):
+ item = self.grid.itemAt(i)
+ if item:
+ w = item.widget()
+ if w:
+ w.deleteLater()
+ elif item.spacerItem():
+ self.grid.removeItem(item)
+
+ cols = 3
+ row = 0
+ col = 0
+
+ for item in fields_to_display:
+ card = self.build_card(item)
+ self.grid.addWidget(card, row, col)
+
+ col += 1
+ if col >= cols:
+ col = 0
+ row += 1
+
+ if self.grid.count() > 0:
+ spacer_h = QSpacerItem(20, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
+ self.grid.addItem(spacer_h, 0, cols)
+
+ spacer_v = QSpacerItem(0, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
+ self.grid.addItem(spacer_v, row, 0)
+
+ def safe_parse_datetime(self, ts: str):
+ if not ts:
+ return datetime.min
+
+ ts = ts.strip()
+
+ # נסיון לתקן ISO עם T → רווח
+ if "T" in ts:
+ ts = ts.replace("T", " ")
+
+ formats = [
+ "%Y-%m-%d %H:%M:%S.%f",
+ "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%d %H:%M",
+ "%d/%m/%y %H:%M",
+ "%Y-%m-%d",
+ "%d/%m/%y"
+ ]
+
+ for fmt in formats:
+ try:
+ return datetime.strptime(ts, fmt)
+ except:
+ pass
+
+ # fallback אחרון
+ try:
+ return datetime.fromisoformat(ts)
+ except:
+ return datetime.min
+
+
+
+ def parse_timestamp(self, ts: str):
+ if not ts or ts == "---":
+ return "", ""
+
+ ts = ts.strip()
+
+ # להחליף T → רווח (כמו ב-2025-11-25T09:11:30)
+ ts = ts.replace("T", " ")
+
+ # רשימת פורמטים אפשריים שמגיעים מה-API שלך
+ formats = [
+ "%Y-%m-%d %H:%M:%S%z", # עם timezone בסוף
+ "%Y-%m-%d %H:%M:%S", # עם שניות
+ "%Y-%m-%d %H:%M", # בלי שניות
+ "%Y-%m-%d", # רק תאריך
+ ]
+
+ for fmt in formats:
+ try:
+ dt = datetime.strptime(ts, fmt)
+
+ # הפורמט שרצית:
+ # 25/11/2025 09:11
+ pretty = dt.strftime("%d/%m/%Y %H:%M")
+
+ return pretty, "" # מחזירים שדה אחד בלבד להצגה
+ except:
+ pass
+
+ # fallback — אם לא הצלחנו לפענח
+ return ts, ""
+
+
+ def build_card(self, data):
+ gis_value = data.get("gis", "Unknown")
+ timestamp = data.get("timestamp", "---")
+ img_bytes = data.get("image_bytes", b"")
+ name = data.get("field", "Unknown")
+ anomaly_count = data.get("anomaly_count", 0)
+
+ pretty_timestamp, _ = self.parse_timestamp(timestamp)
+
+
+ if len(name) > 15:
+ short_gis_value = f"{name[22:27]}"
+ else:
+ short_gis_value = name
+
+ card = QFrame()
+ card.setObjectName("card")
+ # card.setCursor(Qt.CursorShape.PointingHandCursor)
+
+ CARD_WIDTH = 400
+ CARD_HEIGHT = 520
+ card.setFixedSize(CARD_WIDTH, CARD_HEIGHT)
+ img_key = data.get("key")
+ card.mousePressEvent = lambda e: (
+ print("[Metadata] Card clicked → img_key:", img_key),
+ self.open_field_callback(img_key)
+ )
+ layout = QVBoxLayout(card)
+ layout.setSpacing(10)
+ layout.setContentsMargins(15, 15, 15, 15)
+
+ IMG_WIDTH = CARD_WIDTH - 30
+ IMG_HEIGHT = 220
+
+ pixmap = QPixmap()
+ pixmap.loadFromData(img_bytes)
+
+ thumb = pixmap.scaled(
+ IMG_WIDTH,
+ IMG_HEIGHT,
+ Qt.AspectRatioMode.KeepAspectRatioByExpanding,
+ Qt.TransformationMode.SmoothTransformation
+ )
+
+ img_frame = QFrame()
+ img_frame.setFixedSize(IMG_WIDTH, IMG_HEIGHT)
+ img_frame.setStyleSheet("""
+ QFrame {
+ border-radius: 15px;
+ background-color: #E9ECEF;
+ }
+ """)
+
+ img_layout = QVBoxLayout(img_frame)
+ img_layout.setContentsMargins(0, 0, 0, 0)
+
+ img_label = QLabel()
+ img_label.setPixmap(thumb)
+ img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ img_layout.addWidget(img_label)
+ layout.addWidget(img_frame)
+
+ layout.addSpacing(15)
+
+ field_name_label = QLabel(f"Field Name: {short_gis_value}")
+ field_name_label.setToolTip(gis_value)
+ field_name_label.setWordWrap(True)
+ field_name_label.setStyleSheet("""
+ QLabel {
+ font-size: 17px;
+ font-weight: 600;
+ color: #2C3E50;
+ }
+ """)
+ layout.addWidget(field_name_label, alignment=Qt.AlignmentFlag.AlignLeft)
+
+ date_time_container = QFrame()
+ date_time_layout = QVBoxLayout(date_time_container)
+ date_time_layout.setSpacing(6)
+ date_time_layout.setContentsMargins(0, 5, 0, 5)
+
+ if pretty_timestamp:
+ date_label = QLabel(f"📅 Date: {pretty_timestamp}")
+ date_label.setStyleSheet("""
+ QLabel {
+ font-size: 16px;
+ color: #2C3E50;
+ font-weight: 600;
+ }
+ """)
+ date_time_layout.addWidget(date_label, alignment=Qt.AlignmentFlag.AlignLeft)
+
+ layout.addWidget(date_time_container)
+
+ anomaly_container = QFrame()
+ anomaly_layout = QVBoxLayout(anomaly_container)
+ anomaly_layout.setContentsMargins(0, 8, 0, 8)
+ anomaly_layout.setSpacing(5)
+
+ anomaly_text_label = QLabel("Anomalies:")
+ anomaly_text_label.setStyleSheet("""
+ QLabel {
+ font-size: 15px;
+ color: #343A40;
+ font-weight: 600;
+ }
+ """)
+ anomaly_layout.addWidget(anomaly_text_label, alignment=Qt.AlignmentFlag.AlignLeft)
+
+ # Create horizontal bar graph
+ bar_container = QFrame()
+ bar_layout = QHBoxLayout(bar_container)
+ bar_layout.setContentsMargins(0, 0, 0, 0)
+ bar_layout.setSpacing(0)
+
+ # Determine color based on anomaly count
+ if isinstance(anomaly_count, int):
+ if anomaly_count == 0:
+ bar_color = "#28A745" # Green
+ bar_width_percent = 10
+ elif anomaly_count <= 2:
+ bar_color = "#FFC107" # Orange
+ bar_width_percent = min(anomaly_count * 20, 60)
+ else:
+ bar_color = "#DC3545" # Red
+ bar_width_percent = min(anomaly_count * 15, 100)
+ else:
+ bar_color = "#28A745"
+ bar_width_percent = 10
+
+ # Create the filled bar
+ filled_bar = QFrame()
+ filled_bar.setFixedHeight(20)
+ filled_bar.setStyleSheet(f"""
+ QFrame {{
+ background-color: {bar_color};
+ border-radius: 10px;
+ }}
+ """)
+
+ # Create the empty bar background
+ empty_bar = QFrame()
+ empty_bar.setFixedHeight(20)
+ empty_bar.setStyleSheet("""
+ QFrame {
+ background-color: #E9ECEF;
+ border-radius: 10px;
+ }
+ """)
+
+ # Add bars with proper ratio
+ bar_layout.addWidget(filled_bar, bar_width_percent)
+ bar_layout.addWidget(empty_bar, 100 - bar_width_percent)
+
+ anomaly_layout.addWidget(bar_container)
+
+ count_label = QLabel(f"{anomaly_count} Anomalies Detected")
+ count_label.setStyleSheet("""
+ QLabel {
+ font-size: 13px;
+ color: #6C757D;
+ }
+ """)
+ anomaly_layout.addWidget(count_label, alignment=Qt.AlignmentFlag.AlignLeft)
+
+ layout.addWidget(anomaly_container)
+
+ layout.addSpacing(10)
+
+ separator = QFrame()
+ separator.setFrameShape(QFrame.Shape.HLine)
+ separator.setStyleSheet("QFrame { background-color: #E0E0E0; border: none; height: 1px; }")
+ layout.addWidget(separator)
+
+ layout.addSpacing(8)
+ open_button = QPushButton("Open Field")
+ open_button.clicked.connect(lambda: self.open_field_callback(gis_value))
+ layout.addWidget(open_button, alignment=Qt.AlignmentFlag.AlignCenter)
+
+ layout.addStretch(1)
+
+ return card
\ No newline at end of file
diff --git a/GUI/src/vast/views/aerial_main_view.py b/GUI/src/vast/views/aerial_main_view.py
new file mode 100644
index 000000000..1a4c965df
--- /dev/null
+++ b/GUI/src/vast/views/aerial_main_view.py
@@ -0,0 +1,73 @@
+from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QStackedWidget
+from PyQt6.QtCore import Qt
+from dashboard_api import DashboardApi
+
+from views.aerial_img_galery import FieldsGridView
+from views.aerial_view import AerialImagesView
+
+
+class AerialView(QWidget):
+ def __init__(self, api: DashboardApi, parent=None):
+ super().__init__(parent)
+ self.api = api
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # --- גלריה ---
+ self.gallery_view = FieldsGridView(
+ api=self.api,
+ open_field_callback=self.open_field_page,
+ )
+ layout.addWidget(self.gallery_view)
+
+ # --- OVERLAY שחור/שקוף ---
+ self.overlay = QWidget(self)
+ self.overlay.setGeometry(self.rect())
+ self.overlay.setStyleSheet("""
+ background-color: rgba(0, 0, 0, 150);
+ """)
+ self.overlay.hide()
+
+ # --- תצוגת תמונה ---
+ self.image_view = AerialImagesView(
+ api=self.api,
+ parent=self,
+ on_close=self.return_to_gallery
+ )
+
+ self.image_view.hide()
+
+ # תמיד לוודא שהם מכסים הכל
+ self.overlay.raise_()
+ self.image_view.raise_()
+
+ def resizeEvent(self, event):
+ """הגדלה/הקטנה דינמית של overlay ושל image_view"""
+ super().resizeEvent(event)
+ self.overlay.setGeometry(self.rect())
+ self.image_view.setGeometry(self.rect())
+
+ def open_field_page(self, img_key):
+ """פתיחת תמונה — הצגת overlay + תצוגת תמונה"""
+ print("[AerialView] open_field_page received img_key:", img_key)
+
+ # מאחורי התמונה יהיה overlay כהה
+ self.overlay.show()
+ self.overlay.raise_()
+
+ # התמונה למעלה
+ self.image_view.setGeometry(self.rect())
+ self.image_view.show()
+ self.image_view.raise_()
+
+ # טען תמונות
+ self.image_view.load_latest_images()
+ self.image_view.focus_on_image_by_key(img_key)
+
+ def return_to_gallery(self):
+ """סגירת התמונה והסרת ה־overlay"""
+ print("close")
+ self.overlay.hide()
+ self.image_view.hide()
\ No newline at end of file
diff --git a/GUI/src/vast/views/aerial_view.py b/GUI/src/vast/views/aerial_view.py
new file mode 100644
index 000000000..f0f613d59
--- /dev/null
+++ b/GUI/src/vast/views/aerial_view.py
@@ -0,0 +1,1092 @@
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QScrollArea, QComboBox, QListWidget, QListWidgetItem, QFrame, QGraphicsDropShadowEffect
+)
+from PyQt6.QtGui import (
+ QPixmap, QImage, QPainter, QColor, QPen, QWheelEvent, QPolygonF, QBrush, QIcon, QFont
+)
+from PyQt6.QtCore import Qt, QPoint, QPointF
+from PIL import Image
+import io, random, re
+from shapely import wkb
+
+import json
+from shapely.geometry import Polygon
+
+PALETTE = {
+ (0, 0, 0): (0, "Other"),
+ (210, 180, 140): (1, "Bareland"),
+ (152, 251, 152): (2, "Rangeland"),
+ (128, 128, 128): (3, "Developed space"),
+ (255, 255, 255): (4, "Road"),
+ (0, 100, 0): (5, "Tree"),
+ (30, 144, 255): (6, "Water"),
+ (255, 215, 0): (7, "Agriculture"),
+ (178, 34, 34): (8, "Building"),
+}
+
+
+NAME_TO_DBKEY_MAP = {
+ "Other": "other",
+ "Bareland": "bareland",
+ "Rangeland": "rangeland",
+ "Developed space": "developed_space",
+ "Road": "road",
+ "Tree": "tree",
+ "Water": "water",
+ "Agriculture": "agriculture",
+ "Building": "building",
+}
+
+class ZoomableImageLabel(QLabel):
+ def __init__(self):
+ super().__init__()
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._pixmap = None
+ self._scale = 1.0
+ self._start_pos = None
+ self._offset = QPoint(0, 0)
+ self.drawing_mode = False
+ self.drawing_points = []
+
+ def setPixmap(self, pixmap: QPixmap):
+ self._pixmap = pixmap
+ self._scale = 1.0
+ self._offset = QPoint(0, 0)
+ super().setPixmap(pixmap)
+
+ def wheelEvent(self, event: QWheelEvent):
+ if self._pixmap is None or self.drawing_mode:
+ return
+ factor = 1.25 if event.angleDelta().y() > 0 else 0.8
+ self._scale = max(0.2, min(5.0, self._scale * factor))
+ self.update_display()
+
+ def mousePressEvent(self, event):
+ if self.drawing_mode:
+ if event.button() == Qt.MouseButton.LeftButton:
+ pos = event.pos()
+ self.drawing_points.append((pos.x(), pos.y()))
+ self.update()
+ elif event.button() == Qt.MouseButton.RightButton:
+ self.finish_polygon()
+ return
+ if event.button() == Qt.MouseButton.LeftButton:
+ self._start_pos = event.pos()
+
+ def mouseMoveEvent(self, event):
+ if self.drawing_mode:
+ return
+ if event.buttons() & Qt.MouseButton.LeftButton and self._pixmap:
+ delta = event.pos() - self._start_pos
+ self._offset += delta
+ self._start_pos = event.pos()
+ self.update_display()
+
+ def update_display(self):
+ if not self._pixmap:
+ return
+ size = self.size()
+ scaled_pixmap = self._pixmap.scaled(
+ int(self._pixmap.width() * self._scale),
+ int(self._pixmap.height() * self._scale),
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+ canvas = QPixmap(size)
+ canvas.fill(Qt.GlobalColor.white)
+ painter = QPainter(canvas)
+ x = (size.width() - scaled_pixmap.width()) // 2 + self._offset.x()
+ y = (size.height() - scaled_pixmap.height()) // 2 + self._offset.y()
+ painter.drawPixmap(x, y, scaled_pixmap)
+ painter.end()
+ super().setPixmap(canvas)
+
+ def toggle_drawing_mode(self, enabled: bool):
+ self.drawing_mode = enabled
+ self.drawing_points = []
+ self.update()
+
+ def paintEvent(self, event):
+ super().paintEvent(event)
+ if not self.drawing_mode or not self.drawing_points:
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ pen = QPen(QColor(255, 0, 0), 3)
+ painter.setPen(pen)
+ for x, y in self.drawing_points:
+ painter.drawEllipse(QPoint(x, y), 4, 4)
+ for i in range(len(self.drawing_points) - 1):
+ p1 = QPoint(*self.drawing_points[i])
+ p2 = QPoint(*self.drawing_points[i + 1])
+ painter.drawLine(p1, p2)
+ painter.end()
+
+ def finish_polygon(self):
+ if not self.drawing_mode:
+ return None
+ pts = self.drawing_points.copy()
+ self.drawing_points.clear()
+ self.drawing_mode = False
+ self.update()
+ return pts
+
+ def get_scale(self):
+ return self._scale
+
+class AerialImagesView(QWidget):
+ def __init__(self, api, parent=None, on_close=None):
+ super().__init__(parent)
+ self.api = api
+ self.images = []
+ self.current_index = 0
+ self.current_image = None
+ self.color_map = {}
+ self.mode = None
+ self.on_close = on_close
+
+ self.setStyleSheet("""
+ QWidget {
+ background-color: rgba(20, 20, 25, 0.75);
+ font-family: 'Segoe UI', Arial, sans-serif;
+ }
+ """)
+
+ outer_layout = QVBoxLayout(self)
+ outer_layout.setContentsMargins(30, 30, 30, 30)
+ outer_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ modal_card = QFrame()
+ modal_card.setFixedSize(1300, 750)
+ modal_card.setStyleSheet("""
+ QFrame {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
+ stop:0 rgba(255, 255, 255, 0.98),
+ stop:1 rgba(248, 252, 250, 0.98));
+ border-radius: 25px;
+
+ }
+ """)
+
+ shadow = QGraphicsDropShadowEffect()
+ shadow.setBlurRadius(80)
+ shadow.setXOffset(0)
+ shadow.setYOffset(15)
+ shadow.setColor(QColor(31, 138, 112, 80))
+ modal_card.setGraphicsEffect(shadow)
+
+ card_layout = QVBoxLayout(modal_card)
+ card_layout.setContentsMargins(0, 0, 0, 0)
+ card_layout.setSpacing(0)
+
+ header = QFrame()
+ header.setFixedHeight(60)
+ header.setStyleSheet("""
+ QFrame {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
+ stop:0 #1F8A70, stop:0.3 #1FA87D, stop:0.7 #22B08A, stop:1 #26D0A8);
+ border-top-left-radius: 25px;
+ border-top-right-radius: 25px;
+ border-bottom-left-radius: 0px;
+ border-bottom-right-radius: 0px;
+ }
+ """)
+ header_layout = QHBoxLayout(header)
+ header_layout.setContentsMargins(25, 0, 25, 0)
+
+ close_btn = QPushButton("✕")
+ close_btn.setFixedSize(38, 38)
+ close_btn.setStyleSheet("""
+ QPushButton {
+ background-color: rgba(255, 255, 255, 0.25);
+ color: white;
+ border-radius: 19px;
+ font-size: 20px;
+ font-weight: bold;
+ border: 2px solid rgba(255, 255, 255, 0.4);
+ }
+ QPushButton:hover {
+ background-color: rgba(255, 90, 90, 0.85);
+ border: 2px solid rgba(255, 255, 255, 0.6);
+ }
+ QPushButton:pressed {
+ background-color: rgba(220, 50, 50, 0.95);
+ }
+ """)
+ close_btn.clicked.connect(lambda: self.on_close() if self.on_close else None)
+
+ title_label = QLabel("📷 Aerial Image Viewer")
+ title_label.setStyleSheet("""
+ QLabel {
+ color: white;
+ font-size: 20px;
+ font-weight: 700;
+ background: transparent;
+ letter-spacing: 0.8px;
+ text-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
+ }
+ """)
+
+ header_layout.addWidget(close_btn)
+ header_layout.addWidget(title_label)
+ header_layout.addStretch()
+
+ card_layout.addWidget(header)
+
+ content = QFrame()
+ content.setStyleSheet("""
+ QFrame {
+ background-color: #FAFBFC;
+ border-top-left-radius: 0px;
+ border-top-right-radius: 0px;
+ border-bottom-left-radius: 25px;
+ border-bottom-right-radius: 25px;
+ }
+ """)
+ content_layout = QHBoxLayout(content)
+ content_layout.setContentsMargins(15, 20, 15, 20)
+ content_layout.setSpacing(15)
+
+ sidebar = QFrame()
+ sidebar.setFixedWidth(260)
+ sidebar.setStyleSheet("""
+ QFrame {
+ background-color: rgba(255, 255, 255, 0.95);
+ border-radius: 16px;
+ border: 2px solid rgba(31, 138, 112, 0.2);
+ }
+ """)
+ sidebar_layout = QVBoxLayout(sidebar)
+ sidebar_layout.setContentsMargins(16, 16, 16, 16)
+ sidebar_layout.setSpacing(14)
+
+ widget_style = """
+ QPushButton {
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+ stop:0 #1F8A70, stop:1 #17997B);
+ color: white;
+ font-weight: 600;
+ font-size: 13px;
+ padding: 12px 16px;
+ border-radius: 10px;
+ border: none;
+ }
+ QPushButton:hover {
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
+ stop:0 #17997B, stop:1 #117C66);
+ }
+ QPushButton:pressed {
+ background: #117C66;
+ }
+ QComboBox {
+ background-color: #F8FAFB;
+ border-radius: 10px;
+ padding: 10px 12px;
+ font-size: 13px;
+ font-weight: 500;
+ color: #2C3E50;
+ }
+ QComboBox:hover {
+ border: 2px solid #1F8A70;
+ }
+ QComboBox::drop-down {
+ border: none;
+ padding-right: 8px;
+ }
+ QListWidget {
+ background-color: #F8FAFB;
+ border: 2px solid #E5E9EB;
+ border-radius: 12px;
+ padding: 10px;
+ font-size: 12px;
+ }
+ QListWidget::item {
+ padding: 8px;
+ border-radius: 7px;
+ margin: 2px 0px;
+ }
+ QListWidget::item:hover {
+ background-color: rgba(31, 138, 112, 0.1);
+ }
+
+ QListWidget QScrollBar:vertical {
+ background: rgba(240, 240, 240, 0.7); /* רקע בהיר */
+ border-radius: 4px;
+ width: 8px; /* עובי דק */
+ margin: 2px;
+ }
+
+ QListWidget QScrollBar:horizontal {
+ height: 0px; /* הסתרה מוחלטת */
+ width: 0px;
+ }
+
+ QListWidget QScrollBar::handle:vertical {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
+ stop:0 rgba(31, 138, 112, 0.7),
+ stop:1 rgba(38, 208, 168, 0.7));
+ border: none; /* הסרת מסגרת שחורה */
+ border-radius: 4px;
+ min-height: 30px;
+ }
+
+ QListWidget QScrollBar::handle:hover {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
+ stop:0 #1F8A70,
+ stop:1 #26D0A8);
+ border: none;
+ }
+
+ QListWidget QScrollBar::add-line, QListWidget QScrollBar::sub-line {
+ height: 0px;
+ width: 0px;
+ }
+
+ QLabel {
+ color: #2C3E50;
+ font-weight: 600;
+ font-size: 12px;
+ background: transparent;
+ }
+ """
+
+ date_label = QLabel("📅 Select Date")
+ date_label.setStyleSheet("font-size: 14px; color: #1F8A70; font-weight: 700;")
+ self.date_selector = QComboBox()
+ self.date_selector.setStyleSheet(widget_style)
+ self.date_selector.currentIndexChanged.connect(self.change_date)
+
+ sidebar_layout.addWidget(date_label)
+ sidebar_layout.addWidget(self.date_selector)
+
+ separator = QFrame()
+ separator.setFrameShape(QFrame.Shape.HLine)
+ separator.setStyleSheet("background-color: rgba(31, 138, 112, 0.2); max-height: 2px;")
+ sidebar_layout.addWidget(separator)
+
+ # Legend
+ legend_label = QLabel("🗺️ Legend")
+ legend_label.setStyleSheet("font-size: 14px; color: #1F8A70; font-weight: 700;")
+ self.legend = QListWidget()
+ self.legend.setStyleSheet(widget_style)
+ sidebar_layout.addWidget(legend_label)
+ sidebar_layout.addWidget(self.legend, stretch=1)
+
+ sidebar_layout.addStretch()
+
+ self.btn_polygon = QPushButton("🔺 Show Polygon")
+ self.btn_objects = QPushButton("🎯 Show Objects")
+ self.btn_anomalies = QPushButton("⚠️ Show Anomalies")
+ self.btn_segments = QPushButton("🧩 Show Segments")
+
+ for btn in [self.btn_polygon, self.btn_objects, self.btn_anomalies, self.btn_segments]:
+ btn.setStyleSheet(widget_style)
+
+ self.btn_polygon.clicked.connect(lambda: self.toggle_mode("polygon"))
+ self.btn_objects.clicked.connect(lambda: self.toggle_mode("objects"))
+ self.btn_anomalies.clicked.connect(lambda: self.toggle_mode("anomalies"))
+ self.btn_segments.clicked.connect(lambda: self.toggle_mode("segments"))
+
+ self.current_polygon = None
+ self.action_btn = QPushButton()
+ self.action_btn.setStyleSheet(widget_style)
+ self.action_btn.hide()
+
+ sidebar_layout.addWidget(self.action_btn)
+ sidebar_layout.addWidget(self.btn_polygon)
+ sidebar_layout.addWidget(self.btn_objects)
+ sidebar_layout.addWidget(self.btn_anomalies)
+ sidebar_layout.addWidget(self.btn_segments)
+
+ content_layout.addWidget(sidebar)
+
+ image_container = QFrame()
+ image_container.setStyleSheet("""
+ QFrame {
+ background-color: rgba(255, 255, 255, 0.95);
+ border-radius: 16px;
+ border: 2px solid rgba(31, 138, 112, 0.2);
+ }
+ """)
+ image_layout = QVBoxLayout(image_container)
+ image_layout.setContentsMargins(15, 15, 15, 15)
+ image_layout.setSpacing(0)
+
+ self.scroll = QScrollArea()
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.scroll.setStyleSheet("""
+ QScrollArea {
+ border: none;
+ background-color: #F8FAFB;
+ border-radius: 12px;
+ }
+ QScrollBar:vertical, QScrollBar:horizontal {
+ background: rgba(245, 248, 250, 0.6);
+ border-radius: 5px;
+ width: 10px;
+ height: 10px;
+ margin: 2px;
+ }
+ QScrollBar::handle:vertical, QScrollBar::handle:horizontal {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
+ stop:0 rgba(31, 138, 112, 0.3),
+ stop:1 rgba(38, 208, 168, 0.3));
+ border-radius: 5px;
+ min-height: 30px;
+ min-width: 30px;
+ }
+ QScrollBar::handle:hover {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
+ stop:0 rgba(31, 138, 112, 0.5),
+ stop:1 rgba(38, 208, 168, 0.5));
+ }
+ QScrollBar::add-line, QScrollBar::sub-line {
+ height: 0px;
+ width: 0px;
+ }
+ """)
+ self.image_label = ZoomableImageLabel()
+ self.image_label.wheelEvent = self.custom_wheel_event
+ self.scroll.setWidget(self.image_label)
+
+ image_layout.addWidget(self.scroll, stretch=1)
+
+ content_layout.addWidget(image_container, stretch=1)
+ card_layout.addWidget(content, stretch=1)
+
+ outer_layout.addWidget(modal_card)
+
+ def custom_wheel_event(self, event: QWheelEvent):
+ # Call original wheel event
+ ZoomableImageLabel.wheelEvent(self.image_label, event)
+
+ # Update scrollbar visibility based on zoom level
+ scale = self.image_label.get_scale()
+ if scale <= 1.0:
+ # No zoom - hide scrollbars
+ self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ else:
+ # Zoomed in - show scrollbars when needed
+ self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ def load_latest_images(self):
+ print("[AerialImagesView] 🔄 load_latest_images called")
+ try:
+ data = self.api.list_latest_images_per_gis()
+ if not data:
+ self.image_label.setText("❌ No aerial images found.")
+ return
+ self.images = data
+ self.current_index = 0
+ self.show_image(0)
+ self.update_date_selector()
+ except Exception as e:
+ self.image_label.setText(f"[ERROR] {e}")
+
+
+
+ def show_image(self, index):
+ if not self.images:
+ return
+ index = index % len(self.images)
+ self.current_index = index
+ img_info = self.images[index]
+ key = img_info.get("img_key")
+ print("show image", key)
+ self.current_image = None
+ self.title = img_info.get('file_name', 'Image')
+ try:
+ img_bytes = self.api.get_image_bytes_from_minio(key)
+ if not img_bytes:
+ raise ValueError("Empty image data")
+ self.current_image = img_bytes
+ image = Image.open(io.BytesIO(img_bytes)).convert("RGB")
+ qimage = QImage(image.tobytes(), image.width, image.height, QImage.Format.Format_RGB888)
+ pixmap = QPixmap.fromImage(qimage)
+ self.image_label.setPixmap(pixmap)
+ self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ except Exception as e:
+ self.image_label.setText(f"❌ Failed to load image: {e}")
+ self.legend.hide()
+
+ def update_date_selector(self):
+ self.date_selector.clear()
+ if not self.images:
+ return
+ gis = self.images[self.current_index].get("gis")
+ dates = self.api.list_dates_for_gis(gis)
+ for d in sorted(dates, reverse=True):
+ self.date_selector.addItem(d)
+
+ def change_date(self):
+ selected = self.date_selector.currentText()
+ if not selected or not self.images:
+ return
+ gis = self.images[self.current_index].get("gis")
+ images = self.api.list_all_images_for_gis(gis)
+ match = next((img for img in images if selected in img.get("timestamp_utc", "")), None)
+ if match:
+ self.images[self.current_index] = match
+ self.show_image(self.current_index)
+
+ def show_next_gis(self):
+ if not self.images:
+ return
+ self.action_btn.hide()
+ self.show_image((self.current_index + 1) % len(self.images))
+ self.mode = None
+
+ def show_prev_gis(self):
+ if not self.images:
+ return
+ self.action_btn.hide()
+ self.show_image((self.current_index - 1) % len(self.images))
+ self.mode = None
+
+ def toggle_mode(self, mode: str):
+ self.action_btn.hide()
+ self.current_polygon = None
+ self.legend.hide()
+
+ if self.mode == mode:
+ self.mode = None
+ self.refresh_image()
+ return
+
+ self.mode = mode
+ self.refresh_image()
+
+ if mode == "objects":
+ self.show_objects()
+ elif mode == "anomalies":
+ self.show_anomalies()
+ elif mode == "segments":
+ self.show_segments()
+ elif mode == "polygon":
+ self.handle_polygon_button()
+
+ def refresh_image(self):
+ if not self.current_image:
+ return
+ image = Image.open(io.BytesIO(self.current_image)).convert("RGB")
+ qimage = QImage(image.tobytes(), image.width, image.height, QImage.Format.Format_RGB888)
+ pixmap = QPixmap.fromImage(qimage)
+ self.image_label.setPixmap(pixmap)
+
+ def show_anomalies(self):
+ key = self.images[self.current_index].get("img_key")
+ detections = [a for a in self.api.list_anomalies() if a.get("img_key") == key]
+ self.draw_boxes(detections, "anomaly")
+
+ def show_objects(self):
+ key = self.images[self.current_index].get("img_key")
+ detections = self.api.list_objects(key)
+ self.draw_boxes(detections, "object")
+
+ def draw_boxes(self, detections, mode="object"):
+ print('draw:', detections)
+ if not self.current_image:
+ return
+
+ print("###############")
+
+ self.color_map.clear()
+ self.refresh_image()
+
+ # Load image
+ image = Image.open(io.BytesIO(self.current_image)).convert("RGB")
+ w, h = image.size
+ qimg = QImage(image.tobytes(), w, h, QImage.Format.Format_RGB888)
+ pix = QPixmap.fromImage(qimg)
+ painter = QPainter(pix)
+
+ for d in detections:
+ label = d.get("label", "Obj" if mode == "object" else "Anomaly")
+
+ # Color per label
+ if label not in self.color_map:
+ if mode == "object":
+ self.color_map[label] = QColor(
+ random.randint(0, 200),
+ random.randint(80, 255),
+ random.randint(0, 200)
+ )
+ else:
+ self.color_map[label] = QColor(
+ random.randint(180, 255),
+ random.randint(50, 100),
+ random.randint(50, 100)
+ )
+
+ pen = QPen(self.color_map[label], 3)
+ painter.setPen(pen)
+
+ # --- FIX HERE: detect pixel vs normalized ---
+ x1 = d["bbox_x1"]
+ y1 = d["bbox_y1"]
+ x2 = d["bbox_x2"]
+ y2 = d["bbox_y2"]
+
+ if x1 <= 1 and y1 <= 1 and x2 <= 1 and y2 <= 1:
+ x1 *= w
+ y1 *= h
+ x2 *= w
+ y2 *= h
+
+ painter.drawRect(
+ int(x1),
+ int(y1),
+ int(x2 - x1),
+ int(y2 - y1)
+ )
+
+ # טקסט
+ painter.drawText(int(x1) + 5, int(y1) + 15, label)
+
+ painter.end()
+
+ self.image_label.setPixmap(pix)
+ self.update_legend()
+
+
+ def update_legend(self):
+ self.legend.clear()
+ self.legend.show()
+
+ for label, color in self.color_map.items():
+ item = QListWidgetItem(f" {label}")
+ item.setForeground(QColor("black"))
+
+ pixmap = QPixmap(16, 16)
+ pixmap.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setBrush(QBrush(color))
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.drawEllipse(2, 2, 12, 12)
+ painter.end()
+
+ item.setIcon(QIcon(pixmap))
+ self.legend.addItem(item)
+
+ def _dbg_print_gis(self, label, g):
+ print(f"[DBG] {label} =", g, "type=", type(g))
+ if isinstance(g, dict):
+ for k, v in g.items():
+ print(f" └─ {k}: {v} (type={type(v)})")
+
+ def handle_polygon_button(self):
+ self.refresh_image()
+ self.legend.hide()
+ self.action_btn.hide()
+
+ img = self.images[self.current_index]
+ gis_origin = img.get("gis_origin")
+
+ if not gis_origin:
+ print("[Polygon] No gis_origin in image metadata")
+ return
+
+ def normalize_gis(g):
+ if not g:
+ return None
+ try:
+ return {
+ "latitude": float(g["latitude"]),
+ "longitude": float(g["longitude"])
+ }
+ except:
+ return None
+
+ gis_norm = normalize_gis(gis_origin)
+
+ all_polygons = self.api.list_polygons()
+ polygons = [
+ p for p in all_polygons
+ if normalize_gis(p.get("gis_origin")) == gis_norm
+ ]
+
+ if polygons:
+ print("[DEBUG] FOUND polygon")
+ self.current_polygon = polygons[0]
+ self.current_polygon_id = self.current_polygon.get("id")
+
+ px = self.current_polygon.get("pixel_points")
+ if px:
+ print("[DEBUG] drawing pixel polygon")
+ if isinstance(px, dict) and "points" in px:
+ px = px["points"]
+ self.draw_pixel_polygon(px)
+ else:
+ print("[DEBUG] polygon has NO pixel_points")
+
+ self.action_btn.setText("Update Polygon")
+ try:
+ self.action_btn.clicked.disconnect()
+ except:
+ pass
+ self.action_btn.show()
+ self.action_btn.clicked.connect(lambda: self.start_polygon_drawing(update=True))
+
+ else:
+ print("[DEBUG] NO polygon found → new")
+ self.current_polygon = None
+ self.current_polygon_id = None
+
+ self.action_btn.setText("Create Polygon")
+ try:
+ self.action_btn.clicked.disconnect()
+ except:
+ pass
+ self.action_btn.show()
+ self.action_btn.clicked.connect(lambda: self.start_polygon_drawing(update=False))
+
+ def draw_polygon_from_hex(self, polygon_hex: str):
+ try:
+ geom = wkb.loads(bytes.fromhex(polygon_hex))
+ self.draw_polygon_from_wkt(geom.wkt)
+ except Exception as e:
+ print(f"[draw_polygon_from_hex] Error: {e}")
+
+ def draw_polygon_from_wkt(self, polygon_wkt: str):
+ if not self.current_image or not polygon_wkt:
+ return
+ match = re.search(r"POLYGON\s*$$$$(.+)$$$$", polygon_wkt)
+ if not match:
+ return
+ coords_text = match.group(1).strip()
+ pairs = coords_text.split(",")
+ points = []
+ for p in pairs:
+ try:
+ lon, lat = map(float, p.strip().split())
+ points.append((lon, lat))
+ except ValueError:
+ pass
+ if not points:
+ return
+
+ image = Image.open(io.BytesIO(self.current_image)).convert("RGB")
+ w, h = image.size
+ qimg = QImage(image.tobytes(), w, h, QImage.Format.Format_RGB888)
+ pix = QPixmap.fromImage(qimg)
+ painter = QPainter(pix)
+ pen = QPen(QColor(255, 100, 100), 4)
+ painter.setPen(pen)
+ painter.setBrush(QBrush(QColor(255, 0, 0, 50)))
+
+ lons = [p[0] for p in points]
+ lats = [p[1] for p in points]
+ min_lon, max_lon = min(lons), max(lons)
+ min_lat, max_lat = min(lats), max(lats)
+
+ poly = QPolygonF()
+ for lon, lat in points:
+ x = (lon - min_lon) / (max_lon - min_lon) * w
+ y = h - (lat - min_lat) / (max_lat - min_lat) * h
+ poly.append(QPointF(x, y))
+
+ painter.drawPolygon(poly)
+ painter.end()
+ self.image_label.setPixmap(pix)
+
+ def draw_segmentation_overlay(self, mask_bytes):
+ try:
+ base_img = Image.open(io.BytesIO(self.current_image)).convert("RGBA")
+ mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
+ mask_img = mask_img.resize(base_img.size, Image.Resampling.NEAREST)
+
+ alpha = 120
+ r, g, b, a = mask_img.split()
+ a = a.point(lambda i: alpha)
+ mask_img = Image.merge("RGBA", (r, g, b, a))
+
+ merged = Image.alpha_composite(base_img, mask_img)
+
+ qimg = QImage(merged.tobytes(), merged.width, merged.height, QImage.Format.Format_RGBA8888)
+ pixmap = QPixmap.fromImage(qimg)
+
+ self.image_label.setPixmap(pixmap)
+
+ except Exception as e:
+ self.image_label.setText(f"Failed to draw segmentation: {e}")
+
+ # def show_segments(self):
+ # if not self.current_image:
+ # return
+ # img_key = self.images[self.current_index].get("img_key")
+ # print("DEBUG img_key from GUI:", img_key)
+ # seg = self.api.get_segmentation_record(img_key)
+
+ # if not seg:
+ # self.image_label.setText("No segmentation found for this image.")
+ # return
+
+ # mask_path = seg.get("mask_path")
+ # if not mask_path:
+ # self.image_label.setText("Segmentation exists but mask_path is empty.")
+ # return
+
+ # mask_bytes = self.api.get_mask_bytes_from_minio(mask_path)
+
+ # if not mask_bytes:
+ # self.image_label.setText("Failed to load segmentation mask from storage.")
+ # return
+
+ # self.draw_segmentation_overlay(mask_bytes)
+ # self.update_legend_from_mask(mask_bytes)
+
+ def show_segments(self):
+ if not self.current_image:
+ return
+ img_key = self.images[self.current_index].get("img_key")
+ print("DEBUG img_key from GUI:", img_key)
+ seg = self.api.get_segmentation_record(img_key) # <--- הנתונים המלאים של האחוזים נמצאים כאן
+
+ if not seg:
+ self.image_label.setText("No segmentation found for this image.")
+ return
+
+ mask_path = seg.get("mask_path")
+ if not mask_path:
+ self.image_label.setText("Segmentation exists but mask_path is empty.")
+ return
+
+ mask_bytes = self.api.get_mask_bytes_from_minio(mask_path)
+
+ if not mask_bytes:
+ self.image_label.setText("Failed to load segmentation mask from storage.")
+ return
+
+ self.draw_segmentation_overlay(mask_bytes)
+ self.update_legend_from_mask(seg)
+
+ def update_legend_from_mask(self, segmentation_data: dict):
+ self.legend.clear()
+ self.legend.show()
+
+ global NAME_TO_DBKEY_MAP
+
+ for rgb, (class_id, name) in PALETTE.items():
+
+ db_key = NAME_TO_DBKEY_MAP.get(name)
+ print(db_key)
+ percentage = segmentation_data.get(db_key)
+ print(percentage)
+
+ if percentage is None or not isinstance(percentage, (int, float)) or percentage <= 0.01:
+ continue
+
+ item_text = f" {name} ({percentage:.2f}%)"
+ item = QListWidgetItem(item_text)
+ item.setForeground(QColor("black"))
+
+ icon_pix = QPixmap(16, 16)
+ icon_pix.fill(Qt.GlobalColor.transparent)
+
+ painter = QPainter(icon_pix)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setBrush(QBrush(QColor(*rgb)))
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.drawEllipse(2, 2, 12, 12)
+ painter.end()
+
+ item.setIcon(QIcon(icon_pix))
+ self.legend.addItem(item)
+
+
+ # def update_legend_from_mask(self, mask_bytes):
+ # import numpy as np
+
+ # mask_img = Image.open(io.BytesIO(mask_bytes)).convert("RGB")
+ # arr = np.array(mask_img)
+
+ # unique_colors = set(tuple(c) for c in arr.reshape(-1, 3))
+
+ # self.legend.clear()
+ # self.legend.show()
+
+ # for rgb, (class_id, name) in PALETTE.items():
+ # if rgb not in unique_colors:
+ # continue
+
+ # item = QListWidgetItem(f" {name}")
+ # item.setForeground(QColor("black"))
+
+ # icon_pix = QPixmap(16, 16)
+ # icon_pix.fill(Qt.GlobalColor.transparent)
+
+ # painter = QPainter(icon_pix)
+ # painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ # painter.setBrush(QBrush(QColor(*rgb)))
+ # painter.setPen(Qt.PenStyle.NoPen)
+ # painter.drawEllipse(2, 2, 12, 12)
+ # painter.end()
+
+ # item.setIcon(QIcon(icon_pix))
+ # self.legend.addItem(item)
+
+ def update_polygon_button_state(self):
+ gis = self.images[self.current_index].get("gis")
+ polygons = [p for p in self.api.list_polygons() if p.get("gis") == gis]
+
+ if polygons:
+ self.current_polygon = polygons[0]
+ self.btn_polygon.setText("Show Polygon")
+ else:
+ self.current_polygon = None
+ self.btn_polygon.setText("Create Polygon")
+
+ def start_polygon_drawing(self, update=False):
+ self.refresh_image()
+ self.current_polygon = None
+
+ self.image_label.toggle_drawing_mode(True)
+ self.action_btn.setText("Save Polygon")
+ self.action_btn.show()
+
+ try:
+ self.action_btn.clicked.disconnect()
+ except TypeError:
+ pass
+
+ self.action_btn.clicked.connect(lambda: self.save_polygon(update))
+
+ def save_polygon(self, update=False):
+ points = self.image_label.finish_polygon()
+ if not points:
+ return
+
+ if points[0] != points[-1]:
+ points.append(points[0])
+
+ print("[DEBUG] save_polygon called")
+ print("[DEBUG] pixel points =", points)
+
+ img = self.images[self.current_index]
+ gis_origin = img.get("gis_origin")
+
+ lon_lat_pairs = [f"{x/100000:.6f} {y/100000:.6f}" for x, y in points]
+ polygon_str = ", ".join(lon_lat_pairs)
+ wkt_polygon = f"POLYGON(({polygon_str}))"
+
+ payload = {
+ "gis_origin": gis_origin,
+ "boundary": wkt_polygon,
+ "pixel_points": {
+ "points": [{"x": x, "y": y} for x, y in points]
+ }
+ }
+
+ print("[DEBUG] Final payload =", json.dumps(payload, indent=4))
+
+ if update and self.current_polygon_id:
+ ok = self.api.update_db_api("field_polygons", self.current_polygon_id, payload)
+ else:
+ ok = self.api.write_to_db_api("field_polygons", payload)
+
+ print("[DEBUG] DB RESULT:", ok)
+
+ if ok:
+ print("[Polygon] Saved OK")
+
+ self.current_polygon = payload
+ self.draw_pixel_polygon(payload["pixel_points"]["points"])
+
+ self.action_btn.setText("Update Polygon")
+ self.action_btn.show()
+
+ try:
+ self.action_btn.clicked.disconnect()
+ except:
+ pass
+
+ self.action_btn.clicked.connect(lambda: self.start_polygon_drawing(update=True))
+ else:
+ print("[Polygon] FAILED")
+
+ def draw_pixel_polygon(self, pixel_points):
+ if not self.current_image:
+ return
+
+ # Decode formats
+ if isinstance(pixel_points, dict) and "points" in pixel_points:
+ pixel_points = pixel_points["points"]
+
+ if isinstance(pixel_points, str):
+ try:
+ pixel_points = json.loads(pixel_points).get("points", [])
+ except:
+ print("[Polygon] Failed to decode pixel_points string")
+ return
+
+ if not isinstance(pixel_points, list) or not pixel_points:
+ print("[Polygon] No valid points")
+ return
+
+ # Load image
+ image = Image.open(io.BytesIO(self.current_image)).convert("RGB")
+ w, h = image.size
+ qimg = QImage(image.tobytes(), w, h, QImage.Format.Format_RGB888)
+ pix = QPixmap.fromImage(qimg)
+
+ painter = QPainter(pix)
+ pen = QPen(QColor(255, 0, 0), 4)
+ painter.setPen(pen)
+ painter.setBrush(QBrush(QColor(255, 0, 0, 70)))
+
+ # --- HERE: OFFSET IN PERCENT ---
+ offset_x_percent = -7 # נגיד להזיז 20% שמאלה
+ offset_y_percent = 3 # אין הזזה ב-Y
+
+ offset_x = w * (offset_x_percent / 100.0)
+ offset_y = h * (offset_y_percent / 100.0)
+
+ # Build polygon
+ poly = QPolygonF()
+ for p in pixel_points:
+ try:
+ x = p["x"] + offset_x
+ y = p["y"] + offset_y
+ poly.append(QPointF(x, y))
+ except Exception as e:
+ print("[Polygon] Bad point:", p, "error:", e)
+
+ painter.drawPolygon(poly)
+ painter.end()
+
+ self.image_label.setPixmap(pix)
+
+
+ def focus_on_image_by_key(self, img_key):
+ print("[AerialImagesView] Looking for img_key:", img_key)
+
+ if not self.images:
+ print("[AerialImagesView] No images loaded!")
+ return
+
+ for idx, img in enumerate(self.images):
+ print(f"[AerialImagesView] Checking index {idx} → {img.get('img_key')}")
+
+ if img.get("img_key") == img_key:
+ print("[AerialImagesView] FOUND matching image at index:", idx)
+
+ selected = self.images.pop(idx)
+ self.images.insert(0, selected)
+
+ self.current_index = 0
+ print("[AerialImagesView] Reordered images → displaying new index 0")
+
+ self.show_image(0)
+ print("[AerialImagesView] show_image(0) completed")
+
+ self.update_date_selector()
+ print("[AerialImagesView] Date selector updated")
+ return
+
+ print("[AerialImagesView] WARNING: img_key not found in images!")
\ No newline at end of file
diff --git a/GUI/src/vast/views/alerts_for_fruits.py b/GUI/src/vast/views/alerts_for_fruits.py
new file mode 100644
index 000000000..d28a381eb
--- /dev/null
+++ b/GUI/src/vast/views/alerts_for_fruits.py
@@ -0,0 +1,607 @@
+from __future__ import annotations
+from typing import List
+
+from PyQt6.QtCore import Qt, QUrl, QByteArray, QTimer
+from PyQt6.QtGui import QColor, QBrush, QPixmap
+from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget,
+ QTableWidgetItem, QHeaderView, QLineEdit, QMessageBox, QScrollArea,
+ QWidget, QFrame, QGridLayout
+)
+import base64
+
+from dashboard_api import DashboardApi
+
+
+# ==============================
+# Dialog: Images for an alert
+# ==============================
+class AlertImagesDialog(QDialog):
+ def __init__(self, api: DashboardApi, alert_id: str, parent=None):
+ super().__init__(parent)
+ self.api = api
+ self.alert_id = alert_id
+
+ self.setWindowTitle(f"Alert Images — {alert_id}")
+ self.resize(960, 640)
+
+ self.setStyleSheet("""
+ QDialog {
+ background: #ffffff;
+ font-family: "Segoe UI", system-ui, sans-serif;
+ font-size: 11pt;
+ color: #111827;
+ }
+ QLabel#title {
+ font-size: 20px;
+ font-weight: 700;
+ color: #111827;
+ }
+ QLabel#subtitle {
+ font-size: 11pt;
+ color: #6b7280;
+ }
+ QPushButton {
+ padding: 9px 18px;
+ border-radius: 8px;
+ border: 1px solid #d1d5db;
+ background: #ffffff;
+ font-weight: 600;
+ font-size: 10pt;
+ }
+ QPushButton:hover {
+ background: #f9fafb;
+ }
+ QPushButton#btn_refresh {
+ background: #10b981;
+ color: #ffffff;
+ border: none;
+ }
+ QPushButton#btn_refresh:hover {
+ background: #059669;
+ }
+ QPushButton#btn_close {
+ background: #111827;
+ color: #ffffff;
+ border: none;
+ }
+ QPushButton#btn_close:hover {
+ background: #1f2937;
+ }
+ QScrollArea {
+ border: none;
+ }
+ QFrame#imageCard {
+ background: #ffffff;
+ border-radius: 12px;
+ border: 1px solid #e5e7eb;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ }
+ QLabel#caption {
+ color: #4b5563;
+ font-size: 10pt;
+ padding: 4px;
+ }
+ """)
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(20, 20, 20, 20)
+ root.setSpacing(12)
+
+ # Top bar
+ header = QHBoxLayout()
+ header.setSpacing(8)
+
+ title_wrap = QVBoxLayout()
+ self.lbl_title = QLabel(f"Alert Images — {alert_id}")
+ self.lbl_title.setObjectName("title")
+ self.lbl_sub = QLabel("Thumbnails of all images for this alert")
+ self.lbl_sub.setObjectName("subtitle")
+ title_wrap.addWidget(self.lbl_title)
+ title_wrap.addWidget(self.lbl_sub)
+ header.addLayout(title_wrap, 1)
+
+ header.addStretch(1)
+
+ self.btn_refresh = QPushButton("Refresh")
+ self.btn_refresh.setObjectName("btn_refresh")
+ header.addWidget(self.btn_refresh)
+
+ root.addLayout(header)
+
+ # Scrollable images container
+ self.scroll = QScrollArea(self)
+ self.scroll.setWidgetResizable(True)
+
+ self.container = QWidget(self.scroll)
+ self.grid = QGridLayout(self.container)
+ self.grid.setContentsMargins(4, 4, 4, 4)
+ self.grid.setHorizontalSpacing(12)
+ self.grid.setVerticalSpacing(12)
+
+ self.scroll.setWidget(self.container)
+ root.addWidget(self.scroll, 1)
+
+ # Bottom bar
+ bottom = QHBoxLayout()
+ self.lbl_status = QLabel("Loading images...")
+ self.btn_close = QPushButton("Close")
+ self.btn_close.setObjectName("btn_close")
+ bottom.addWidget(self.lbl_status)
+ bottom.addStretch(1)
+ bottom.addWidget(self.btn_close)
+ root.addLayout(bottom)
+
+ # Signals
+ self.btn_refresh.clicked.connect(self.load_images)
+ self.btn_close.clicked.connect(self.accept)
+
+ # Network manager for async image loading
+ self.nam = QNetworkAccessManager(self)
+
+ # Initial load
+ self.load_images()
+
+ def _badge_style_for_label(self, label: str) -> tuple[str, str]:
+ """Returns (text, background-color) for a ripeness label."""
+ l = (label or "").strip().lower()
+ if l == "ripe":
+ return "RIPE", "#10b981"
+ if l == "unripe":
+ return "UNRIPE", "#f59e0b"
+ if l == "overripe":
+ return "OVERRIPE", "#ef4444"
+ if l == "disease":
+ return "DISEASE", "#dc2626"
+ return "UNKNOWN", "#6b7280"
+
+ def _attach_ripeness_badge(self, img_label: QLabel, label: str):
+ if not isinstance(img_label, QLabel):
+ return
+ text, color = self._badge_style_for_label(label)
+
+ badge = QLabel(text, img_label)
+ badge.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
+ badge.setStyleSheet(f"""
+ QLabel {{
+ background-color: {color};
+ color: white;
+ padding: 4px 12px;
+ border-radius: 6px;
+ font-size: 9pt;
+ font-weight: 600;
+ }}
+ """)
+ badge.adjustSize()
+ badge.move(8, 8)
+ badge.raise_()
+
+ def load_images(self):
+ while self.grid.count():
+ item = self.grid.takeAt(0)
+ w = item.widget()
+ if w:
+ w.deleteLater()
+
+ try:
+ rows = self.api.get_alert_images(self.alert_id)
+ print("[DEBUG] alert images rows =", rows, flush=True)
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to load images:\n{e}")
+ rows = []
+
+ if not rows:
+ self.grid.addWidget(QLabel("No images for this alert."), 0, 0)
+ self.lbl_status.setText("0 images.")
+ return
+
+ cols = 4
+ for idx, r in enumerate(rows):
+ label = r.get("ripeness_label", "")
+ score_raw = r.get("ripeness_score", 0.0)
+ try:
+ score = float(score_raw)
+ except (TypeError, ValueError):
+ score = 0.0
+
+ ts = str(r.get("ts", "")).replace("T", " ")[:19]
+ inf_id = r.get("inference_log_id", "")
+
+ # Card container
+ card = QFrame()
+ card.setObjectName("imageCard")
+ lay = QVBoxLayout(card)
+ lay.setContentsMargins(8, 8, 8, 8)
+ lay.setSpacing(6)
+
+ # Image thumbnail
+ img_label = QLabel("Loading...")
+ img_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ img_label.setMinimumHeight(120)
+ img_label.setStyleSheet("background:#ffffff; border-radius:10px; color:#9ca3af; border: 1px solid #e5e7eb;")
+ lay.addWidget(img_label)
+
+ # Caption
+ caption = QLabel(f"{label or '—'} ({score:.2f})\n{ts}")
+ caption.setObjectName("caption")
+ caption.setWordWrap(True)
+ lay.addWidget(caption)
+
+ # Place in grid
+ row_idx = idx // cols
+ col_idx = idx % cols
+ self.grid.addWidget(card, row_idx, col_idx)
+
+ data_url = r.get("image_data_url")
+ url = r.get("image_url", "")
+
+ if isinstance(data_url, str) and data_url.startswith("data:"):
+ self._set_pixmap_from_data_url(data_url, img_label, label)
+ elif url:
+ req = QNetworkRequest(QUrl(str(url)))
+ reply = self.nam.get(req)
+ reply.finished.connect(
+ lambda rp=reply, lbl=img_label, lab=label: self._on_image_loaded(rp, lbl, lab)
+ )
+ else:
+ img_label.setText("No image url")
+
+ last_row = (len(rows) - 1) // cols + 1
+ self.grid.setRowStretch(last_row, 1)
+ self.lbl_status.setText(f"{len(rows)} images loaded.")
+
+ def _set_pixmap_from_data_url(self, data_url: str, label_widget: QLabel, ripeness: str, target_height: int = 160):
+ try:
+ if not (isinstance(data_url, str) and data_url.startswith("data:")):
+ label_widget.setText("No image data")
+ return
+ _, b64data = data_url.split(",", 1)
+ img_bytes = base64.b64decode(b64data)
+ pm = QPixmap()
+ if pm.loadFromData(img_bytes):
+ pm = pm.scaledToHeight(target_height, Qt.TransformationMode.SmoothTransformation)
+ label_widget.setPixmap(pm)
+ label_widget.setText("")
+ self._attach_ripeness_badge(label_widget, ripeness)
+ else:
+ label_widget.setText("Failed to decode image")
+ except Exception as e:
+ label_widget.setText(f"Decode error: {e}")
+
+ def _on_image_loaded(self, reply, label_widget: QLabel, ripeness: str):
+ if reply.error():
+ label_widget.setText(f"Failed: {reply.errorString()}")
+ reply.deleteLater()
+ return
+ data: QByteArray = reply.readAll()
+ pm = QPixmap()
+ if pm.loadFromData(bytes(data)):
+ pm = pm.scaledToHeight(160, Qt.TransformationMode.SmoothTransformation)
+ label_widget.setPixmap(pm)
+ label_widget.setText("")
+ self._attach_ripeness_badge(label_widget, ripeness)
+ else:
+ label_widget.setText("Invalid image data.")
+ reply.deleteLater()
+
+
+# ==============================
+# Dialog: Alerts browser
+# ==============================
+class AlertsBrowserDialog(QDialog):
+ def __init__(self, api: DashboardApi, parent=None):
+ super().__init__(parent)
+ self.api = api
+
+ self.setWindowTitle("Fruit Alerts")
+ self.resize(1024, 680)
+
+ self.setStyleSheet("""
+ QDialog {
+ background: #f9fafb;
+ font-family: "Segoe UI", system-ui, sans-serif;
+ font-size: 11pt;
+ color: #111827;
+ }
+ QLabel#title {
+ font-size: 20px;
+ font-weight: 700;
+ color: #111827;
+ }
+ QLabel#subtitle {
+ font-size: 11pt;
+ color: #6b7280;
+ }
+ QFrame#card {
+ background: #ffffff;
+ border-radius: 12px;
+ border: 1px solid #e5e7eb;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ }
+ QLineEdit#searchBox {
+ padding: 10px 14px;
+ border-radius: 8px;
+ border: 1px solid #d1d5db;
+ background: #ffffff;
+ font-size: 10.5pt;
+ }
+ QLineEdit#searchBox:focus {
+ border: 1px solid #10b981;
+ }
+ QPushButton {
+ padding: 9px 18px;
+ border-radius: 8px;
+ border: 1px solid #d1d5db;
+ background: #ffffff;
+ font-weight: 600;
+ font-size: 10pt;
+ color: #374151;
+ }
+ QPushButton:hover {
+ background: #f9fafb;
+ }
+ QPushButton#btn_refresh {
+ background: #10b981;
+ color: #ffffff;
+ border: none;
+ }
+ QPushButton#btn_refresh:hover {
+ background: #059669;
+ }
+ QPushButton#btn_close {
+ background: #111827;
+ color: #ffffff;
+ border: none;
+ }
+ QPushButton#btn_close:hover {
+ background: #1f2937;
+ }
+ QPushButton#btn_images {
+ padding: 6px 14px;
+ }
+ QTableWidget {
+ background: #ffffff;
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+ gridline-color: #f3f4f6;
+ font-size: 10.5pt;
+ }
+ QTableWidget::item {
+ padding: 6px;
+ }
+ QTableWidget::item:selected {
+ background-color: #ecfdf5;
+ color: #111827;
+ }
+ QHeaderView::section {
+ background: #f9fafb;
+ color: #374151;
+ padding: 10px 8px;
+ border: none;
+ border-bottom: 2px solid #e5e7eb;
+ font-weight: 600;
+ font-size: 10pt;
+ }
+ QLabel#statusLabel {
+ color: #6b7280;
+ }
+ """)
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(20, 20, 20, 20)
+ root.setSpacing(14)
+
+ # Title
+ title_row = QHBoxLayout()
+ title_col = QVBoxLayout()
+ lbl_title = QLabel("Fruit Alerts Dashboard")
+ lbl_title.setObjectName("title")
+ lbl_sub = QLabel("Overview of alerts across your fruit monitoring system")
+ lbl_sub.setObjectName("subtitle")
+ title_col.addWidget(lbl_title)
+ title_col.addWidget(lbl_sub)
+ title_row.addLayout(title_col)
+ title_row.addStretch(1)
+ root.addLayout(title_row)
+
+ # Card wrapper
+ card = QFrame()
+ card.setObjectName("card")
+ card_layout = QVBoxLayout(card)
+ card_layout.setContentsMargins(16, 16, 16, 16)
+ card_layout.setSpacing(12)
+
+ # Toolbar
+ toolbar = QHBoxLayout()
+ self.search = QLineEdit()
+ self.search.setObjectName("searchBox")
+ self.search.setPlaceholderText("Search by fruit, type or alert id...")
+ self.btn_refresh = QPushButton("Refresh")
+ self.btn_refresh.setObjectName("btn_refresh")
+ toolbar.addWidget(self.search, 1)
+ toolbar.addStretch(0)
+ toolbar.addWidget(self.btn_refresh)
+ card_layout.addLayout(toolbar)
+
+ # Table
+ self.tbl = QTableWidget(0, 5, self)
+ self.tbl.setAlternatingRowColors(False)
+ self.tbl.setHorizontalHeaderLabels([
+ "Fruit", "Severity", "% Ripe", "Started", "Images"
+ ])
+ hdr = self.tbl.horizontalHeader()
+ hdr.setStretchLastSection(False)
+ hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
+ self.tbl.verticalHeader().setVisible(False)
+ self.tbl.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+ card_layout.addWidget(self.tbl, 1)
+
+ # Bottom bar inside card
+ inner_bottom = QHBoxLayout()
+ self.lbl_status = QLabel("Loading alerts...")
+ self.lbl_status.setObjectName("statusLabel")
+ inner_bottom.addWidget(self.lbl_status)
+ inner_bottom.addStretch(1)
+ card_layout.addLayout(inner_bottom)
+
+ root.addWidget(card, 1)
+
+ # Bottom bar (dialog-level)
+ bottom = QHBoxLayout()
+ bottom.addStretch(1)
+ self.btn_close = QPushButton("Close")
+ self.btn_close.setObjectName("btn_close")
+ bottom.addWidget(self.btn_close)
+ root.addLayout(bottom)
+
+ # Signals
+ self.btn_refresh.clicked.connect(self.load_alerts)
+ self.btn_close.clicked.connect(self.accept)
+ self.search.textChanged.connect(self._apply_filter)
+
+ self._cache_rows: List[dict] = []
+ self._initial_load_done = False
+
+ def showEvent(self, event):
+ super().showEvent(event)
+ if not self._initial_load_done:
+ self._initial_load_done = True
+ QTimer.singleShot(0, self.load_alerts)
+
+ def _color_for_severity(self, sev: int | None) -> QColor:
+ if sev is None:
+ return QColor("#e5e7eb")
+ if sev >= 4:
+ return QColor("#fca5a5")
+ if sev == 3:
+ return QColor("#fdba74")
+ if sev == 2:
+ return QColor("#93c5fd")
+ return QColor("#86efac")
+
+ def _color_for_pct(self, pct: float | None) -> QColor:
+ if pct is None:
+ return QColor("#e5e7eb")
+ if pct >= 0.75:
+ return QColor("#86efac")
+ if pct >= 0.5:
+ return QColor("#fef08a")
+ return QColor("#bfdbfe")
+
+ def load_alerts(self):
+ try:
+ rows = self.api.list_alerts(limit=100, offset=0)
+ self._cache_rows = rows or []
+ except Exception as e:
+ print(f"[ALERTS] load_alerts failed: {e}", flush=True)
+ self._cache_rows = []
+ self.tbl.setRowCount(0)
+ self.lbl_status.setText("Failed to load alerts.")
+ return
+
+ try:
+ self._render_rows(self._cache_rows)
+ except RuntimeError as e:
+ print(f"[WARN] _render_rows aborted: {e}", flush=True)
+ return
+
+ self.lbl_status.setText(f"Loaded {len(self._cache_rows)} alerts.")
+
+ def _render_rows(self, rows: List[dict]):
+ self.tbl.setRowCount(0)
+ for r in rows:
+ row = self.tbl.rowCount()
+ self.tbl.insertRow(row)
+
+ alert_id = r.get("alert_id", "")
+ alert_type = r.get("alert_type", "")
+ fruit = r.get("fruit_type", "")
+ started_at = str(r.get("started_at", "")).replace("T", " ")[:19]
+
+ sev_raw = r.get("severity", None)
+ try:
+ severity = int(sev_raw) if sev_raw is not None else None
+ except (TypeError, ValueError):
+ severity = None
+
+ pct_raw = r.get("pct_ripe", None)
+ pct_ripe = None
+ try:
+ if pct_raw is not None:
+ pct_ripe = float(pct_raw)
+ except (TypeError, ValueError):
+ pct_ripe = None
+
+ # Fruit cell
+ self._set_text(row, 0, fruit or 'Unknown')
+
+ # Severity badge
+ sev_item = QTableWidgetItem("" if severity is None else str(severity))
+ sev_item.setData(Qt.ItemDataRole.ForegroundRole, QBrush(Qt.GlobalColor.white))
+ sev_item.setBackground(QBrush(self._color_for_severity(severity)))
+ sev_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.tbl.setItem(row, 1, sev_item)
+
+ # % ripe badge
+ if pct_ripe is None:
+ pct_txt = "-"
+ else:
+ pct_txt = f"{pct_ripe * 100:.1f}%"
+ pct_item = QTableWidgetItem(pct_txt)
+ pct_item.setData(Qt.ItemDataRole.ForegroundRole, QBrush(Qt.GlobalColor.white))
+ pct_item.setBackground(QBrush(self._color_for_pct(pct_ripe)))
+ pct_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.tbl.setItem(row, 2, pct_item)
+
+ # Started at
+ self._set_text(row, 3, started_at)
+
+ # Images button
+ btn = QPushButton("Gallery", self.tbl)
+ btn.setObjectName("btn_images")
+ btn.clicked.connect(lambda _=False, aid=alert_id: self._open_images_dialog(aid))
+ self.tbl.setCellWidget(row, 4, btn)
+
+ self.tbl.resizeRowsToContents()
+
+ def _set_text(self, row: int, col: int, text: str):
+ item = QTableWidgetItem(text or "")
+ item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ self.tbl.setItem(row, col, item)
+
+ def _apply_filter(self):
+ q = (self.search.text() or "").strip().lower()
+ if not q:
+ try:
+ self._render_rows(self._cache_rows)
+ except RuntimeError as e:
+ print(f"[WARN] _render_rows aborted in filter: {e}", flush=True)
+ self.lbl_status.setText(f"Showing {len(self._cache_rows)}/{len(self._cache_rows)} alerts.")
+ return
+
+ filtered: List[dict] = []
+ for r in self._cache_rows:
+ hay = " ".join([
+ str(r.get("alert_id", "")),
+ str(r.get("alert_type", "")),
+ str(r.get("fruit_type", "")),
+ ]).lower()
+ if q in hay:
+ filtered.append(r)
+
+ try:
+ self._render_rows(filtered)
+ except RuntimeError as e:
+ print(f"[WARN] _render_rows aborted in filter: {e}", flush=True)
+ return
+
+ self.lbl_status.setText(f"Showing {len(filtered)}/{len(self._cache_rows)} alerts.")
+
+ def _open_images_dialog(self, alert_id: str):
+ dlg = AlertImagesDialog(self.api, alert_id, self)
+ dlg.exec()
diff --git a/GUI/src/vast/views/alerts_panel.py b/GUI/src/vast/views/alerts_panel.py
index e9cfd549d..cf1b08cf0 100644
--- a/GUI/src/vast/views/alerts_panel.py
+++ b/GUI/src/vast/views/alerts_panel.py
@@ -37,7 +37,7 @@ def _parse_time(value: str):
# AlertItem Widget
# ────────────────────────────────────────────────
class AlertItem(QFrame):
- """Compact alert box with one-line layout that expands for longer text."""
+ """Compact alert box with clean minimal design."""
def __init__(self, alert):
super().__init__()
@@ -45,15 +45,15 @@ def __init__(self, alert):
self._build_ui()
def _build_ui(self):
- color = "#FFC107" # default amber tone
+ color = "#f59e0b"
layout = QHBoxLayout(self)
- layout.setContentsMargins(10, 6, 10, 6)
- layout.setSpacing(10)
+ layout.setContentsMargins(12, 10, 12, 10)
+ layout.setSpacing(12)
- # Left colored bar
+ # Left colored indicator
bar = QFrame()
- bar.setFixedWidth(5)
+ bar.setFixedWidth(4)
bar.setStyleSheet(f"background-color: {color}; border-radius: 2px;")
layout.addWidget(bar)
@@ -62,14 +62,12 @@ def _build_ui(self):
device = self.alert.get("device_id", "")
summary = self.alert.get("summary", "No summary")
- # Remove ISO timestamps from summary text
summary = re.sub(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:\+\d{2}:\d{2}|Z)?",
"",
summary
).strip()
- # --- Parse and format time ---
start_raw = (
self.alert.get("startsAt")
or self.alert.get("started_at")
@@ -78,17 +76,16 @@ def _build_ui(self):
dt = _parse_time(start_raw)
time_str = dt.strftime("%Y-%m-%d %H:%M") if dt else "–"
- # --- Alert text ---
is_unack = not self.alert.get("ack", False)
font_weight = "font-weight:600;" if is_unack else "font-weight:normal;"
text = QLabel(
f"{alert_type} "
f"on {device} — {summary} "
- f"🕒 {time_str}"
+ f"{time_str}"
)
text.setWordWrap(True)
text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
- text.setFont(QFont("Segoe UI", 9))
+ text.setFont(QFont("Segoe UI", 10))
layout.addWidget(text, 1)
# Right status label
@@ -97,22 +94,18 @@ def _build_ui(self):
self.status_label.setStyleSheet(f"color:{color};")
layout.addWidget(self.status_label, alignment=Qt.AlignmentFlag.AlignRight)
- # Allow box to expand vertically if needed
- self.setMinimumHeight(65)
- self.setMaximumHeight(130)
+ self.setMinimumHeight(60)
+ self.setMaximumHeight(120)
- # Style
self.setStyleSheet("""
- QFrame {
- background-color: #ffffff;
- border: 1px solid #ddd;
- border-radius: 8px;
- }
- """)
+QFrame {
+ background-color: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 10px;
+}
+""")
+
- # ────────────────────────────────────────────────
- # Mark alert as resolved
- # ────────────────────────────────────────────────
def mark_resolved(self, ended_at):
"""Change color and show duration when resolved."""
try:
@@ -136,21 +129,21 @@ def mark_resolved(self, ended_at):
duration = ""
self.status_label.setText(f"✓ {duration}")
- self.status_label.setStyleSheet("color:#2E7D32; font-weight:bold;")
+ self.status_label.setStyleSheet("color:#10b981; font-weight:bold;")
self.setStyleSheet("""
- QFrame {
- background-color: #f6fff6;
- border: 1px solid #b8e5b8;
- border-radius: 8px;
- }
- """)
+QFrame {
+ background-color: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-radius: 10px;
+}
+""")
# ────────────────────────────────────────────────
# AlertsPanel Widget
# ────────────────────────────────────────────────
class AlertsPanel(QWidget):
- """Floating list of alert boxes (like a modern notification dropdown)."""
+ """Clean minimal alerts panel."""
def __init__(self, alert_service):
super().__init__()
@@ -158,8 +151,8 @@ def __init__(self, alert_service):
self.items = {}
layout = QVBoxLayout(self)
- layout.setContentsMargins(10, 10, 10, 10)
- layout.setSpacing(8)
+ layout.setContentsMargins(12, 12, 12, 12)
+ layout.setSpacing(10)
# Scrollable area
scroll = QScrollArea()
@@ -167,31 +160,31 @@ def __init__(self, alert_service):
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll.setStyleSheet("""
- QScrollArea {
- border: none;
- background: transparent;
- }
- QScrollBar:vertical {
- width: 8px;
- background: #f0f0f0;
- margin: 2px;
- border-radius: 4px;
- }
- QScrollBar::handle:vertical {
- background: #bbb;
- border-radius: 4px;
- }
- QScrollBar::handle:vertical:hover {
- background: #999;
- }
- """)
+QScrollArea {
+ border: none;
+ background: transparent;
+}
+QScrollBar:vertical {
+ width: 6px;
+ background: #f3f4f6;
+ margin: 2px;
+ border-radius: 3px;
+}
+QScrollBar::handle:vertical {
+ background: #d1d5db;
+ border-radius: 3px;
+}
+QScrollBar::handle:vertical:hover {
+ background: #9ca3af;
+}
+""")
layout.addWidget(scroll)
# Inner container
container = QWidget()
self.vbox = QVBoxLayout(container)
self.vbox.setContentsMargins(6, 6, 6, 6)
- self.vbox.setSpacing(8)
+ self.vbox.setSpacing(10)
self.vbox.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll.setWidget(container)
diff --git a/GUI/src/vast/views/assets/fruit_devices_map.html b/GUI/src/vast/views/assets/fruit_devices_map.html
new file mode 100644
index 000000000..1c2bf7e06
--- /dev/null
+++ b/GUI/src/vast/views/assets/fruit_devices_map.html
@@ -0,0 +1,448 @@
+
+
+
+
+
+ Fruit Devices Map
+
+
+
+
+
+
+
+
+
+
+ Fruit Devices Map
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/GUI/src/vast/views/assets/gallery.html b/GUI/src/vast/views/assets/gallery.html
new file mode 100644
index 000000000..d975c4f28
--- /dev/null
+++ b/GUI/src/vast/views/assets/gallery.html
@@ -0,0 +1,429 @@
+
+
+
+
+
+ Device Gallery
+
+
+
+
+
+
+
+
+ Device Gallery
+ — click a thumbnail to view
+
+
+
+
+
+
+
No images yet
+
there is no images yet from this device
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/GUI/src/vast/views/assets/grove.jpg b/GUI/src/vast/views/assets/grove.jpg
new file mode 100644
index 000000000..9e4eb4ad2
Binary files /dev/null and b/GUI/src/vast/views/assets/grove.jpg differ
diff --git a/GUI/src/vast/views/assets/sensors_map.html b/GUI/src/vast/views/assets/sensors_map.html
index 3a711258b..37f26f810 100644
--- a/GUI/src/vast/views/assets/sensors_map.html
+++ b/GUI/src/vast/views/assets/sensors_map.html
@@ -1,3 +1,5 @@
+
+
@@ -214,7 +216,7 @@
Zone: ${d.zone||'-'}
Condition: ${cond}
${d.inserted_at||d.ts}
-