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""" + + + + + + + + + """ + + html = 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)}
+ + + + + + + + + + + + {rows} + +
IDNameTypeInstall DateActiveDistance
+ + + """ + + 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 +
+
+
+ + Fruit cameras +
+
+
+
+
+
+
+ + + + + + + + \ 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}
-