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 ef38a55c4..b3c02dd11 100644 --- a/.github/workflows/soak.yaml +++ b/.github/workflows/soak.yaml @@ -48,9 +48,19 @@ jobs: '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 diff --git a/.gitignore b/.gitignore index fe188cd93..5379d1f7c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/ *.pytest_cache/ .venv/ venv/ +.coverage # --- VSCode / Editor --- .vscode/ @@ -35,5 +36,4 @@ venv/ # --- OS files --- .DS_Store -Thumbs.db - +Thumbs.db \ No newline at end of file diff --git a/GUI/requirements.txt b/GUI/requirements.txt index bd9efb1b7..96080bc7a 100644 --- a/GUI/requirements.txt +++ b/GUI/requirements.txt @@ -1,18 +1,37 @@ -PyQt6==6.9.1 -PyQt6-WebEngine==6.9.0 -# Web/API +PyQt6==6.7.1 +PyQt6-WebEngine==6.7.0 +python-vlc + + +# ───── 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 +plotly +shapely +PyJWT>=2.9.0 +sip + + 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..c6c28e50f --- /dev/null +++ b/GUI/src/vast/alerts/alert_service.py @@ -0,0 +1,209 @@ +import yaml +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" + 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" + 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 + + # Apply template enrichment + tmpl = self.templates.get(alert_type) + if tmpl: + a["category"] = tmpl.get("category") + context = { + "device_id": device_id, + "area": a.get("area", "unknown area"), + "confidence": a.get("confidence", "?"), + "timestamp": a.get("started_at", ""), + } + # Use Template.safe_substitute to avoid KeyErrors + a["summary"] = Template(tmpl.get("summary", "")).safe_substitute(context) + a["recommendation"] = Template(tmpl.get("recommendation", "")).safe_substitute(context) + + self.alerts = alerts + 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", []) + print("[AlertService] Realtime message:", alert_msg) + + 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 in memory + existing = next((al for al in self.alerts if al.get("alert_id") == alert_id), None) + + if is_resolved: + # ✅ Don't delete — update existing alert with endedAt timestamp + if existing: + existing["endedAt"] = ends_at + self.alertRemoved.emit(alert_id) + else: + # If not in memory (e.g. loaded from DB earlier) + # create a minimal record so the UI can update + fake_alert = {"alert_id": alert_id, "endedAt": ends_at} + self.alerts.append(fake_alert) + self.alertRemoved.emit(alert_id) + continue + + # ──────────────────────────────── + # ACTIVE alert (new or ongoing) + # ──────────────────────────────── + 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})") + + # Enrich with template + tmpl = self.templates.get(alert_type, {}) + summary = Template(tmpl.get("summary", "")).safe_substitute( + device_id=device_id, + area=ann.get("area", ""), + confidence=ann.get("confidence", ""), + ) + recommendation = tmpl.get("recommendation", "") + category = tmpl.get("category") + + normalized = { + "alert_id": alert_id, + "alert_type": alert_type, + "device_id": device_id, + "lat": lat, + "lon": lon, + "severity": int(ann.get("severity", 1)), + "summary": summary, + "recommendation": recommendation, + "category": category, + "hls": ann.get("hls"), + "vod": ann.get("vod"), + "image_url": ann.get("image_url"), + "startsAt": a.get("startsAt"), + } + + # Update if it already exists, else append + if existing: + existing.update(normalized) + else: + self.alerts.append(normalized) + + self.alertAdded.emit(normalized) + + + def mark_all_acknowledged(self): + """Mark all alerts as acknowledged both locally and in DB (PATCH /api/tables/alerts).""" + unacked = [a for a in self.alerts if not a.get("ack", False)] + if not unacked: + return + + # Update local memory first + for a in unacked: + a["ack"] = True + + # Push updates asynchronously to DB + def _patch_ack(alert): + try: + url = f"{self.api.base}/api/tables/alerts" + 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.") + + + + diff --git a/GUI/src/vast/dashboard_api.py b/GUI/src/vast/dashboard_api.py index bf73c37d0..00a3b2de8 100644 --- a/GUI/src/vast/dashboard_api.py +++ b/GUI/src/vast/dashboard_api.py @@ -1,55 +1,97 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations +import os import json import time +import base64 import pathlib -from typing import Dict, List +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) @@ -57,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 @@ -69,68 +112,333 @@ 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"} - def list_devices(self, model: str | None = None) -> list[dict]: - - url = f"{self.base}/api/devices" - if model: - url += f"?model={model}" - try: - r = self.http.get(url, timeout=10) - if r.status_code == 200: - return r.json() - print(f"[API ERROR] {r.status_code}: {r.text[:100]}") - except Exception as e: - print(f"[API FAIL] {e}") + 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 [] - # ---------- THRESHOLDS ---------- def bulk_set_task_thresholds_labeled( self, - mapping: dict[tuple[str, str], float] | list[dict], + mapping: Dict[Tuple[str, str], float] | List[dict], updated_by: str = "gui", ) -> dict: - - if isinstance(mapping, dict): - items = [ + """ + 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() ] - else: - items = mapping + if isinstance(mapping, dict) else mapping + ) - url = f"{self.base}/api/thresholds/batch" - try: - - r = self.http.post(url, json=items, timeout=20) - if r.status_code in (200, 201): - data = r.json() - # ודאי שמבנה ok/fail תואם + 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": list(data.get("ok", [])), - "fail": list(data.get("fail", [])), + "ok": [], + "fail": [[[i.get("task"), i.get("label","")], f"http-{r.status_code} {r.text[:200]}"] for i in items], } - 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: - return {"ok": [], "fail": [[ [i.get("task"), i.get("label","")], str(e)] for i in items]} \ No newline at end of file + 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) diff --git a/GUI/src/vast/desktop/Dockerfile b/GUI/src/vast/desktop/Dockerfile index 10d413e67..ec0cc9972 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 dependencies ───────── 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,28 @@ 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 \ + procps iproute2 xauth git vlc libvlc5 libvlccore9 \ + fonts-dejavu-core fonts-noto-core fonts-noto-color-emoji\ && rm -rf /var/lib/apt/lists/* + +# (optional) minimal 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; \ + echo "Configuring NetFree certificates..."; \ + cp ./certs/*.crt /usr/local/share/ca-certificates/; \ + update-ca-certificates; \ fi ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt +# ───────── noVNC for remote GUI ───────── 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 +41,44 @@ RUN mkdir -p /opt && \ rm /tmp/novnc.tar.gz && \ git clone --depth 1 https://github.com/novnc/websockify /opt/noVNC/utils/websockify +# ───────── Python deps ───────── COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir \ + "PyQt6==6.8.0" \ + "PyQt6-WebEngine==6.8.0" \ + "argon2-cffi" \ + "requests" \ + "numpy" \ + --extra-index-url https://pypi.org/simple \ + --prefer-binary \ + --break-system-packages \ + && pip show PyQt6 PyQt6-WebEngine argon2-cffi +RUN pip install plotly +RUN pip install PyJWT +# ───────── app setup ───────── RUN useradd -m -s /bin/bash appuser \ && mkdir -p /app /tmp/.X11-unix \ && chown -R appuser:appuser /app /tmp /opt/noVNC /var/tmp + RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/* + 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 + USER appuser -EXPOSE 5900 6080 +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..460dfcafe 100644 --- a/GUI/src/vast/desktop/start.sh +++ b/GUI/src/vast/desktop/start.sh @@ -22,3 +22,10 @@ echo "[INFO] Starting noVNC..." echo "[INFO] Starting PyQt application..." exec python /app/src/vast/main.py + + + +# # ------------------------------ +# # 🚀 Launch the main PyQt application +# # ------------------------------ +# exec /opt/venv/bin/python /app/src/vast/main.py diff --git a/GUI/src/vast/gateway/Dockerfile b/GUI/src/vast/gateway/Dockerfile index 27486d095..745b58831 100644 --- a/GUI/src/vast/gateway/Dockerfile +++ b/GUI/src/vast/gateway/Dockerfile @@ -3,7 +3,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 WORKDIR /app # build arg -ARG USE_NETFREE=true +# 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 @@ -22,12 +22,12 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ # # System CA + add NetFree certs -# RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* -# COPY certs/*.crt /usr/local/share/ca-certificates/ -# RUN update-ca-certificates || true -# ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ -# REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ -# PIP_CERT=/etc/ssl/certs/ca-certificates.crt +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* +COPY certs/*.crt /usr/local/share/ca-certificates/ +RUN update-ca-certificates || true +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ + REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ + PIP_CERT=/etc/ssl/certs/ca-certificates.crt # Python deps COPY requirements.txt /app/requirements.txt @@ -49,4 +49,4 @@ RUN python -m grpc_tools.protoc -I./vast/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"] +CMD ["uvicorn", "vast.gateway.gateway_main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/GUI/src/vast/home_view.py b/GUI/src/vast/home_view.py index c7a5a6cc6..ec2b41df2 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,100 @@ 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..f302d3b81 100644 --- a/GUI/src/vast/main.py +++ b/GUI/src/vast/main.py @@ -100,14 +100,14 @@ 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 @@ -116,7 +116,6 @@ def open_main(user): win.show() shell.hide() - def on_logout(win): win.close() shell.reset() @@ -125,6 +124,9 @@ def on_logout(win): # 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}") diff --git a/GUI/src/vast/main_window.py b/GUI/src/vast/main_window.py index df8594822..f5e3fc80b 100644 --- a/GUI/src/vast/main_window.py +++ b/GUI/src/vast/main_window.py @@ -1,88 +1,343 @@ -from __future__ import annotations 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.alerts_panel import AlertsPanel from views.notification_view import NotificationView -from dashboard_api import DashboardApi 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("VAST – Dashboard") - self.resize(1100, 700) + self.setWindowTitle("AgCloud – Dashboard") + 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) + # ─────────────────────────────── + 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) - for name in [ - "Home", "Sensors", "Sound", "Ground Image", - "Aerial Image", "Fruits", "Security", "Settings", "Notifications" - ]: - QListWidgetItem(name, self.nav_list) - - self.nav_list.setCurrentRow(0) self.nav_list.currentRowChanged.connect(self._on_nav_change) + self.nav_list.itemClicked.connect(self._on_nav_click) + + # ─────────────────────────────── + # ALERT SERVICE + PANEL + # ─────────────────────────────── + 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()) - # ---------- Views ---------- - self.home = HomeView(api, self) + 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.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) - # Stack for switching between views 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 + "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) - - # ---------- 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() 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): - print(f"[MainWindow] Navigating to widget: {widget.__class__.__name__}") current = self.stack.currentWidget() if current not in self.history: self.history.append(current) @@ -97,4 +352,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""" +
| 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. -