Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ WAZUH_PASSWORD=
# ── OPTIONAL: MAPS ───────────────────────────────────────────────────────────
# Google Maps is proxied through Manus — no API key required.
# The VITE_FRONTEND_FORGE_API_KEY handles map authentication automatically.
# VITE_GOOGLE_MAPS_MAP_ID — Google mapId (defaults to DEMO_MAP_ID when unset).
#
# Phase 17 — bundled MapLibre/Cesium geospatial portal (/app/geo/portal):
# VITE_MAP_STYLE_URL=https://tiles.openfreemap.org/styles/liberty # 2D vector style (or your same-origin tile proxy)
# VITE_CESIUM_TOKEN= # Cesium Ion token; UNSET = Ion-free OSM imagery + ellipsoid terrain (no Ion requests)
# VITE_FLUVIO_WS_URL= # AIS/declaration WS feed; unset = wss://<host>/ws; "off" = disabled
#
# Production CSP (fail-closed by default). Whitelist ONLY the tile origins in use:
# CSP_CONNECT_SRC_EXTRA=https://tiles.openfreemap.org,https://tile.openstreetmap.org
# CSP_SCRIPT_SRC_EXTRA= # only if a maps script must load from another origin
# CSP_IMG_SRC_EXTRA= # raster tile origins if img-src is ever tightened

# ── NODE ENVIRONMENT ─────────────────────────────────────────────────────────
NODE_ENV=production
Expand Down
45 changes: 44 additions & 1 deletion client/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME && k !== OFFLINE_QUEUE_NAME).map((k) => caches.delete(k)))
Promise.all(keys.filter((k) => ![CACHE_NAME, OFFLINE_QUEUE_NAME, TILE_CACHE_NAME].includes(k)).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
Expand All @@ -36,10 +36,53 @@ self.addEventListener('message', (event) => {
});

// ─── FETCH ───────────────────────────────────────────────────────────────────
// Phase 17 (G10): CacheFirst map-tile caching (pattern from hydrogenTransport
// PWA). Only well-known open tile origins are cached — no arbitrary origins.
const TILE_CACHE_NAME = 'tradegateway-tiles-v1';
const TILE_ORIGINS = [
'https://tile.openstreetmap.org',
'https://tiles.openfreemap.org',
'https://basemaps.cartocdn.com',
];
const TILE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);

// ── Map tiles/styles/glyphs: cache-first with freshness cap ──────────────
if (request.method === 'GET' && TILE_ORIGINS.some((o) => url.origin === o)) {
event.respondWith(
caches.open(TILE_CACHE_NAME).then(async (cache) => {
const cached = await cache.match(request);
if (cached) {
const fetchedAt = Number(cached.headers.get('X-SW-Cached-At') || 0);
if (Date.now() - fetchedAt < TILE_CACHE_MAX_AGE_MS) return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
const headers = new Headers(response.headers);
headers.set('X-SW-Cached-At', String(Date.now()));
const stamped = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
cache.put(request, stamped.clone());
return stamped;
}
// Network answered but failed: fall back to stale tile if present
return cached || response;
} catch {
// Offline: serve the cached tile or an honest 503 — never a fake tile
return cached || new Response('tile unavailable offline', { status: 503 });
}
})
);
return;
}

// Skip cross-origin requests
if (url.origin !== self.location.origin) return;

Expand Down
5 changes: 5 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import AdminKYCReview from "./pages/app/AdminKYCReview";
import PortHeatmap from "./pages/app/PortHeatmap";
// Lazy-load the specification page (it's large))
import { lazy, Suspense } from "react";
// Phase 17 (G2): Geospatial Portal — lazy, routed (previously dead code)
const GeospatialPortal = lazy(() => import("./pages/geo/GeospatialPortal"));
const Specification = lazy(() => import("./pages/Specification"));
// Lazy-load heavy pages
const SanctionsScreening = lazy(() => import("./pages/app/SanctionsScreening"));
Expand Down Expand Up @@ -279,6 +281,9 @@ function Router() {

{/* Geospatial */}
<Route path="/app/geo/heatmap" component={PortHeatmap} />
<Route path="/app/geo/portal">
<Suspense fallback={<LazyFallback />}><GeospatialPortal /></Suspense>
</Route>
<Route path="/app/geo/congestion-forecast">
<Suspense fallback={<LazyFallback />}><PortCongestionForecast /></Suspense>
</Route>
Expand Down
25 changes: 25 additions & 0 deletions client/src/components/Map.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
/**
* Phase 17 (G8) — MapView smoke test: without a configured Google Maps key
* the component must fail fast into the honest "Map unavailable" fallback
* instead of hanging on a spinner (and must never attempt a script injection).
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { MapView } from "./Map";

describe("MapView", () => {
it("renders the honest fallback when no maps API key is configured", async () => {
render(<MapView />);
await waitFor(() => {
expect(screen.getByText("Map unavailable")).toBeTruthy();
});
// Fail-closed: no runtime CDN script was injected into the document
expect(document.querySelector("script[src*='maps/api/js']")).toBeNull();
});

it("exposes an accessible map region while loading", () => {
const { container } = render(<MapView />);
expect(container.querySelector('[role="region"][aria-label="Interactive map"]')).toBeTruthy();
});
});
18 changes: 14 additions & 4 deletions client/src/components/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
* if (status === "OK" && results[0]) {
* if (status === "OK" && results[0].geometry) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
Expand Down Expand Up @@ -101,7 +101,10 @@ function loadMapScript(): Promise<void> {
return;
}
const script = document.createElement("script");
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
// visualization is required by the heatmap layers in PortHeatmap /
// CargoTrackingMap — it was missing, so google.maps.visualization was
// undefined at runtime.
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry,visualization`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
Expand Down Expand Up @@ -152,7 +155,8 @@ export function MapView({
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
mapId: "DEMO_MAP_ID",
// G9: mapId is operator-configurable; DEMO_MAP_ID only as a dev default.
mapId: import.meta.env.VITE_GOOGLE_MAPS_MAP_ID || "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
Expand All @@ -173,6 +177,12 @@ export function MapView({
}

return (
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
// G12: expose the map region to assistive technology.
<div
ref={mapContainer}
role="region"
aria-label="Interactive map"
className={cn("w-full h-[500px]", className)}
/>
);
}
33 changes: 33 additions & 0 deletions client/src/hooks/useFluvioFeed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Phase 17 (G4/G8) — WS URL resolution tests for useFluvioFeed.
*/
import { describe, it, expect } from "vitest";
import { resolveFluvioWsUrl } from "./useFluvioFeed";

const httpsLoc = { protocol: "https:", host: "trade.gov.ng" };
const httpLoc = { protocol: "http:", host: "localhost:5173" };

describe("resolveFluvioWsUrl", () => {
it("honours an explicit env URL", () => {
expect(resolveFluvioWsUrl("wss://fluvio.internal/ws", httpsLoc)).toBe("wss://fluvio.internal/ws");
});

it('"off" disables the feed (honest disabled state)', () => {
expect(resolveFluvioWsUrl("off", httpsLoc)).toBeNull();
expect(resolveFluvioWsUrl("OFF", httpsLoc)).toBeNull();
});

it("defaults to same-origin wss on https (never ws://localhost)", () => {
expect(resolveFluvioWsUrl(undefined, httpsLoc)).toBe("wss://trade.gov.ng/ws");
});

it("uses ws: only on http dev origins", () => {
expect(resolveFluvioWsUrl("", httpLoc)).toBe("ws://localhost:5173/ws");
});

it("never returns a hardcoded localhost URL in production", () => {
const url = resolveFluvioWsUrl(undefined, httpsLoc);
expect(url).not.toContain("localhost");
expect(url!.startsWith("wss://")).toBe(true);
});
});
32 changes: 29 additions & 3 deletions client/src/hooks/useFluvioFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,31 @@ export interface FluvioEvent {
payload: VesselPosition | Record<string, unknown>;
}

export type FeedStatus = "connecting" | "connected" | "paused" | "reconnecting" | "error";
export type FeedStatus = "connecting" | "connected" | "paused" | "reconnecting" | "error" | "disabled";

// ── Constants ─────────────────────────────────────────────────────────────────

const FLUVIO_WS_URL = "ws://localhost:8085/ws";
/**
* Phase 17 (G4): the Fluvio WS endpoint is env-driven, never hardcoded to
* localhost. Resolution order:
* 1. VITE_FLUVIO_WS_URL (full ws:// or wss:// URL; "off" disables the feed)
* 2. same-origin default: wss://<host>/ws (ws: on http: dev origins)
* When disabled, the hook reports status "disabled" and never opens a socket.
*/
export function resolveFluvioWsUrl(
envValue: string | undefined,
locationLike?: { protocol: string; host: string },
): string | null {
const raw = (envValue ?? "").trim();
if (/^off$/i.test(raw)) return null;
if (/^wss?:\/\/.+/.test(raw)) return raw;
const loc = locationLike ?? (typeof window !== "undefined" ? window.location : undefined);
if (!loc) return null;
const scheme = loc.protocol === "https:" ? "wss:" : "ws:";
return `${scheme}//${loc.host}/ws`;
}

const FLUVIO_WS_URL = resolveFluvioWsUrl(import.meta.env.VITE_FLUVIO_WS_URL);
const MAX_EVENTS = 500; // ring buffer size
const RECONNECT_DELAY_MS = 3000; // 3 s between reconnect attempts
const MAX_RECONNECT_ATTEMPTS = 10;
Expand All @@ -65,7 +85,7 @@ export function useFluvioFeed(options?: {
} = options ?? {};

const [events, setEvents] = useState<FluvioEvent[]>([]);
const [status, setStatus] = useState<FeedStatus>("connecting");
const [status, setStatus] = useState<FeedStatus>(FLUVIO_WS_URL ? "connecting" : "disabled");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [reconnectCount, setReconnectCount] = useState(0);

Expand All @@ -77,6 +97,11 @@ export function useFluvioFeed(options?: {
const connect = useCallback(() => {
if (!mountedRef.current) return;
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
if (!FLUVIO_WS_URL) {
// Honest disabled state: feed not configured for this deployment.
setStatus("disabled");
return;
}

setStatus("connecting");

Expand Down Expand Up @@ -168,6 +193,7 @@ export function useFluvioFeed(options?: {
events,
vesselPositions,
status,
feedUrl: FLUVIO_WS_URL,
lastUpdated,
reconnectCount,
pause,
Expand Down
116 changes: 116 additions & 0 deletions client/src/lib/geo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Phase 17 (G8) — unit tests for the shared geospatial helpers that back the
* map components (GeospatialPortal 2D/3D, AIS layer, track replay, heatmap).
*/
import { describe, it, expect } from "vitest";
import {
toMapVessel,
vesselsToGeoJSON,
trackToLineString,
isFeedStale,
lowDataRasterStyle,
resolveMapStyleUrl,
resolveCesiumIonToken,
VesselTrackingRow,
} from "./geo";

const row: VesselTrackingRow = {
id: 1,
mmsi: "657123456",
vesselName: "MT LAGOS STAR",
imoNumber: "9074729",
latitude: 6.42,
longitude: 3.31,
speed: 12.5,
heading: 270,
destinationPort: "NGAPP",
eta: new Date("2026-01-01T00:00:00Z"),
cargoType: "container",
flagCountry: "NGA",
recordedAt: new Date("2025-12-31T12:00:00Z"),
};

describe("toMapVessel", () => {
it("maps a DB row to a GeoVessel with ISO eta", () => {
const v = toMapVessel(row);
expect(v).not.toBeNull();
expect(v!.mmsi).toBe("657123456");
expect(v!.lat).toBeCloseTo(6.42);
expect(v!.eta).toBe("2026-01-01T00:00:00.000Z");
});

it("drops rows with out-of-range coordinates (fail-closed)", () => {
expect(toMapVessel({ ...row, latitude: 91 })).toBeNull();
expect(toMapVessel({ ...row, longitude: -181 })).toBeNull();
expect(toMapVessel({ ...row, latitude: NaN })).toBeNull();
});
});

describe("vesselsToGeoJSON", () => {
it("builds a FeatureCollection keyed [lng, lat]", () => {
const fc = vesselsToGeoJSON([toMapVessel(row)!]);
expect(fc.type).toBe("FeatureCollection");
expect(fc.features[0].geometry.coordinates).toEqual([3.31, 6.42]);
expect(fc.features[0].properties.mmsi).toBe("657123456");
});
});

describe("trackToLineString", () => {
it("returns a LineString for 2+ valid points", () => {
const line = trackToLineString([
{ latitude: 6.0, longitude: 3.0 },
{ latitude: 6.4, longitude: 3.3 },
{ latitude: NaN, longitude: 3.4 }, // dropped
{ latitude: 6.45, longitude: 3.39 },
]);
expect(line).not.toBeNull();
expect(line!.geometry.coordinates).toHaveLength(3);
});

it("returns null for fewer than 2 valid points (honest no-track)", () => {
expect(trackToLineString([{ latitude: 6.0, longitude: 3.0 }])).toBeNull();
expect(trackToLineString([])).toBeNull();
});
});

describe("isFeedStale", () => {
const now = Date.parse("2026-01-01T00:00:00Z");
it("flags missing/old data as stale", () => {
expect(isFeedStale(null, now)).toBe(true);
expect(isFeedStale("2025-12-31T23:57:00Z", now)).toBe(true);
});
it("accepts fresh data", () => {
expect(isFeedStale("2025-12-31T23:59:30Z", now)).toBe(false);
});
});

describe("lowDataRasterStyle", () => {
it("is a self-contained raster style (no remote style JSON fetch)", () => {
const style = lowDataRasterStyle();
expect(style.version).toBe(8);
expect(style.sources.osm.type).toBe("raster");
expect(style.layers).toHaveLength(1);
});
});

describe("resolveMapStyleUrl", () => {
it("uses the env URL when it is a valid http(s) URL", () => {
expect(resolveMapStyleUrl("https://tiles.internal/styles/liberty")).toBe(
"https://tiles.internal/styles/liberty"
);
});
it("falls back to OpenFreeMap for empty/garbage values", () => {
expect(resolveMapStyleUrl(undefined)).toContain("openfreemap");
expect(resolveMapStyleUrl("javascript:alert(1)")).toContain("openfreemap");
});
});

describe("resolveCesiumIonToken", () => {
it("returns null when unset — never an empty-string token", () => {
expect(resolveCesiumIonToken(undefined)).toBeNull();
expect(resolveCesiumIonToken(" ")).toBeNull();
});
it("returns a configured token", () => {
expect(resolveCesiumIonToken(" tok123 ")).toBe("tok123");
});
});
Loading
Loading