diff --git a/.github/workflows/daily_pytest_slack.yml b/.github/workflows/daily_pytest_slack.yml new file mode 100644 index 000000000..d8285bf6b --- /dev/null +++ b/.github/workflows/daily_pytest_slack.yml @@ -0,0 +1,83 @@ +name: Daily Pytest + Slack (IL 01:00) + +on: + schedule: + # 01:00 Israel time — 22:00 UTC (summer), 23:00 UTC (winter) + - cron: "0 22 * * *" + - cron: "0 23 * * *" + workflow_dispatch: + +jobs: + run_pytests_and_notify: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Run pytest (and keep log) + run: | + pytest -q --maxfail=50 --disable-warnings -rA \ + --junitxml=pytest-report.xml > pytest.log 2>&1 || true + + - name: Parse results + id: results + run: | + python - <<'PY' + import xml.etree.ElementTree as ET + import os + counts = dict(tests=0, failures=0, errors=0, skipped=0) + try: + tree = ET.parse("pytest-report.xml") + root = tree.getroot() + for suite in root.findall(".//testsuite"): + counts["tests"] += int(suite.attrib.get("tests", 0)) + counts["failures"] += int(suite.attrib.get("failures", 0)) + counts["errors"] += int(suite.attrib.get("errors", 0)) + counts["skipped"] += int(suite.attrib.get("skipped", 0)) + except Exception as e: + print("Parse error:", e) + counts["passed"] = counts["tests"] - counts["failures"] - counts["errors"] - counts["skipped"] + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + for k,v in counts.items(): + f.write(f"{k}={v}\n") + f.write(f"has_failures={'true' if (counts['failures']>0 or counts['errors']>0) else 'false'}\n") + PY + + - name: Send Slack notification (if failures) + if: steps.results.outputs.has_failures == 'true' + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "channel": "#vast", + "username": "GitHub Actions", + "icon_emoji": ":rotating_light:", + "text": "🚨 *Pytest Failures Detected!*\n\nRepository: ${{ github.repository }}\nRun: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>\n\n*Passed:* ${{ steps.results.outputs.passed }} / ${{ steps.results.outputs.tests }}\n*Failed:* ${{ steps.results.outputs.failures }}\n*Errors:* ${{ steps.results.outputs.errors }}\n*Skipped:* ${{ steps.results.outputs.skipped }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + - name: Send Slack success notification + if: steps.results.outputs.has_failures == 'false' + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "channel": "#vast", + "username": "GitHub Actions", + "icon_emoji": ":white_check_mark:", + "text": "✅ All tests passed successfully!\n\nRepository: ${{ github.repository }}\nRun: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>\n\nTotal tests: ${{ steps.results.outputs.tests }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/soak.yaml b/.github/workflows/soak.yaml index 76adc94d4..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 @@ -36,6 +36,31 @@ jobs: MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin123 EOF + - name: Prepare env for db_api_service + run: | + mkdir -p services/db_api_service + printf '%s\n' \ + 'DB_DSN=postgresql+psycopg://missions_user:pg123@postgres:5432/missions_db' \ + 'ENV=ci' \ + 'JWT_SECRET=change-me-in-ci' \ + 'JWT_ALGO=HS256' \ + 'ACCESS_TTL_MIN=15' \ + 'REFRESH_TTL_DAYS=14' \ + 'DEV_SA_NAME=ci-service' \ + > services/db_api_service/.env + - name: Prepare env for plant_stress + run: | + mkdir -p services/plant_stress + cat > services/plant_stress/.env <<'EOF' + ADDR=0.0.0.0 + PORT=8001 + MINIO_ENDPOINT=minio:9000 + MINIO_ACCESS_KEY=minioadmin + MINIO_SECRET_KEY=minioadmin123 + MINIO_BUCKET=audio + MINIO_PREFIX=samples/ + WINDOW_MIN=5 + EOF - name: Start core stack run: docker compose up -d kafka mosquitto connect @@ -223,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 7aa2dd1d2..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 @@ -7,15 +12,28 @@ storage_with_mqtt/secrets/ storage_with_mqtt/mqtt_images/secrets/ MQTT_IMAGES/secrets/ +services/sounds/sounds_classifier/src/classification/data/ +services/sounds/sounds_classifier/src/classification/models/panns_data/ + +# Ignore environment and IDE files +.env # --- Python --- __pycache__/ +*.py[cod] *.pyc *.pyo *.pyd *.pytest_cache/ +.pytest_cache/ +<<<<<<< HEAD +======= + + +>>>>>>> 4bb2e60fc0fd9a846955fa89533d661a56b1645a .venv/ venv/ +.coverage # --- VSCode / Editor --- .vscode/ @@ -36,3 +54,49 @@ venv/ 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 bd9efb1b7..779d432a9 100644 --- a/GUI/requirements.txt +++ b/GUI/requirements.txt @@ -1,18 +1,52 @@ + 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 + -# Web/API +# ───── Web/API ───── fastapi>=0.110 uvicorn[standard]>=0.29 flask -# Metrics & HTTP + +# ───── Metrics & HTTP ───── prometheus-client>=0.20 requests>=2.31 httpx==0.27.0 # only needed when you switch to real Flink REST -# gRPC & Protobuf + +# ───── gRPC & Protobuf ───── grpcio>=1.56,<2 grpcio-tools>=1.56,<2 protobuf>=6,<7 -# Validation / crypto + +# ───── Validation / crypto / auth ───── pydantic>=2.9,<3 argon2-cffi +PyJWT>=2.9.0 + +# ───── Geospatial / Math ───── +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_client.py b/GUI/src/vast/alerts/alert_client.py new file mode 100644 index 000000000..7212a10c8 --- /dev/null +++ b/GUI/src/vast/alerts/alert_client.py @@ -0,0 +1,57 @@ + +from PyQt6.QtCore import QObject, pyqtSignal, QUrl, QTimer +from PyQt6.QtWebSockets import QWebSocket +from PyQt6.QtNetwork import QAbstractSocket # ✅ add this +import json + + +class AlertClient(QObject): + """ + Connects to the alerts WebSocket gateway and emits signals + when new alerts or snapshots arrive. + """ + snapshotReceived = pyqtSignal(list) + alertReceived = pyqtSignal(dict) + connectionLost = pyqtSignal() + + def __init__(self, ws_url: str, parent=None): + super().__init__(parent) + self.url = QUrl(ws_url) + self.socket = QWebSocket() + self.socket.connected.connect(self._on_connected) + self.socket.disconnected.connect(self._on_disconnected) + self.socket.textMessageReceived.connect(self._on_message) + self.reconnect_timer = QTimer() + self.reconnect_timer.timeout.connect(self._try_reconnect) + self.reconnect_interval_ms = 5000 # retry every 5s + self._connect() + + def _connect(self): + print(f"[AlertClient] Connecting to {self.url.toString()}") + self.socket.open(self.url) + + def _try_reconnect(self): + # ✅ Use QAbstractSocket.SocketState instead of QWebSocket.SocketState + if self.socket.state() == QAbstractSocket.SocketState.ConnectedState: + self.reconnect_timer.stop() + return + print("[AlertClient] Attempting reconnect...") + self._connect() + + + def _on_connected(self): + print("[AlertClient] Connected to alerts gateway.") + self.reconnect_timer.stop() + + def _on_disconnected(self): + print("[AlertClient] Disconnected from alerts gateway.") + self.connectionLost.emit() + self.reconnect_timer.start(self.reconnect_interval_ms) + + def _on_message(self, msg: str): + try: + payload = json.loads(msg) + if payload["type"] == "alert": + self.alertReceived.emit(payload["data"]) + except Exception as e: + print("[AlertClient] Invalid message:", e, msg) diff --git a/GUI/src/vast/alerts/alert_service.py b/GUI/src/vast/alerts/alert_service.py new file mode 100644 index 000000000..8014e0f86 --- /dev/null +++ b/GUI/src/vast/alerts/alert_service.py @@ -0,0 +1,260 @@ +import yaml +import json, ast +from string import Template +from PyQt6.QtCore import QObject, pyqtSignal +from vast.alerts.alert_client import AlertClient +from concurrent.futures import ThreadPoolExecutor + +class AlertService(QObject): + alertsUpdated = pyqtSignal(list) + alertAdded = pyqtSignal(dict) + alertRemoved = pyqtSignal(str) + + def __init__(self, ws_url, api, templates_path="/app/templates/templates.yml"): + super().__init__() + self.api = api + self.device_locations = {} + self.templates = self._load_templates(templates_path) + self.load_devices() + + self.client = AlertClient(ws_url) + self.client.alertReceived.connect(self._on_realtime) + + self.alerts = [] + + # ──────────────────────────────── + # Load YAML templates + # ──────────────────────────────── + def _load_templates(self, path): + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + print(f"[AlertService] Loaded templates from {path}") + return data.get("templates", {}) + except Exception as e: + print("[AlertService] Failed to load templates:", e) + return {} + + # ──────────────────────────────── + # Fetch devices from DB + # ──────────────────────────────── + def load_devices(self): + try: + 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() + devices = data.get("rows", data) + + self.device_locations = { + d["device_id"]: (d.get("location_lat"), d.get("location_lon")) + for d in devices if d.get("device_id") + } + print(f"[AlertService] Cached {len(self.device_locations)} device locations.") + except Exception as e: + print("[AlertService] Failed to fetch devices:", e) + + # ──────────────────────────────── + # Fetch alerts from DB and enrich with templates + # ──────────────────────────────── + def load_initial(self): + try: + 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() + alerts = data.get("rows", data) + + for a in alerts: + device_id = a.get("device_id") + alert_type = a.get("alert_type") + + # Add lat/lon if missing + if device_id in self.device_locations: + lat, lon = self.device_locations[device_id] + if not a.get("lat") and lat: + a["lat"] = lat + if not a.get("lon") and lon: + a["lon"] = lon + + # ───────────── ENRICH ALERT WITH TEMPLATE ───────────── + tmpl = self.templates.get(alert_type) + if tmpl: + 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", ""), + } + + # 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: + print("[AlertService] Failed to fetch alerts:", e) + + # ──────────────────────────────── + # Handle incoming WebSocket alerts + # ──────────────────────────────── + def _on_realtime(self, alert_msg): + alerts = alert_msg.get("alerts", []) + + for a in alerts: + labels = a.get("labels", {}) + ann = a.get("annotations", {}) + alert_id = labels.get("alert_id") + device_id = labels.get("device") + alert_type = labels.get("alertname") + ends_at = a.get("endsAt") + is_resolved = ends_at and not ends_at.startswith("0001-01-01") + + # Find existing alert + existing = next((al for al in self.alerts if al.get("alert_id") == alert_id), None) + + if is_resolved: + if existing: + existing["endedAt"] = ends_at + self.alertRemoved.emit(alert_id) + else: + fake_alert = {"alert_id": alert_id, "endedAt": ends_at} + self.alerts.append(fake_alert) + self.alertRemoved.emit(alert_id) + continue + + # ACTIVE alert + lat = ann.get("lat") + lon = ann.get("lon") + + # Fill missing coordinates + if (not lat or not lon) and device_id in self.device_locations: + lat, lon = self.device_locations[device_id] + print(f"[AlertService] Filled missing coords for {device_id}: ({lat}, {lon})") + + # ──────────────────────────────── + # 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 = Template(tmpl.get("recommendation", "")).safe_substitute( + device_id=device_id, + area=ann.get("area", ""), + subject=subject, + severity=severity, + ) + + category = tmpl.get("category") + + normalized = { + "alert_id": alert_id, + "alert_type": alert_type, + "device_id": device_id, + "lat": lat, + "lon": lon, + "severity": severity, + "summary": summary, + "recommendation": recommendation, + "category": category, + "hls": ann.get("hls"), + "vod": ann.get("vod"), + "image_url": ann.get("image_url"), + "startsAt": a.get("startsAt"), + } + + 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): + unacked = [a for a in self.alerts if not a.get("ack", False)] + if not unacked: + return + + for a in unacked: + a["ack"] = True + + def _patch_ack(alert): + try: + url = f"{self.api.base}/api/tables/alerts?limit=500" + payload = { + "keys": {"alert_id": alert["alert_id"]}, + "data": {"ack": True}, + } + r = self.api.http.patch(url, json=payload, timeout=5) + r.raise_for_status() + except Exception as e: + print(f"[AlertService] Failed to PATCH ack for {alert['alert_id']}: {e}") + + with ThreadPoolExecutor(max_workers=4) as pool: + for a in unacked: + pool.submit(_patch_ack, a) + + self.alertsUpdated.emit(self.alerts) + 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 11f17f337..790605fa3 100644 --- a/GUI/src/vast/dashboard_api.py +++ b/GUI/src/vast/dashboard_api.py @@ -1,54 +1,97 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations +import os import json import time +import base64 import pathlib +from typing import Dict, List, Optional, Tuple, Union + import requests -from urllib.parse import quote from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry +# ---- Optional deps (do not crash if missing) ---- +try: + from minio import Minio + from minio.error import S3Error +except Exception: # pragma: no cover + Minio = None # type: ignore + S3Error = Exception # type: ignore + +try: + from vast.rel_db import RelDB +except Exception: # pragma: no cover + RelDB = None # type: ignore + + +# ========================= +# CONFIG +# ========================= +# --- HTTP API --- +DB_API_BASE = os.getenv("DB_API_BASE", "http://db_api_service:8001") +DB_API_AUTH_MODE = os.getenv("DB_API_AUTH_MODE", "service") # "service" | "bearer" +DB_API_TOKEN_FILE = os.getenv("DB_API_TOKEN_FILE", "/app/secrets/db_api_token") +DB_API_TOKEN = os.getenv("DB_API_TOKEN", "auto") +DB_API_SERVICE_NAME = os.getenv("DB_API_SERVICE_NAME", "GUI_H") + +# --- RelDB (used inside RelDB class; here only for reference/env) --- +DB_HOST = os.getenv("DB_HOST", "127.0.0.1") +DB_PORT = int(os.getenv("DB_PORT", "5432")) +DB_USER = os.getenv("DB_USER", "missions_user") +DB_PASS = os.getenv("DB_PASS", "pg123") +DB_NAME = os.getenv("DB_NAME", "missions_db") + +# --- MinIO --- +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "127.0.0.1:9001") # host:exposed_port +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin") +MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" -# ---------- CONFIG ---------- -DB_API_BASE = "http://host.docker.internal:8001" -DB_API_AUTH_MODE = "service" -DB_API_TOKEN_FILE = "/app/secrets/db_api_token" -DB_API_TOKEN = "auto" -DB_API_SERVICE_NAME = "GUI_H" +DEFAULT_GROUND_BUCKET = os.getenv("GROUND_BUCKET", "ground") +DEFAULT_GROUND_PREFIX = os.getenv("GROUND_PREFIX", "") -# ---------- TOKEN BOOTSTRAP ---------- +# ========================= +# TOKEN BOOTSTRAP HELPERS +# ========================= def _safe_join_url(base: str, path: str) -> str: return f"{base.rstrip('/')}/{path.lstrip('/')}" -def _read_token_from_file(path: str) -> str | None: +def _read_token_from_file(path: str) -> Optional[str]: p = pathlib.Path(path) if p.exists(): token = p.read_text(encoding="utf-8").strip() return token or None return None -def _fetch_token_via_dev_bootstrap(base: str, retries: int = 3, backoff: float = 0.8) -> str | None: +def _fetch_token_via_dev_bootstrap(base: str, retries: int = 3, backoff: float = 0.8) -> Optional[str]: + """ + Calls /auth/_dev_bootstrap to mint/rotate a service token for this client. + """ url = _safe_join_url(base, "/auth/_dev_bootstrap") payload = {"service_name": DB_API_SERVICE_NAME, "rotate_if_exists": True} + last_exc: Optional[Exception] = None for attempt in range(1, retries + 1): try: r = requests.post(url, json=payload, timeout=10) if r.status_code in (200, 201): - data = r.json() + data = r.json() if r.content else {} raw = (data.get("service_account", {}) or {}).get("raw_token") \ - or (data.get("service_account", {}) or {}).get("token") + or (data.get("service_account", {}) or {}).get("token") if raw and isinstance(raw, str) and "***" not in raw: return raw.strip() - except Exception: - time.sleep(backoff * attempt) + except Exception as e: + last_exc = e + time.sleep(backoff * attempt) + if last_exc: + print(f"[BOOTSTRAP][WARN] last error: {last_exc}") return None - -def get_or_bootstrap_token() -> str | None: - print(f"[DEBUG] Checking for existing token file at: {DB_API_TOKEN_FILE}", flush=True) - +def get_or_bootstrap_token() -> Optional[str]: if DB_API_TOKEN and DB_API_TOKEN.lower() != "auto": - print(f"[DEBUG] Using static token from config", flush=True) + print("[DEBUG] Using static token from DB_API_TOKEN", flush=True) return DB_API_TOKEN token = _read_token_from_file(DB_API_TOKEN_FILE) @@ -56,11 +99,12 @@ def get_or_bootstrap_token() -> str | None: print(f"[DEBUG] Loaded token from {DB_API_TOKEN_FILE}", flush=True) return token - print(f"[DEBUG] No existing token found, bootstrapping via {DB_API_BASE}/auth/_dev_bootstrap", flush=True) + print(f"[DEBUG] No token found, bootstrapping via {DB_API_BASE}/auth/_dev_bootstrap", flush=True) token = _fetch_token_via_dev_bootstrap(DB_API_BASE) if token: - pathlib.Path(DB_API_TOKEN_FILE).parent.mkdir(parents=True, exist_ok=True) - pathlib.Path(DB_API_TOKEN_FILE).write_text(token, encoding="utf-8") + p = pathlib.Path(DB_API_TOKEN_FILE) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(token, encoding="utf-8") print(f"[BOOTSTRAP] wrote token to {DB_API_TOKEN_FILE}", flush=True) return token @@ -68,35 +112,703 @@ def get_or_bootstrap_token() -> str | None: return None +# ========================= +# UTILITIES +# ========================= +def _image_id_from_object_key(object_key: str) -> str: + """ + 'some/prefix/image (3).jpg' -> 'image (3)' + """ + base = os.path.basename(object_key or "") + return base.rsplit(".", 1)[0] if "." in base else base -# ---------- API CLIENT ---------- +# ========================= +# DASHBOARD API +# ========================= class DashboardApi: - def __init__(self): + """ + Unified client: + - REST to DB-API (with token bootstrap/refresh) + - Optional MinIO helper + - Optional RelDB helper + """ + + def __init__(self) -> None: + # ---- HTTP session ---- self.base = DB_API_BASE.rstrip("/") self.http = requests.Session() + + # Attach robust retries + retry = Retry( + total=5, + backoff_factor=0.5, + status_forcelist=[500, 502, 503, 504], + allowed_methods=frozenset(["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"]) + ) + self.http.mount("http://", HTTPAdapter(max_retries=retry)) + self.http.mount("https://", HTTPAdapter(max_retries=retry)) + self.http.headers.update({"Content-Type": "application/json"}) + + # ---- Auth ---- token = get_or_bootstrap_token() + self.token: Optional[str] = token + self.token_type = "service" if DB_API_AUTH_MODE == "service" else "bearer" + self._apply_auth_header(token) + + # ---- MinIO (optional) ---- + self.minio: Optional[Minio] = None + if Minio is not None: + try: + self.minio = Minio( + MINIO_ENDPOINT, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=MINIO_SECURE, + ) + except Exception as e: # pragma: no cover + print(f"[MINIO][INIT][WARN] {e}") + + # ---- RelDB (optional) ---- + self.rdb: Optional[RelDB] = None + if RelDB is not None: + try: + self.rdb = RelDB() + except Exception as e: # pragma: no cover + print(f"[RelDB][INIT][WARN] {e}") + + # --------------------------- + # Auth helpers + # --------------------------- + def _apply_auth_header(self, token: Optional[str]) -> None: + # Clean previous header variants + for h in ["X-Service-Token", "Authorization"]: + if h in self.http.headers: + del self.http.headers[h] if token: if DB_API_AUTH_MODE == "service": self.http.headers.update({"X-Service-Token": token}) else: self.http.headers.update({"Authorization": f"Bearer {token}"}) - self.http.headers.update({"Content-Type": "application/json"}) - self.http.mount("http://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]))) - self.http.mount("https://", HTTPAdapter(max_retries=Retry(total=5, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]))) - # ---------- METHODS ---------- + def get_token_info(self) -> dict: + """ + Tries to decode JWT payload. If not a JWT, returns basic info. + """ + t = self.token + if not t: + return {"type": self.token_type, "status": "missing"} + + if "." in t: + try: + payload_b64 = t.split(".")[1] + padded = payload_b64 + "=" * (-len(payload_b64) % 4) + data = json.loads(base64.urlsafe_b64decode(padded)) + exp = data.get("exp") + secs_left = exp - int(time.time()) if exp else None + return {"type": "jwt", "exp": exp, "secs_left": secs_left, "payload": data} + except Exception: + pass + return {"type": self.token_type, "token_length": len(t)} + + def refresh_token(self) -> bool: + """ + Fetches a new service token via dev bootstrap and updates headers + file. + """ + new_token = _fetch_token_via_dev_bootstrap(self.base) + if new_token: + try: + pathlib.Path(DB_API_TOKEN_FILE).parent.mkdir(parents=True, exist_ok=True) + pathlib.Path(DB_API_TOKEN_FILE).write_text(new_token, encoding="utf-8") + except Exception as e: + print(f"[TOKEN][WARN] Could not persist new token: {e}") + self.token = new_token + self._apply_auth_header(new_token) + print("[TOKEN] refreshed", flush=True) + return True + print("[TOKEN][ERROR] refresh failed", flush=True) + return False + + # --------------------------- + # REST: examples / utilities + # --------------------------- + def list_devices(self, model: Optional[str] = None) -> List[dict]: + """ + Tries modern path /api/devices; falls back to /api/tables/devices for older servers. + """ + paths = ["/api/devices", "/api/tables/devices"] + last_err: Optional[str] = None + for path in paths: + url = f"{self.base}{path}" + if model: + sep = "&" if "?" in url else "?" + url = f"{url}{sep}model={model}" + try: + r = self.http.get(url, timeout=10) + if r.status_code == 200: + try: + return r.json() + except Exception: + print("[API WARN] devices response is not JSON", flush=True) + return [] + if r.status_code in (404, 405): + last_err = f"http-{r.status_code}" + continue + print(f"[API ERROR] {r.status_code}: {r.text[:200]}") + return [] + except Exception as e: + last_err = str(e) + continue + if last_err: + print(f"[API FAIL] list_devices: {last_err}") + return [] + + def bulk_set_task_thresholds_labeled( + self, + mapping: Dict[Tuple[str, str], float] | List[dict], + updated_by: str = "gui", + ) -> dict: + """ + Unified + fallback: + 1) POST /api/task_thresholds/batch + 2) if 404/405 -> POST /api/thresholds/batch + Body shape is normalized to: {"task": str, "label": str, "threshold": float, "updated_by": str} + """ + items = ( + [ + {"task": t, "label": l or "", "threshold": thr, "updated_by": updated_by} + for (t, l), thr in mapping.items() + ] + if isinstance(mapping, dict) else mapping + ) + + paths = ["/api/task_thresholds/batch", "/api/thresholds/batch"] + last_err: Optional[str] = None + for path in paths: + url = f"{self.base}{path}" + try: + r = self.http.post(url, json=items, timeout=20) + if r.status_code in (200, 201): + data = r.json() if r.content else {} + return {"ok": list(data.get("ok", [])), "fail": list(data.get("fail", []))} + if r.status_code in (404, 405): + last_err = f"http-{r.status_code}" + continue + return { + "ok": [], + "fail": [[[i.get("task"), i.get("label","")], f"http-{r.status_code} {r.text[:200]}"] for i in items], + } + except Exception as e: + last_err = str(e) + continue + return {"ok": [], "fail": [[[i.get("task"), i.get("label","")], last_err or "unknown"] for i in items]} + + # --------------------------- + # MinIO helpers (optional) + # --------------------------- + def list_minio_objects(self, bucket: str, prefix: str = "", limit: int = 100) -> List[dict]: + """ + Returns: [{'key': 'path/file.jpg', 'size': int, 'last_modified': iso}, ...] + """ + if not self.minio: + print("[MINIO][WARN] MinIO client not available") + return [] + out: List[dict] = [] + try: + for i, obj in enumerate(self.minio.list_objects(bucket, prefix=prefix, recursive=True)): + if i >= limit: + break + lm = getattr(obj, "last_modified", None) + out.append({ + "key": getattr(obj, "object_name", None) or getattr(obj, "name", None), + "size": getattr(obj, "size", None), + "last_modified": lm.isoformat() if lm else None, + }) + except Exception as e: + print(f"[MINIO LIST FAIL] {e}") + return out + + def get_latest_minio_key(self, bucket: str, prefix: str = "") -> Optional[str]: + objs = self.list_minio_objects(bucket, prefix=prefix, limit=200) + if not objs: + return None + objs_sorted = sorted(objs, key=lambda o: o.get("last_modified") or "", reverse=True) + key = objs_sorted[0].get("key") + return key if isinstance(key, str) and key.strip() else None + + def get_image_bytes_from_minio(self, key: str, bucket: Optional[str] = None) -> Optional[bytes]: + if not self.minio: + print("[MINIO][WARN] MinIO client not available") + return None + bucket_name = bucket or DEFAULT_GROUND_BUCKET + try: + response = self.minio.get_object(bucket_name, key) + data = response.read() + response.close() + response.release_conn() + print(f"[DEBUG] Got {len(data)} bytes from {bucket_name}/{key}") + return data + except Exception as e: + print(f"[MINIO GET FAIL] {e}") + return None + + # --------------------------- + # RelDB delegates (optional) + # --------------------------- + def _rdb_guard(self) -> bool: + if not self.rdb: + print("[RelDB][WARN] RelDB client not available") + return False + return True + + def get_weekly_phi(self) -> dict: + if not self._rdb_guard(): return {} + return self.rdb.get_weekly_phi() + + def get_latest_rows(self, limit: int = 20) -> List[dict]: + if not self._rdb_guard(): return [] + return self.rdb.get_latest_anomalies(limit=limit) + + def get_latest_detections(self, limit: int = 20) -> List[dict]: + if not self._rdb_guard(): return [] + return self.rdb.get_latest_anomalies(limit=limit) + + def get_rows_by_image(self, image_name: str, limit: int = 50) -> List[dict]: + """ + image_name is image_id without extension. + """ + if not self._rdb_guard(): return [] + return self.rdb.get_anomalies_by_image(image_name, limit=limit) + + def get_last_row_by_image(self, image_name: str) -> Optional[dict]: + if not self._rdb_guard(): return None + return self.rdb.get_last_anomaly_by_image(image_name) + + def get_rows_by_day(self, date_iso: str, limit: int = 1000) -> List[dict]: + if not self._rdb_guard(): return [] + return self.rdb.get_anomalies_by_day(date_iso, limit=limit) + + # --------------------------- + # Image-centric (MinIO→image_id→RelDB) + # --------------------------- + def get_latest_image_key(self) -> Optional[str]: + """ + Prefer the newest in MinIO; if none—fallback to DB (if available). + """ + key = None + if self.minio: + key = self.get_latest_minio_key(DEFAULT_GROUND_BUCKET, DEFAULT_GROUND_PREFIX) + if key: + return key + if self.rdb: + try: + return self.rdb.get_latest_image_key() + except Exception as e: + print(f"[RelDB][WARN] get_latest_image_key fallback failed: {e}") + return None + + def get_anomalies_for_image_key(self, object_key: str, limit: int = 50) -> List[dict]: + if not self._rdb_guard(): return [] + image_id = _image_id_from_object_key(object_key) + return self.rdb.get_anomalies_by_image(image_id, limit=limit) + + def get_anomalies_for_current_image(self, limit: int = 100) -> List[dict]: + if not self._rdb_guard(): return [] + key = self.get_latest_image_key() + if not key: + return [] + image_id = _image_id_from_object_key(key) + return self.rdb.get_anomalies_by_image(image_id, limit=limit) + + def get_last_anomaly_for_current_image(self) -> Optional[dict]: + if not self._rdb_guard(): return None + key = self.get_latest_image_key() + if not key: + return None + image_id = _image_id_from_object_key(key) + return self.rdb.get_last_anomaly_by_image(image_id) + + def get_phi_for_image(self, image_name_or_key: str) -> dict: + if not self._rdb_guard(): + return {"phi": None, "severity_avg": None, "density": None, "coverage": None, "trend": None} + image_id = _image_id_from_object_key(image_name_or_key) + return self.rdb.get_phi_for_image(image_id) + + def get_phi_for_current_image(self) -> dict: + if not self._rdb_guard(): + return {"phi": None, "severity_avg": None, "density": None, "coverage": None, "trend": None} + key = self.get_latest_image_key() + if not key: + return {"phi": None, "severity_avg": None, "density": None, "coverage": None, "trend": None} + image_id = _image_id_from_object_key(key) + return self.rdb.get_phi_for_image(image_id) + + + # ===================================================== + # ===== 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) + - def list_devices(self, model: str | None = None) -> list[dict]: - - url = f"{self.base}/api/devices" - if model: - url += f"?model={model}" + # ===================================================== + # ===== 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: - r = self.http.get(url, timeout=10) - if r.status_code == 200: - return r.json() - print(f"[API ERROR] {r.status_code}: {r.text[:100]}") + 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"[API FAIL] {e}") - return [] \ No newline at end of file + 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 10d413e67..d60b2dc24 100644 --- a/GUI/src/vast/desktop/Dockerfile +++ b/GUI/src/vast/desktop/Dockerfile @@ -1,6 +1,8 @@ FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app + +# ───────────────────── 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 \ @@ -10,24 +12,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpango-1.0-0 libharfbuzz0b libatk1.0-0 libatk-bridge2.0-0 libnss3 \ 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 \ - && rm -rf /var/lib/apt/lists/* + procps iproute2 xauth git vlc libvlc5 libvlccore9 \ + fonts-dejavu-core fonts-noto-core fonts-noto-color-emoji && \ + rm -rf /var/lib/apt/lists/* + +# 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/* + libxcb-randr0 && rm -rf /var/lib/apt/lists/* +# ───────────────────── optional CA certs ───────────────────── COPY certs /app/certs RUN if [ -d ./certs ] && [ "$(ls ./certs/*.crt 2>/dev/null)" ]; then \ - echo "Configuring NetFree certificates..."; \ - cp ./certs/*.crt /usr/local/share/ca-certificates/; \ - update-ca-certificates; \ + 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 ───────────────────── 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 && \ @@ -35,22 +39,54 @@ RUN mkdir -p /opt && \ rm /tmp/novnc.tar.gz && \ git clone --depth 1 https://github.com/novnc/websockify /opt/noVNC/utils/websockify +# ───────────────────── 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 useradd -m -s /bin/bash appuser \ - && mkdir -p /app /tmp/.X11-unix \ - && chown -R appuser:appuser /app /tmp /opt/noVNC /var/tmp +RUN pip install --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 + --prefer-binary --break-system-packages +RUN pip install plotly PyJWT + +# ───────────────────── App setup ───────────────────── +RUN useradd -m -s /bin/bash appuser && \ + mkdir -p /app /tmp/.X11-unix && chown -R appuser:appuser /app /tmp /opt/noVNC /var/tmp + RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/* +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 chmod +x /app/start.sh && chown -R appuser:appuser /app +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 ENV PYTHONPATH=/app/src:/app ENV DISPLAY=:0 ENV NO_VNC_PORT=6080 +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 cd4ca25f9..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 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 27486d095..29549ecba 100644 --- a/GUI/src/vast/gateway/Dockerfile +++ b/GUI/src/vast/gateway/Dockerfile @@ -1,27 +1,48 @@ +# 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; \ +# ARG USE_NETFREE=true + +# 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 +# # # 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 @@ -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"] diff --git a/GUI/src/vast/home_view.py b/GUI/src/vast/home_view.py index c7a5a6cc6..b82ef2ef1 100644 --- a/GUI/src/vast/home_view.py +++ b/GUI/src/vast/home_view.py @@ -1,28 +1,56 @@ from __future__ import annotations from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtCore import QUrl, pyqtSignal -from PyQt6.QtWidgets import QWidget, QGridLayout, QVBoxLayout, QLabel, QSizePolicy, QPushButton +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QSizePolicy, QPushButton +) from orthophoto_canvas.ui.viewer_factory import create_orthophoto_viewer from vast.orthophoto_canvas.ui.sensors_layer import SensorLayer, add_sensors_by_gps_bulk from orthophoto_canvas.ag_io import sensors_api import os +from vast.orthophoto_canvas.ui.alert_layer import AlertLayer + class HomeView(QWidget): openSensorsRequested = pyqtSignal() - def __init__(self, api, parent: QWidget | None = None): + def __init__(self, api, alert_service, parent: QWidget | None = None): super().__init__(parent) + self.api = api + self.alert_service = alert_service + # ───────────────────────────── + # Root vertical layout + # ───────────────────────────── root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + # Header header = QLabel("Sensors Dashboard (Grafana)") - header.setStyleSheet("font-size: 20px; font-weight: 600;") + header.setStyleSheet("font-size: 20px; font-weight: 600; margin-bottom: 8px;") root.addWidget(header) - grid = QGridLayout() - grid.setHorizontalSpacing(12) - grid.setVerticalSpacing(12) - root.addLayout(grid) + # ───────────────────────────── + # Main content: Map (left) + Panels (right) + # ───────────────────────────── + main_layout = QHBoxLayout() + main_layout.setSpacing(12) + root.addLayout(main_layout, stretch=1) + + # ───── Map on the left ───── + tiles_root = "./src/vast/orthophoto_canvas/data/tiles" + self.viewer = create_orthophoto_viewer(tiles_root, forced_scheme=None, parent=self) + self.viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.viewer.setMinimumSize(700, 700) # Ensures it's visibly large and square + main_layout.addWidget(self.viewer, stretch=3) + + # ───── Grafana panels on the right ───── + right_box = QVBoxLayout() + right_box.setSpacing(10) + main_layout.addLayout(right_box, stretch=2) grafana_host = os.getenv("GRAFANA_HOST", "grafana") base = f"http://{grafana_host}:3000" @@ -31,20 +59,20 @@ def __init__(self, api, parent: QWidget | None = None): QUrl(f"{base}/d-solo/agcloud-sensors/sensors?orgId=1&panelId=2&from=now-6h&to=now&refresh=10s&theme=light"), ] - for i, url in enumerate(panel_urls): + for url in panel_urls: view = QWebEngineView(self) - view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + view.setFixedHeight(300) view.setUrl(url) - r, c = divmod(i, 2) - grid.addWidget(view, r, c) - - tiles_root = "./src/vast/orthophoto_canvas/data/tiles" - self.viewer = create_orthophoto_viewer(tiles_root, forced_scheme=None, parent=self) - grid.addWidget(self.viewer, 1, 0, 1, 2) + right_box.addWidget(view) + # ───────────────────────────── + # Load sensors layer + # ───────────────────────────── gateway_url = os.getenv("GATEWAY_URL", "http://gateway:8000") sensors_api.GATEWAY_URL = gateway_url rows = sensors_api.get_sensors() + self.sensor_layer = SensorLayer(self.viewer) add_sensors_by_gps_bulk( self.sensor_layer, @@ -53,6 +81,96 @@ def __init__(self, api, parent: QWidget | None = None): default_radius_px=0.2 ) + # ───────────────────────────── + # Alerts layer setup + # ───────────────────────────── + self.alert_layer = AlertLayer(self.viewer) + self.alert_service.alertsUpdated.connect(self._on_alerts_updated) + self.alert_service.alertAdded.connect(self._on_alert_added) + self.alert_service.alertRemoved.connect(self._on_alert_removed) + self.alert_service.load_initial() + + # ───────────────────────────── + # Footer button + # ───────────────────────────── self.sensor_types_btn = QPushButton("Sensor Types") + self.sensor_types_btn.setStyleSheet("padding: 8px 12px; font-weight: 500;") self.sensor_types_btn.clicked.connect(self.openSensorsRequested.emit) root.addWidget(self.sensor_types_btn) + + # ───────────────────────────── + # Keep the map square on resize + # ───────────────────────────── + def resizeEvent(self, event): + super().resizeEvent(event) + if self.viewer: + # Square size = min(available height, available width fraction) + left_width = int(self.width() * 0.6) + height = self.height() - 100 + size = min(left_width, height) + if size > 400: + self.viewer.setFixedSize(size-50, size-50) + + # ───────────────────────────── + # Alerts Handlers + # ───────────────────────────── + def _on_alerts_updated(self, alerts: list): + print(f"[HomeView] Full alert update: {len(alerts)} alerts") + + active_alerts = [a for a in alerts if not a.get("ended_at") and not a.get("endedAt")] + print(f"[HomeView] Displaying {len(active_alerts)} active alerts on map") + + self.alert_layer.clear_alerts() + for alert in active_alerts: + self.alert_layer.add_or_update_alert(alert) + + def _on_alert_added(self, alert: dict): + print(f"[HomeView] New alert added: {alert.get('alert_id')}") + self.alert_layer.add_or_update_alert(alert) + + def _on_alert_removed(self, alert_id: str): + print(f"[HomeView] Removing alert: {alert_id}") + self.alert_layer.remove_alert(alert_id) + + # ───────────────────────────── + # Real-time alert normalization + # ───────────────────────────── + def _on_alert_realtime(self, alert: dict): + alerts = alert.get("alerts", []) + if not alerts: + print("[HomeView] No alerts in payload.") + return + + for a in alerts: + labels = a.get("labels", {}) + ann = a.get("annotations", {}) + + normalized = { + "alert_id": labels.get("alert_id"), + "alert_type": labels.get("alertname"), + "device_id": labels.get("device"), + "lat": float(ann.get("lat")) if ann.get("lat") else None, + "lon": float(ann.get("lon")) if ann.get("lon") else None, + "severity": int(ann.get("severity", 1)), + "confidence": float(ann.get("confidence", 0)), + "area": ann.get("area"), + "summary": ann.get("summary"), + "category": ann.get("category"), + "recommendation": ann.get("recommendation"), + "meta": ann.get("meta"), + "startsAt": a.get("startsAt"), + "endsAt": a.get("endsAt"), + } + + alert_id = normalized.get("alert_id") + ended_at = normalized.get("endsAt") + is_resolved = ended_at and not ended_at.startswith("0001-01-01") + + if is_resolved: + print(f"[HomeView] Removing resolved alert: {alert_id}") + self.alert_layer.remove_alert(alert_id) + continue + + 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 394e23859..4517b96d6 100644 --- a/GUI/src/vast/main.py +++ b/GUI/src/vast/main.py @@ -100,36 +100,37 @@ def main() -> int: print("[main] starting QApplication") app = QApplication(sys.argv) - # 1) show auth shell first + + # 1) create the auth shell but do NOT show it shell = AuthShell() shell.setWindowTitle("Sign in") - shell.show() + # shell.show() # disabled to skip the login window + # 2) when login succeeds -> open MainWindow def open_main(user): - api = DashboardApi() # pass user if needed + 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) + print("[main] window shown, entering event loop") 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 d7f815745..726b129c6 100644 --- a/GUI/src/vast/main_window.py +++ b/GUI/src/vast/main_window.py @@ -1,16 +1,938 @@ -from __future__ import annotations +# # 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, QStackedWidget, - QVBoxLayout, QWidget + QMainWindow, QDockWidget, QListWidget, QListWidgetItem, QStatusBar, + QStackedWidget, QToolButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, + QGraphicsDropShadowEffect, QPushButton ) -from PyQt6.QtGui import QAction, QIcon -from PyQt6.QtWebEngineWidgets import QWebEngineView -from PyQt6.QtCore import QUrl +from PyQt6.QtGui import QAction, QIcon, QFont, QColor +import os + from home_view import HomeView from views.sensors_view import SensorsView -from views.sound_view import SoundView +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 dashboard_api import DashboardApi +from vast.alerts.alert_service import AlertService + + + +# === Irrigation imports === +from views.irrigation.irrigation_view import IrrigationView class MainWindow(QMainWindow): @@ -19,72 +941,345 @@ class MainWindow(QMainWindow): def __init__(self, api: DashboardApi, parent=None): super().__init__(parent) self.setWindowTitle("VAST – Dashboard") - self.resize(1100, 700) + self.resize(1280, 760) self.api = api - # ---------- 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") - - # ---------- Dock navigation ---------- + 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 = self.addToolBar("Main Toolbar") + toolbar.setMovable(False) + toolbar.setFloatable(False) + toolbar.setIconSize(QSize(32, 32)) + + top_bar = QWidget() + top_bar_layout = QHBoxLayout(top_bar) + top_bar_layout.setContentsMargins(8, 0, 8, 0) + top_bar_layout.setSpacing(10) + + # Back button + back_btn = QToolButton() + back_btn.setIcon(QIcon.fromTheme("go-previous")) + back_btn.setIconSize(QSize(28, 28)) + back_btn.setToolTip("Go back") + back_btn.clicked.connect(self.go_back) + + # Logout button + 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) + + # Bell button + 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; + } + """) + + # 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; /* blue */ + color: white; + font-size: 10pt; + font-weight: bold; + border-radius: 12px; + border: 2px solid white; + } + """) + self.alert_badge.hide() + + # Position badge dynamically + def reposition_badge(): + btn_w = self.alert_button.width() + self.alert_badge.move(btn_w - 22, 2) + self.alert_badge.raise_() + + self.alert_button.resizeEvent = lambda e: ( + QToolButton.resizeEvent(self.alert_button, e), + reposition_badge() + ) + reposition_badge() + + # Title + title_label = QLabel("VAST Dashboard") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + title_label.setStyleSheet(""" + QLabel { + font-size: 17pt; + font-weight: 600; + color: #111827; + } + """) + + # Shadow + 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_label) + top_bar_layout.addStretch() + + toolbar.addWidget(top_bar) + + # ─────────────────────────────── + # 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) + self.nav_list.setFont(font) for name in [ - "Home", "Sensors", "Sound", "Ground Image", - "Aerial Image", "Fruits", "Security", "Settings" + "Home", "Sensors", "Sound", "Ground Image", + "Aerial Image", "Fruits", "Security", "Settings", "Notifications", "Irrigation" ]: - QListWidgetItem(name, self.nav_list) - + QListWidgetItem(f" {name}", self.nav_list) + self.nav_list.setCurrentRow(0) self.nav_list.currentRowChanged.connect(self._on_nav_change) - # ---------- Views ---------- - self.home = HomeView(api, self) + # ─────────────────────────────── + # 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) + 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.sound_view = SoundView(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) + + 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 - # Stack for switching between views self.stack = QStackedWidget() self.setCentralWidget(self.stack) - + self.views = { "Home": self.home, "Sensors": self.sensors_view, - "Sound": self.sound_view, + "Notifications": self.notification_view, + "Security": self.security_view, + "Fruits": self.fruits_view, + "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: list = [] - # ---------- History for Back ---------- - self.history = [] - - # ---------- Status bar ---------- + # ─────────────────────────────── + # 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() - print(f"[MainWindow] Navigation changed to: {name}") - + 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 navigate_to(self, widget): - print(f"[MainWindow] Navigating to widget: {widget.__class__.__name__}") + 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: @@ -95,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/alert_layer.py b/GUI/src/vast/orthophoto_canvas/ui/alert_layer.py new file mode 100644 index 000000000..defc7612e --- /dev/null +++ b/GUI/src/vast/orthophoto_canvas/ui/alert_layer.py @@ -0,0 +1,203 @@ +from PyQt6.QtWidgets import ( + QGraphicsTextItem, QLabel, QVBoxLayout, QWidget, QGraphicsDropShadowEffect +) +from PyQt6.QtCore import Qt, QPoint +from PyQt6.QtGui import QColor, QFont +from src.vast.orthophoto_canvas.ui.sensors_layer import _latlon_to_xy_at_max_zoom, TILE_SIZE + + +# ───────────────────────────────────────────── +# Frameless Popup Widget +# ───────────────────────────────────────────── +class AlertPopupWidget(QWidget): + """Frameless popup with rounded corners, colored border, and drop shadow.""" + + def __init__(self, html: str, border_color: str = "#444", parent=None): + super().__init__(parent) + self.setWindowFlags(Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + label = QLabel() + label.setTextFormat(Qt.TextFormat.RichText) + label.setText(html) + label.setWordWrap(True) + label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + label.setStyleSheet(f""" + QLabel {{ + background-color: #ffffff; + border: 2px solid {border_color}; + border-radius: 12px; + padding: 10px 12px; + font-family: 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif; + font-size: 12px; + color: #111; + }} + """) + layout.addWidget(label) + + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(18) + shadow.setOffset(0, 4) + shadow.setColor(QColor(0, 0, 0, 70)) + self.setGraphicsEffect(shadow) + + self.adjustSize() + + def show_near(self, global_pos: QPoint): + """Show popup slightly above and to the right of the marker.""" + self.adjustSize() + self.move(global_pos + QPoint(12, -self.height() - 12)) + self.show() + + +# ───────────────────────────────────────────── +# Marker Item +# ───────────────────────────────────────────── +class _AlertMarker(QGraphicsTextItem): + """A single alert marker (emoji icon) that shows a modern popup on hover.""" + + def __init__(self, alert_id, alert_data, *args, **kwargs): + severity = int(alert_data.get("severity", 1)) + icon = {1: "⚠️", 2: "🚨"}.get(severity, "🚨") + super().__init__(icon, *args, **kwargs) + + self.alert_id = alert_id + self.alert_data = alert_data + self._popup = None + + self.setZValue(1_000_000) + self.setFont(QFont("Noto Color Emoji", 12)) + self.setDefaultTextColor(QColor("#222")) + self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIgnoresTransformations, True) + self.setAcceptHoverEvents(True) + + def hoverEnterEvent(self, event): + alert = self.alert_data + severity = int(alert.get("severity", 1)) + alert_type = alert.get("alert_type", "Alert").replace("_", " ") + device_id = alert.get("device_id", "unknown") + summary = alert.get("summary") or "No additional details." + started_at = alert.get("startsAt", "") + + border_color = {1: "#f1c232", 2: "#f39c12", 3: "#e67e22", + 4: "#cc0000", 5: "#8b0000"}.get(severity, "#999") + + tooltip_html = f""" +
| ID | +Name | +Type | +Install Date | +Active | +Distance | +
|---|
| Time | Issue | Severity | Source |
|---|
Apache License
Version 2.0, January 2004
-http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
- -"License" shall mean the terms and conditions for use, reproduction, and -distribution as defined by Sections 1 through 9 of this document.
-"Licensor" shall mean the copyright owner or entity authorized by the -copyright owner that is granting the License.
-"Legal Entity" shall mean the union of the acting entity and all other -entities that control, are controlled by, or are under common control with -that entity. For the purposes of this definition, "control" means (i) the -power, direct or indirect, to cause the direction or management of such -entity, whether by contract or otherwise, or (ii) ownership of fifty -percent (50%) or more of the outstanding shares, or (iii) beneficial -ownership of such entity.
-"You" (or "Your") shall mean an individual or Legal Entity exercising -permissions granted by this License.
-"Source" form shall mean the preferred form for making modifications, -including but not limited to software source code, documentation source, -and configuration files.
-"Object" form shall mean any form resulting from mechanical transformation -or translation of a Source form, including but not limited to compiled -object code, generated documentation, and conversions to other media types.
-"Work" shall mean the work of authorship, whether in Source or Object form, -made available under the License, as indicated by a copyright notice that -is included in or attached to the work (an example is provided in the -Appendix below).
-"Derivative Works" shall mean any work, whether in Source or Object form, -that is based on (or derived from) the Work and for which the editorial -revisions, annotations, elaborations, or other modifications represent, as -a whole, an original work of authorship. For the purposes of this License, -Derivative Works shall not include works that remain separable from, or -merely link (or bind by name) to the interfaces of, the Work and Derivative -Works thereof.
-"Contribution" shall mean any work of authorship, including the original -version of the Work and any modifications or additions to that Work or -Derivative Works thereof, that is intentionally submitted to Licensor for -inclusion in the Work by the copyright owner or by an individual or Legal -Entity authorized to submit on behalf of the copyright owner. For the -purposes of this definition, "submitted" means any form of electronic, -verbal, or written communication sent to the Licensor or its -representatives, including but not limited to communication on electronic -mailing lists, source code control systems, and issue tracking systems that -are managed by, or on behalf of, the Licensor for the purpose of discussing -and improving the Work, but excluding communication that is conspicuously -marked or otherwise designated in writing by the copyright owner as "Not a -Contribution."
-"Contributor" shall mean Licensor and any individual or Legal Entity on -behalf of whom a Contribution has been received by Licensor and -subsequently incorporated within the Work.
-2. Grant of Copyright License. Subject to the -terms and conditions of this License, each Contributor hereby grants to You -a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable -copyright license to reproduce, prepare Derivative Works of, publicly -display, publicly perform, sublicense, and distribute the Work and such -Derivative Works in Source or Object form.
-3. Grant of Patent License. Subject to the terms -and conditions of this License, each Contributor hereby grants to You a -perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable -(except as stated in this section) patent license to make, have made, use, -offer to sell, sell, import, and otherwise transfer the Work, where such -license applies only to those patent claims licensable by such Contributor -that are necessarily infringed by their Contribution(s) alone or by -combination of their Contribution(s) with the Work to which such -Contribution(s) was submitted. If You institute patent litigation against -any entity (including a cross-claim or counterclaim in a lawsuit) alleging -that the Work or a Contribution incorporated within the Work constitutes -direct or contributory patent infringement, then any patent licenses -granted to You under this License for that Work shall terminate as of the -date such litigation is filed.
-4. Redistribution. You may reproduce and -distribute copies of the Work or Derivative Works thereof in any medium, -with or without modifications, and in Source or Object form, provided that -You meet the following conditions:
-5. Submission of Contributions. Unless You -explicitly state otherwise, any Contribution intentionally submitted for -inclusion in the Work by You to the Licensor shall be under the terms and -conditions of this License, without any additional terms or conditions. -Notwithstanding the above, nothing herein shall supersede or modify the -terms of any separate license agreement you may have executed with Licensor -regarding such Contributions.
-6. Trademarks. This License does not grant -permission to use the trade names, trademarks, service marks, or product -names of the Licensor, except as required for reasonable and customary use -in describing the origin of the Work and reproducing the content of the -NOTICE file.
-7. Disclaimer of Warranty. Unless required by -applicable law or agreed to in writing, Licensor provides the Work (and -each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT -WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, -without limitation, any warranties or conditions of TITLE, -NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You -are solely responsible for determining the appropriateness of using or -redistributing the Work and assume any risks associated with Your exercise -of permissions under this License.
-8. Limitation of Liability. In no event and -under no legal theory, whether in tort (including negligence), contract, or -otherwise, unless required by applicable law (such as deliberate and -grossly negligent acts) or agreed to in writing, shall any Contributor be -liable to You for damages, including any direct, indirect, special, -incidental, or consequential damages of any character arising as a result -of this License or out of the use or inability to use the Work (including -but not limited to damages for loss of goodwill, work stoppage, computer -failure or malfunction, or any and all other commercial damages or losses), -even if such Contributor has been advised of the possibility of such -damages.
-9. Accepting Warranty or Additional Liability. -While redistributing the Work or Derivative Works thereof, You may choose -to offer, and charge a fee for, acceptance of support, warranty, indemnity, -or other liability obligations and/or rights consistent with this License. -However, in accepting such obligations, You may act only on Your own behalf -and on Your sole responsibility, not on behalf of any other Contributor, -and only if You agree to indemnify, defend, and hold each Contributor -harmless for any liability incurred by, or claims asserted against, such -Contributor by reason of your accepting any such warranty or additional -liability.
-END OF TERMS AND CONDITIONS
-To apply the Apache License to your work, attach the following boilerplate -notice, with the fields enclosed by brackets "[]" replaced with your own -identifying information. (Don't include the brackets!) The text should be -enclosed in the appropriate comment syntax for the file format. We also -recommend that a file or class name and description of purpose be included -on the same "printed page" as the copyright notice for easier -identification within third-party archives.
-Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -