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 6f7a243..85bff9c 100644
Binary files a/backend/dbLocation.sqlite and b/backend/dbLocation.sqlite differ
diff --git a/backend/src/api/grafana/controllers/grafana.js b/backend/src/api/grafana/controllers/grafana.js
new file mode 100644
index 0000000..6454c36
--- /dev/null
+++ b/backend/src/api/grafana/controllers/grafana.js
@@ -0,0 +1,92 @@
+"use strict";
+const fetch = require("node-fetch");
+
+module.exports = {
+ fetchMetrics: async (ctx) => {
+ 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 ? (
-
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 ? (
-
- ) : (
-
- )
- ) : 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 ? (
+
+ ) : (
+
+ )}
+
- ) : 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) => (
+
+
+
+ ))}
+
+ );
+}