From c74c0a00e8650ed27d1c4f5056ca9e7dff5c79e6 Mon Sep 17 00:00:00 2001 From: Thibault <54189871+Asaren1070@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:18:00 +0200 Subject: [PATCH] Adding telemetry graphs (#464) * Adding telemetry graphs * fixing the Lint rules for telemetry --- .github/workflows/autoredeploy.yml | 1 + backend/dbLocation.sqlite | Bin 2547712 -> 2547712 bytes .../src/api/grafana/controllers/grafana.js | 92 ++++++ backend/src/api/grafana/routes/grafana.js | 14 + .../satellites/[satelliteSlug]/satImage.tsx | 16 +- .../satellites/[satelliteSlug]/satInfo.tsx | 40 +-- .../satellites/[satelliteSlug]/satTabs.tsx | 114 +++++--- .../[satelliteSlug]/satTelemetry.tsx | 266 ++++++++++++++++++ 8 files changed, 468 insertions(+), 75 deletions(-) create mode 100644 backend/src/api/grafana/controllers/grafana.js create mode 100644 backend/src/api/grafana/routes/grafana.js create mode 100644 frontend/src/app/satellites/[satelliteSlug]/satTelemetry.tsx diff --git a/.github/workflows/autoredeploy.yml b/.github/workflows/autoredeploy.yml index 0abf435..6899d92 100644 --- a/.github/workflows/autoredeploy.yml +++ b/.github/workflows/autoredeploy.yml @@ -30,6 +30,7 @@ jobs: echo "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" >> .env echo "SLACK_CHANNEL_ID=${{ secrets.SLACK_CHANNEL_ID }}" >> .env echo "SLACK_USER_TOKEN=${{ secrets.SLACK_USER_TOKEN }}" >> .env + echo "GRAFANA_BOT_TOKEN=${{ secrets.GRAFANA_BOT_TOKEN }}" >> .env # Create .env.production for the frontend nextjs echo "BACKEND_INTERNAL_URL=${{ vars.BACKEND_INTERNAL_URL }}" >> .env.production diff --git a/backend/dbLocation.sqlite b/backend/dbLocation.sqlite index 6f7a24339dbec02d8bf289d7752798fa6119264b..85bff9c46d6738ed5f531d080902d7a1db499375 100644 GIT binary patch delta 232 zcmWN=I}*WA6b9fN@5fEzE#5a?1v5y|hsrsL{nf4seL0i00$p^AGdpIKcn_ delta 231 zcmWN=I}QO+6b9fq7|&~FykCRsS%?^KdK)>3My$dVCn^cmL{xU55v>JSgq`r`%XfI+ z=z!-MT`%s2u09Ksa7Z{T91%8!qrx%axNt%^DV%z;x;Jg>ZkV(s`q(FXMcbF7)y0Y# zhOt}uxnSQ?L!~DZJ)P { + const { satSQL } = ctx.request.body; // Get satellite from request parameters + if (!satSQL) { + return ctx.badRequest("Satellite parameter is required"); + } + try { + const grafanaToken = process.env.GRAFANA_BOT_TOKEN; // Grafana API token + const grafanaHost = "https://monitoring.hypso.space"; // Grafana URL + const datasourceId = 3; // Replace with your datasource UID + + const fields = [ + { refId: "batteryVoltage", field: "vBatt", measurement: "eps" }, + { refId: "battCurr", field: "curBattIn", measurement: "eps" }, + { refId: "uptime", field: "uptimeInS", measurement: "eps" }, + { + refId: "solarPanelTemp1", + field: "solarPanelTemp1", + measurement: "fc", + }, + { + refId: "solarPanelTemp2", + field: "solarPanelTemp2", + measurement: "fc", + }, + { + refId: "solarPanelTemp3", + field: "solarPanelTemp3", + measurement: "fc", + }, + { + refId: "solarPanelTemp4", + field: "solarPanelTemp4", + measurement: "fc", + }, + { + refId: "solarPanelTemp5", + field: "solarPanelTemp5", + measurement: "fc", + }, + { + refId: "solarPanelTemp6", + field: "solarPanelTemp6", + measurement: "fc", + }, + ]; + + const queries = fields.map((item) => ({ + refId: item.refId, + datasourceId: datasourceId, + resultFormat: "time_series", + rawQuery: true, + query: `SELECT "${item.field}" FROM "${satSQL}.${item.measurement}.GeneralTelemetry" WHERE time > now() - 7d`, + })); + + const query = { queries }; + + // Make a POST request to Grafana's datasource proxy API + const response = await fetch(`${grafanaHost}/api/ds/query`, { + method: "POST", + headers: { + Authorization: `Bearer ${grafanaToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(query), + }); + + if (!response.ok) { + throw new Error(`Grafana query failed with status ${response.status}`); + } + + const data = await response.json(); + const values = fields.reduce((acc, item) => { + const result = data.results[item.refId]; + if (result && result.frames && result.frames.length > 0) { + acc[item.refId] = result.frames[0].data.values; // Store as key-value pair + } + return acc; + }, {}); + // Return the data to the client + ctx.send(values); + } catch (error) { + console.error("Error fetching metrics:", error); + return ctx.internalServerError( + "Failed to fetch metrics: " + error.message + ); + } + }, +}; diff --git a/backend/src/api/grafana/routes/grafana.js b/backend/src/api/grafana/routes/grafana.js new file mode 100644 index 0000000..574224c --- /dev/null +++ b/backend/src/api/grafana/routes/grafana.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + routes: [ + { + method: "POST", + path: "/grafana-metrics", + handler: "grafana.fetchMetrics", + config: { + auth: false, + }, + }, + ], +}; diff --git a/frontend/src/app/satellites/[satelliteSlug]/satImage.tsx b/frontend/src/app/satellites/[satelliteSlug]/satImage.tsx index a7324ad..e55325e 100644 --- a/frontend/src/app/satellites/[satelliteSlug]/satImage.tsx +++ b/frontend/src/app/satellites/[satelliteSlug]/satImage.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from "react"; import { useSatelliteStore } from "@/lib/store"; import { SatelliteNumber } from "@/lib/store"; +import Image from "next/image"; export default function SatImage({ STRAPI_URL, @@ -110,7 +111,15 @@ export default function SatImage({ } } fetchSlackImages(); - }, [satImage, selectedSatellite]); + }, [ + satImage, + selectedSatellite, + STRAPI_URL, + noradID, + satNumToEntry, + makeTheImagePublic, + createImageUrl, + ]); if (loading) { return
Loading satellite image...
; @@ -120,11 +129,12 @@ export default function SatImage({ } return satImage ? (
- Satellite Image
diff --git a/frontend/src/app/satellites/[satelliteSlug]/satInfo.tsx b/frontend/src/app/satellites/[satelliteSlug]/satInfo.tsx index fa2580a..c57022b 100644 --- a/frontend/src/app/satellites/[satelliteSlug]/satInfo.tsx +++ b/frontend/src/app/satellites/[satelliteSlug]/satInfo.tsx @@ -1,7 +1,5 @@ "use client"; -import React, { useState, useEffect } from "react"; -import Image from "next/image"; -import Render3DMod from "../render3DMod"; +import React from "react"; import SatTabs from "./satTabs"; import { SatAttributes } from "@/lib/utils"; import TabBar from "./tabBars"; @@ -16,22 +14,6 @@ export default function SatInfo({ STRAPI_URL: string | undefined; BACKEND_INTERNAL_URL: string | undefined; }) { - const [imageURL, setImageURL] = useState(undefined); - const [is3DModel, setIs3DModel] = useState(false); - useEffect(() => { - let satelliteImage = - satAttributes?.satelliteImage?.data?.attributes?.url; - if (BACKEND_INTERNAL_URL && satelliteImage) { - const fullImage = BACKEND_INTERNAL_URL + satelliteImage; - setImageURL(fullImage); - setIs3DModel( - satelliteImage.endsWith(".glb") || - satelliteImage.endsWith(".gltf") || - satelliteImage.endsWith(".glb?"), - ); - } - }, [satAttributes, BACKEND_INTERNAL_URL]); - return ( <> {" "} @@ -45,26 +27,8 @@ export default function SatInfo({ - - {/* Image container */} -
-
- {imageURL ? ( - is3DModel ? ( - - ) : ( - {satAttributes?.name - ) - ) : null} -
-
diff --git a/frontend/src/app/satellites/[satelliteSlug]/satTabs.tsx b/frontend/src/app/satellites/[satelliteSlug]/satTabs.tsx index 7502b53..518406b 100644 --- a/frontend/src/app/satellites/[satelliteSlug]/satTabs.tsx +++ b/frontend/src/app/satellites/[satelliteSlug]/satTabs.tsx @@ -1,57 +1,103 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import SatelliteDataHome from "@/components/satelliteData/SatelliteDataHome"; import { SatelliteNumber } from "@/lib/store"; import { SatAttributes } from "@/lib/utils"; import { useTabContext } from "../tabContext"; import SatImage from "./satImage"; +import Render3DMod from "../render3DMod"; +import Image from "next/image"; +import dynamic from "next/dynamic"; + +const SatTelemetry = dynamic(() => import("./satTelemetry"), { + ssr: false, +}); export default function SatTabs({ satAttributes, STRAPI_URL, + BACKEND_INTERNAL_URL, }: { satAttributes: SatAttributes; STRAPI_URL: string | undefined; + BACKEND_INTERNAL_URL: string | undefined; }) { let noradId = Number(satAttributes?.catalogNumberNORAD) as SatelliteNumber; + const [imageURL, setImageURL] = useState(undefined); + const [is3DModel, setIs3DModel] = useState(false); + useEffect(() => { + let satelliteImage = + satAttributes?.satelliteImage?.data?.attributes?.url; + if (BACKEND_INTERNAL_URL && satelliteImage) { + const fullImage = BACKEND_INTERNAL_URL + satelliteImage; + setImageURL(fullImage); + setIs3DModel( + satelliteImage.endsWith(".glb") || + satelliteImage.endsWith(".gltf") || + satelliteImage.endsWith(".glb?"), + ); + } + }, [satAttributes, BACKEND_INTERNAL_URL]); const { selectedTab } = useTabContext(); return ( -
- {selectedTab === "sat parameters" ? ( - // Render the parameters of the satellite -
-
-
-

NORAD ID:

- {noradId ? ( - - {noradId} - - ) : ( - - No NORAD ID has been assigned yet{" "} - - )} -
+
+
+ {selectedTab === "sat parameters" ? ( + // Render the parameters of the satellite +
+
+
+

NORAD ID:

+ {noradId ? ( + + {noradId} + + ) : ( + + No NORAD ID has been assigned yet{" "} + + )} +
-

- {satAttributes?.massKg - ? "Mass: " + satAttributes?.massKg + " kg" - : null} -

-
- {satAttributes?.missionStatus === "IN ORBIT" ? ( -
- +

+ {satAttributes?.massKg + ? "Mass: " + satAttributes?.massKg + " kg" + : null} +

- ) : null} + {satAttributes?.missionStatus === "IN ORBIT" ? ( +
+ +
+ ) : null} +
+ ) : selectedTab === "satellite image" ? ( + + ) : selectedTab === "satellite telemetry" ? ( + + ) : null} +
+ {/* Image container */} + {imageURL && selectedTab !== "satellite telemetry" ? ( +
+
+ {is3DModel ? ( + + ) : ( + {satAttributes?.name + )} +
- ) : selectedTab === "satellite image" ? ( - ) : null}
); diff --git a/frontend/src/app/satellites/[satelliteSlug]/satTelemetry.tsx b/frontend/src/app/satellites/[satelliteSlug]/satTelemetry.tsx new file mode 100644 index 0000000..b6cef43 --- /dev/null +++ b/frontend/src/app/satellites/[satelliteSlug]/satTelemetry.tsx @@ -0,0 +1,266 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { useSatelliteStore } from "@/lib/store"; +import { SatelliteNumber } from "@/lib/store"; +import HighchartsReact from "highcharts-react-official"; +import Highcharts from "highcharts"; + +export default function SatTelemetry({ + STRAPI_URL, + noradID, +}: { + STRAPI_URL: string | undefined; + noradID: number | undefined; +}) { + const satNumToEntry = useSatelliteStore((state) => state.satNumToEntry); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + useEffect(() => { + async function fetchTelemetryData() { + try { + let satSQL; + if (noradID !== undefined) { + const satName: string | undefined = + satNumToEntry[noradID as SatelliteNumber]?.name; + if (satName === "HYPSO-1") satSQL = "hypso1"; + if (satName === "HYPSO-2") satSQL = "hypso2"; + } + const queryDetails = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + satSQL: satSQL, + }), + }; + setLoading(true); + const query = await fetch( + STRAPI_URL + "/api/grafana-metrics", + queryDetails, + ); + if (!query.ok) { + throw new Error( + `Error fetching telemetry data: ${query.status}`, + ); + } + const responseData = await query.json(); + if (!responseData || responseData.length === 0) { + throw new Error("No telemetry data available"); + } + setData(responseData); + } catch (error) { + console.error("Error fetching telemetry data:", error); + setError( + `Failed to load telemetry data. Please try again later. ${error}`, + ); + } finally { + setLoading(false); + } + } + fetchTelemetryData(); + }, [STRAPI_URL, noradID, satNumToEntry]); + + if (loading) { + return
Loading telemetry data...
; + } + if (error) { + return
{error}
; + } + + { + /* Battery Voltage Data */ + } + const batteryVoltageData = data?.batteryVoltage; + const chartDataVBatt = batteryVoltageData[0].map( + (timestamp: number, index: number) => [ + timestamp, + batteryVoltageData[1][index] / 1000, + ], + ); + + { + /* Solar Panel Temperature Data */ + } + const SolarTempData = data.solarPanelTemp1; + console.log( + "SolarTempData", + SolarTempData[0].map((timestamp: number) => { + return new Date(timestamp).toLocaleString(); + }), + ); + const solarPanelTempData = [ + data?.solarPanelTemp1, + data?.solarPanelTemp2, + data?.solarPanelTemp3, + data?.solarPanelTemp4, + data?.solarPanelTemp5, + data?.solarPanelTemp6, + ].filter((temp) => temp); // Filter out any undefined values + const currentTime = Date.now(); + const solarPanelChartData = solarPanelTempData.map( + (tempData, panelIndex) => ({ + name: `Solar Panel ${panelIndex + 1}`, + data: tempData[0] + .map((timestamp: number, index: number) => [ + timestamp, + tempData[1][index], // Assuming you need to divide by 1000 + ]) + .filter(([timestamp]: number[]) => timestamp <= currentTime), // Filter out future timestamps + color: `hsl(${panelIndex * 60}, 70%, 50%)`, // Different color for each panel + }), + ); + + { + /* Battery Current Data */ + } + const batteryCurrentData = data?.battCurr; + const chartDataIBatt = batteryCurrentData[0].map( + (timestamp: number, index: number) => [ + timestamp, + batteryCurrentData[1][index] / 1000, // Convert to Amperes + ], + ); + + { + /* Uptime Data */ + } + const uptimeData = data?.uptime; + const chartDataUptime = uptimeData[0].map( + (timestamp: number, index: number) => [ + timestamp, + parseFloat((uptimeData[1][index] / (3600 * 24 * 7)).toFixed(2)), // Convert seconds to hours + ], + ); + + const chartConfigs = [ + { + title: "Battery Voltage", + yAxisTitle: "Voltage (V)", + series: [ + { + name: "Battery Voltage", + data: chartDataVBatt, + color: "blue", + }, + ], + valueSuffix: " V", + }, + { + title: "Battery Current", + yAxisTitle: "Current (A)", + series: [ + { + name: "Battery Current", + data: chartDataIBatt, + color: "yellow", + }, + ], + valueSuffix: " A", + }, + { + title: "Solar Panel Temperatures", + yAxisTitle: "Temperature (°C)", + series: solarPanelChartData, + valueSuffix: " °C", + }, + { + title: "Uptime", + yAxisTitle: "Uptime (weeks)", + series: [ + { name: "Uptime", data: chartDataUptime, color: "orange" }, + ], + valueSuffix: " weeks", + }, + ]; + + // Base chart template + const createChartOptions = (config: any) => ({ + chart: { + type: "line", + backgroundColor: "transparent", + reflow: true, + }, + title: { + text: config.title, + style: { + color: "#ffffff", + fontSize: "24px", + }, + }, + xAxis: { + type: "datetime", + labels: { + style: { + color: "#ffffff", + fontSize: "14px", + }, + }, + }, + yAxis: { + title: { + text: config.yAxisTitle, + style: { + color: "#ffffff", + fontSize: "18px", + }, + }, + labels: { + style: { + color: "#ffffff", + fontSize: "14px", + }, + }, + }, + series: config.series.map((serie: any) => ({ + ...serie, + tooltip: { + valueSuffix: config.valueSuffix, + }, + })), + plotOptions: { + series: { + marker: { enabled: false }, + lineWidth: 2, + }, + }, + credits: { enabled: false }, + legend: { + itemStyle: { + color: "#ffffff", + fontSize: "16px", + }, + itemHoverStyle: { + color: "#ff0000", + }, + }, + tooltip: { + backgroundColor: "#000000", + style: { + color: "#ffffff", + fontSize: "14px", + }, + borderColor: "#ffffff", + borderRadius: 5, + }, + }); + + return ( +
+ {chartConfigs.map((config, index) => ( +
+ +
+ ))} +
+ ); +}