Skip to content
Open
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
7 changes: 6 additions & 1 deletion server/.env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,10 @@ PGADMIN_DEFAULT_PASSWORD=P@ssw0rd
# Sentry
SENTRY_URL=

# Live tracking
LIVE_TRACKING_ENABLED=true
FLARM_URL=https://flarm.xccup.net/flarm
FLARM_API_KEY=

# Live scoring API
IGC_XC_SCORE_HOST=igc-xc-score
IGC_XC_SCORE_HOST=igc-xc-score
1 change: 1 addition & 0 deletions server/.env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ SERVER_LOG_LEVEL=debug
DISABLE_API_PROTECTION=true

SERVER_IMPORT_TEST_DATA=true
LIVE_TRACKING_ENABLED=false

JWT_LOGIN_TOKEN=bfd2419fb9a02e6b5ee15f4849df10c1b98426c77292f030f04d8a01f4c293a31ba9b6dcf9ad37ef6220a461db11d3e180b85afee58abe94be204b086ca6f01c
JWT_REFRESH_TOKEN=1430de12edb529a469d5cc91d4f4b2d4495cfe72664df4e7fd1c8ce9f4dc21800e7b8ace49765a98cdad48ff7b4193736e3158c85a9c459a71822ea090a89980
Expand Down
6 changes: 6 additions & 0 deletions server/config/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ const config = convict({
default: "qwertz12345",
env: "FLARM_API_KEY",
},
liveTrackingEnabled: {
doc: "Whether live FLARM tracking is enabled",
format: Boolean,
default: true,
env: "LIVE_TRACKING_ENABLED",
},
jwtLogin: {
doc: "The login token for the jwt authentication mechanism",
format: check128Hex,
Expand Down
12 changes: 5 additions & 7 deletions server/controller/HomeController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express, { Request, Response } from "express";
import service from "../service/HomeService";
import liveTrackingService from "../service/LivetrackingService";
import { getCache, setCache } from "./CacheManager";

const router = express.Router();
Expand All @@ -10,24 +11,21 @@ const router = express.Router();
* @route GET /
*/
router.get("/", async (req: Request, res: Response, next) => {
// const liveFlights =
// req.authStatus === "VALID" ? liveTrackingService.getActiveDistances() : [];
const liveFlights =
req.authStatus === "VALID" ? liveTrackingService.getActiveDistances() : [];

try {
const cached = getCache(req);
if (cached) return res.json(cached);
// if (cached) return res.json({ ...cached, liveFlights });
if (cached) return res.json({ ...cached, liveFlights });

const homeData = await service.get();

setCache(req, homeData);

res.json(homeData);
// res.json({ ...homeData, liveFlights });
res.json({ ...homeData, liveFlights });
} catch (error) {
next(error);
}
});

export default router;
module.exports = router;
26 changes: 14 additions & 12 deletions server/controller/LivetrackingController.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import express from "express";
import express, { NextFunction, Request, Response } from "express";
import liveTrackingService from "../service/LivetrackingService";
import { requesterMustBeLoggedIn } from "./Auth";

const router = express.Router();

// @desc Gets the live tracking data
// @route GET /live
// router.get(
// "/",
// requesterMustBeLoggedIn,
// async (_: Request, res: Response, next: NextFunction) => {
// try {
// res.json(liveTrackingService.getActive());
// } catch (error) {
// next(error);
// }
// }
// );
router.get(
"/",
requesterMustBeLoggedIn,
async (_: Request, res: Response, next: NextFunction) => {
try {
res.json(liveTrackingService.getActive());
} catch (error) {
next(error);
}
}
);

export default router;
4 changes: 2 additions & 2 deletions server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import GeneralController from "../controller/GeneralController";
import MailController from "../controller/MailController";
import SiteController from "../controller/SiteController";
import CacheController from "../controller/CacheController";
// import LivetrackingController from "../controller/LivetrackingController";
import LivetrackingController from "../controller/LivetrackingController";

import TestDataController from "../controller/TestDataController";
import ImportDataController from "../controller/ImportDataController";
Expand All @@ -39,7 +39,7 @@ router.use("/media", MediaController);
router.use("/general", GeneralController);
router.use("/mail", MailController);
router.use("/sites", SiteController);
// router.use("/live", LivetrackingController);
router.use("/live", LivetrackingController);
router.use("/cache", CacheController);

if (config.get("env") !== "production" || config.get("overruleActive")) {
Expand Down
44 changes: 33 additions & 11 deletions server/service/LivetrackingService.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import axios from "axios";
import NodeCache from "node-cache";
import cron from "node-cron";
import sequelize from "sequelize";
import config from "../config/env-config";
import logger from "../config/logger";
import db from "../db";

const FLARM_URL = config.get("flarmUrl");
const FLARM_API_KEY = config.get("flarmApiKey");
const LIVE_TRACKING_ENABLED = config.get("liveTrackingEnabled");
const FLARM_USER_IDS_KEY = "user-flarm-ids";

const IGC_XC_SCORE_HOST = config.get("igcXcScoreHost");

const CACHE_INDEFINITELY = 0;
const CACHE_10_MINUTES = 600;
const EXTERNAL_REQUEST_TIMEOUT_MS = 20_000;

const cache = new NodeCache({ stdTTL: CACHE_10_MINUTES });

Expand Down Expand Up @@ -47,9 +50,15 @@ const TRACK_RESOLUTION = 10; // 10 seconds (s not ms)
const TRACK_RESOLUTION_HOME = 60; // 60 seconds (s not ms)

// Run the job every 60 seconds between 8 in the morning and 9 in the evening
// TODO: Re-enable cron job for live tracking: Skipped for now due to errors on server
// const task = cron.schedule("* 8-21 * * *", fetchFlarmData);
// task.start();
if (LIVE_TRACKING_ENABLED) {
cron.schedule("* 8-21 * * *", fetchFlarmData, {
noOverlap: true,
timezone: config.get("timezone"),
});
logger.info("LT: Live tracking cron job enabled");
} else {
logger.info("LT: Live tracking disabled by configuration");
}

const service = {
getActive: () => cache.get<ReducedFlightData>("flarm") || [],
Expand Down Expand Up @@ -90,10 +99,17 @@ async function fetchFlarmData() {
const idsWithNames = await getUserFlarmIds();
const ids = idsWithNames.map((user) => user.flarmId);

if (ids.length === 0) {
logger.debug("LT: No FLARM IDs configured");
cache.set<ReducedFlightData>("flarm", []);
return;
}

const url = `${FLARM_URL}/${ids.join(",")}`;

const { data } = await axios.get<ResponseType>(url, {
headers: { "API-Key": FLARM_API_KEY },
timeout: EXTERNAL_REQUEST_TIMEOUT_MS,
});

logger.debug(`LT: Active FLARM IDs: ${Object.keys(data.tracks)}`);
Expand Down Expand Up @@ -139,7 +155,8 @@ async function calculateLiveTrackDistance(fixes: FlightData[]) {
try {
const { data } = await axios.post(
`http://${IGC_XC_SCORE_HOST}:3030`,
transformedFixes
transformedFixes,
{ timeout: EXTERNAL_REQUEST_TIMEOUT_MS }
);

if (typeof data === "number") return data;
Expand All @@ -162,7 +179,7 @@ function reduceResolution(array: FlightData[], resolution: number) {
}

type FlarmIdWithName = {
flarmId: string | null | undefined;
flarmId: string;
name: string;
};

Expand All @@ -176,12 +193,17 @@ async function getUserFlarmIds(): Promise<FlarmIdWithName[]> {
attributes: ["flarmId", "firstName", "lastName"],
});

const arrangedIds = ids.map((user) => {
return {
flarmId: user.flarmId,
name: `${user.firstName} ${user.lastName}`,
};
});
const arrangedIds = ids
.filter(
(user): user is typeof user & { flarmId: string } =>
typeof user.flarmId === "string" && user.flarmId.length === 6
)
.map((user) => {
return {
flarmId: user.flarmId,
name: `${user.firstName} ${user.lastName}`,
};
});
cache.set(FLARM_USER_IDS_KEY, arrangedIds, CACHE_INDEFINITELY);
return arrangedIds;
}
Expand Down
151 changes: 151 additions & 0 deletions server/test/LivetrackingService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
const mockSchedule = jest.fn();
const mockFindAll = jest.fn();
const mockAxiosGet = jest.fn();
const mockAxiosPost = jest.fn();
let mockLiveTrackingEnabled = true;

jest.mock("node-cron", () => ({
__esModule: true,
default: { schedule: mockSchedule },
}));

jest.mock("axios", () => ({
__esModule: true,
default: {
get: mockAxiosGet,
post: mockAxiosPost,
},
}));

jest.mock("../db", () => ({
__esModule: true,
default: {
User: { findAll: mockFindAll },
},
}));

jest.mock("../config/logger", () => ({
__esModule: true,
default: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
},
}));

jest.mock("../config/env-config", () => ({
__esModule: true,
default: {
get: jest.fn((key: string) => {
const values: Record<string, string | boolean> = {
flarmUrl: "https://flarm.example/flarm",
flarmApiKey: "test-key",
liveTrackingEnabled: mockLiveTrackingEnabled,
igcXcScoreHost: "igc-xc-score",
timezone: "Europe/Berlin",
};
return values[key];
}),
},
}));

import liveTrackingService from "../service/LivetrackingService";

type ScheduledCallback = () => Promise<void>;

test("fetches, scores, and caches configured live tracks", async () => {
expect(mockSchedule).toHaveBeenCalledWith(
"* 8-21 * * *",
expect.any(Function),
{
noOverlap: true,
timezone: "Europe/Berlin",
}
);

mockFindAll.mockResolvedValue([
{ flarmId: "", firstName: "Ignored", lastName: "Pilot" },
{ flarmId: "ABC123", firstName: "Live", lastName: "Pilot" },
]);

const fixes = Array.from({ length: 8 }, (_, index) => ({
address: "ABC123",
timestamp: 1_700_000_000 + index * 10,
altitude: 500 + index,
lon: 7 + index / 100,
lat: 50 + index / 100,
}));

mockAxiosGet.mockResolvedValue({
data: {
tracks: { ABC123: fixes },
distances: {},
},
});
mockAxiosPost.mockResolvedValue({ data: 42.5 });

const scheduledFetch = mockSchedule.mock.calls[0][1] as ScheduledCallback;
await scheduledFetch();

expect(mockAxiosGet).toHaveBeenCalledWith(
"https://flarm.example/flarm/ABC123",
{
headers: { "API-Key": "test-key" },
timeout: 20_000,
}
);
expect(mockAxiosPost).toHaveBeenCalledWith(
"http://igc-xc-score:3030",
expect.arrayContaining([
expect.objectContaining({
timestamp: fixes[0].timestamp,
latitude: fixes[0].lat,
longitude: fixes[0].lon,
valid: true,
}),
]),
{ timeout: 20_000 }
);
expect(liveTrackingService.getActive()).toEqual([
{
name: "Live Pilot",
distance: 42.5,
fixes,
},
]);
expect(liveTrackingService.getActiveDistances()).toEqual([
{
user: { fullName: "Live Pilot" },
flightDistance: 42.5,
id: "ABC123",
isLive: true,
track: [
{
lat: fixes[0].lat,
long: fixes[0].lon,
timestamp: fixes[0].timestamp * 1000,
},
{
lat: fixes[6].lat,
long: fixes[6].lon,
timestamp: fixes[6].timestamp * 1000,
},
],
},
]);
});

test("does not schedule collection when live tracking is disabled", () => {
mockSchedule.mockClear();
mockLiveTrackingEnabled = false;

jest.isolateModules(() => {
const disabledModule = require("../service/LivetrackingService");
const disabledService = disabledModule.default ?? disabledModule;

expect(disabledService.getActive()).toEqual([]);
});

expect(mockSchedule).not.toHaveBeenCalled();
mockLiveTrackingEnabled = true;
});
Loading