From d6c0572b140ec6b8defff3485712d785439e9e5d Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:01:30 +0300 Subject: [PATCH 1/8] added grid hooks and apis --- netmanager-app/app/types/grids.ts | 23 ++++++++ netmanager-app/core/apis/grids.ts | 66 +++++++++++++++++++++ netmanager-app/core/hooks/useGrids.ts | 82 +++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 netmanager-app/app/types/grids.ts create mode 100644 netmanager-app/core/apis/grids.ts create mode 100644 netmanager-app/core/hooks/useGrids.ts diff --git a/netmanager-app/app/types/grids.ts b/netmanager-app/app/types/grids.ts new file mode 100644 index 0000000000..d1c4bc2478 --- /dev/null +++ b/netmanager-app/app/types/grids.ts @@ -0,0 +1,23 @@ +import { Site } from "./sites"; + +export interface CreateGrid { + name: string; + admin_level: string; + shape: { + type: "MultiPolygon" | "Polygon"; + coordinates: number[][][][]; + }; + network: string; +} + +export interface Grid { + _id: string; + visibility: boolean; + name: string; + admin_level: string; + network: string; + long_name: string; + createdAt: string; + sites: Site[]; + numberOfSites: number; +} diff --git a/netmanager-app/core/apis/grids.ts b/netmanager-app/core/apis/grids.ts new file mode 100644 index 0000000000..042cc79911 --- /dev/null +++ b/netmanager-app/core/apis/grids.ts @@ -0,0 +1,66 @@ +import createAxiosInstance from "./axiosConfig"; +import { DEVICES_MGT_URL } from "../urls"; +import { AxiosError } from "axios"; +import { CreateGrid } from "@/app/types/grids"; + +const axiosInstance = createAxiosInstance(); + +interface ErrorResponse { + message: string; +} + +export const grids = { + getGridsSummary: async (networkId: string) => { + try { + const response = await axiosInstance.get( + `${DEVICES_MGT_URL}/grids/summary?network=${networkId}` + ); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + throw new Error( + axiosError.response?.data?.message || "Failed to fetch grids summary" + ); + } + }, + getGridDetails: async (gridId: string) => { + try { + const response = await axiosInstance.get( + `${DEVICES_MGT_URL}/grids/${gridId}` + ); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + throw new Error( + axiosError.response?.data?.message || "Failed to fetch grid details" + ); + } + }, + updateGridDetails: async (gridId: string) => { + try { + const response = await axiosInstance.put( + `${DEVICES_MGT_URL}/grids/${gridId}` + ); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + throw new Error( + axiosError.response?.data?.message || "Failed to update grid details" + ); + } + }, + createGrid: async (data: CreateGrid) => { + try { + const response = await axiosInstance.post( + `${DEVICES_MGT_URL}/grids`, + data + ); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + throw new Error( + axiosError.response?.data?.message || "Failed to create grid" + ); + } + }, +}; diff --git a/netmanager-app/core/hooks/useGrids.ts b/netmanager-app/core/hooks/useGrids.ts new file mode 100644 index 0000000000..0f5b8db727 --- /dev/null +++ b/netmanager-app/core/hooks/useGrids.ts @@ -0,0 +1,82 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { grids } from "../apis/grids"; +import { CreateGrid, Grid } from "@/app/types/grids"; + +interface ErrorResponse { + message: string; +} + +// Response type for the grid summary +interface GridSummaryResponse { + success: boolean; + message: string; + grids: Grid[]; +} + +// Hook to get the grid summary +export const useGridSummary = (networkId: string) => { + return useQuery>( + ["gridSummary", networkId], + () => grids.getGridsSummary(networkId), + { + staleTime: 5 * 60 * 1000, // Cache results for 5 minutes + } + ); +}; + +// Hook to get grid details by gridId +export const useGridDetails = (gridId: string) => { + return useQuery>( + ["gridDetails", gridId], + () => grids.getGridDetails(gridId), + { + staleTime: 5 * 60 * 1000, // Cache results for 5 minutes + } + ); +}; + +// Hook to update grid details +export const useUpdateGridDetails = (gridId: string) => { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: async () => await grids.updateGridDetails(gridId), + onSuccess: () => { + // Invalidate and refetch the grid details + queryClient.invalidateQueries({ queryKey: ["gridDetails", gridId] }); + }, + onError: (error: AxiosError) => { + console.error( + "Failed to update grid details:", + error.response?.data?.message + ); + }, + }); + + return { + updateGridDetails: mutation.mutate, + isLoading: mutation.isPending, + error: mutation.error, + }; +}; + +// Hook to create a new grid +export const useCreateGrid = () => { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: async (newGrid: CreateGrid) => await grids.createGrid(newGrid), + onSuccess: () => { + // Invalidate and refetch the grid summary after creating a new grid + queryClient.invalidateQueries({ queryKey: ["gridSummary"] }); + }, + onError: (error: AxiosError) => { + console.error("Failed to create grid:", error.response?.data?.message); + }, + }); + + return { + createGrid: mutation.mutate, + isLoading: mutation.isPending, + error: mutation.error, + }; +}; From b31323eeb98386a5583b973625a6de061e7f8451 Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Thu, 23 Jan 2025 14:15:37 +0300 Subject: [PATCH 2/8] fix typescript errors on analytics page --- netmanager-app/app/types/devices.ts | 92 +++++++ netmanager-app/app/types/sites.ts | 98 ++++++- .../Analytics/AnalyticsDropdown.tsx | 108 +++++--- .../components/Analytics/GridDashboard.tsx | 67 +++-- netmanager-app/components/Analytics/index.tsx | 259 +++++++++++------- .../{ErrorBoundory.tsx => ErrorBoundary.tsx} | 5 +- netmanager-app/core/apis/analytics.ts | 30 +- netmanager-app/core/apis/devices.ts | 17 +- netmanager-app/core/apis/sites.ts | 1 - netmanager-app/core/hooks/useDevices.ts | 51 +++- netmanager-app/core/hooks/useGrids.ts | 29 +- .../core/hooks/useReadingsSiteCount.ts | 75 +++++ .../core/redux/slices/gridsSlice.ts | 80 +++--- netmanager-app/lib/utils.ts | 34 +++ 14 files changed, 692 insertions(+), 254 deletions(-) rename netmanager-app/components/ui/{ErrorBoundory.tsx => ErrorBoundary.tsx} (86%) create mode 100644 netmanager-app/core/hooks/useReadingsSiteCount.ts diff --git a/netmanager-app/app/types/devices.ts b/netmanager-app/app/types/devices.ts index 96bbe7cb8b..80c3072e20 100644 --- a/netmanager-app/app/types/devices.ts +++ b/netmanager-app/app/types/devices.ts @@ -1,3 +1,5 @@ +import { Site } from "./sites"; + export interface DeviceSite { _id: string; visibility: boolean; @@ -60,3 +62,93 @@ export interface DevicesSummaryResponse { message: string; devices: Device[]; } + +interface HealthTip { + title: string; + description: string; + image: string; +} + +interface AQIRange { + min: number; + max?: number; +} + +interface AQIRanges { + good: AQIRange; + moderate: AQIRange; + u4sg: AQIRange; + unhealthy: AQIRange; + very_unhealthy: AQIRange; + hazardous: AQIRange; +} + +// Define the structure for the averages +interface Averages { + dailyAverage: number; + percentageDifference: number; + weeklyAverages: { + currentWeek: number; + previousWeek: number; + }; +} + +// Define the structure for the site details +// interface SiteDetails { +// _id: string; +// formatted_name: string; +// location_name: string; +// search_name: string; +// town: string; +// city: string; +// region: string; +// country: string; +// name: string; +// approximate_latitude: number; +// approximate_longitude: number; +// bearing_in_radians: number; +// data_provider: string; +// description: string; +// site_category: { +// tags: string[]; +// area_name: string; +// category: string; +// highway: string; +// landuse: string; +// latitude: number; +// longitude: number; +// natural: string; +// search_radius: number; +// waterway: string; +// }; +// } + +export interface Measurement { + _id: string; + site_id: string; + time: string; + __v: number; + aqi_category: string; + aqi_color: string; + aqi_color_name: string; + aqi_ranges: AQIRanges; + averages: Averages; + createdAt: string; + device: string; + device_id: string; + frequency: string; + health_tips: HealthTip[]; + is_reading_primary: boolean; + no2: Record; + pm10: { value: number }; + pm2_5: { value: number }; + siteDetails: Site; + timeDifferenceHours: number; + updatedAt: string; +} + +export interface ReadingsApiResponse { + success: boolean; + message: string; + measurements: Measurement[]; +} diff --git a/netmanager-app/app/types/sites.ts b/netmanager-app/app/types/sites.ts index 6f37d9ecea..0fea5d7ae1 100644 --- a/netmanager-app/app/types/sites.ts +++ b/netmanager-app/app/types/sites.ts @@ -1,20 +1,98 @@ export interface Site { - id: string; - name: string; - description: string; - country: string; + _id: string; + nearest_tahmo_station: { + id: number; + code: string | null; + longitude: number; + latitude: number; + timezone: string | null; + }; + images: unknown[]; + groups: unknown[]; + site_codes: string[]; + site_tags: string[]; + isOnline: boolean; + formatted_name: string; + location_name: string; + search_name: string; + parish: string; + village: string; + sub_county: string; + city: string; district: string; + county: string; region: string; + country: string; latitude: number; longitude: number; + name: string; network: string; - parish: string; - subCounty: string; + approximate_latitude: number; + approximate_longitude: number; + bearing_in_radians: number; + approximate_distance_in_km: number; + lat_long: string; + generated_name: string; altitude: number; - greenness?: string; - nearestRoad?: number; - mobileAppName?: string; - mobileAppDescription?: string; + data_provider: string; + description: string; + weather_stations: Array<{ + code: string; + name: string; + country: string; + longitude: number; + latitude: number; + timezone: string; + distance: number; + _id: string; + }>; + createdAt: string; + lastActive: string; + grids: Array<{ + _id: string; + name: string; + admin_level: string; + visibility: boolean; + }>; + devices: Array<{ + _id: string; + visibility: boolean; + mobility: boolean; + status: string; + isPrimaryInLocation: boolean; + category: string; + isActive: boolean; + device_number: number; + name: string; + createdAt: string; + device_codes: string[]; + network: string; + approximate_distance_in_km: number; + bearing_in_radians: number; + latitude: number; + longitude: number; + ISP: string; + previous_sites: string[]; + groups: string[]; + host_id: string | null; + cohorts: unknown[]; + serial_number: string; + isOnline: boolean; + lastActive: string; + }>; + airqlouds: unknown[]; + site_category?: { + tags: string[]; + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + }; } export interface Device { diff --git a/netmanager-app/components/Analytics/AnalyticsDropdown.tsx b/netmanager-app/components/Analytics/AnalyticsDropdown.tsx index 6b47fccc7e..9f9cd7010e 100644 --- a/netmanager-app/components/Analytics/AnalyticsDropdown.tsx +++ b/netmanager-app/components/Analytics/AnalyticsDropdown.tsx @@ -1,24 +1,29 @@ -'use client' +"use client"; -import React from 'react' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Grid } from '@/app/types/grids' -import { Cohort } from '@/app/types/cohorts' +import React from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Grid } from "@/app/types/grids"; +import { Cohort } from "@/app/types/cohorts"; interface AnalyticsAirqloudsDropDownProps { - isCohort: boolean - airqloudsData?: Cohort[] | Grid[] - onSelect: (id: string) => void - selectedId: string | null + isCohort: boolean; + airqloudsData?: Cohort[] | Grid[]; + onSelect: (id: string) => void; + selectedId: string | null; } -const AnalyticsAirqloudsDropDown =({ +const AnalyticsAirqloudsDropDown = ({ isCohort, - airqloudsData = [], + airqloudsData = [], onSelect, - selectedId + selectedId, }: AnalyticsAirqloudsDropDownProps) => { - const handleAirqloudChange = (value: string) => { const selectedAirqloud = airqloudsData.find((a) => a._id === value); if (selectedAirqloud) { @@ -30,52 +35,67 @@ const AnalyticsAirqloudsDropDown =({ const formatString = (string: string) => { return string - .replace(/_/g, ' ') + .replace(/_/g, " ") .replace(/\w\S*/g, (txt) => { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); }) - .replace('Id', 'ID') - } + .replace("Id", "ID"); + }; return (
{airqloudsData.length === 0 ? ( -

{isCohort ? 'No Cohorts' : 'No Grids'} data available

+

+ {isCohort ? "No Cohorts" : "No Grids"} data available +

) : ( - - - {selectedId && formatString(airqloudsData.find(a => a._id === selectedId)?.name || '')} + + {selectedId && + formatString( + airqloudsData.find((a) => a._id === selectedId)?.name || "" + )} - {airqloudsData.map((airqloud) => ( - -
-
- {formatString(airqloud.name)} + {Array.isArray(airqloudsData) && + airqloudsData.map((airqloud) => ( + +
+
+ + {formatString(airqloud.name)} + +
+
+ + {isCohort + ? "devices" in airqloud + ? `${airqloud.devices?.length || 0} devices` + : "" + : "sites" in airqloud + ? `${airqloud.sites?.length || 0} sites` + : ""} + +
-
- - {isCohort - ? 'devices' in airqloud - ? `${airqloud.devices?.length || 0} devices` - : '' - : 'sites' in airqloud ? `${airqloud.sites?.length || 0} sites` : ''} - -
-
- - ))} + + ))} )}
- ) -} + ); +}; export default AnalyticsAirqloudsDropDown; diff --git a/netmanager-app/components/Analytics/GridDashboard.tsx b/netmanager-app/components/Analytics/GridDashboard.tsx index 6e28128b9b..5d6b23d063 100644 --- a/netmanager-app/components/Analytics/GridDashboard.tsx +++ b/netmanager-app/components/Analytics/GridDashboard.tsx @@ -4,13 +4,9 @@ import { LineCharts } from "../Charts/Line"; import { BarCharts } from "../Charts/Bar"; import { ExceedancesChart } from "./ExceedanceLine"; import { PM_25_CATEGORY } from "@/core/hooks/categories"; -import { Grid, Site } from "@/app/types/grids"; +import { Grid } from "@/app/types/grids"; -import { - Card, - CardContent, - CardHeader, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { DropdownMenu, @@ -18,9 +14,10 @@ import { DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; +import { Site } from "@/app/types/sites"; interface Categories { - [key: string]: Site[]; + [key: string]: Site[]; } interface RecentEventFeature { @@ -37,7 +34,12 @@ interface GridDashboardProps { recentEventsData: { features: RecentEventFeature[] }; } -const GridDashboard: React.FC = ({ gridId, loading, grids, recentEventsData }) => { +const GridDashboard: React.FC = ({ + gridId, + loading, + grids, + recentEventsData, +}) => { const [chartType, setChartType] = useState<"line" | "bar">("line"); const [pm2_5SiteCount, setPm2_5SiteCount] = useState<{ Good: Site[]; @@ -55,15 +57,22 @@ const GridDashboard: React.FC = ({ gridId, loading, grids, r Hazardous: [], }); - const activeGrid = useMemo(() => grids.find((grid) => grid._id === gridId), [grids, gridId]); + const activeGrid = useMemo( + () => grids.find((grid) => grid._id === gridId), + [grids, gridId] + ); useEffect(() => { if (!activeGrid || !recentEventsData?.features) return; - const categorizeSite = (site: Site, pm2_5: number, categories: Categories) => { + const categorizeSite = ( + site: Site, + pm2_5: number, + categories: Categories + ) => { Object.keys(PM_25_CATEGORY).forEach((key) => { const [min, max] = PM_25_CATEGORY[key as keyof typeof PM_25_CATEGORY]; - if (pm2_5 >= 0 && pm2_5 > min && pm2_5 <= max) { + if (pm2_5 >= 0 && pm2_5 > min && pm2_5 <= max) { categories[key].push({ ...site, pm2_5, label: site.label || "" }); } }); @@ -78,10 +87,13 @@ const GridDashboard: React.FC = ({ gridId, loading, grids, r Hazardous: [], }; - const gridSitesObj = activeGrid.sites.reduce((acc: Record, curr: Site) => { - acc[curr._id] = curr; - return acc; - }, {}); + const gridSitesObj = activeGrid.sites.reduce( + (acc: Record, curr: Site) => { + acc[curr._id] = curr; + return acc; + }, + {} + ); recentEventsData.features.forEach((feature: RecentEventFeature) => { const siteId = feature.properties.site_id; @@ -96,7 +108,10 @@ const GridDashboard: React.FC = ({ gridId, loading, grids, r setPm2_5SiteCount(initialCount); }, [activeGrid, recentEventsData]); - const categories: { pm25level: keyof typeof pm2_5SiteCount; iconClass: string }[] = [ + const categories: { + pm25level: keyof typeof pm2_5SiteCount; + iconClass: string; + }[] = [ { pm25level: "Good", iconClass: "bg-green-500" }, { pm25level: "Moderate", iconClass: "bg-yellow-500" }, { pm25level: "UHFSG", iconClass: "bg-orange-500" }, @@ -126,8 +141,12 @@ const GridDashboard: React.FC = ({ gridId, loading, grids, r

-

Number of Sites

-

{loading ? "..." : activeGrid?.sites.length || 0}

+

+ Number of Sites +

+

+ {loading ? "..." : activeGrid?.sites.length || 0} +

@@ -162,8 +181,8 @@ const GridDashboard: React.FC = ({ gridId, loading, grids, r viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" - role="img" - aria-label="Toggle chart type" + role="img" + aria-label="Toggle chart type" > = ({ gridId, loading, grids, r - setChartType("line")}>Line - setChartType("bar")}>Bar + setChartType("line")}> + Line + + setChartType("bar")}> + Bar + diff --git a/netmanager-app/components/Analytics/index.tsx b/netmanager-app/components/Analytics/index.tsx index fa294d1164..e15f2c94bb 100644 --- a/netmanager-app/components/Analytics/index.tsx +++ b/netmanager-app/components/Analytics/index.tsx @@ -1,35 +1,56 @@ -"use client" - -import React, { useState, useEffect } from 'react' -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { ImportIcon, MoreHorizontal } from 'lucide-react' -import AnalyticsAirqloudsDropDown from './AnalyticsDropdown' -import GridDashboard from './GridDashboard' -import CohortDashboard from './CohortsDashboard' -import { useGrids } from '@/core/hooks/useGrids' -import { useCohorts } from '@/core/hooks/useCohorts' -import { dataExport } from '@/core/apis/analytics' -import { useToast } from "@/components/ui/use-toast" +"use client"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ImportIcon, MoreHorizontal } from "lucide-react"; +import AnalyticsAirqloudsDropDown from "./AnalyticsDropdown"; +import GridDashboard from "./GridDashboard"; +import CohortDashboard from "./CohortsDashboard"; +import { useGrids } from "@/core/hooks/useGrids"; +import { useCohorts } from "@/core/hooks/useCohorts"; +import { dataExport } from "@/core/apis/analytics"; +import { useToast } from "@/components/ui/use-toast"; +import { useAppSelector } from "@/core/redux/hooks"; +import { Cohort } from "@/app/types/cohorts"; +import { Grid } from "@/app/types/grids"; +import { useDevices } from "@/core/hooks/useDevices"; +import { transformDataToGeoJson } from "@/lib/utils"; const NewAnalytics: React.FC = () => { - const [isCohort, setIsCohort] = useState(false) - const [downloadingData, setDownloadingData] = useState(false) - const [activeGrid, setActiveGrid] = useState(null) - const [activeCohort, setActiveCohort] = useState(null) + const [isCohort, setIsCohort] = useState(false); + const [downloadingData, setDownloadingData] = useState(false); + const [activeGrid, setActiveGrid] = useState(); + const [activeCohort, setActiveCohort] = useState(); + const activeNetwork = useAppSelector((state) => state.user.activeNetwork); + const [transformedReadings, setTransformedReadings] = useState<{ + type: string; + features: { + type: "Feature"; + properties: unknown; + geometry: { type: "Point"; coordinates: [number, number] }; + }[]; // Updated type + } | null>(null); - const { toast } = useToast() - const { grids, isLoading: isGridsLoading } = useGrids() - const { cohorts, isLoading: isCohortsLoading } = useCohorts() + const { toast } = useToast(); + const { grids, isLoading: isGridsLoading } = useGrids( + activeNetwork?.net_name ?? "" + ); + const { cohorts, isLoading: isCohortsLoading } = useCohorts(); + const { mapReadings, isLoading: isReadingsLoading } = useDevices(); - const airqloudsData = isCohort ? cohorts : grids + const airqloudsData = isCohort ? cohorts : grids; const handleAirqloudSelect = (id: string) => { const selectedData = isCohort - ? cohorts.find((cohort) => cohort._id === id) - : grids.find((grid) => grid._id === id); + ? cohorts.find((cohort: Cohort) => cohort._id === id) + : grids.find((grid: Grid) => grid._id === id); if (selectedData) { const storageKey = isCohort ? "activeCohort" : "activeGrid"; const setActive = isCohort ? setActiveCohort : setActiveGrid; @@ -39,108 +60,153 @@ const NewAnalytics: React.FC = () => { }; useEffect(() => { - const getStoredData = (key: string, data: any[], setter: React.Dispatch>) => { - const storedData = localStorage.getItem(key); - if (storedData) { - setter(JSON.parse(storedData)); - } else if (data.length > 0) { - setter(data[0]); - } - }; + const activeGrid = localStorage.getItem("activeGrid"); + const activeCohort = localStorage.getItem("activeCohort"); + + setActiveGrid( + activeGrid && Array.isArray(grids) && grids.length > 0 + ? JSON.parse(activeGrid) + : Array.isArray(grids) && grids.length > 0 + ? grids[0] + : null + ); - getStoredData('activeGrid', grids, setActiveGrid); - getStoredData('activeCohort', cohorts, setActiveCohort); + setActiveCohort( + activeCohort && Array.isArray(cohorts) && cohorts.length > 0 + ? JSON.parse(activeCohort) + : (cohorts && cohorts[0]) || null + ); }, [grids, cohorts]); + // useEffect(()=>{ + // if(mapReadings) { + // const values = transformDataToGeoJson( + // mapReadings, + // { + // longitude: 'Longitude', + // latitude: 'Latitude' + // }, + // (feature) => [ + // feature.siteDetails && feature.siteDetails.approximate_longitude, + // feature.siteDetails && feature.siteDetails.approximate_latitude + // ] + // ); + + // setTransformedReadings(values); + // } + // }, [mapReadings]); + const handleSwitchGridsCohort = () => { - setIsCohort(!isCohort) - } + setIsCohort(!isCohort); + }; const handleDownloadData = async () => { - setDownloadingData(true) + setDownloadingData(true); try { await dataExport({ - sites: !isCohort ? activeGrid?.sites.map(site => site._id) : [], - devices: isCohort ? activeCohort?.devices.map(device => device._id) : [], - startDateTime: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + ...(isCohort + ? {} + : { sites: activeGrid?.sites.map((site) => site._id) || [] }), + ...(isCohort + ? { + device_names: + activeCohort?.devices.map((device) => device.name) || [], + } + : {}), + startDateTime: new Date( + Date.now() - 5 * 24 * 60 * 60 * 1000 + ).toISOString(), endDateTime: new Date().toISOString(), - frequency: 'hourly', - pollutants: ['pm2_5', 'pm10'], - downloadType: isCohort ? 'csv' : 'json', - outputFormat: 'airqo-standard' - }) + frequency: "hourly", + pollutants: ["pm2_5", "pm10"], + downloadType: isCohort ? "csv" : "json", + outputFormat: "airqo-standard", + minimum: true, + datatype: "raw", + }); toast({ title: "Success", description: "Air quality data download successful", - }) + }); } catch (error) { toast({ title: "Error", - description: "Error downloading data", + description: error, variant: "destructive", - }) + }); } finally { - setDownloadingData(false) + setDownloadingData(false); } - } + }; const handleRefreshGrid = async () => { toast({ title: "Refresh", description: "Grid refresh initiated", - }) - } + }); + }; return (
-
- -
-
- - - {!isCohort && ( - - - - - - - Refresh Grid - - - - )} -
+
+
+
+ + + {!isCohort && ( + + + + + + + Refresh Grid + + + + )} +
+
{!isCohort && activeGrid && ( ({ + properties: { + site_id: site._id, + pm2_5: { value: site.approximate_longitude }, + }, + }))} + grids={grids as Grid[]} /> )} {isCohort && activeCohort && ( @@ -152,8 +218,7 @@ const NewAnalytics: React.FC = () => { )}
- ) -} - -export default NewAnalytics + ); +}; +export default NewAnalytics; diff --git a/netmanager-app/components/ui/ErrorBoundory.tsx b/netmanager-app/components/ui/ErrorBoundary.tsx similarity index 86% rename from netmanager-app/components/ui/ErrorBoundory.tsx rename to netmanager-app/components/ui/ErrorBoundary.tsx index cc27e2a774..deb8574ca7 100644 --- a/netmanager-app/components/ui/ErrorBoundory.tsx +++ b/netmanager-app/components/ui/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, ErrorInfo, ReactNode } from 'react' +import React, { Component, ErrorInfo, ReactNode } from "react"; interface Props { children: ReactNode; @@ -10,7 +10,7 @@ interface State { class ErrorBoundary extends Component { public state: State = { - hasError: false + hasError: false, }; public static getDerivedStateFromError(): State { @@ -31,4 +31,3 @@ class ErrorBoundary extends Component { } export default ErrorBoundary; - diff --git a/netmanager-app/core/apis/analytics.ts b/netmanager-app/core/apis/analytics.ts index 5f7e7f59a8..9f189c4cbc 100644 --- a/netmanager-app/core/apis/analytics.ts +++ b/netmanager-app/core/apis/analytics.ts @@ -3,14 +3,26 @@ import { ANALYTICS_MGT_URL } from "@/core/urls"; const axiosInstance = createAxiosInstance(); -export const dataExport = async (data: FormData) => { +interface DataExportForm { + startDateTime: string; + endDateTime: string; + sites?: string[] | []; + device_names?: string[] | []; + datatype: "raw" | "calibrated"; + frequency: "daily" | "hourly" | "monthly" | "weekly"; + pollutants?: string[]; + downloadType: "csv" | "json"; + outputFormat: "airqo-standard"; + minimum?: true; +} - const headers = { - service: 'data-export' - } - return axiosInstance.post<{ downloadUrl: string }>( - `${ANALYTICS_MGT_URL}/data-download`, - data, - { headers } - ); +export const dataExport = async (data: DataExportForm) => { + const headers = { + service: "data-export", + }; + return axiosInstance.post<{ downloadUrl: string }>( + `${ANALYTICS_MGT_URL}/data-download`, + data, + { headers } + ); }; diff --git a/netmanager-app/core/apis/devices.ts b/netmanager-app/core/apis/devices.ts index bb7a7684d6..864ecc33c5 100644 --- a/netmanager-app/core/apis/devices.ts +++ b/netmanager-app/core/apis/devices.ts @@ -4,13 +4,14 @@ import { AxiosError } from "axios"; import type { DevicesSummaryResponse } from "@/app/types/devices"; const axiosInstance = createAxiosInstance(); +const axiosInstanceWithTokenAccess = createAxiosInstance(false); interface ErrorResponse { message: string; } export const devices = { - getDevicesSummary: async (networkId: string, groupName: string) => { + getDevicesSummaryApi: async (networkId: string, groupName: string) => { try { const response = await axiosInstance.get( `${DEVICES_MGT_URL}/summary?network=${networkId}&group=${groupName}` @@ -23,4 +24,18 @@ export const devices = { ); } }, + getMapReadingsApi: async () => { + try { + const response = + await axiosInstanceWithTokenAccess.get( + `${DEVICES_MGT_URL}/readings/map` + ); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + throw new Error( + axiosError.response?.data?.message || "Failed to fetch events" + ); + } + }, }; diff --git a/netmanager-app/core/apis/sites.ts b/netmanager-app/core/apis/sites.ts index 7781465bae..ac53c8c843 100644 --- a/netmanager-app/core/apis/sites.ts +++ b/netmanager-app/core/apis/sites.ts @@ -35,7 +35,6 @@ export const sites = { ); } }, - createSite: async (data: { name: string; latitude: string; diff --git a/netmanager-app/core/hooks/useDevices.ts b/netmanager-app/core/hooks/useDevices.ts index 42ebfecf35..2365232774 100644 --- a/netmanager-app/core/hooks/useDevices.ts +++ b/netmanager-app/core/hooks/useDevices.ts @@ -1,35 +1,58 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { useDispatch } from "react-redux"; import { devices } from "../apis/devices"; import { setDevices, setError } from "../redux/slices/devicesSlice"; import { useAppSelector } from "../redux/hooks"; -import type { DevicesSummaryResponse } from "@/app/types/devices"; +import type { + DevicesSummaryResponse, + ReadingsApiResponse, +} from "@/app/types/devices"; +import { AxiosError } from "axios"; + +interface ErrorResponse { + message: string; +} export const useDevices = () => { const dispatch = useDispatch(); const activeNetwork = useAppSelector((state) => state.user.activeNetwork); const activeGroup = useAppSelector((state) => state.user.activeGroup); - const { data, isLoading, error } = useQuery({ + const devicesQuery = useQuery< + DevicesSummaryResponse, + AxiosError + >({ queryKey: ["devices", activeNetwork?.net_name, activeGroup?.grp_title], queryFn: () => - devices.getDevicesSummary( + devices.getDevicesSummaryApi( activeNetwork?.net_name || "", activeGroup?.grp_title || "" ), enabled: !!activeNetwork?.net_name && !!activeGroup?.grp_title, - onSuccess: (data, error) => { - if (error) { - dispatch(setError(error.message)); - } else if (data) { - dispatch(setDevices(data.devices)); - } + onSuccess: (data: DevicesSummaryResponse) => { + dispatch(setDevices(data.devices)); + }, + onError: (error: AxiosError) => { + dispatch(setError(error.message)); + }, + } as UseQueryOptions>); + + const mapReadingsQuery = useQuery< + ReadingsApiResponse, + AxiosError + >({ + queryKey: ["mapReadings"], + queryFn: () => + devices.getMapReadingsApi() as Promise as Promise, + onError: (error: AxiosError) => { + dispatch(setError(error.message)); }, - }); + } as UseQueryOptions>); return { - devices: data?.devices || [], - isLoading, - error: error as Error | null, + devices: devicesQuery.data?.devices || [], + mapReadings: mapReadingsQuery.data?.measurements || [], + isLoading: devicesQuery.isLoading || mapReadingsQuery.isLoading, + error: devicesQuery.error || mapReadingsQuery.error, }; }; diff --git a/netmanager-app/core/hooks/useGrids.ts b/netmanager-app/core/hooks/useGrids.ts index 488acd93f2..3579755f09 100644 --- a/netmanager-app/core/hooks/useGrids.ts +++ b/netmanager-app/core/hooks/useGrids.ts @@ -1,31 +1,44 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + UseQueryOptions, +} from "@tanstack/react-query"; import { AxiosError } from "axios"; import { grids } from "../apis/grids"; import { CreateGrid } from "@/app/types/grids"; import { GridsState, setError, setGrids } from "../redux/slices/gridsSlice"; import { useDispatch } from "react-redux"; +import { useAppSelector } from "../redux/hooks"; interface ErrorResponse { message: string; } // Hook to get the grid summary -export const useGridSummary = (networkId: string) => { +export const useGrids = (networkId: string) => { const dispatch = useDispatch(); - const mutation = useMutation({ - mutationFn: async () => await grids.getGridsApi(networkId), + const activeNetwork = useAppSelector((state) => state.user.activeNetwork); + + const { data, isLoading, error } = useQuery< + GridsState, + AxiosError + >({ + queryKey: ["grids", activeNetwork?.net_name], + queryFn: () => grids.getGridsApi(networkId), + enabled: !!activeNetwork?.net_name, onSuccess: (data: GridsState) => { dispatch(setGrids(data.grids)); }, onError: (error: AxiosError) => { dispatch(setError(error.message)); }, - }); + } as UseQueryOptions>); return { - grids: mutation.mutate || [], - isLoading: mutation.isPending, - error: mutation.error as Error | null, + grids: data?.grids ?? [], + isLoading, + error, }; }; diff --git a/netmanager-app/core/hooks/useReadingsSiteCount.ts b/netmanager-app/core/hooks/useReadingsSiteCount.ts new file mode 100644 index 0000000000..3ca2b13399 --- /dev/null +++ b/netmanager-app/core/hooks/useReadingsSiteCount.ts @@ -0,0 +1,75 @@ +// import { useEffect, useState } from 'react'; + +// const PM_25_CATEGORY = { +// Good: [0, 12], +// Moderate: [12.1, 35.4], +// UHFSG: [35.5, 55.4], +// Unhealthy: [55.5, 150.4], +// VeryUnhealthy: [150.5, 250.4], +// Hazardous: [250.5, Infinity], +// }; + +// export const createDeviceOptions = (devices) => { +// const options = []; +// devices.map((device) => { +// options.push({ +// value: device._id, +// label: device.name +// }); +// }); +// return options; +// }; + +// const useReadingsSiteCount = (recentEventsData: any, activeGrid: any, activeCohort: any) => { +// const [pm2_5SiteCount, setPm2_5SiteCount] = useState({ +// Good: 0, +// Moderate: 0, +// UHFSG: 0, +// Unhealthy: 0, +// VeryUnhealthy: 0, +// Hazardous: 0, +// }); + +// useEffect(() => { +// const initialCount = { +// Good: 0, +// Moderate: 0, +// UHFSG: 0, +// Unhealthy: 0, +// VeryUnhealthy: 0, +// Hazardous: 0, +// }; + +// const devices = activeCohort?.devices || []; +// const sites = activeGrid?.sites || []; + +// const cohortDevicesObj = devices.map((device:any) => createDeviceOptions([device])).flat().reduce((acc, curr) => { +// acc[curr.value] = curr; +// return acc; +// }, {}); + +// const allFeatures = recentEventsData.features || []; + +// allFeatures.forEach((feature: any) => { +// const deviceId = feature.properties.device_id; +// const device = cohortDevicesObj[deviceId]; + +// if (device) { +// const pm2_5 = feature.properties.pm2_5.value; + +// Object.keys(PM_25_CATEGORY).forEach((key) => { +// const valid = PM_25_CATEGORY[key]; +// if (pm2_5 > valid[0] && pm2_5 <= valid[1]) { +// initialCount[key] += 1; +// } +// }); +// } +// }); + +// setPm2_5SiteCount(initialCount); +// }, [recentEventsData, activeGrid, activeCohort]); + +// return pm2_5SiteCount; +// }; + +// export default useReadingsSiteCount; diff --git a/netmanager-app/core/redux/slices/gridsSlice.ts b/netmanager-app/core/redux/slices/gridsSlice.ts index 3eac0fad53..a938981af8 100644 --- a/netmanager-app/core/redux/slices/gridsSlice.ts +++ b/netmanager-app/core/redux/slices/gridsSlice.ts @@ -1,52 +1,42 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; -import { Site } from "./sitesSlice"; +import { Grid } from "@/app/types/grids"; - export interface Grid { - _id: string; - visibility: boolean; - name: string; - admin_level: string; - network: string; - long_name: string; - createdAt: string; - sites: Site[]; - } - - export interface GridsState { - grids: Grid[]; - activeGrid: Grid[] | null; - isLoading: boolean; - error: string | null; - } - const initialState: GridsState = { - grids: [], - activeGrid: null, - isLoading: false, - error: null, - }; +export interface GridsState { + grids: Grid[]; + activeGrid: Grid[] | null; + isLoading: boolean; + error: string | null; +} +const initialState: GridsState = { + grids: [], + activeGrid: null, + isLoading: false, + error: null, +}; - const gridsSlice = createSlice({ - name: "grids", - initialState, - reducers: { - setGrids(state, action: PayloadAction) { - state.grids = action.payload; - state.isLoading = false; - state.error = null; - }, - setActiveCohort(state: GridsState, action: PayloadAction) { - state.activeGrid = action.payload; - }, - setLoading(state, action: PayloadAction) { - state.isLoading = action.payload; - }, - setError(state, action: PayloadAction) { - state.error = action.payload; - state.isLoading = false; - }, +const gridsSlice = createSlice({ + name: "grids", + initialState, + reducers: { + setGrids(state, action: PayloadAction) { + state.grids = action.payload; + state.isLoading = false; + state.error = null; }, - }); + setActiveCohort(state: GridsState, action: PayloadAction) { + state.activeGrid = action.payload; + }, + setLoading(state, action: PayloadAction) { + state.isLoading = action.payload; + }, + setError(state, action: PayloadAction) { + state.error = action.payload; + state.isLoading = false; + }, + }, +}); -export const { setGrids, setActiveCohort, setLoading, setError } = gridsSlice.actions; +export const { setGrids, setActiveCohort, setLoading, setError } = + gridsSlice.actions; export default gridsSlice.reducer; diff --git a/netmanager-app/lib/utils.ts b/netmanager-app/lib/utils.ts index 27685d9a88..83ec2e20d5 100644 --- a/netmanager-app/lib/utils.ts +++ b/netmanager-app/lib/utils.ts @@ -1,3 +1,4 @@ +import { Measurement } from "@/app/types/devices"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; @@ -8,3 +9,36 @@ export function cn(...inputs: ClassValue[]) { export const stripTrailingSlash = (url: string) => { return url.replace(/\/$/, ""); }; + +export const transformDataToGeoJson = ( + data: Measurement[], + { longitude, latitude, ...rest }: { longitude: string; latitude: string }, + coordinateGetter?: (feature: Measurement) => [number, number], + filter: (feature: Measurement) => boolean = () => true +) => { + const features: { + type: "Feature"; + properties: unknown; + geometry: { type: "Point"; coordinates: [number, number] }; + }[] = []; + data.map((feature) => { + if (filter(feature)) { + features.push({ + type: "Feature", + properties: { ...rest, ...feature }, + geometry: { + type: "Point", + coordinates: (coordinateGetter && coordinateGetter(feature)) || [ + feature[longitude], + feature[latitude], + ], + }, + }); + } + }); + + return { + type: "FeatureCollection", + features, + }; +}; From 39ccdad95b13ceada66c7b367c4b975deaee1c1d Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:17:26 +0300 Subject: [PATCH 3/8] dockerfile --- netmanager-app/Dockerfile | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 netmanager-app/Dockerfile diff --git a/netmanager-app/Dockerfile b/netmanager-app/Dockerfile new file mode 100644 index 0000000000..a67f97c6c2 --- /dev/null +++ b/netmanager-app/Dockerfile @@ -0,0 +1,20 @@ +# Use the official Node.js image as a base +FROM node:18 + +# Set the working directory +WORKDIR /app + +# Copy package.json and package-lock.json +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy the rest of the application code +COPY . . + +# Expose the application port +EXPOSE 3000 + +# Command to run the application +CMD ["npm", "start"] \ No newline at end of file From 9e7ff7b2a0added1a871a08554a5ef37c79ab484 Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:22:44 +0300 Subject: [PATCH 4/8] Update devices.ts --- netmanager-app/app/types/devices.ts | 31 ----------------------------- 1 file changed, 31 deletions(-) diff --git a/netmanager-app/app/types/devices.ts b/netmanager-app/app/types/devices.ts index 80c3072e20..5109c0ec08 100644 --- a/netmanager-app/app/types/devices.ts +++ b/netmanager-app/app/types/devices.ts @@ -83,7 +83,6 @@ interface AQIRanges { hazardous: AQIRange; } -// Define the structure for the averages interface Averages { dailyAverage: number; percentageDifference: number; @@ -93,36 +92,6 @@ interface Averages { }; } -// Define the structure for the site details -// interface SiteDetails { -// _id: string; -// formatted_name: string; -// location_name: string; -// search_name: string; -// town: string; -// city: string; -// region: string; -// country: string; -// name: string; -// approximate_latitude: number; -// approximate_longitude: number; -// bearing_in_radians: number; -// data_provider: string; -// description: string; -// site_category: { -// tags: string[]; -// area_name: string; -// category: string; -// highway: string; -// landuse: string; -// latitude: number; -// longitude: number; -// natural: string; -// search_radius: number; -// waterway: string; -// }; -// } - export interface Measurement { _id: string; site_id: string; From 39ca4bd85cf070e0dfe71ccb3786dc1422d90a05 Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:32:19 +0300 Subject: [PATCH 5/8] Create .dockerignore --- netmanager-app/.dockerignore | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 netmanager-app/.dockerignore diff --git a/netmanager-app/.dockerignore b/netmanager-app/.dockerignore new file mode 100644 index 0000000000..eacfb05643 --- /dev/null +++ b/netmanager-app/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +node_modules +.next +.env* +npm-debug.log* +README.md +.vscode +coverage \ No newline at end of file From 6e5b738f0ff732c9dabd08ae10a4164d87f65b69 Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:36:44 +0300 Subject: [PATCH 6/8] Update Dockerfile --- netmanager-app/Dockerfile | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/netmanager-app/Dockerfile b/netmanager-app/Dockerfile index a67f97c6c2..1e755865ee 100644 --- a/netmanager-app/Dockerfile +++ b/netmanager-app/Dockerfile @@ -1,20 +1,31 @@ # Use the official Node.js image as a base -FROM node:18 +FROM node:18-alpine AS builder # Set the working directory WORKDIR /app +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package*.json ./ + # Copy package.json and package-lock.json COPY package*.json ./ -# Install dependencies -RUN npm install +RUN npm ci # Copy the rest of the application code COPY . . +RUN npm run build + +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +USER node + # Expose the application port EXPOSE 3000 # Command to run the application -CMD ["npm", "start"] \ No newline at end of file +CMD ["npm", "run", "start"] \ No newline at end of file From 2aa8a4fc66d503898783d9e5c76aa6db41498634 Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:39:13 +0300 Subject: [PATCH 7/8] Update utils.ts --- netmanager-app/lib/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netmanager-app/lib/utils.ts b/netmanager-app/lib/utils.ts index 83ec2e20d5..40778cbc29 100644 --- a/netmanager-app/lib/utils.ts +++ b/netmanager-app/lib/utils.ts @@ -23,14 +23,14 @@ export const transformDataToGeoJson = ( }[] = []; data.map((feature) => { if (filter(feature)) { - features.push({ + features.forEach({ type: "Feature", properties: { ...rest, ...feature }, geometry: { type: "Point", coordinates: (coordinateGetter && coordinateGetter(feature)) || [ - feature[longitude], - feature[latitude], + feature[longitude] || 0, + feature[latitude] || 0, ], }, }); From edaa282bbd7062464054c4118023437ee2cbaa7f Mon Sep 17 00:00:00 2001 From: Belinda Marion Kobusingye <46527380+Codebmk@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:44:22 +0300 Subject: [PATCH 8/8] Update index.tsx --- netmanager-app/components/Analytics/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netmanager-app/components/Analytics/index.tsx b/netmanager-app/components/Analytics/index.tsx index e15f2c94bb..e40875704a 100644 --- a/netmanager-app/components/Analytics/index.tsx +++ b/netmanager-app/components/Analytics/index.tsx @@ -131,7 +131,10 @@ const NewAnalytics: React.FC = () => { } catch (error) { toast({ title: "Error", - description: error, + description: + error instanceof Error + ? error.message + : "Failed to download data. Please try again.", variant: "destructive", }); } finally {