diff --git a/.github/workflows/soak.yaml b/.github/workflows/soak.yaml index b3c02dd11..ff0b3234e 100644 --- a/.github/workflows/soak.yaml +++ b/.github/workflows/soak.yaml @@ -15,7 +15,7 @@ jobs: SOAK_RATE_PER_SEC: "1000" MQTT_TOPIC: "mqtt/soak" KAFKA_TOPIC: "dev-robot-alerts" - LOSS_THRESHOLD_PCT: "1.0" # סף כשל: % אובדן מותר (שני לפי הצורך) + LOSS_THRESHOLD_PCT: "1.0" steps: - name: Checkout @@ -248,10 +248,7 @@ jobs: if: always() run: | set -euo pipefail - # ודא שהצרכן נעצר if [ -f kcat.pid ]; then kill -9 "$(cat kcat.pid)" 2>/dev/null || true; fi - # נקה את סביבת ה-compose docker compose down -v || true - # ניקוי נוסף למקרה של שאריות docker ps -aq | xargs -r docker rm -f || true docker network prune -f || true diff --git a/.gitignore b/.gitignore index 348249e4c..1f4c108c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +data/rover_samples/** +!data/rover_samples/.gitkeep + +# Ignore data payloads +/data/ # --- Secrets and Certificates --- *.env *.crt @@ -10,12 +15,22 @@ 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 @@ -38,3 +53,50 @@ venv/ .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 96080bc7a..779d432a9 100644 --- a/GUI/requirements.txt +++ b/GUI/requirements.txt @@ -1,6 +1,9 @@ -PyQt6==6.7.1 -PyQt6-WebEngine==6.7.0 +PyQt6==6.9.1 +PyQt6-WebEngine==6.9.0 +PyQt6-Charts==6.9.0 +PyQt6-Qt6==6.9.1 +PyQt6-sip>=13.6 python-vlc @@ -29,9 +32,21 @@ shapely # ───── Async / misc ───── aiohttp + +folium plotly shapely PyJWT>=2.9.0 sip +SQLAlchemy>=2.0 +geoalchemy2>=0.15.0 + +tenacity>=8.2 +openai>=1.0.0 +python-dotenv>=1.0.0 +jsonschema>=4.0.0 +psycopg2-binary>=2.9.0 +matplotlib>=3.7.0 + diff --git a/GUI/src/vast/alerts/alert_service.py b/GUI/src/vast/alerts/alert_service.py index c6c28e50f..8014e0f86 100644 --- a/GUI/src/vast/alerts/alert_service.py +++ b/GUI/src/vast/alerts/alert_service.py @@ -1,4 +1,5 @@ import yaml +import json, ast from string import Template from PyQt6.QtCore import QObject, pyqtSignal from vast.alerts.alert_client import AlertClient @@ -39,7 +40,7 @@ def _load_templates(self, path): # ──────────────────────────────── def load_devices(self): try: - url = f"{self.api.base}/api/tables/devices" + url = f"{self.api.base}/api/tables/devices?limit=500" r = self.api.http.get(url, timeout=10) r.raise_for_status() data = r.json() @@ -58,7 +59,7 @@ def load_devices(self): # ──────────────────────────────── def load_initial(self): try: - url = f"{self.api.base}/api/tables/alerts" + url = f"{self.api.base}/api/tables/alerts?limit=500" r = self.api.http.get(url, timeout=10) r.raise_for_status() data = r.json() @@ -76,21 +77,49 @@ def load_initial(self): if not a.get("lon") and lon: a["lon"] = lon - # Apply template enrichment + # ───────────── ENRICH ALERT WITH TEMPLATE ───────────── tmpl = self.templates.get(alert_type) if tmpl: - a["category"] = tmpl.get("category") + raw_meta = a.get("meta", {}) or {} + meta = {} + + # FIX: correct meta parsing + if isinstance(raw_meta, dict): + meta = raw_meta + elif isinstance(raw_meta, str): + try: + meta = json.loads(raw_meta) + except Exception: + try: + meta = ast.literal_eval(raw_meta) + except Exception: + meta = {} + + subject = meta.get("subject", "animal") + severity = int(a.get("severity", meta.get("severity", 1))) + + # build context context = { "device_id": device_id, "area": a.get("area", "unknown area"), "confidence": a.get("confidence", "?"), "timestamp": a.get("started_at", ""), + "subject": subject, + "severity": severity, + "started_at": a.get("startsAt", ""), } - # Use Template.safe_substitute to avoid KeyErrors + + # enrich record + a["category"] = tmpl.get("category") + a["severity"] = severity + a["subject"] = subject a["summary"] = Template(tmpl.get("summary", "")).safe_substitute(context) a["recommendation"] = Template(tmpl.get("recommendation", "")).safe_substitute(context) self.alerts = alerts + self.alerts.sort( + key=lambda a: a.get("started_at") or a.get("startsAt") or "", + ) self.alertsUpdated.emit(self.alerts) print(f"[AlertService] Loaded {len(alerts)} enriched alerts.") except Exception as e: @@ -101,7 +130,6 @@ def load_initial(self): # ──────────────────────────────── def _on_realtime(self, alert_msg): alerts = alert_msg.get("alerts", []) - print("[AlertService] Realtime message:", alert_msg) for a in alerts: labels = a.get("labels", {}) @@ -112,25 +140,20 @@ def _on_realtime(self, alert_msg): ends_at = a.get("endsAt") is_resolved = ends_at and not ends_at.startswith("0001-01-01") - # Find existing alert in memory + # Find existing alert existing = next((al for al in self.alerts if al.get("alert_id") == alert_id), None) if is_resolved: - # ✅ Don't delete — update existing alert with endedAt timestamp if existing: existing["endedAt"] = ends_at self.alertRemoved.emit(alert_id) else: - # If not in memory (e.g. loaded from DB earlier) - # create a minimal record so the UI can update fake_alert = {"alert_id": alert_id, "endedAt": ends_at} self.alerts.append(fake_alert) self.alertRemoved.emit(alert_id) continue - # ──────────────────────────────── - # ACTIVE alert (new or ongoing) - # ──────────────────────────────── + # ACTIVE alert lat = ann.get("lat") lon = ann.get("lon") @@ -139,14 +162,44 @@ def _on_realtime(self, alert_msg): lat, lon = self.device_locations[device_id] print(f"[AlertService] Filled missing coords for {device_id}: ({lat}, {lon})") - # Enrich with template + # ──────────────────────────────── + # FIXED meta parsing + # ──────────────────────────────── tmpl = self.templates.get(alert_type, {}) + raw_meta = ann.get("meta", {}) or {} + meta = {} + + if isinstance(raw_meta, dict): + meta = raw_meta + elif isinstance(raw_meta, str): + try: + meta = json.loads(raw_meta) + except Exception: + try: + meta = ast.literal_eval(raw_meta) + except Exception: + meta = {} + + subject = meta.get("subject", "animal") + severity = int(ann.get("severity", 1)) + started_at = a.get("startsAt") or "" + summary = Template(tmpl.get("summary", "")).safe_substitute( device_id=device_id, area=ann.get("area", ""), confidence=ann.get("confidence", ""), + subject=subject, + severity=severity, + started_at=started_at, ) - recommendation = tmpl.get("recommendation", "") + + recommendation = Template(tmpl.get("recommendation", "")).safe_substitute( + device_id=device_id, + area=ann.get("area", ""), + subject=subject, + severity=severity, + ) + category = tmpl.get("category") normalized = { @@ -155,7 +208,7 @@ def _on_realtime(self, alert_msg): "device_id": device_id, "lat": lat, "lon": lon, - "severity": int(ann.get("severity", 1)), + "severity": severity, "summary": summary, "recommendation": recommendation, "category": category, @@ -165,29 +218,31 @@ def _on_realtime(self, alert_msg): "startsAt": a.get("startsAt"), } - # Update if it already exists, else append if existing: existing.update(normalized) else: self.alerts.append(normalized) + self.alerts.sort( + key=lambda a: a.get("started_at") or a.get("startsAt") or "", + ) + self.alertAdded.emit(normalized) - + # ──────────────────────────────── + # Mark all as acknowledged + # ──────────────────────────────── def mark_all_acknowledged(self): - """Mark all alerts as acknowledged both locally and in DB (PATCH /api/tables/alerts).""" unacked = [a for a in self.alerts if not a.get("ack", False)] if not unacked: return - # Update local memory first for a in unacked: a["ack"] = True - # Push updates asynchronously to DB def _patch_ack(alert): try: - url = f"{self.api.base}/api/tables/alerts" + url = f"{self.api.base}/api/tables/alerts?limit=500" payload = { "keys": {"alert_id": alert["alert_id"]}, "data": {"ack": True}, @@ -202,8 +257,4 @@ def _patch_ack(alert): pool.submit(_patch_ack, a) self.alertsUpdated.emit(self.alerts) - print(f"[AlertService] Marked {len(unacked)} alerts as acknowledged.") - - - - + print(f"[AlertService] Marked {len(unacked)} alerts as acknowledged.") \ No newline at end of file diff --git a/GUI/src/vast/dashboard_api.py b/GUI/src/vast/dashboard_api.py index 00a3b2de8..790605fa3 100644 --- a/GUI/src/vast/dashboard_api.py +++ b/GUI/src/vast/dashboard_api.py @@ -442,3 +442,373 @@ def get_phi_for_current_image(self) -> dict: 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) + + + # ===================================================== + # ===== AUDIO ANALYTICS METHODS WITH SOUND FILTER ===== + # ===================================================== + + def get_audio_stats(self, time_range: str = 'all', sound_types: List[str] = None) -> 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'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + }.get(time_range, '') + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + 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} + """ + + results = self.run_query(query) + return results[0] if results else {} + + def get_audio_distribution(self, time_range: str = 'all', limit: int = 10, sound_types: List[str] = None) -> List[Dict]: + """Get distribution of audio classifications""" + 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'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + }.get(time_range, '') + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + 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} + """ + + return self.run_query(query) + + def get_audio_confidence_by_class(self, time_range: str = 'all', limit: int = 10, sound_types: List[str] = None) -> List[Dict]: + """Get average confidence levels by classification""" + 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'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + }.get(time_range, '') + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + 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_audio_detailed_table(self, time_range: str = 'all', limit: int = 20, sound_types: List[str] = None) -> List[Dict]: + """Get detailed table data with class probabilities""" + 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'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + }.get(time_range, '') + + sound_filter = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + 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) -> List[Dict]: + """Get critical sound events""" + 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'", + 'month': "AND r.started_at > NOW() - INTERVAL '30 days'" + }.get(time_range, "AND r.started_at > NOW() - INTERVAL '24 hours'") + + if sound_types and len(sound_types) > 0: + 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 + 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 + JOIN agcloud_audio.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} + """ + + return self.run_query(query) + + def get_audio_timeline(self, time_range: str = 'day', sound_types: List[str] = 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 = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + 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 + 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, fa.head_pred_label + ORDER BY time_bucket ASC, count DESC + """ + + return self.run_query(query) + + def get_audio_heatmap(self, time_range: str = 'week', sound_types: List[str] = 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 = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + query = f""" + SELECT + 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 + WHERE fa.head_pred_label IS NOT NULL + {time_filter} + {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_correlations(self, time_range: str = 'day', sound_types: List[str] = None) -> List[Dict]: + """Get sound detection data for correlation analysis using linked_time from sound_new_sounds_connections""" + 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 = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_list})" + + query = f""" + SELECT + (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 public.sound_new_sounds_connections c + ON c.id = fa.file_id + WHERE fa.head_pred_label IS NOT NULL + {time_filter} + {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) -> 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 = "" + if sound_types and len(sound_types) > 0: + sound_list = "'" + "','".join(sound_types) + "'" + sound_filter = f"AND fa.head_pred_label IN ({sound_filter})" + + 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 ===== + # ===================================================== + def get_sensors(self) -> List[Dict]: + """Get all sensors from the sensors table""" + 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""" + 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 + """ + return self.run_query(query, (limit,)) + + def acknowledge_alert(self, alert_id: str) -> bool: + """Mark an alert as acknowledged""" + conn = None + cursor = None + try: + conn = self._get_connection() + cursor = conn.cursor() + query = "UPDATE alerts SET ack = true WHERE alert_id = %s" + cursor.execute(query, (alert_id,)) + conn.commit() + print(f"[DashboardApi] Alert {alert_id} acknowledged", flush=True) + return True + except Exception as e: + print(f"[DashboardApi] Error acknowledging alert: {e}", flush=True) + return False + finally: + if cursor: + cursor.close() + if conn: + conn.close() + def get_ripeness_stats(self) -> Dict: + """Get ripeness prediction statistics""" + query = """ + SELECT + COUNT(*) as total_predictions, + SUM(CASE WHEN ripeness_label = 'ripe' THEN 1 ELSE 0 END) as ripe_count, + SUM(CASE WHEN ripeness_label = 'unripe' THEN 1 ELSE 0 END) as unripe_count, + SUM(CASE WHEN ripeness_label = 'overripe' THEN 1 ELSE 0 END) as overripe_count + FROM ripeness_predictions + """ + results = self.run_query(query) + return results[0] if results else {} diff --git a/GUI/src/vast/desktop/Dockerfile b/GUI/src/vast/desktop/Dockerfile index ec0cc9972..d60b2dc24 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,25 @@ 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 ───────── +# ───────────────────── 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; \ + 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,37 +39,50 @@ 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.8.0" \ - "PyQt6-WebEngine==6.8.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 + --prefer-binary --break-system-packages +RUN pip install plotly PyJWT -RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/* +# ───────────────────── 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/* +RUN pip install psycopg2-binary COPY src/vast /app/src/vast COPY src/vast/desktop/start.sh /app/start.sh RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh && chown -R appuser:appuser /app 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 +90,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..12884fe5d 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 @@ -22,10 +26,3 @@ echo "[INFO] Starting noVNC..." echo "[INFO] Starting PyQt application..." exec python /app/src/vast/main.py - - - -# # ------------------------------ -# # 🚀 Launch the main PyQt application -# # ------------------------------ -# exec /opt/venv/bin/python /app/src/vast/main.py diff --git a/GUI/src/vast/dsl/builder.py b/GUI/src/vast/dsl/builder.py index 93ab0785e..cf17cf985 100644 --- a/GUI/src/vast/dsl/builder.py +++ b/GUI/src/vast/dsl/builder.py @@ -24,13 +24,11 @@ class SQLState: source: str dialect: Dialect clauses: Dict[str, List[Clause]] = field(default_factory=lambda: defaultdict(list)) - CLAUSE_ORDER: List[str] = field(default_factory=lambda: ["select", "from", "where"]) # extend externally + CLAUSE_ORDER: List[str] = field(default_factory=lambda: [ + "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) @@ -75,10 +73,19 @@ def compile(self, plan: Plan | Dict[str, Any]) -> tuple[str, List[Any]]: p = plan if isinstance(plan, Plan) else Plan.from_dict(plan) st = SQLState(source=p.source, dialect=self.dialect) for op in p._ops: - if set(op.keys()) - {"op", "columns", "cond"}: + if not isinstance(op, dict): + raise TypeError(f"Each operation must be a dict, got {type(op).__name__}: {op}") + + allowed_keys = {"op", "columns", "cond", "directions", "limit", "offset"} + extra = set(op.keys()) - allowed_keys + if extra: raise ValueError(f"Unknown keys in op: {op}") + op_type = op.get("op") if op_type not in Op.registry: raise ValueError(f"Unsupported op {op_type!r}. Allowed: {sorted(Op.registry)}") + + # Pass all keys except "op" as kwargs Op.registry[op_type](**{k: v for k, v in op.items() if k != "op"}).apply(st) - return st.build() \ No newline at end of file + + return st.build() diff --git a/GUI/src/vast/dsl/clauses.py b/GUI/src/vast/dsl/clauses.py index f2d0f225b..d2f2ecf48 100644 --- a/GUI/src/vast/dsl/clauses.py +++ b/GUI/src/vast/dsl/clauses.py @@ -27,12 +27,23 @@ def fragment(self, ctx: CompileCtx) -> str: ... @dataclass class SelectClause(Clause): columns: List[str] + @property - def phase(self) -> str: return "select" - def keyword(self) -> str: return "SELECT" - def joiner(self) -> str: return ", " + def phase(self) -> str: + return "select" + + def keyword(self) -> str: + return "SELECT" + + def joiner(self) -> str: + return ", " + def fragment(self, ctx: CompileCtx) -> str: def star_aware_quote(col: str) -> str: + # Skip quoting if expression or alias (COUNT(*), AVG(...), AS, etc.) + if any(token in col.upper() for token in ("(", ")", " AS ")): + return col # treat as SQL expression + # Support "*", "tbl.*", and dotted names that may end with * parts = col.split(".") quoted = [] @@ -42,11 +53,13 @@ def star_aware_quote(col: str) -> str: else: quoted.append(ctx.dialect.quote_ident(p)) return ".".join(quoted) + if not self.columns: return "*" # default return ", ".join(star_aware_quote(c) for c in self.columns) + @dataclass class FromClause(Clause): source: str @@ -65,4 +78,78 @@ def phase(self) -> str: return "where" def keyword(self) -> str: return "WHERE" def joiner(self) -> str: return " AND " def fragment(self, ctx: CompileCtx) -> str: - return self.cond.compile(ctx) \ No newline at end of file + return self.cond.compile(ctx) + + +@dataclass +class OrderByClause(Clause): + columns: list[str] + directions: list[str] + + @property + def phase(self): return "order_by" + def keyword(self): return "ORDER BY" + 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 "(" in col or ")" in col: + return col + return ctx.dialect.quote_ident(col) + + cols = [quote_or_passthrough(c) for c in self.columns] + dirs = self.directions or ["ASC"] * len(cols) + return ", ".join(f"{c} {d.upper()}" for c, d in zip(cols, dirs)) + + + +@dataclass +class LimitClause(Clause): + limit: int + @property + def phase(self): return "limit" + def keyword(self): return "LIMIT" + def joiner(self): return " " + def fragment(self, ctx): return str(self.limit) + + +@dataclass +class OffsetClause(Clause): + offset: int + @property + def phase(self): return "offset" + def keyword(self): return "OFFSET" + def joiner(self): return " " + def fragment(self, ctx): return str(self.offset) + + +@dataclass +class GroupByClause(Clause): + columns: list[str] + + @property + def phase(self): return "group_by" + def keyword(self): return "GROUP BY" + def joiner(self): return ", " + + def fragment(self, ctx): + def quote_or_passthrough(col: str) -> str: + # Skip quoting if it's a SQL expression or function call + if any(token in col.upper() for token in ( + "(", ")", " AS ", "COUNT", "AVG", "SUM", "MAX", "MIN", "DATE_TRUNC" + )): + return col + return ctx.dialect.quote_ident(col) + return ", ".join(quote_or_passthrough(c) for c in self.columns) + + + +@dataclass +class HavingClause(Clause): + cond: Cond + @property + def phase(self): return "having" + def keyword(self): return "HAVING" + def joiner(self): return " AND " + def fragment(self, ctx): return self.cond.compile(ctx) diff --git a/GUI/src/vast/dsl/dialects.py b/GUI/src/vast/dsl/dialects.py index 078ec65cc..b83ce6c9f 100644 --- a/GUI/src/vast/dsl/dialects.py +++ b/GUI/src/vast/dsl/dialects.py @@ -35,19 +35,33 @@ 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) + def __init__(self, style: str = "named"): """ - - if style not in ("psycopg", "numeric"): - raise ValueError("PostgresDialect.style must be 'psycopg' or 'numeric'") + style: + - 'psycopg' → %s placeholders (for psycopg2) + - 'numeric' → $1, $2 placeholders (for asyncpg) + - 'named' → :p1, :p2 placeholders (for SQLAlchemy.text) + """ + if style not in ("psycopg", "numeric", "named"): + raise ValueError("PostgresDialect.style must be 'psycopg', 'numeric', or 'named'") self.style = style + def quote_ident(self, name: str) -> str: parts = name.split(".") - return ".".join('"' + p.replace('"', '""') + '"' for p in parts) + escaped = [] + for p in parts: + escaped_name = p.replace('"', '""') + escaped.append(f'"{escaped_name}"') + return ".".join(escaped) + + def normalize_bool(self, v: Any) -> Any: - return v # PostgreSQL has a real boolean type + return v + def placeholder(self, idx: int) -> str: - return "%s" if self.style == "psycopg" else f"${idx}" \ No newline at end of file + if self.style == "psycopg": + return "%s" + elif self.style == "numeric": + return f"${idx}" + else: # named + return f":p{idx}" diff --git a/GUI/src/vast/dsl/expr.py b/GUI/src/vast/dsl/expr.py index 03abf40d1..d9c41aff3 100644 --- a/GUI/src/vast/dsl/expr.py +++ b/GUI/src/vast/dsl/expr.py @@ -47,8 +47,37 @@ def to_ir(self) -> Dict[str, Any]: return {"col": self.name} class Literal(Expr): """A literal value that becomes a bound parameter (with a placeholder).""" value: Any - def compile(self, ctx: CompileCtx) -> str: return ctx.add_param(self.value) - def to_ir(self) -> Dict[str, Any]: return {"literal": self.value} + + def compile(self, ctx: CompileCtx) -> str: + # Detect SQL expressions that should be inlined (not parameterized) + if isinstance(self.value, str) and any( + kw in self.value.lower() + for kw in ("now()", "interval", "date_trunc", "current_date", "current_timestamp") + ): + return self.value # inline directly as SQL expression + + # Default: treat as bound parameter + return ctx.add_param(self.value) + + 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.""" @@ -72,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)})" @@ -96,34 +125,76 @@ 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: - """Decode a strict Cond IR object into Cond.""" - if not isinstance(d, dict): raise TypeError("Cond node must be an object") + """Decode a strict Cond IR object into Cond with defensive validation.""" + # ───────────── DEBUG LOG ───────────── + import traceback + print("\n[DEBUG cond_from_ir] called with:", repr(d)) + # Optional: print call stack to see which Op invoked this + # print("".join(traceback.format_stack(limit=3))) + + if not isinstance(d, dict): + raise TypeError(f"Cond node must be an object, got {type(d).__name__}: {d}") + keys = set(d.keys()) + + # ───── Logical AND if keys == {"all"}: arr = d["all"] - if not isinstance(arr, list): raise TypeError("all must be an array of Cond") + if not isinstance(arr, list): + raise TypeError(f"'all' must be a list of condition objects, got {type(arr).__name__}") + for i, item in enumerate(arr): + if not isinstance(item, dict): + raise TypeError(f"List argument at index {i} must be a dict, got {type(item).__name__}: {item}") return All(*[cond_from_ir(x) for x in arr]) + + # ───── Logical OR if keys == {"any"}: arr = d["any"] - if not isinstance(arr, list): raise TypeError("any must be an array of Cond") + if not isinstance(arr, list): + raise TypeError(f"'any' must be a list of condition objects, got {type(arr).__name__}") + for i, item in enumerate(arr): + if not isinstance(item, dict): + raise TypeError(f"List argument at index {i} must be a dict, got {type(item).__name__}: {item}") return Any(*[cond_from_ir(x) for x in arr]) + + # ───── Predicate if keys == {"op", "left", "right"}: try: - op = BinOp(d["op"]) # raises ValueError if unknown -> nice error + op = BinOp(d["op"]) except ValueError as e: allowed = ", ".join(o.value for o in BinOp) raise ValueError(f"Operator {d['op']!r} not allowed. Allowed: [{allowed}]") from e return Predicate(expr_from_ir(d["left"]), op, expr_from_ir(d["right"])) + if "col" in d and "literal" in d: + return Predicate(expr_from_ir({"col": d["col"]}), BinOp.EQ, expr_from_ir({"literal": d["literal"]})) + + raise ValueError(f"Invalid condition node: {d}") + + + # Convenience aliases AND = All -OR = Any \ No newline at end of file +OR = Any diff --git a/GUI/src/vast/dsl/ops.py b/GUI/src/vast/dsl/ops.py index 0cbc7678d..cc66239c0 100644 --- a/GUI/src/vast/dsl/ops.py +++ b/GUI/src/vast/dsl/ops.py @@ -7,7 +7,7 @@ from __future__ import annotations from typing import Any, Dict, List, Type from abc import ABC, abstractmethod -from .clauses import SelectClause, WhereClause +from .clauses import SelectClause, WhereClause,OrderByClause,LimitClause,HavingClause,OffsetClause,GroupByClause from .expr import cond_from_ir # from .builder import SQLState @@ -28,11 +28,55 @@ 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: Dict[str, Any] = self.payload["cond"] - st.add_where(cond_from_ir(cond_ir)) + cond_ir = self.payload.get("cond") + if not isinstance(cond_ir, dict): + raise TypeError("Invalid WHERE condition") + expr = cond_from_ir(cond_ir) + st.add_clause(WhereClause(expr)) + + + +class HavingOp(Op): + op_type = "having" + def apply(self, st): + cond_ir = self.payload.get("cond") + if not isinstance(cond_ir, dict): + raise TypeError( + f"Invalid HAVING condition: expected dict, got {type(cond_ir).__name__} → {cond_ir}" + ) + st.add_clause(HavingClause(cond_from_ir(cond_ir))) + + + +class OrderByOp(Op): + op_type = "order_by" + def apply(self, st): + st.add_clause(OrderByClause( + self.payload.get("columns", []), + self.payload.get("directions", []) + )) + +class LimitOp(Op): + op_type = "limit" + def apply(self, st): + st.add_clause(LimitClause(self.payload["limit"])) + +class OffsetOp(Op): + op_type = "offset" + def apply(self, st): + st.add_clause(OffsetClause(self.payload["offset"])) + +class GroupByOp(Op): + op_type = "group_by" + def apply(self, st): + st.add_clause(GroupByClause(self.payload.get("columns", []))) + + + diff --git a/GUI/src/vast/dsl/query.py b/GUI/src/vast/dsl/query.py index 12d5457f7..b21965bae 100644 --- a/GUI/src/vast/dsl/query.py +++ b/GUI/src/vast/dsl/query.py @@ -26,6 +26,28 @@ def where(self, cond: Cond) -> "Query": self._plan._ops.append({"op": "where", "cond": cond.to_ir()}) return self + def order_by(self, *columns: str, directions: list[str] | None = None) -> "Query": + directions = directions or ["ASC"] * len(columns) + self._plan._ops.append({"op": "order_by", "columns": list(columns), "directions": directions}) + return self + + def limit(self, n: int) -> "Query": + self._plan._ops.append({"op": "limit", "limit": n}) + return self + + def offset(self, n: int) -> "Query": + self._plan._ops.append({"op": "offset", "offset": n}) + return self + + def group_by(self, *columns: str) -> "Query": + self._plan._ops.append({"op": "group_by", "columns": list(columns)}) + return self + + def having(self, cond: Cond) -> "Query": + self._plan._ops.append({"op": "having", "cond": cond.to_ir()}) + return self + + def to_plan(self) -> Plan: """Return the underlying Plan object (e.g., for transport).""" return self._plan diff --git a/GUI/src/vast/dsl/runtime.py b/GUI/src/vast/dsl/runtime.py index 20fcc8aef..15fdaefb8 100644 --- a/GUI/src/vast/dsl/runtime.py +++ b/GUI/src/vast/dsl/runtime.py @@ -9,10 +9,13 @@ class CompileCtx: def __init__(self, dialect: Dialect) -> None: - self.params: List[Any] = [] # Accumulated bound parameters in order of appearance. + self.params: dict[str, Any] = {} self.dialect = dialect def add_param(self, v: Any) -> str: - """Append a value (after dialect-specific normalization) and return its placeholder.""" - self.params.append(self.dialect.normalize_bool(v)) - return self.dialect.placeholder(len(self.params)) + """Append a value and return its dialect-specific placeholder.""" + idx = len(self.params) + 1 + name = f"p{idx}" + self.params[name] = self.dialect.normalize_bool(v) + return self.dialect.placeholder(idx) + diff --git a/GUI/src/vast/gateway/Dockerfile b/GUI/src/vast/gateway/Dockerfile index 745b58831..29549ecba 100644 --- a/GUI/src/vast/gateway/Dockerfile +++ b/GUI/src/vast/gateway/Dockerfile @@ -1,33 +1,54 @@ +# syntax=docker/dockerfile:1.6 + FROM python:3.11-slim -ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 -WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app # build arg # 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 -# System CA + add NetFree certs -RUN if [ "$USE_NETFREE" = "true" ] && [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \ - echo "Configuring NetFree certificates..."; \ - cp ./certs/*.crt /usr/local/share/ca-certificates/; \ - update-ca-certificates; \ +# Toggle NetFree handling at build time: +# docker build --build-arg USE_NETFREE=true -t image:netfree . +# docker build --build-arg USE_NETFREE=false -t image:default . +ARG USE_NETFREE=false + +# Base system tools (certificates + curl) +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# Optional extra CA certs (e.g. NetFree) from ./certs +COPY certs/ /tmp/certs + +RUN set -eux; \ + if [ "${USE_NETFREE}" = "true" ] \ + && [ -d /tmp/certs ] \ + && ls /tmp/certs/*.crt >/dev/null 2>&1; then \ + echo "Adding extra CA certs from /tmp/certs ..."; \ + cp /tmp/certs/*.crt /usr/local/share/ca-certificates/; \ + chmod 644 /usr/local/share/ca-certificates/*.crt; \ + update-ca-certificates; \ + else \ + echo "No extra CA certs configured (USE_NETFREE=${USE_NETFREE})."; \ fi -# SSL certs env +# System-wide SSL env (works with or without extra CAs) ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ PIP_CERT=/etc/ssl/certs/ca-certificates.crt +ENV PIP_DEFAULT_TIMEOUT=600 -# # System CA + add NetFree certs -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/ -RUN update-ca-certificates || true -ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ - REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ - PIP_CERT=/etc/ssl/certs/ca-certificates.crt +# # # System CA + add NetFree certs +# 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/ +# RUN update-ca-certificates || true +# ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ +# REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ +# PIP_CERT=/etc/ssl/certs/ca-certificates.crt # Python deps COPY requirements.txt /app/requirements.txt @@ -37,16 +58,17 @@ RUN pip install --no-cache-dir -r /app/requirements.txt \ --trusted-host pypi.python.org \ --trusted-host files.pythonhosted.org -# Copy source under /app/vast/* +# App code COPY src/vast/gateway /app/vast/gateway COPY src/vast/proto /app/vast/proto -# Generate stubs +# Generate gRPC stubs RUN python -m grpc_tools.protoc -I./vast/proto \ --python_out=. --grpc_python_out=. \ ./vast/proto/query.proto ENV PYTHONPATH=/app/vast/proto/generated:/app ENV RUNNER_ADDR=runner:50051 + EXPOSE 8000 -CMD ["uvicorn", "vast.gateway.gateway_main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "vast.gateway.gateway_main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/GUI/src/vast/home_view.py b/GUI/src/vast/home_view.py index ec2b41df2..b82ef2ef1 100644 --- a/GUI/src/vast/home_view.py +++ b/GUI/src/vast/home_view.py @@ -174,7 +174,3 @@ def _on_alert_realtime(self, alert: dict): print(f"[HomeView] Active alert: {normalized['alert_type']} " f"from {normalized['device_id']} ({normalized['lat']}, {normalized['lon']})") self.alert_layer.add_or_update_alert(normalized) - - - - diff --git a/GUI/src/vast/main.py b/GUI/src/vast/main.py index f302d3b81..4517b96d6 100644 --- a/GUI/src/vast/main.py +++ b/GUI/src/vast/main.py @@ -100,30 +100,31 @@ def main() -> int: print("[main] starting QApplication") app = QApplication(sys.argv) + # 1) create the auth shell but do NOT show it shell = AuthShell() shell.setWindowTitle("Sign in") # shell.show() # disabled to skip the login window + # 2) when login succeeds -> open MainWindow def open_main(user): api = DashboardApi() # create API instance (user not required) win = MainWindow(api) - # connect logout back to login win.logoutRequested.connect(lambda: on_logout(win)) - win.show() shell.hide() + def on_logout(win): win.close() shell.reset() shell.show() - # wire callback shell.on_login_success = open_main + # open the main window directly (skip login) open_main(None) @@ -131,7 +132,5 @@ def on_logout(win): rc = app.exec() print(f"[main] event loop exited with code {rc}") return rc - - if __name__ == "__main__": sys.exit(main()) diff --git a/GUI/src/vast/main_window.py b/GUI/src/vast/main_window.py index 0dfc7c1d9..726b129c6 100644 --- a/GUI/src/vast/main_window.py +++ b/GUI/src/vast/main_window.py @@ -1,3 +1,916 @@ +# # 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 ( QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar, @@ -9,19 +922,17 @@ 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.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 + + +# === Irrigation imports === +from views.irrigation.irrigation_view import IrrigationView class MainWindow(QMainWindow): @@ -37,18 +948,52 @@ def __init__(self, api: DashboardApi, parent=None): # 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; + 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; } - 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; } """) # ─────────────────────────────── @@ -59,12 +1004,13 @@ def __init__(self, api: DashboardApi, parent=None): 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) + # TOP BAR # ─────────────────────────────── toolbar = self.addToolBar("Main Toolbar") toolbar.setMovable(False) @@ -76,6 +1022,13 @@ def __init__(self, api: DashboardApi, parent=None): 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 logout_btn = QPushButton("Logout") logout_btn.setToolTip("Log out") @@ -90,12 +1043,16 @@ def __init__(self, api: DashboardApi, parent=None): font-size: 11pt; font-weight: 600; } - QPushButton:hover { background-color: #059669; } - QPushButton:pressed { background-color: #047857; } + QPushButton:hover { + background-color: #059669; + } + QPushButton:pressed { + background-color: #047857; + } """) logout_btn.clicked.connect(self._logout) - # Alert bell + # Bell button self.alert_button = QToolButton() self.alert_button.setToolTip("Show alerts") self.alert_button.setText("🔔") @@ -108,16 +1065,18 @@ def __init__(self, api: DashboardApi, parent=None): padding: 4px; border-radius: 8px; } - QToolButton:hover { background-color: #e5e7eb; } + QToolButton:hover { + background-color: #e5e7eb; + } """) - # Alert badge + # Larger blue 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; + background-color: #3b82f6; /* blue */ color: white; font-size: 10pt; font-weight: bold; @@ -139,12 +1098,18 @@ def 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; } + QLabel { + font-size: 17pt; + font-weight: 600; + color: #111827; + } """) + # Shadow shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(8) shadow.setColor(QColor(0, 0, 0, 35)) @@ -156,44 +1121,42 @@ def reposition_badge(): top_bar_layout.addStretch() top_bar_layout.addWidget(title_label) top_bar_layout.addStretch() + toolbar.addWidget(top_bar) # ─────────────────────────────── - # NAVIGATION + # 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_dock.setWidget(self.nav_list) self.nav_dock.setMinimumWidth(220) - font = QFont(); font.setPointSize(12) + font = QFont() + font.setPointSize(12) self.nav_list.setFont(font) - # Menu with expandable Sensors section - 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) + for name in [ + "Home", "Sensors", "Sound", "Ground Image", + "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Irrigation" + ]: + QListWidgetItem(f" {name}", self.nav_list) + 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 + PANEL + # 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()) + # 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) @@ -213,38 +1176,54 @@ def reposition_badge(): 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) self.ground_view = GroundView(api, self) self.auth_status = AuthStatusView(api, self) - # New Sensors views self.sensors_status_summary = SensorsStatusSummary(api, self) self.sensors_health = SensorsView(api, self) self.sensors_main = SensorsMainView(api, self) + print("[DEBUG] Creating IrrigationView...") + try: + self.irrigation_view = IrrigationView(api, self) + print("[DEBUG] IrrigationView created successfully") + except Exception as e: + print(f"[ERROR] Failed to create IrrigationView: {e}") + import traceback + traceback.print_exc() + self.irrigation_view = QWidget() # Fallback empty widget 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, + "Security": self.security_view, "Fruits": self.fruits_view, - "Ground": self.ground_view, - "Auth": self.auth_status, + "Ground Image": self.ground_view, + "Irrigation": self.irrigation_view, + "Auth": self.auth_status + } for view in self.views.values(): self.stack.addWidget(view) self.stack.setCurrentWidget(self.home) - self.history = [] + self.history: list = [] # ─────────────────────────────── # 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") @@ -284,39 +1263,23 @@ def toggle_alert_panel(self): # ─────────────────────────────── def _on_nav_change(self, row: int) -> None: name = self.nav_list.item(row).text().strip() + print(f"[DEBUG] _on_nav_change: row={row}, name='{name}'") + print(f"[DEBUG] Available views: {list(self.views.keys())}") if name in self.views: + print(f"[DEBUG] Navigating to '{name}'") self.navigate_to(self.views[name]) else: + print(f"[DEBUG] Section '{name}' not found in views") 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): + print(f"[DEBUG] navigate_to called with widget: {widget.__class__.__name__}") current = self.stack.currentWidget() if current not in self.history: self.history.append(current) + print(f"[DEBUG] Setting current widget to: {widget.__class__.__name__}") self.stack.setCurrentWidget(widget) + print(f"[DEBUG] Current widget is now: {self.stack.currentWidget().__class__.__name__}") def go_back(self): if self.history: @@ -327,4 +1290,4 @@ def go_back(self): def _logout(self) -> None: self.statusBar().showMessage("Logged out (demo)") - self.logoutRequested.emit() + 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..ef2c122c9 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 @@ -25,18 +25,7 @@ class OrthophotoViewer(QGraphicsView): def __init__(self, tiles: Union[TileStore, str, Path]) -> None: super().__init__() - # ───────────────────────────── - # Load tiles - # ───────────────────────────── - # if isinstance(tiles, TileStore): - # self.ts = tiles - # else: - # self.ts = TileStore(Path(tiles)) - - # self.min_zoom_fs = self.ts.min_zoom - # self.max_zoom_fs = self.ts.max_zoom - # self.z_ranges = self.ts.z_ranges - # self.is_tms = self.ts.is_tms + # ───────────────────────────── # Load tiles # ───────────────────────────── @@ -125,8 +114,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 +267,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/views/SimilarPeriodsSensors.py b/GUI/src/vast/views/SimilarPeriodsSensors.py new file mode 100644 index 000000000..8c0a0c84d --- /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(""" +
| ID | +Name | +Type | +Install Date | +Active | +Distance | +
|---|