From b362e1d35a1bbcf83ad34b2b41363f1f58f75c14 Mon Sep 17 00:00:00 2001 From: GiZano Date: Fri, 14 Aug 2026 23:23:08 +0200 Subject: [PATCH] refactor(fullstack): clear sonarcloud code smells and cpd false positive - extract nested ternaries into branches (S3358): mobile dashboard and map, simulate_zone - reduce cognitive complexity in geo, timescale and worker modules (S3776) - dedupe 'Zone not found' literal into a constant (S1192) - name the anonymous namespaces in the gnss firmware module (S1000) - use Array.at and toSorted in the mobile seismograph window (S7755, S4043) - exclude the structurally identical map theme palettes from duplication (cpd) --- backend/scripts/simulate_zone.py | 7 ++- backend/src/geo.py | 21 +++---- backend/src/main.py | 8 ++- backend/src/timescale.py | 74 +++++++++++++++--------- backend/src/worker.py | 70 +++++++++++++---------- firmware/src/GnssModule.cpp | 4 +- firmware/src/GnssModule.h | 2 +- mobile/app/(tabs)/index.tsx | 96 ++++++++++++++++++-------------- mobile/app/(tabs)/map.tsx | 7 ++- sonar-project.properties | 6 ++ 10 files changed, 179 insertions(+), 116 deletions(-) diff --git a/backend/scripts/simulate_zone.py b/backend/scripts/simulate_zone.py index 83fcc1f..f809e7a 100644 --- a/backend/scripts/simulate_zone.py +++ b/backend/scripts/simulate_zone.py @@ -130,7 +130,12 @@ def main() -> None: resp = requests.post(f"{API_URL}/readings/", json=payload, headers=headers(), timeout=10) status = resp.status_code mag = estimate_magnitude(value) - flag = " 🚨 CRITICAL!" if mag >= 4.5 else (" ⚠️ caution" if mag >= 4.0 else "") + if mag >= 4.5: + flag = " 🚨 CRITICAL!" + elif mag >= 4.0: + flag = " ⚠️ caution" + else: + flag = "" print(f" t+{step:>2}s value={value:>5} M≈{mag:.2f}{flag} http {status}", flush=True) if status != 202: print(f" API: {resp.text}", flush=True) diff --git a/backend/src/geo.py b/backend/src/geo.py index 006403b..436f74f 100644 --- a/backend/src/geo.py +++ b/backend/src/geo.py @@ -76,6 +76,15 @@ def point_to_geohash(latitude: float, longitude: float, precision: int) -> str: return "".join(out) +def _refine_axis(ranges: list[float], cd: int, mask: int) -> None: + """Narrow ``ranges`` to the half-interval selected by one geohash bit.""" + mid = (ranges[0] + ranges[1]) / 2.0 + if cd & mask: + ranges[0] = mid + else: + ranges[1] = mid + + def geohash_bounds(geohash: str) -> tuple[float, float, float, float]: """Decode a geohash into ``(lon_min, lat_min, lon_max, lat_max)``.""" lat_range = [-90.0, 90.0] @@ -85,17 +94,9 @@ def geohash_bounds(geohash: str) -> tuple[float, float, float, float]: cd = _BASE32.index(char) for mask in (16, 8, 4, 2, 1): if even: - mid = (lon_range[0] + lon_range[1]) / 2.0 - if cd & mask: - lon_range[0] = mid - else: - lon_range[1] = mid + _refine_axis(lon_range, cd, mask) else: - mid = (lat_range[0] + lat_range[1]) / 2.0 - if cd & mask: - lat_range[0] = mid - else: - lat_range[1] = mid + _refine_axis(lat_range, cd, mask) even = not even return lon_range[0], lat_range[0], lon_range[1], lat_range[1] diff --git a/backend/src/main.py b/backend/src/main.py index cb1ceae..4c8e063 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -44,6 +44,8 @@ PING_QUERY = "SELECT 1" +ZONE_NOT_FOUND = "Zone not found" + # --- SECURE CONFIGURATION --- REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") @@ -497,7 +499,7 @@ def get_zone_readings(zone_id: int, limit: int = 60, db: Session = Depends(get_d """ zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first() if not zone: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ZONE_NOT_FOUND) limit = max(1, min(limit, 200)) return ( db.query(models.Reading) @@ -519,7 +521,7 @@ def delete_zone_readings(zone_id: int, db: Session = Depends(get_db)): """ zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first() if not zone: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ZONE_NOT_FOUND) sensor_ids = [ row[0] @@ -545,7 +547,7 @@ def get_zone_alerts(zone_id: int, limit: int = 20, db: Session = Depends(get_db) """ zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first() if not zone: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ZONE_NOT_FOUND) limit = max(1, min(limit, 100)) return ( db.query(models.Alert) diff --git a/backend/src/timescale.py b/backend/src/timescale.py index 3670d33..ef46244 100644 --- a/backend/src/timescale.py +++ b/backend/src/timescale.py @@ -22,30 +22,18 @@ TSDB_RETENTION_DAYS = os.getenv("TSDB_RETENTION_DAYS", "180") -def apply_timescale(db: Session) -> dict: - """Apply TimescaleDB DDL where possible. Returns a per-step report dict. - - Steps are isolated: a failure in any step never blocks the others nor the - application startup. - """ - report = { - "timescaledb": False, - "hypertable": False, - "aggregate": False, - "retention": False, - } - - # 1. Extension --------------------------------------------------------- +def _enable_extension(db: Session) -> bool: try: db.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb")) db.commit() - report["timescaledb"] = True + return True except Exception as e: db.rollback() print(f"⚠️ TimescaleDB extension unavailable: {e}", flush=True) - return report + return False - # 2. Hypertable -------------------------------------------------------- + +def _create_hypertable(db: Session) -> bool: try: already_hypertable = db.execute( text( @@ -61,13 +49,15 @@ def apply_timescale(db: Session) -> dict: ) ) db.commit() - report["hypertable"] = True print("✅ readings is a TimescaleDB hypertable (chunked on recorded_at).", flush=True) + return True except Exception as e: db.rollback() print(f"⚠️ Hypertable creation skipped: {e}", flush=True) + return False - # 3. Continuous aggregate (per-sensor minute rollups) ------------------- + +def _create_continuous_aggregate(db: Session) -> bool: try: db.execute( text( @@ -91,13 +81,15 @@ def apply_timescale(db: Session) -> dict: db.commit() except Exception: db.rollback() # policy already present or not yet refreshable - report["aggregate"] = True print("✅ Continuous aggregate readings_minute created.", flush=True) + return True except Exception as e: db.rollback() print(f"⚠️ Continuous aggregate skipped: {e}", flush=True) + return False - # 4. Compression + retention ------------------------------------------- + +def _apply_compression(db: Session) -> None: try: # Newer TimescaleDB (2.13+/3.x) requires columnstore on the hypertable # before a columnstore compression policy can be added. @@ -114,6 +106,9 @@ def apply_timescale(db: Session) -> dict: db.rollback() if "already exists" not in str(e).lower(): print(f"⚠️ Compression policy skipped: {e}", flush=True) + + +def _apply_retention(db: Session) -> bool: try: db.execute( text( @@ -122,13 +117,42 @@ def apply_timescale(db: Session) -> dict: ) ) db.commit() - report["retention"] = True + return True except Exception as e: db.rollback() if "already exists" in str(e).lower(): - report["retention"] = True - else: - print(f"⚠️ Retention policy skipped: {e}", flush=True) + return True + print(f"⚠️ Retention policy skipped: {e}", flush=True) + return False + + +def apply_timescale(db: Session) -> dict: + """Apply TimescaleDB DDL where possible. Returns a per-step report dict. + + Steps are isolated: a failure in any step never blocks the others nor the + application startup. + """ + report = { + "timescaledb": False, + "hypertable": False, + "aggregate": False, + "retention": False, + } + + # 1. Extension --------------------------------------------------------- + if not _enable_extension(db): + return report + report["timescaledb"] = True + + # 2. Hypertable -------------------------------------------------------- + report["hypertable"] = _create_hypertable(db) + + # 3. Continuous aggregate (per-sensor minute rollups) ------------------- + report["aggregate"] = _create_continuous_aggregate(db) + + # 4. Compression + retention ------------------------------------------- + _apply_compression(db) + report["retention"] = _apply_retention(db) if report["retention"]: print(f"✅ Retention policy set to {TSDB_RETENTION_DAYS} days.", flush=True) diff --git a/backend/src/worker.py b/backend/src/worker.py index 6223c3b..37931c9 100644 --- a/backend/src/worker.py +++ b/backend/src/worker.py @@ -174,6 +174,44 @@ def enqueue_ai_report(db: Session, event: dict, alert_id: int, zone_id: int, mag redis_sync.lpush(AI_REPORT_QUEUE, json.dumps(ai_payload)) print(f"🤖 AI Report enqueued (report_id={report.id})", flush=True) +def _parse_batch(batch: list) -> list: + """Decode raw stream entries; park malformed payloads on the DLQ.""" + pending = [] + for message_id, payload in batch: + try: + event = json.loads(payload) + except Exception: + print("❌ Malformed payload -> DLQ.", flush=True) + try: + move_to_dlq(redis_sync, message_id, payload, reason="malformed_json") + except Exception as e: + print(f"❌ DLQ write failed: {e}", flush=True) + continue + pending.append((message_id, event, payload)) + return pending + + +def _process_pending(pending: list, db: Session) -> None: + """Persist a parsed batch in one transaction and acknowledge the stream.""" + try: + process_batch([event for _, event, _ in pending], db) + ack(redis_sync, [message_id for message_id, _, _ in pending]) + for _, event, _ in pending: + print( + f"✅ Processed sensor {event.get('sensor_id')} -> {event.get('value')} " + f"(Mag: {estimate_magnitude(event.get('value', 0))})", + flush=True, + ) + except Exception as e: + print(f"❌ Batch DB Error: {e}. Moving batch to DLQ.", flush=True) + db.rollback() + for message_id, _, payload in pending: + try: + move_to_dlq(redis_sync, message_id, payload, reason=f"process_error: {e}") + except Exception as dlq_err: + print(f"❌ DLQ write failed: {dlq_err}", flush=True) + + def run_worker(): consumer = f"{CONSUMER_PREFIX}-{socket.gethostname()}-{os.getpid()}" print(f"👷 Worker started. stream='{READINGS_STREAM}' group='{READINGS_GROUP}' consumer='{consumer}'") @@ -195,39 +233,11 @@ def run_worker(): if not batch: continue - pending = [] - for message_id, payload in batch: - try: - event = json.loads(payload) - except Exception: - print("❌ Malformed payload -> DLQ.", flush=True) - try: - move_to_dlq(redis_sync, message_id, payload, reason="malformed_json") - except Exception as e: - print(f"❌ DLQ write failed: {e}", flush=True) - continue - pending.append((message_id, event, payload)) - + pending = _parse_batch(batch) if not pending: continue - try: - process_batch([event for _, event, _ in pending], db) - ack(redis_sync, [message_id for message_id, _, _ in pending]) - for _, event, _ in pending: - print( - f"✅ Processed sensor {event.get('sensor_id')} -> {event.get('value')} " - f"(Mag: {estimate_magnitude(event.get('value', 0))})", - flush=True, - ) - except Exception as e: - print(f"❌ Batch DB Error: {e}. Moving batch to DLQ.", flush=True) - db.rollback() - for message_id, _, payload in pending: - try: - move_to_dlq(redis_sync, message_id, payload, reason=f"process_error: {e}") - except Exception as dlq_err: - print(f"❌ DLQ write failed: {dlq_err}", flush=True) + _process_pending(pending, db) except Exception as e: print(f"❌ Redis Connection Error: {e}", flush=True) diff --git a/firmware/src/GnssModule.cpp b/firmware/src/GnssModule.cpp index a47cefc..6d7aff8 100644 --- a/firmware/src/GnssModule.cpp +++ b/firmware/src/GnssModule.cpp @@ -2,7 +2,7 @@ #ifdef GNSS_ENABLED -namespace { +namespace quakeguard_gnss { float scaleLat(double lat) { return static_cast(lat); @@ -12,7 +12,7 @@ float scaleLon(double lon) { return static_cast(lon); } -} // namespace +} // namespace quakeguard_gnss GnssModule& gnss() { static GnssModule instance; diff --git a/firmware/src/GnssModule.h b/firmware/src/GnssModule.h index c7c4fbf..5cb08b0 100644 --- a/firmware/src/GnssModule.h +++ b/firmware/src/GnssModule.h @@ -36,7 +36,7 @@ #define GPS_SERIAL_BAUD 9600 #endif -namespace { +namespace quakeguard_gnss { constexpr const char* GNSS_NVS_NAMESPACE = "quake-gnss"; constexpr const char* GNSS_NVS_LAT = "lat"; constexpr const char* GNSS_NVS_LON = "lon"; diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 40aec27..7dfd1a7 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -145,7 +145,12 @@ function ZoneSummaryStrip({ activeNodes, totalNodes, latestMagnitude, isAlertAct }>) { const styles = createStyles(colors); const mag = latestMagnitude ?? 0; - const magColor = mag >= 4.5 ? colors.alert : mag >= 4.0 ? colors.caution : colors.live; + let magColor = colors.live; + if (mag >= 4.5) { + magColor = colors.alert; + } else if (mag >= 4.0) { + magColor = colors.caution; + } return ( @@ -180,14 +185,14 @@ function NetworkChart({ points, isAlertActive, colors }: Readonly<{ }>) { const styles = createStyles(colors); const theme = useMemo(() => createQuakeGuardTheme(colors), [colors]); - const last = points[points.length - 1]; - const lineColor = last - ? thresholdOf(last.y) === "alert" - ? colors.alert - : thresholdOf(last.y) === "caution" - ? colors.caution - : colors.live - : colors.live; + const last = points.at(-1); + const threshold = last ? thresholdOf(last.y) : "live"; + let lineColor = colors.live; + if (threshold === "alert") { + lineColor = colors.alert; + } else if (threshold === "caution") { + lineColor = colors.caution; + } if (points.length === 0) { return ( @@ -308,7 +313,7 @@ export default function MonitorScreen() { const list = [...merged.values()].filter(({ t }) => (now - t) / 1000 <= WINDOW_SECONDS); if (list.length === 0) return prev; return list - .sort((a, b) => a.t - b.t) + .toSorted((a, b) => a.t - b.t) .slice(-WINDOW_MAX) .map(({ t, y }) => ({ // Clamp to the domain so nothing can ever spill past the plot edges. @@ -360,6 +365,43 @@ export default function MonitorScreen() { const loading = loadingSensors || loadingZones || loadingReadings; const errored = errorSensors || errorZones || errorReadings; + let dashboardContent; + if (errored) { + dashboardContent = ; + } else if (loading) { + dashboardContent = ; + } else { + dashboardContent = ( + <> + + + + + SEISMOGRAPH // {selectedZone?.city?.toUpperCase() ?? "ZONE"} + + Z-ACCEL // RAW + + + + + + + + + ); + } + return ( - {errored ? ( - - ) : loading ? ( - - ) : ( - <> - - - - - SEISMOGRAPH // {selectedZone?.city?.toUpperCase() ?? "ZONE"} - - Z-ACCEL // RAW - - - - - - - - - )} + {dashboardContent} diff --git a/mobile/app/(tabs)/map.tsx b/mobile/app/(tabs)/map.tsx index feb05a8..0130bc5 100644 --- a/mobile/app/(tabs)/map.tsx +++ b/mobile/app/(tabs)/map.tsx @@ -71,12 +71,17 @@ export default function MapScreen() { ); } + let mapStyle: any; + if (Platform.OS === "android") { + mapStyle = isDark ? darkMapStyle : lightMapStyle; + } + return (