From 30f5a9547a40f70601eed9567bec9ed4140177d0 Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 12 Nov 2025 16:47:13 +0100 Subject: [PATCH 01/30] removed logging for expired token --- controllers/authController.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/controllers/authController.js b/controllers/authController.js index 6f92405..c83ad8b 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -239,7 +239,9 @@ async function decryptToken(accessToken) { tokenContent: payload, }; } catch (err) { - error("Failed to verify session: " + err, { category: "Auth" }); + if (err.name !== "TokenExpiredError") { + error("Failed to verify session: " + err, { category: "Auth" }); + } return null; } } From 7fecc06a156f817b721c750dc47f90f134216c2e Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 12 Nov 2025 16:50:14 +0100 Subject: [PATCH 02/30] Switch expired stand log from warn to info --- services/occupancyService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/occupancyService.js b/services/occupancyService.js index 6481b25..be2aa79 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -142,7 +142,7 @@ class StandRegistry { for (const [key, stand] of this.occupied) { if (predicateFn(stand)) { this.occupied.delete(key); - warn( + info( `Clearing expired occupied stand ${stand.name} at ${stand.icao} for ${stand.callsign}`, { category: "Stand Management", From 40429c210f7832a58f85968f4f00c24ee4c1c2e7 Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 12 Nov 2025 17:57:00 +0100 Subject: [PATCH 03/30] Fixed error logging missing actual error --- controllers/authController.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/controllers/authController.js b/controllers/authController.js index c83ad8b..03f2538 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -11,7 +11,7 @@ exports.getLocalUser = async (req, res) => { const localUser = await redisService.getLocalUser(cid); res.json(localUser); } catch (err) { - error("Failed to get local user:", err); + error(`Failed to get local user: ${err}`, { category: "Auth" }); res.status(500).json({ error: "Failed to get local user" }); } }; @@ -24,7 +24,7 @@ exports.getAllLocalUsers = async (req, res) => { } res.json(users); } catch (err) { - error("Failed to get all local users:", err); + error(`Failed to get all local users: ${err}`, { category: "Auth" }); res.status(500).json({ error: "Failed to get users" }); } }; @@ -39,7 +39,7 @@ exports.updateLocalUser = async (req, res) => { } res.json(updated); } catch (err) { - error("Failed to update local user:", err); + error(`Failed to update local user: ${err}`, { category: "Auth" }); res.status(500).json({ error: "Failed to update local user" }); } }; @@ -67,7 +67,7 @@ exports.grantRole = async function (req, res) { } res.json({ ok: true, user }); } catch (err) { - error("Failed to grant role:", err); + error(`Failed to grant role: ${err}`, { category: "Auth" }); res.status(500).json({ error: "Failed to grant role" }); } }; @@ -90,7 +90,7 @@ exports.revokeRole = async function (req, res) { } res.json({ ok: true, user }); } catch (err) { - error("Failed to revoke role:", err); + error(`Failed to revoke role: ${err}`, { category: "Auth" }); res.status(500).json({ error: "Failed to revoke role" }); } }; @@ -111,7 +111,7 @@ exports.requireRoles = (roles) => { return res.status(403).json({ error: "Insufficient permissions" }); } catch (err) { - error("Failed to check roles:", err); + error(`Failed to check roles: ${err}`, { category: "Auth" }); return res.status(500).json({ error: "Failed to check permissions" }); } }; @@ -202,7 +202,7 @@ exports.requireAuth = async (req, res, next) => { return next(); } catch (err) { - error("Auth error:", err); + error(`Auth error: ${err.message || err}`, { category: "Auth" }); return res.status(401).json({ error: "Not authenticated" }); } }; @@ -371,7 +371,7 @@ exports.getKeys = async (req, res) => { const keys = await redisService.getAllKeys(); return res.json(keys); } catch (err) { - error("Error fetching keys:", err); + error(`Error fetching keys: ${err}`, { category: "Auth" }); return res.status(500).json({ error: "Internal Server Error" }); } }; @@ -385,7 +385,7 @@ exports.getUserKey = async (req, res) => { } return res.status(404).json({ error: "Key not found" }); } catch (err) { - error("Error fetching key:", err); + error(`Error fetching key: ${err}`, { category: "Auth" }); return res.status(500).json({ error: "Internal Server Error" }); } }; @@ -396,7 +396,7 @@ exports.createKey = async (req, res) => { const newKey = await redisService.createKey(id); return res.status(201).json(newKey); } catch (err) { - error("Error creating key:", err); + error(`Error creating key: ${err}`, { category: "Auth" }); return res.status(500).json({ error: "Internal Server Error" }); } }; @@ -410,7 +410,7 @@ exports.renewKey = async (req, res) => { } return res.status(404).json({ error: "Key not found" }); } catch (err) { - error("Error renewing key:", err); + error(`Error renewing key: ${err}`, { category: "Auth" }); return res.status(500).json({ error: "Internal Server Error" }); } }; @@ -424,7 +424,7 @@ exports.deleteKey = async (req, res) => { } return res.status(404).json({ error: "Key not found" }); } catch (err) { - error("Error deleting key:", err); + error(`Error deleting key: ${err}`, { category: "Auth" }); return res.status(500).json({ error: "Internal Server Error" }); } }; From 3551bd2f2b1ee622187118193ce06f2586c6f2c0 Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 12 Nov 2025 17:58:38 +0100 Subject: [PATCH 04/30] removed user from valid role list --- services/redisService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/redisService.js b/services/redisService.js index 6933e8d..f9a9e83 100644 --- a/services/redisService.js +++ b/services/redisService.js @@ -282,7 +282,7 @@ class RedisService { } } - VALID_ROLES = ["admin", "user"]; + VALID_ROLES = ["admin"]; async validateRole(role) { return VALID_ROLES.includes(role); From b2e629d126df43f710c7c12470ee3045ccf42320 Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 12 Nov 2025 18:46:05 +0100 Subject: [PATCH 05/30] Reduced unzoomed map airport circle size --- viewer/script.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/viewer/script.js b/viewer/script.js index 93ce53f..03ffa86 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1276,7 +1276,7 @@ function getStandColor(standName, apron) { } // Map variables and constants -var zoomThreshold = 5; // <= show meter circle, > show screen-sized marker +var zoomThreshold = 6; // <= show meter circle, > show screen-sized marker var zoomHideThreshold = 13; // > hide marker entirely var meterRadius = 50000; // meters for the L.Circle when zoomed out var labelZoomThreshold = 17; // show stand labels at this zoom level and above @@ -1322,9 +1322,6 @@ function updateMarkerSizes() { }); } } -// map.on("zoomend", updateMarkerSizes); // Moved to initializeMap() - -// Home button control and map.whenReady moved to initializeMap() // Configs page // generate buttons for available config presets @@ -1666,7 +1663,7 @@ function loadMapData() { const zoomThreshold = 5; const zoomHideThreshold = 13; - const meterRadius = 50000; + const meterRadius = 25000; airports.forEach(function (airport) { airport.circle = L.circle(airport.coords, { From 37983c00b53092b55d744fcf2f133f430334a591 Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 13 Nov 2025 07:44:45 +0100 Subject: [PATCH 06/30] Fixed log/configs user verification --- viewer/script.js | 10 ++++------ viewer/viewer.html | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/viewer/script.js b/viewer/script.js index 03ffa86..56ed4fb 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1115,10 +1115,11 @@ document.addEventListener("DOMContentLoaded", () => { ); function showPage(page) { - sections.forEach((s) => { + sections.forEach(async (s) => { if (page === "log" || page === "configs") { // Block access to logs and configs if not authenticated - if (!isUserAdmin(fetchCurrentUser())) { + const currentUser = await fetchCurrentUser(); + if (!isUserAdmin(currentUser)) { console.log("Access denied to page:", page); s.style.display = "none"; // redirect to status page @@ -1805,12 +1806,10 @@ function displayDashboard(user) { if (isAdmin) { renderAdminList('adminUserList'); document.getElementById("dashboardAdmin").style.display = "block"; - document.getElementById("dashboardUser").style.display = "block"; updateControllerNumber(); updateApiKeyList(); } else { document.getElementById("dashboardAdmin").style.display = "none"; - document.getElementById("dashboardUser").style.display = "block"; } } @@ -1836,8 +1835,7 @@ function renderLoginLayout(user) { } else { Array.from(document.getElementsByClassName("connectedLayout")).forEach(el => el.style.display = "inline"); } - document.getElementById("usernameUser").textContent = user ? user.core.firstName : "Guest"; - document.getElementById("usernameAdmin").textContent = user ? user.core.firstName : "Guest"; + document.getElementById("username").textContent = user ? user.core.firstName : "Guest"; apiKeyDisplay(user); } } diff --git a/viewer/viewer.html b/viewer/viewer.html index 1201e31..7e63506 100644 --- a/viewer/viewer.html +++ b/viewer/viewer.html @@ -133,7 +133,7 @@

Configs

-

Welcome back, User!

+

Welcome back, User!

Your API Key:

From 8541390c1ce9ee0765ceb4eaed3cf0db5cc8e0db Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 13 Nov 2025 07:53:46 +0100 Subject: [PATCH 07/30] Viewer is now at root url --- controllers/authController.js | 4 ++-- index.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/controllers/authController.js b/controllers/authController.js index 03f2538..119580c 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -151,7 +151,7 @@ exports.logout = async (req, res) => { try { deleteSession(res); const baseURL = process.env.BASE_URL; - return res.redirect(baseURL + "/rampagent/debug/"); + return res.redirect(baseURL + "/rampagent/"); } catch (err) { error("logout error: " + (err.message || err), { category: "Auth" }); return res.status(500).send("Error during logout"); @@ -357,7 +357,7 @@ exports.loginCallback = async (req, res) => { // Redirect back to UI const baseURL = process.env.BASE_URL; - return res.redirect(baseURL + "/rampagent/debug/#dashboard"); + return res.redirect(baseURL + "/rampagent/#dashboard"); } catch (err) { error("loginCallback error: " + (err.message || err), { category: "Auth" }); return res.status(401).send("Authentication failed, check logs"); diff --git a/index.js b/index.js index 805c681..173ba6f 100644 --- a/index.js +++ b/index.js @@ -66,9 +66,10 @@ app.post('/api/config-webhook', async (req, res) => { app.use(express.json()); // Serve viewer -app.get("/debug", (req, res) => { +app.get("/", (req, res) => { res.sendFile(path.join(__dirname, "viewer", "viewer.html")); }); +app.use("/", express.static(path.join(__dirname, "viewer"))); // Authentication routes app.use("/api/auth", authRoutes); @@ -86,7 +87,6 @@ app.use("/api/airports", airportRoutes); app.use("/api/stats", statRoutes); // Register routes -app.use("/debug", express.static(path.join(__dirname, "viewer"))); app.use("/api/assign", assignRoutes); app.use("/api/occupancy", occupancyRoutes); From 6ae41465d40d47fca34273da29845f70555d7c14 Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 13 Nov 2025 07:54:19 +0100 Subject: [PATCH 08/30] Fixed no API display message --- viewer/script.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viewer/script.js b/viewer/script.js index 56ed4fb..fbef0f4 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1911,12 +1911,12 @@ function updateApiKeyList() { tbody.appendChild(row); }); updateApiKeyCount(); - showNoApiKeysMessageIfEmpty(); }) .catch((err) => { console.error("Failed to fetch API key list", err); }); -} + showNoApiKeysMessageIfEmpty(); + } function updateApiKeyCount() { const countElem = document.getElementById("apiKeyCount"); From d35e0d9912f527817a43d99ecd6347cbfdc153a7 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 14 Nov 2025 08:03:36 +0100 Subject: [PATCH 09/30] Fixed requireRole function error --- controllers/authController.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/controllers/authController.js b/controllers/authController.js index 119580c..cb58a0d 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -96,6 +96,7 @@ exports.revokeRole = async function (req, res) { }; exports.requireRoles = (roles) => { + const requiredRoles = Array.isArray(roles) ? roles : roles ? [roles] : []; return async (req, res, next) => { if (!req.user || !req.user.cid) { return res.status(401).json({ error: "Not authenticated" }); @@ -103,7 +104,7 @@ exports.requireRoles = (roles) => { try { const userRoles = req.user.roles || []; - const hasRequiredRole = roles.some((role) => userRoles.includes(role)); + const hasRequiredRole = requiredRoles.some((role) => userRoles.includes(role)); if (hasRequiredRole) { return next(); @@ -259,7 +260,7 @@ exports.getSession = async (req, res) => { if (!sessionData) return res.status(401).json({ error: "Invalid session" }); const coreUserUrl = - process.env.CORE_URL_INTERNAL + `/v1/user/${sessionData.tokenContent.cid}`; //FIXME: is correct ? + process.env.CORE_URL_INTERNAL + `/v1/user/${sessionData.tokenContent.cid}`; const coreRes = await fetch(coreUserUrl, { method: "GET", headers: { From 267b88d303988096256715ced0843a4f865d87c3 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 14 Nov 2025 08:09:00 +0100 Subject: [PATCH 10/30] secured more routes --- routes/auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/auth.js b/routes/auth.js index 5c36c52..e13f424 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -7,8 +7,8 @@ router.get('/logout', authController.logout); router.get('/callback', authController.loginCallback); router.get('/session', authController.requireAuth, authController.getSession); router.get('/internal/localusers', authController.requireAuth, authController.requireRoles('admin'), authController.getAllLocalUsers); -router.get('/internal/localuser/:cid', authController.getLocalUser); -router.post('/internal/localuser/:cid/update', express.json(), authController.updateLocalUser); +router.get('/internal/localuser/:cid', authController.requireAuth, authController.requireRoles('admin'), authController.getLocalUser); +router.post('/internal/localuser/:cid/update', express.json(), authController.requireAuth, authController.requireRoles('admin'), authController.updateLocalUser); router.post('/internal/localuser/:cid/roles', authController.requireAuth, authController.requireRoles('admin'), express.json(), authController.grantRole); router.delete('/internal/localuser/:cid/roles', authController.requireAuth, authController.requireRoles('admin'), express.json(), authController.revokeRole); // router.post('/verify', authController.verify); From 2f0f0211ad2cd8c9f47d5da641070f44a481d5d8 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 14 Nov 2025 11:28:58 +0100 Subject: [PATCH 11/30] Made expired stand log info instead of warn --- services/occupancyService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/occupancyService.js b/services/occupancyService.js index be2aa79..8162e66 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -155,7 +155,7 @@ class StandRegistry { for (const [key, stand] of this.assigned) { if (predicateFn(stand)) { this.assigned.delete(key); - warn( + info( `Clearing expired assigned stand ${stand.name} at ${stand.icao} for ${stand.callsign}`, { category: "Stand Management", @@ -168,7 +168,7 @@ class StandRegistry { for (const [key, stand] of this.blocked) { if (predicateFn(stand)) { this.blocked.delete(key); - warn( + info( `Clearing expired blocked stand ${stand.name} at ${stand.icao} for ${stand.callsign}`, { category: "Stand Management", From 1b1eae5cae38bd636f2160b9edb4dbeccd53d004 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 14 Nov 2025 20:33:16 +0100 Subject: [PATCH 12/30] Fixed manual assign token verification --- controllers/authController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/authController.js b/controllers/authController.js index cb58a0d..b303fde 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -121,7 +121,7 @@ exports.requireRoles = (roles) => { // Verifying token from plugins for manual stand assignement exports.verifyToken = (token, client) => { // Return true if token is valid, false otherwise - const secret = process.env.CORE_JWT_KEY; + const secret = process.env.AUTH_SECRET; if (!secret) { error("No secret found", { category: "Auth" }); From 7d500534fb483e38ddcb3ab21f7763647ded05a8 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 09:13:11 +0100 Subject: [PATCH 13/30] Added isArray check for APIkey --- viewer/script.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/viewer/script.js b/viewer/script.js index fbef0f4..e1ab2a3 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1895,6 +1895,11 @@ function updateApiKeyList() { console.log("Fetched API keys:", data); // Populate table with API keys + // Make sure it is an array + if (!Array.isArray(data.keys)) { + console.error("API keys data is not an array:", data.keys); + return; + } data.keys.forEach(key => { const row = document.createElement("tr"); row.id = key.cid; From c9e60447467a98969b903f4592c610bdcc144c4f Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 09:40:22 +0100 Subject: [PATCH 14/30] Ignoring databases --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f68a83c..8a473e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .vscode/settings.json .env -logs.db +*.db data/ From 1eb1ba0d9f1c2c52ab368e311b2be1768ecdc45b Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 09:40:52 +0100 Subject: [PATCH 15/30] apiKeys are now in there own database --- controllers/APIkeyController.js | 144 +++++++++++++++++++++++++++++ controllers/authController.js | 67 +------------- index.js | 6 +- routes/APIkey.js | 13 +++ routes/auth.js | 7 -- services/redisService.js | 159 -------------------------------- viewer/script.js | 6 +- 7 files changed, 166 insertions(+), 236 deletions(-) create mode 100644 controllers/APIkeyController.js create mode 100644 routes/APIkey.js diff --git a/controllers/APIkeyController.js b/controllers/APIkeyController.js new file mode 100644 index 0000000..fb5bece --- /dev/null +++ b/controllers/APIkeyController.js @@ -0,0 +1,144 @@ +const { error, warn } = require("../utils/logger"); +const sqlite3 = require('sqlite3').verbose(); +const crypto = require("crypto"); +const path = require('path'); + +const VALIDITY_PERIOD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +// Setup database for API keys +const dbPath = path.join(__dirname, '../APIkeys.db'); +const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('Error opening database:', err); + } else { + console.log('Connected to API keys database'); + initializeDatabase(); + } +}); + +// Initialize database schema +function initializeDatabase() { + db.serialize(() => { + // Create table + db.run(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + last_used_at INTEGER, + expires_at INTEGER + ) + `, (err) => { + if (err) console.error('Error creating API keys table:', err); + else console.log('API keys table ready'); + }); + + // Create indexes + db.run('CREATE INDEX IF NOT EXISTS idx_user_id ON api_keys(user_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_key ON api_keys(key)'); + db.run('CREATE INDEX IF NOT EXISTS idx_created_at ON api_keys(created_at)'); + db.run('CREATE INDEX IF NOT EXISTS idx_expires_at ON api_keys(expires_at)'); + }); +} + +exports.getKeys = (req, res) => { + const sql = 'SELECT id, user_id, key, created_at, last_used_at, expires_at FROM api_keys'; + db.all(sql, [], (err, rows) => { + if (err) { + console.error('Error fetching API keys:', err); + res.status(500).json({ error: 'Internal Server Error' }); + } else { + const apiKeys = rows.map(row => ({ + id: row.id, + user_id: row.user_id, + key: row.key, + created_at: row.created_at, + last_used_at: row.last_used_at, + expires_at: row.expires_at + })); + res.json({ keys: apiKeys }); + } + }); +}; + +exports.getUserKey = (req, res) => { + const userId = req.params.id; + const sql = 'SELECT id, user_id, key, created_at, last_used_at, expires_at FROM api_keys WHERE user_id = ?'; + db.get(sql, [userId], (err, row) => { + if (err) { + console.error('Error fetching API key:', err); + res.status(500).json({ error: 'Internal Server Error' }); + } else { + res.json({ key: row }); + } + }); +}; + +exports.createKey = (req, res) => { + const userId = req.params.id; + const newKey = crypto.randomBytes(32).toString('hex'); + const sql = 'INSERT INTO api_keys (user_id, key) VALUES (?, ?)'; + db.run(sql, [userId, newKey], function (err) { + if (err) { + console.error('Error creating API key:', err); + res.status(500).json({ error: 'Internal Server Error' }); + } else { + res.status(201).json({ key: { id: this.lastID, user_id: userId, key: newKey } }); + } + }); +}; + +exports.renewKey = (req, res) => { + const userId = req.params.id; + const sql = 'UPDATE api_keys SET expires_at = ? WHERE user_id = ?'; + const expiresAt = Date.now() + VALIDITY_PERIOD_MS; + + db.run(sql, [expiresAt, userId], function (err) { + if (err) { + console.error('Error renewing API key:', err); + res.status(500).json({ error: 'Internal Server Error' }); + } else { + res.json({ key: { user_id: userId, expires_at: expiresAt } }); + } + }); +}; + +exports.deleteKey = (req, res) => { + const userId = req.params.id; + const sql = 'DELETE FROM api_keys WHERE user_id = ?'; + db.run(sql, [userId], function (err) { + if (err) { + console.error('Error deleting API key:', err); + res.status(500).json({ error: 'Internal Server Error' }); + } else { + res.json({ message: 'API key deleted successfully' }); + } + }); +}; + +// Cleanup old keys (older than 60 days) +exports.cleanupOldKeys = () => { + const sql = 'DELETE FROM api_keys WHERE expires_at < ?'; + const now = Date.now() - (2 * VALIDITY_PERIOD_MS); // 60 days ago + db.run(sql, [now], function (err) { + if (err) { + console.error('Error cleaning up old API keys:', err); + } else { + console.log(`Deleted ${this.changes} old API keys`); + } + }); +}; + +// Run cleanup periodically (every days) +setInterval(exports.cleanupOldKeys, 24 * 60 * 60 * 1000); // every days + + +// Graceful shutdown +process.on('SIGINT', () => { + db.close((err) => { + if (err) console.error('Error closing database:', err); + else console.log('Database connection closed'); + process.exit(0); + }); +}); \ No newline at end of file diff --git a/controllers/authController.js b/controllers/authController.js index b303fde..94db5ee 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -363,69 +363,4 @@ exports.loginCallback = async (req, res) => { error("loginCallback error: " + (err.message || err), { category: "Auth" }); return res.status(401).send("Authentication failed, check logs"); } -}; - -// API Key Management - -exports.getKeys = async (req, res) => { - try { - const keys = await redisService.getAllKeys(); - return res.json(keys); - } catch (err) { - error(`Error fetching keys: ${err}`, { category: "Auth" }); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -exports.getUserKey = async (req, res) => { - try { - const keyId = req.params.id; - const key = await redisService.getKeyById(keyId); - if (key) { - return res.json(key); - } - return res.status(404).json({ error: "Key not found" }); - } catch (err) { - error(`Error fetching key: ${err}`, { category: "Auth" }); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -exports.createKey = async (req, res) => { - try { - const id = req.params.id; - const newKey = await redisService.createKey(id); - return res.status(201).json(newKey); - } catch (err) { - error(`Error creating key: ${err}`, { category: "Auth" }); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -exports.renewKey = async (req, res) => { - try { - const keyId = req.params.id; - const renewed = await redisService.renewKey(keyId); - if (renewed) { - return res.status(200).json({ message: "Key renewed successfully" }); - } - return res.status(404).json({ error: "Key not found" }); - } catch (err) { - error(`Error renewing key: ${err}`, { category: "Auth" }); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -exports.deleteKey = async (req, res) => { - try { - const keyId = req.params.id; - const deleted = await redisService.deleteKey(keyId); - if (deleted) { - return res.status(200).json({ message: "Key deleted successfully" }); - } - return res.status(404).json({ error: "Key not found" }); - } catch (err) { - error(`Error deleting key: ${err}`, { category: "Auth" }); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; +}; \ No newline at end of file diff --git a/index.js b/index.js index 173ba6f..5c6cb24 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ const redisService = require("./services/redisService"); const airportService = require("./services/airportService"); const healthRoutes = require("./routes/health"); const authRoutes = require("./routes/auth"); +const apiKeyRoutes = require("./routes/APIkey"); const app = express(); @@ -86,10 +87,13 @@ app.use("/api/airports", airportRoutes); // API endpoint to get stats (call service and return JSON) app.use("/api/stats", statRoutes); -// Register routes +// API endpoint for Stands management app.use("/api/assign", assignRoutes); app.use("/api/occupancy", occupancyRoutes); +// API endpoint for API key management +app.use("/api/apikey", apiKeyRoutes); + // Connect to Redis redisService .connect() diff --git a/routes/APIkey.js b/routes/APIkey.js new file mode 100644 index 0000000..2c741d7 --- /dev/null +++ b/routes/APIkey.js @@ -0,0 +1,13 @@ +const express = require('express'); +const router = express.Router(); +const authController = require('../controllers/authController'); +const apiKeyController = require('../controllers/APIkeyController'); + +router.get('/', authController.requireAuth, authController.requireRoles('admin'), apiKeyController.getKeys); +router.get('/:id', authController.requireAuth, apiKeyController.getUserKey); +router.post('/:id', authController.requireAuth, apiKeyController.createKey); +router.post('/:id/renew', authController.requireAuth, apiKeyController.renewKey); +router.delete('/:id', authController.requireAuth, apiKeyController.deleteKey); +// router.post('/verify', authController.verify); //verify key middleware + +module.exports = router; \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js index e13f424..9872059 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -11,12 +11,5 @@ router.get('/internal/localuser/:cid', authController.requireAuth, authControlle router.post('/internal/localuser/:cid/update', express.json(), authController.requireAuth, authController.requireRoles('admin'), authController.updateLocalUser); router.post('/internal/localuser/:cid/roles', authController.requireAuth, authController.requireRoles('admin'), express.json(), authController.grantRole); router.delete('/internal/localuser/:cid/roles', authController.requireAuth, authController.requireRoles('admin'), express.json(), authController.revokeRole); -// router.post('/verify', authController.verify); -router.get('/keys', authController.requireAuth, authController.requireRoles('admin'), authController.getKeys); -router.get('/keys/:id', authController.requireAuth, authController.getUserKey); -router.post('/key/:id', authController.requireAuth, authController.createKey); -router.post('/keys/:id/renew', authController.requireAuth, authController.renewKey); -router.delete('/keys/:id', authController.requireAuth, authController.deleteKey); - module.exports = router; diff --git a/services/redisService.js b/services/redisService.js index f9a9e83..81ec69c 100644 --- a/services/redisService.js +++ b/services/redisService.js @@ -394,165 +394,6 @@ class RedisService { user.roles = user.roles.filter((r) => r !== role); return await this.updateLocalUser(cid, user); } - - async getAllKeys() { - if (!this.isConnected) return []; - - try { - // Get all keys except metadata keys - const keys = await this.client.keys("*"); - const nonMetaKeys = keys.filter((key) => !key.startsWith("meta:")); - - // Get metadata for each key - const keysWithMetadata = await Promise.all( - nonMetaKeys.map(async (key) => { - const metadata = await this.getKeyMetadata(key); - return { - key, - metadata, - }; - }) - ); - - return keysWithMetadata; - } catch (err) { - logger.warn(`Failed to get all keys: ${err.message}`, { - category: "System", - }); - return []; - } - } - - async createKey(key, expireIn = RedisService.KEY_EXPIRATION) { - if (!this.isConnected) return false; - try { - const raw = crypto.randomBytes(bytes); // cryptographically secure - // base64url: replace +/ with -_ and remove trailing = padding - const value = raw - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - // Store the key value with expiration - await this.client.set(key, value, { EX: expireIn }); - - // Store metadata about the key - const metadata = { - created_at: Date.now(), - last_used: Date.now(), - expires_at: Date.now() + expireIn * 1000, - value_type: typeof value, - }; - - await this.client.set(`meta:${key}`, JSON.stringify(metadata), { - EX: RedisService.KEY_METADATA_EXPIRATION, - }); - - return true; - } catch (err) { - logger.warn(`Failed to create key ${key}: ${err.message}`, { - category: "System", - }); - return false; - } - } - - async renewKey(key, value, expireIn = RedisService.KEY_EXPIRATION) { - if (!this.isConnected) return false; - try { - const exists = await this.client.exists(key); - if (exists) { - // Update the key value and reset expiration - await this.client.set(key, value, { EX: expireIn }); - - // Update metadata - const metaKey = `meta:${key}`; - const existingMeta = await this.client.get(metaKey); - const metadata = existingMeta ? JSON.parse(existingMeta) : {}; - - metadata.last_used = Date.now(); - metadata.expires_at = Date.now() + expireIn * 1000; - - await this.client.set(metaKey, JSON.stringify(metadata), { - EX: RedisService.KEY_METADATA_EXPIRATION, - }); - - return true; - } - return false; - } catch (err) { - logger.warn(`Failed to renew key ${key}: ${err.message}`, { - category: "System", - }); - return false; - } - } - - async getKeyMetadata(key) { - if (!this.isConnected) return null; - try { - const metaKey = `meta:${key}`; - const metadata = await this.client.get(metaKey); - return metadata ? JSON.parse(metadata) : null; - } catch (err) { - logger.warn(`Failed to get metadata for key ${key}: ${err.message}`, { - category: "System", - }); - return null; - } - } - - async deleteKey(key) { - if (!this.isConnected) return false; - try { - const exists = await this.client.exists(key); - if (exists) { - // Delete both the key and its metadata - await Promise.all([ - this.client.del(key), - this.client.del(`meta:${key}`), - ]); - return true; - } - return false; - } catch (err) { - logger.warn(`Failed to delete key ${key}: ${err.message}`, { - category: "System", - }); - return false; - } - } - - async getKeyById(id) { - if (!this.isConnected) return null; - try { - // Get key value - const value = await this.client.get(id); - if (!value) return null; - - // Get metadata separately - const metadata = await this.getKeyMetadata(id); - - // Try to parse value if it's JSON, otherwise return as is - let parsedValue; - try { - parsedValue = JSON.parse(value); - } catch { - parsedValue = value; - } - - return { - value: parsedValue, - expires_at: metadata?.expires_at || null, - metadata, - }; - } catch (err) { - logger.warn(`Failed to get key ${id}: ${err.message}`, { - category: "System", - }); - return null; - } - } } // Singleton instance diff --git a/viewer/script.js b/viewer/script.js index e1ab2a3..00588ad 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1875,7 +1875,7 @@ function generateApiKey() { function updateApiKeyList() { const tbody = document.querySelector("#apiKeyListTable tbody"); if (!tbody) return; - fetch(API_BASE_URL + "/api/auth/keys", { + fetch(API_BASE_URL + "/api/apikey/", { headers: { "Content-Type": "application/json" }, @@ -1934,7 +1934,7 @@ function updateApiKeyCount() { // API actions function renewApiKey(cid) { console.log("Renewing API key of CID:", cid); - fetch(API_BASE_URL + `/api/auth/key/${cid}/renew`, { + fetch(API_BASE_URL + `/api/apikey/${cid}/renew`, { method: "POST", headers: { "Content-Type": "application/json" @@ -1947,7 +1947,7 @@ function renewApiKey(cid) { function revokeApiKey(cid) { console.log("Revoking API key of CID:", cid); - fetch(API_BASE_URL + `/api/auth/key/${cid}/revoke`, { + fetch(API_BASE_URL + `/api/apikey/${cid}/revoke`, { method: "DELETE", headers: { "Content-Type": "application/json" From 82687849c6ad3bce8d172a686c832665225c6a0b Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 15:00:09 +0100 Subject: [PATCH 16/30] Now check if entire callsign is inside the stand restriction --- services/occupancyService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/occupancyService.js b/services/occupancyService.js index 8162e66..ec6ce76 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -595,7 +595,7 @@ function assignStand(airportConfig, config, ac) { } } if (standDef.Callsigns && Array.isArray(standDef.Callsigns)) { - if (!standDef.Callsigns.includes(compagnyPrefix)) { + if (!standDef.Callsigns.includes(compagnyPrefix) && !standDef.Callsigns.includes(ac.callsign.toUpperCase())) { continue; } } From ba2016f99a5fa7d53a38dd3da1415614c3051599 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 15:00:09 +0100 Subject: [PATCH 17/30] Now check if entire callsign is inside the stand restriction --- services/occupancyService.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/services/occupancyService.js b/services/occupancyService.js index 8162e66..fa5586e 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -595,7 +595,17 @@ function assignStand(airportConfig, config, ac) { } } if (standDef.Callsigns && Array.isArray(standDef.Callsigns)) { - if (!standDef.Callsigns.includes(compagnyPrefix)) { +const cs = (ac.callsign || "").toUpperCase(); + let match = false; + // check prefixes from length 3 up to full callsign + for (let len = 3; len <= cs.length; len++) { + const prefix = cs.substring(0, len); + if (standDef.Callsigns.includes(prefix)) { + match = true; + break; + } + } + if (!match) { continue; } } From 73b0dfe03c34e71fdc5e0120b8c2579a907e9571 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 19:43:47 +0100 Subject: [PATCH 18/30] Added polygon drawing on map --- viewer/script.js | 473 +++++++++++++++++++++++++++--------------- viewer/styles.css | 13 +- viewer/styles.css.map | 2 +- viewer/styles.scss | 5 + 4 files changed, 322 insertions(+), 171 deletions(-) diff --git a/viewer/script.js b/viewer/script.js index 00588ad..ee6c37b 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1,4 +1,4 @@ -const API_BASE_URL = "https://pintade.vatsim.fr/rampagent"; +const API_BASE_URL = ""; /* Set the width of the side navigation to 250px */ function openNav() { @@ -15,7 +15,7 @@ function closeNav() { } document.addEventListener("click", function (event) { - if (event.x <= 200) return; + if (event.x <= 200) return; const sidenav = document.getElementById("mySidenav"); if (sidenav && sidenav.style.width !== "0") { if (!sidenav.contains(event.target)) { @@ -40,7 +40,7 @@ function toggleDarkMode() { document.addEventListener("DOMContentLoaded", function () { // Check localStorage for dark mode preference const darkMode = localStorage.getItem("darkMode"); - + // Restore manual performance-mode preference from previous session const performanceModeStored = localStorage.getItem("performanceModeManual"); manualToggle = performanceModeStored === "true"; @@ -58,7 +58,7 @@ document.addEventListener("DOMContentLoaded", function () { window.switchMapLayer(); } }, 100); - + checkAuthAndUpdateUI(); updateApiKeyCount(); }); @@ -70,7 +70,8 @@ const HIGH_VOLUME_THRESHOLD = 50; // Number of stands that triggers performance function checkVolumeAndTogglePerformanceMode(standCount) { let shouldBeInPerformanceMode = standCount >= HIGH_VOLUME_THRESHOLD; - if (window.innerWidth < 700) { // Mobiles always in performance mode since lower power + if (window.innerWidth < 700) { + // Mobiles always in performance mode since lower power shouldBeInPerformanceMode = true; } @@ -387,21 +388,21 @@ function updateChartColors(isDarkMode) { // Update grid colors reportsChart.options.scales.x.grid.color = gridColor; reportsChart.options.scales.y.grid.color = gridColor; - + // Update tick colors reportsChart.options.scales.x.ticks.color = axisTextColor; reportsChart.options.scales.y.ticks.color = axisTextColor; - + // Update legend color reportsChart.options.plugins.legend.labels.color = legendTextColor; - - reportsChart.update('active'); + + reportsChart.update("active"); } // Update airport chart if it exists if (airportChart) { airportChart.options.plugins.legend.labels.color = legendTextColor; - airportChart.update('active'); + airportChart.update("active"); } } @@ -1579,7 +1580,6 @@ function loadMapData() { .then((data) => { if (!Array.isArray(data)) throw new Error("Stands response is not an array"); - stands = data.filter((s) => { return ( Array.isArray(s.coords) && @@ -1597,15 +1597,58 @@ function loadMapData() { } else { stands.forEach((stand) => { const color = getStandColor(stand.name, stand.apron); - stand.circle = L.circle(stand.coords, { - color: color[0], - fillColor: color[1], - fillOpacity: 0.8, - radius: stand.radius, - weight: 3, - }).bindPopup(() => { - return createStandPopupContent(stand.name); - }); + if (stand.apron && stand.apron.Coordinates) { + // Parse and validate apron coordinates + // Currently, coordinates are "lat:lng" strings - convert to [lat, lng] arrays + if (Array.isArray(stand.apron.Coordinates)) { + stand.apron.Coordinates = stand.apron.Coordinates.map((coord) => { + const [lat, lng] = coord.split(":").map(Number); + return [lat, lng]; + }); + } + + const apronCoordsValid = + stand && + stand.apron && + Array.isArray(stand.apron.Coordinates) && + stand.apron.Coordinates.length > 0 && + stand.apron.Coordinates.every( + (c) => + Array.isArray(c) && + c.length === 2 && + Number.isFinite(c[0]) && + Number.isFinite(c[1]) + ); + + if (!apronCoordsValid) { + console.warn( + "Invalid apron coordinates for stand:", + stand + ); + return; + } + stand.polygon = L.polygon(stand.apron.Coordinates, { + color: color[0], + fillColor: color[1], + fillOpacity: 0.8, + weight: 3, + lineJoin: "round", // <- round joins + lineCap: "round", // <- round end caps + smoothFactor: 1.5 + }).bindPopup(() => { + return createStandPopupContent(stand.name); + }); + } else { + stand.circle = L.circle(stand.coords, { + color: color[0], + fillColor: color[1], + fillOpacity: 0.8, + radius: stand.radius, + weight: 3, + }).bindPopup(() => { + return createStandPopupContent(stand.name); + }); + } stand.label = L.marker(stand.coords, { interactive: false, @@ -1615,7 +1658,11 @@ function loadMapData() { }), }); - stand.circle.addTo(map); + if (stand.circle) { + stand.circle.addTo(map); + } else if (stand.polygon) { + stand.polygon.addTo(map); + } }); } }) @@ -1722,16 +1769,16 @@ if (document.readyState === "loading") { initializeMap(); } - - // Dashboard async function fetchCurrentUser() { try { - const res = await fetch(API_BASE_URL + '/api/auth/session', { credentials: 'same-origin' }); + const res = await fetch(API_BASE_URL + "/api/auth/session", { + credentials: "same-origin", + }); if (!res.ok) return null; return await res.json(); } catch (err) { - console.warn('fetchCurrentUser error', err); + console.warn("fetchCurrentUser error", err); return null; } } @@ -1742,7 +1789,12 @@ function isUserConnected(user) { function isUserAdmin(user) { if (!user) return false; - if (user.local && Array.isArray(user.local.roles) && user.local.roles.includes('admin')) return true; + if ( + user.local && + Array.isArray(user.local.roles) && + user.local.roles.includes("admin") + ) + return true; return false; } @@ -1753,21 +1805,25 @@ async function checkAuthAndUpdateUI() { } async function fetchLocalUsers() { - const res = await fetch(API_BASE_URL + '/api/auth/internal/localusers', { credentials: 'same-origin' }); - if (!res.ok) throw new Error('Failed to fetch users'); + const res = await fetch(API_BASE_URL + "/api/auth/internal/localusers", { + credentials: "same-origin", + }); + if (!res.ok) throw new Error("Failed to fetch users"); return res.json(); } async function toggleAdminRole(cid, add) { - const url = API_BASE_URL + `/api/auth/internal/localuser/${encodeURIComponent(cid)}/roles`; - const method = add ? 'POST' : 'DELETE'; + const url = + API_BASE_URL + + `/api/auth/internal/localuser/${encodeURIComponent(cid)}/roles`; + const method = add ? "POST" : "DELETE"; const res = await fetch(url, { method, - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'admin' }) + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: "admin" }), }); - if (!res.ok) throw new Error('Failed to update role'); + if (!res.ok) throw new Error("Failed to update role"); return res.json(); } @@ -1777,25 +1833,29 @@ async function renderAdminList(containerId) { if (!container) return; try { const users = await fetchLocalUsers(); - container.innerHTML = users.map(u => { - const isAdmin = Array.isArray(u.roles) && u.roles.includes('admin'); - return `
- ${u.cid} ${u.full_name ? '- ' + u.full_name : ''} -
`; - }).join(''); - container.querySelectorAll('.role-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { + }) + .join(""); + container.querySelectorAll(".role-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { const cid = btn.dataset.cid; - const add = btn.dataset.action === 'grant'; + const add = btn.dataset.action === "grant"; await toggleAdminRole(cid, add); await renderAdminList(containerId); }); }); } catch (err) { - container.textContent = 'Error loading users'; + container.textContent = "Error loading users"; console.error(err); } } @@ -1804,7 +1864,7 @@ async function renderAdminList(containerId) { function displayDashboard(user) { const isAdmin = isUserAdmin(user); if (isAdmin) { - renderAdminList('adminUserList'); + renderAdminList("adminUserList"); document.getElementById("dashboardAdmin").style.display = "block"; updateControllerNumber(); updateApiKeyList(); @@ -1818,24 +1878,38 @@ function renderLoginLayout(user) { const isAdmin = isUserAdmin(user); // Handle sidenav items visibility - const adminOnlyItems = document.querySelectorAll('.sidenav a[data-admin-only]'); - adminOnlyItems.forEach(item => { - item.style.display = isConnected && isAdmin ? 'block' : 'none'; + const adminOnlyItems = document.querySelectorAll( + ".sidenav a[data-admin-only]" + ); + adminOnlyItems.forEach((item) => { + item.style.display = isConnected && isAdmin ? "block" : "none"; }); // Rest of existing login layout logic if (!isConnected) { - Array.from(document.getElementsByClassName("loginLayout")).forEach(el => el.style.display = "flex"); - Array.from(document.getElementsByClassName("connectedLayout")).forEach(el => el.style.display = "none"); + Array.from(document.getElementsByClassName("loginLayout")).forEach( + (el) => (el.style.display = "flex") + ); + Array.from(document.getElementsByClassName("connectedLayout")).forEach( + (el) => (el.style.display = "none") + ); } else { - Array.from(document.getElementsByClassName("loginLayout")).forEach(el => el.style.display = "none"); + Array.from(document.getElementsByClassName("loginLayout")).forEach( + (el) => (el.style.display = "none") + ); // Check maxwidth to adjust layout if (window.innerWidth <= 600) { - Array.from(document.getElementsByClassName("connectedLayout")).forEach(el => el.style.display = "flex"); + Array.from(document.getElementsByClassName("connectedLayout")).forEach( + (el) => (el.style.display = "flex") + ); } else { - Array.from(document.getElementsByClassName("connectedLayout")).forEach(el => el.style.display = "inline"); + Array.from(document.getElementsByClassName("connectedLayout")).forEach( + (el) => (el.style.display = "inline") + ); } - document.getElementById("username").textContent = user ? user.core.firstName : "Guest"; + document.getElementById("username").textContent = user + ? user.core.firstName + : "Guest"; apiKeyDisplay(user); } } @@ -1852,7 +1926,7 @@ function apiKeyDisplay(user) { function generateApiKey() { console.log("Generating new API key..."); - fetchLocalUsers().then(user => { + fetchLocalUsers().then((user) => { if (!user) { console.error("Cannot generate API key: user not found"); return; @@ -1860,14 +1934,14 @@ function generateApiKey() { fetch(API_BASE_URL + `/api/auth/key/${user.cid}`, { method: "POST", headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, - body: JSON.stringify({ userId: user.cid }) + body: JSON.stringify({ userId: user.cid }), }); }); // Refresh the dashboard to show new key - fetchLocalUsers().then(user => { + fetchLocalUsers().then((user) => { apiKeyDisplay(user); }); } @@ -1877,9 +1951,9 @@ function updateApiKeyList() { if (!tbody) return; fetch(API_BASE_URL + "/api/apikey/", { headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, - credentials: "same-origin" + credentials: "same-origin", }) .then((res) => { if (!res.ok) throw new Error("Network response was not ok"); @@ -1900,7 +1974,7 @@ function updateApiKeyList() { console.error("API keys data is not an array:", data.keys); return; } - data.keys.forEach(key => { + data.keys.forEach((key) => { const row = document.createElement("tr"); row.id = key.cid; row.innerHTML = ` @@ -1920,12 +1994,14 @@ function updateApiKeyList() { .catch((err) => { console.error("Failed to fetch API key list", err); }); - showNoApiKeysMessageIfEmpty(); - } + showNoApiKeysMessageIfEmpty(); +} function updateApiKeyCount() { const countElem = document.getElementById("apiKeyCount"); - const apiKeyCounter = document.querySelectorAll("#apiKeyListTable tbody tr").length; + const apiKeyCounter = document.querySelectorAll( + "#apiKeyListTable tbody tr" + ).length; if (countElem) { countElem.textContent = apiKeyCounter; } @@ -1937,10 +2013,10 @@ function renewApiKey(cid) { fetch(API_BASE_URL + `/api/apikey/${cid}/renew`, { method: "POST", headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, credentials: "same-origin", - body: JSON.stringify({ cid }) + body: JSON.stringify({ cid }), }); } @@ -1950,10 +2026,10 @@ function revokeApiKey(cid) { fetch(API_BASE_URL + `/api/apikey/${cid}/revoke`, { method: "DELETE", headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, credentials: "same-origin", - body: JSON.stringify({ cid }) + body: JSON.stringify({ cid }), }); // Remove entire row from table @@ -1968,10 +2044,10 @@ function revokeApiKey(cid) { } function showNoApiKeysMessageIfEmpty() { - const tbody = document.querySelector('#apiKeyListTable tbody'); + const tbody = document.querySelector("#apiKeyListTable tbody"); if (tbody && tbody.children.length === 0) { - const noKeysRow = document.createElement('tr'); - const noKeysCell = document.createElement('td'); + const noKeysRow = document.createElement("tr"); + const noKeysCell = document.createElement("td"); noKeysCell.colSpan = 5; noKeysCell.textContent = "No API keys found"; noKeysRow.appendChild(noKeysCell); @@ -2004,57 +2080,66 @@ setInterval(updateControllerNumber, 15000); // update every 15 seconds // Swipe buttons (function enableRowSwipeActions() { - const tbody = document.querySelector('#apiKeyListTable tbody'); + const tbody = document.querySelector("#apiKeyListTable tbody"); if (!tbody) return; - let startX = 0, startY = 0, activeRow = null; + let startX = 0, + startY = 0, + activeRow = null; let dragging = false; const HORIZONTAL_THRESHOLD = 50; // px needed to count as swipe const MAX_TRANSLATE = 120; // px maximum visual translation function getRow(el) { - while (el && el !== tbody && el.tagName !== 'TR') el = el.parentElement; - return (el && el.tagName === 'TR') ? el : null; + while (el && el !== tbody && el.tagName !== "TR") el = el.parentElement; + return el && el.tagName === "TR" ? el : null; } function ensureIndicators(row) { if (!row) return; - if (!row.querySelector('.swipe-indicator.left')) { - const left = document.createElement('div'); - left.className = 'swipe-indicator left'; - left.innerHTML = 'Renew'; + if (!row.querySelector(".swipe-indicator.left")) { + const left = document.createElement("div"); + left.className = "swipe-indicator left"; + left.innerHTML = "Renew"; row.appendChild(left); } - if (!row.querySelector('.swipe-indicator.right')) { - const right = document.createElement('div'); - right.className = 'swipe-indicator right'; - right.innerHTML = 'Revoke'; + if (!row.querySelector(".swipe-indicator.right")) { + const right = document.createElement("div"); + right.className = "swipe-indicator right"; + right.innerHTML = "Revoke"; row.appendChild(right); } } function startDrag(x, y, target) { - startX = x; startY = y; + startX = x; + startY = y; activeRow = getRow(target); if (!activeRow) return; ensureIndicators(activeRow); dragging = true; - activeRow.classList.add('swipe-dragging'); + activeRow.classList.add("swipe-dragging"); // guard everything that touches style with a check if (activeRow) { - activeRow.style.transition = 'none'; - activeRow.style.willChange = 'transform'; - activeRow.style.zIndex = '1500'; - activeRow.style.boxShadow = '0 12px 30px rgba(0,0,0,0.18)'; - activeRow.style.transform = 'translateX(0) scale(1.01)'; - activeRow.style.userSelect = 'none'; + activeRow.style.transition = "none"; + activeRow.style.willChange = "transform"; + activeRow.style.zIndex = "1500"; + activeRow.style.boxShadow = "0 12px 30px rgba(0,0,0,0.18)"; + activeRow.style.transform = "translateX(0) scale(1.01)"; + activeRow.style.userSelect = "none"; } // initialize indicators - const left = activeRow.querySelector('.swipe-indicator.left'); - const right = activeRow.querySelector('.swipe-indicator.right'); - if (left) { left.style.width = '0px'; left.style.opacity = '0'; } - if (right) { right.style.width = '0px'; right.style.opacity = '0'; } + const left = activeRow.querySelector(".swipe-indicator.left"); + const right = activeRow.querySelector(".swipe-indicator.right"); + if (left) { + left.style.width = "0px"; + left.style.opacity = "0"; + } + if (right) { + right.style.width = "0px"; + right.style.opacity = "0"; + } } function moveDrag(x, y) { @@ -2066,8 +2151,8 @@ setInterval(updateControllerNumber, 15000); // update every 15 seconds const scale = 1 + Math.min(Math.abs(limited) / 800, 0.03); activeRow.style.transform = `translateX(${limited}px) scale(${scale})`; - const left = activeRow.querySelector('.swipe-indicator.left'); - const right = activeRow.querySelector('.swipe-indicator.right'); + const left = activeRow.querySelector(".swipe-indicator.left"); + const right = activeRow.querySelector(".swipe-indicator.right"); if (limited > 0) { // reveal left indicator proportionally if (left) { @@ -2075,8 +2160,8 @@ setInterval(updateControllerNumber, 15000); // update every 15 seconds left.style.opacity = String(Math.min(1, Math.abs(limited) / 20)); } if (right) { - right.style.width = '0px'; - right.style.opacity = '0'; + right.style.width = "0px"; + right.style.opacity = "0"; } } else if (limited < 0) { // reveal right indicator proportionally @@ -2086,17 +2171,26 @@ setInterval(updateControllerNumber, 15000); // update every 15 seconds right.style.opacity = String(Math.min(1, Math.abs(limited) / 20)); } if (left) { - left.style.width = '0px'; - left.style.opacity = '0'; + left.style.width = "0px"; + left.style.opacity = "0"; } } else { - if (left) { left.style.width = '0px'; left.style.opacity = '0'; } - if (right) { right.style.width = '0px'; right.style.opacity = '0'; } + if (left) { + left.style.width = "0px"; + left.style.opacity = "0"; + } + if (right) { + right.style.width = "0px"; + right.style.opacity = "0"; + } } } function endDrag(x, y) { - if (!activeRow) { dragging = false; return; } + if (!activeRow) { + dragging = false; + return; + } const dx = x - startX; const dy = y - startY; dragging = false; @@ -2104,97 +2198,144 @@ setInterval(updateControllerNumber, 15000); // update every 15 seconds // use a localRef to avoid race if activeRow is cleared/removed later const rowRef = activeRow; - if (rowRef) rowRef.style.transition = 'transform 220ms ease, box-shadow 180ms ease'; + if (rowRef) + rowRef.style.transition = "transform 220ms ease, box-shadow 180ms ease"; - const left = rowRef ? rowRef.querySelector('.swipe-indicator.left') : null; - const right = rowRef ? rowRef.querySelector('.swipe-indicator.right') : null; + const left = rowRef ? rowRef.querySelector(".swipe-indicator.left") : null; + const right = rowRef + ? rowRef.querySelector(".swipe-indicator.right") + : null; if (Math.abs(dx) >= HORIZONTAL_THRESHOLD && Math.abs(dx) > Math.abs(dy)) { - const apiCell = rowRef ? rowRef.querySelector('.apiValue') : null; + const apiCell = rowRef ? rowRef.querySelector(".apiValue") : null; const apiKey = apiCell ? apiCell.textContent.trim() : null; if (apiKey && rowRef) { - const direction = dx > 0 ? 'right' : 'left'; + const direction = dx > 0 ? "right" : "left"; const finishTranslate = dx > 0 ? MAX_TRANSLATE : -MAX_TRANSLATE; rowRef.style.transform = `translateX(${finishTranslate}px) scale(1.02)`; - if (direction === 'right' && left) { left.style.width = `${MAX_TRANSLATE}px`; left.style.opacity = '1'; } - if (direction === 'left' && right) { right.style.width = `${MAX_TRANSLATE}px`; right.style.opacity = '1'; } + if (direction === "right" && left) { + left.style.width = `${MAX_TRANSLATE}px`; + left.style.opacity = "1"; + } + if (direction === "left" && right) { + right.style.width = `${MAX_TRANSLATE}px`; + right.style.opacity = "1"; + } setTimeout(() => { - if (direction === 'right') { - try { renewApiKey(apiKey); } catch (err) { console.error(err); } + if (direction === "right") { + try { + renewApiKey(apiKey); + } catch (err) { + console.error(err); + } } else { - try { revokeApiKey(apiKey); } catch (err) { console.error(err); } + try { + revokeApiKey(apiKey); + } catch (err) { + console.error(err); + } } if (rowRef) { - rowRef.style.transform = 'translateX(0) scale(1)'; - if (left) { left.style.width = '0px'; left.style.opacity = '0'; } - if (right) { right.style.width = '0px'; right.style.opacity = '0'; } + rowRef.style.transform = "translateX(0) scale(1)"; + if (left) { + left.style.width = "0px"; + left.style.opacity = "0"; + } + if (right) { + right.style.width = "0px"; + right.style.opacity = "0"; + } } }, 180); } else if (rowRef) { - rowRef.style.transform = 'translateX(0) scale(1)'; + rowRef.style.transform = "translateX(0) scale(1)"; } } else { if (rowRef) { - rowRef.style.transform = 'translateX(0) scale(1)'; - if (left) { left.style.width = '0px'; left.style.opacity = '0'; } - if (right) { right.style.width = '0px'; right.style.opacity = '0'; } + rowRef.style.transform = "translateX(0) scale(1)"; + if (left) { + left.style.width = "0px"; + left.style.opacity = "0"; + } + if (right) { + right.style.width = "0px"; + right.style.opacity = "0"; + } } } const cleanup = () => { if (!rowRef) return; - rowRef.classList.remove('swipe-dragging'); + rowRef.classList.remove("swipe-dragging"); // clear inline styles safely - rowRef.style.transition = ''; - rowRef.style.transform = ''; - rowRef.style.willChange = ''; - rowRef.style.boxShadow = ''; - rowRef.style.zIndex = ''; - rowRef.style.userSelect = ''; - const l = rowRef.querySelector('.swipe-indicator.left'); - const r = rowRef.querySelector('.swipe-indicator.right'); + rowRef.style.transition = ""; + rowRef.style.transform = ""; + rowRef.style.willChange = ""; + rowRef.style.boxShadow = ""; + rowRef.style.zIndex = ""; + rowRef.style.userSelect = ""; + const l = rowRef.querySelector(".swipe-indicator.left"); + const r = rowRef.querySelector(".swipe-indicator.right"); if (l) l.remove(); if (r) r.remove(); - rowRef.removeEventListener('transitionend', cleanup); + rowRef.removeEventListener("transitionend", cleanup); // only null the shared activeRow after cleanup finishes if (activeRow === rowRef) activeRow = null; }; - if (rowRef) rowRef.addEventListener('transitionend', cleanup); + if (rowRef) rowRef.addEventListener("transitionend", cleanup); } // Touch handlers - tbody.addEventListener('touchstart', (e) => { - const t = e.changedTouches[0]; - startDrag(t.clientX, t.clientY, e.target); - }, { passive: true }); - - tbody.addEventListener('touchmove', (e) => { - if (!dragging) return; - const t = e.changedTouches[0]; - moveDrag(t.clientX, t.clientY); - }, { passive: true }); - - tbody.addEventListener('touchend', (e) => { - const t = e.changedTouches[0]; - endDrag(t.clientX, t.clientY); - }, { passive: true }); - - tbody.addEventListener('touchcancel', () => { - if (activeRow) { - activeRow.style.transition = 'transform 150ms ease'; - activeRow.style.transform = 'translateX(0) scale(1)'; - activeRow.addEventListener('transitionend', () => { - if (activeRow) { - activeRow.classList.remove('swipe-dragging'); - activeRow.style.transition = ''; - activeRow.style.transform = ''; - activeRow = null; - } - }, { once: true }); - } - dragging = false; - }, { passive: true }); + tbody.addEventListener( + "touchstart", + (e) => { + const t = e.changedTouches[0]; + startDrag(t.clientX, t.clientY, e.target); + }, + { passive: true } + ); - -})(); \ No newline at end of file + tbody.addEventListener( + "touchmove", + (e) => { + if (!dragging) return; + const t = e.changedTouches[0]; + moveDrag(t.clientX, t.clientY); + }, + { passive: true } + ); + + tbody.addEventListener( + "touchend", + (e) => { + const t = e.changedTouches[0]; + endDrag(t.clientX, t.clientY); + }, + { passive: true } + ); + + tbody.addEventListener( + "touchcancel", + () => { + if (activeRow) { + activeRow.style.transition = "transform 150ms ease"; + activeRow.style.transform = "translateX(0) scale(1)"; + activeRow.addEventListener( + "transitionend", + () => { + if (activeRow) { + activeRow.classList.remove("swipe-dragging"); + activeRow.style.transition = ""; + activeRow.style.transform = ""; + activeRow = null; + } + }, + { once: true } + ); + } + dragging = false; + }, + { passive: true } + ); +})(); diff --git a/viewer/styles.css b/viewer/styles.css index e63ca3d..362a7df 100644 --- a/viewer/styles.css +++ b/viewer/styles.css @@ -383,6 +383,11 @@ body.dark-mode .log-container::-webkit-scrollbar-track { z-index: 0; } +.leaflet-interactive { + stroke-linejoin: round; + stroke-linecap: round; +} + .map-layer { filter: brightness(1) contrast(1); } @@ -1888,22 +1893,22 @@ body.dark-mode .letter::after { transform: rotateX(0deg); } 20% { - content: "R"; + content: "K"; transform: rotateX(360deg); } 40% { color: #afafaf; - content: "S"; + content: "O"; transform: rotateX(0deg); } 60% { color: #afafaf; - content: "C"; + content: "S"; transform: rotateX(360deg); } 80% { color: #afafaf; - content: "U"; + content: "2"; transform: rotateX(0deg); } 100% { diff --git a/viewer/styles.css.map b/viewer/styles.css.map index 5df145e..7f7634c 100644 --- a/viewer/styles.css.map +++ b/viewer/styles.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["styles.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAEF;AAAA;EAEE;EACA;EACA;EACA;EACA;EACA;;;AAEF;AAAA;EAEE;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAIF;EACE;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;AACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAIF;EACE;AACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAIF;EACE;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAIF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;AACA;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;AACA;EAAe;;;AACf;EAAe;;;AACf;EAAe;;;AACf;EAAe;;;AACf;EAAe;;;AAGf;AAUA;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE,kBA3BgB;EA4BhB;EACA,OA7BgB;EA8BhB;EACA;EACA;EACA;EACA,OAjCU;EAkCV;;;AAEF;EACE,kBAnC0B;EAoC1B,OAnCoB;;;AAsCtB;EACE,kBA3CgB;EA4ChB;EACA;EACA;EACA;EACA;EACA;EACA,OAjDU;EAkDV;;;AAEF;EACE,kBAnD0B;EAoD1B,OAnDoB;EAoDpB;;;AAEF;EACE,kBA3DgB;EA4DhB;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAlEU;EAmEV;;;AAEF;EACE,kBApE0B;EAqE1B,OApEoB;;;AAsEtB;AACA;EACE;;;AAEF;EACE;;;AAGF;AAEE;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AAqFR;AAEE;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACE;IACE;;EAEF;IACE;IACA;;EAEF;IACE;IACA;IACA;;EAEF;IACE;IACA;IACA;;EAEF;IACE;IACA;IACA;;EAEF;IACE;;;AAIJ;AACA;AAAA;EAEE;EACA;;;AAGF;EACE;EACA;;;AAGF;AAAA;EAEE;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;AAEA;AAEA;EACE;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAGF;AACA;EACE;EACA;;;AAGF;AACA;EACE;;;AAEF;EACE;;;AAGF;AACA;EACE;IACE;;;AAIJ;EACE;IACE;;;AAIJ;EACE;IACE;;;AAIJ;AACI;EACF;AAAA;IAEE;;EAGF;IACE;;AAGF;EACA;IACE;IACA;IACA;;;AAIJ;EACE;IACE;;EAEF;IACE;;EAEF;IACE;;EAEF;IACE;IACA;;EAEF;IACE;;;AAIJ;EACE;IACE","file":"styles.css"} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["styles.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAEF;AAAA;EAEE;EACA;EACA;EACA;EACA;EACA;;;AAEF;AAAA;EAEE;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAIF;EACE;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;AACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAIF;EACE;AACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAIF;EACE;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAIF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;;;AAEF;EACE;EACA;EACA;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;AACA;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;;;AAGF;AACA;EAAe;;;AACf;EAAe;;;AACf;EAAe;;;AACf;EAAe;;;AACf;EAAe;;;AAGf;AAUA;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE,kBA3BgB;EA4BhB;EACA,OA7BgB;EA8BhB;EACA;EACA;EACA;EACA,OAjCU;EAkCV;;;AAEF;EACE,kBAnC0B;EAoC1B,OAnCoB;;;AAsCtB;EACE,kBA3CgB;EA4ChB;EACA;EACA;EACA;EACA;EACA;EACA,OAjDU;EAkDV;;;AAEF;EACE,kBAnD0B;EAoD1B,OAnDoB;EAoDpB;;;AAEF;EACE,kBA3DgB;EA4DhB;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAlEU;EAmEV;;;AAEF;EACE,kBApE0B;EAqE1B,OApEoB;;;AAsEtB;AACA;EACE;;;AAEF;EACE;;;AAGF;AAEE;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AA8EN;EACE,SA/EI;;;AAiFN;EACE,SAlFI;;;AAqFR;AAEE;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAdF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACE;IACE;;EAEF;IACE;IACA;;EAEF;IACE;IACA;IACA;;EAEF;IACE;IACA;IACA;;EAEF;IACE;IACA;IACA;;EAEF;IACE;;;AAIJ;AACA;AAAA;EAEE;EACA;;;AAGF;EACE;EACA;;;AAGF;AAAA;EAEE;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;AAEA;AAEA;EACE;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;;;AAEF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAEF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAGF;AACA;EACE;EACA;EACA;;;AAGF;AACA;EACE;EACA;;;AAGF;AACA;EACE;;;AAEF;EACE;;;AAGF;AACA;EACE;IACE;;;AAIJ;EACE;IACE;;;AAIJ;EACE;IACE;;;AAIJ;AACI;EACF;AAAA;IAEE;;EAGF;IACE;;AAGF;EACA;IACE;IACA;IACA;;;AAIJ;EACE;IACE;;EAEF;IACE;;EAEF;IACE;;EAEF;IACE;IACA;;EAEF;IACE;;;AAIJ;EACE;IACE","file":"styles.css"} \ No newline at end of file diff --git a/viewer/styles.scss b/viewer/styles.scss index 0b6ef63..4e5c0c1 100644 --- a/viewer/styles.scss +++ b/viewer/styles.scss @@ -364,6 +364,11 @@ body.dark-mode .log-container::-webkit-scrollbar-track { z-index: 0; } +.leaflet-interactive { + stroke-linejoin: round; + stroke-linecap: round; +} + .map-layer { filter: brightness(1) contrast(1); } From dfff0e50390b01c097decd06a890d6fdcdb2248e Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 19:44:43 +0100 Subject: [PATCH 19/30] Now support new Apron def + max size --- services/occupancyService.js | 53 +++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/services/occupancyService.js b/services/occupancyService.js index fa5586e..ebc88dd 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -105,6 +105,17 @@ class StandRegistry { this.apron.set(stand.key(), stand); } + getApronOccupancyLevel(standName, icao) { + // Count how many stands with the same name/icao exist in apron + let count = 0; + for (const [key, apronStand] of this.apron.entries()) { + if (apronStand.name === standName && apronStand.icao === icao) { + count++; + } + } + return count; + } + removeApron(stand) { this.apron.delete(stand.key()); } @@ -278,6 +289,17 @@ const isAircraftOnStand = async ( coords.lon ); + if (standDef.Apron && standDef.Apron.Coordinates && Array.isArray(standDef.Apron.Coordinates)) { + // Check if aircraft is inside apron polygon + const apronCoords = standDef.Apron.Coordinates.map((coordString) => { + const coord = parseCoordinates(coordString); + return coord ? { lat: coord.lat, lon: coord.lon } : null; + }).filter(c => c !== null); + + if (isPointInPolygon({ lat: ac.latitude, lon: ac.longitude }, apronCoords)) { + return standName; + } + } if (aircraftDist <= coords.radius) { if (!ac.flight_plan || !ac.flight_plan.aircraft_short || ac.flight_plan.aircraft_short === "UNKNOWN" || ac.flight_plan.aircraft_short === "") { if (ac.flight_plan) { @@ -379,6 +401,22 @@ const blockStands = (standDef, icao, callsign) => { } }; +function isPointInPolygon(point, polygon) { + let inside = false; + const x = point.lon; + const y = point.lat; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].lon, + yi = polygon[i].lat; + const xj = polygon[j].lon, + yj = polygon[j].lat; + + const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); + if (intersect) inside = !inside; + } + return inside; +} + async function getAirportCoordinates(icao) { const airport = await airportService.getAirportConfig(icao); if (!airport || !airport.Coordinates) { @@ -609,7 +647,7 @@ const cs = (ac.callsign || "").toUpperCase(); continue; } } - if (standDef.Apron === undefined || standDef.Apron === false) { + if (standDef.Apron === undefined) { if (registry.isOccupied(ac.destination, standName)) { continue; } @@ -619,6 +657,13 @@ const cs = (ac.callsign || "").toUpperCase(); if (registry.isBlocked(ac.destination, standName)) { continue; } + } else { + const apronSize = standDef.Apron.Size; + const currentApronOccupancy = registry.getApronOccupancyLevel(standName, airportConfig.ICAO); + if (currentApronOccupancy >= apronSize) { + // Apron is full + continue; + } } availableStandList.push(standDef); } @@ -665,7 +710,7 @@ const cs = (ac.callsign || "").toUpperCase(); callsign: ac.callsign, icao: airportConfig.ICAO, }); - if (selectedStandDef.apron === undefined || selectedStandDef.apron === false) { + if (selectedStandDef.apron === undefined) { registry.addAssigned(stand); blockStands(selectedStandDef, ac.destination, ac.callsign); } else { @@ -763,7 +808,7 @@ processDatafeed = async (aircrafts) => { airportJson && airportJson.Stands && airportJson.Stands[ac.stand]; if ( standDef && - (standDef.Apron === undefined || standDef.Apron === false) + (standDef.Apron === undefined) ) { let aircraftCode = "UNKNOWN"; if (ac.flight_plan && ac.flight_plan.aircraft_short && ac.flight_plan.aircraft_short !== "UNKNOWN" && ac.flight_plan.aircraft_short !== "") { @@ -906,7 +951,7 @@ async function assignStandToPilot(standName, icao, callsign, client) { }; } - if (standDef.Apron === undefined || standDef.Apron === false) { + if (standDef.Apron === undefined) { if (registry.isOccupied(icao, standName)) { warn( `Cannot assign stand ${standName} at ${icao} to ${callsign} - already occupied, Requester: ${client}`, From 443297ac10d4e4d13ca063243576b426f758dacd Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 15 Nov 2025 19:52:41 +0100 Subject: [PATCH 20/30] updated API url --- viewer/script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewer/script.js b/viewer/script.js index ee6c37b..199531a 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1,4 +1,4 @@ -const API_BASE_URL = ""; +const API_BASE_URL = "https://pintade.vatsim.fr/rampagent"; /* Set the width of the side navigation to 250px */ function openNav() { From 50ae5d7f26bd58bdc99a8540782b5eab65de943f Mon Sep 17 00:00:00 2001 From: Alexis Date: Sun, 16 Nov 2025 09:15:07 +0100 Subject: [PATCH 21/30] Fixed generate API key --- viewer/script.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viewer/script.js b/viewer/script.js index 199531a..32120f2 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1931,12 +1931,12 @@ function generateApiKey() { console.error("Cannot generate API key: user not found"); return; } - fetch(API_BASE_URL + `/api/auth/key/${user.cid}`, { + fetch(API_BASE_URL + `/api/auth/key/${user.core.cid}`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ userId: user.cid }), + body: JSON.stringify({ userId: user.core.cid }), }); }); From b15dcbe248a311ee78dde9f134cc07a9f043a476 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sun, 16 Nov 2025 09:17:05 +0100 Subject: [PATCH 22/30] Fixed apron display in map --- viewer/script.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viewer/script.js b/viewer/script.js index 32120f2..fabd178 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1630,8 +1630,8 @@ function loadMapData() { stand.polygon = L.polygon(stand.apron.Coordinates, { color: color[0], fillColor: color[1], - fillOpacity: 0.8, - weight: 3, + fillOpacity: 0.5, + weight: 2, lineJoin: "round", // <- round joins lineCap: "round", // <- round end caps smoothFactor: 1.5 From ef77cd5a999c894713268ab416bd6b662b4bedcd Mon Sep 17 00:00:00 2001 From: Alexis Date: Sun, 16 Nov 2025 10:50:10 +0100 Subject: [PATCH 23/30] Fixed apron full when manually assigning stand --- services/occupancyService.js | 41 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/services/occupancyService.js b/services/occupancyService.js index ebc88dd..1ab28e0 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -991,16 +991,39 @@ async function assignStandToPilot(standName, icao, callsign, client) { message: `Stand ${standName} could not be assigned to ${callsign} as it is blocked`, }; } + const stand = new Stand(standName, icao, callsign); + registry.addAssigned(stand); + // Block stands + blockStands(standDef, icao, callsign); + info(`Manually assigned stand ${standName} at ${icao} to ${callsign}, Requester: ${client}`, { + category: "Manual Assign", + callsign: callsign, + icao: icao, + }); + } else { + const size = standDef.Apron.Size; + if (registry.getApronOccupancyLevel(standName, icao) >= size) { + warn( + `Cannot assign apron stand ${standName} at ${icao} to ${callsign} - apron full, Requester: ${client}`, + { category: "Manual Assign", callsign: callsign, icao: icao } + ); + return { + action: "full", + stand: standName, + callsign: callsign, + icao: icao, + message: `Apron ${standName} at ${icao} is full and cannot be assigned to ${callsign}`, + }; + } else { + const stand = new Stand(standName, icao, callsign); + registry.addApron(stand); + info(`Manually assigned apron stand ${standName} at ${icao} to ${callsign}, Requester: ${client}`, { + category: "Manual Assign", + callsign: callsign, + icao: icao, + }); + } } - const stand = new Stand(standName, icao, callsign); - registry.addAssigned(stand); - // Block stands - blockStands(standDef, icao, callsign); - info(`Manually assigned stand ${standName} at ${icao} to ${callsign}, Requester: ${client}`, { - category: "Manual Assign", - callsign: callsign, - icao: icao, - }); return { action: "assign", stand: standName, From 561a0d5142c01588c8b9d369e98457833cf63f4f Mon Sep 17 00:00:00 2001 From: Alexis Date: Sun, 16 Nov 2025 19:54:41 +0100 Subject: [PATCH 24/30] Fixing apron detection --- services/occupancyService.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/occupancyService.js b/services/occupancyService.js index 1ab28e0..e364243 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -402,9 +402,8 @@ const blockStands = (standDef, icao, callsign) => { }; function isPointInPolygon(point, polygon) { + // Ray casting algorithm for point-in-polygon let inside = false; - const x = point.lon; - const y = point.lat; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].lon, yi = polygon[i].lat; From 7078098da3367de062e68f2d91f722bf1ed630de Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 17 Nov 2025 10:56:02 +0100 Subject: [PATCH 25/30] Fixed missing variable definition --- services/occupancyService.js | 1 + 1 file changed, 1 insertion(+) diff --git a/services/occupancyService.js b/services/occupancyService.js index e364243..8830942 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -410,6 +410,7 @@ function isPointInPolygon(point, polygon) { const xj = polygon[j].lon, yj = polygon[j].lat; + const x = point.lon, y = point.lat; const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); if (intersect) inside = !inside; } From b2c30439c06b940ebd08eb0352722ac390fafb31 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 17 Nov 2025 17:53:17 +0100 Subject: [PATCH 26/30] Fixed apron management --- controllers/occupancyController.js | 6 + services/occupancyService.js | 411 +++++++++++++++-------------- viewer/script.js | 103 ++++++-- 3 files changed, 298 insertions(+), 222 deletions(-) diff --git a/controllers/occupancyController.js b/controllers/occupancyController.js index dab2fc9..b47ddd9 100644 --- a/controllers/occupancyController.js +++ b/controllers/occupancyController.js @@ -24,6 +24,8 @@ exports.getOccupied = (req, res) => { name: s.name, icao: s.icao, callsign: s.callsign || null, + remark: s.remark || null, + apronSize: s.apronSize || 0, })); res.json(occupied); } catch (err) { @@ -41,6 +43,8 @@ exports.getAssigned = (req, res) => { name: s.name, icao: s.icao, callsign: s.callsign || null, + remark: s.remark || null, + apronSize: s.apronSize || 0, })); res.json(assigned); } catch (err) { @@ -58,6 +62,8 @@ exports.getBlocked = (req, res) => { name: s.name, icao: s.icao, callsign: s.callsign || null, + remark: s.remark || null, + apronSize: s.apronSize || 0, })); res.json(blocked); } catch (err) { diff --git a/services/occupancyService.js b/services/occupancyService.js index 8830942..c5e2764 100644 --- a/services/occupancyService.js +++ b/services/occupancyService.js @@ -37,24 +37,28 @@ function parseCoordinates(coordString, defaultRadius = 30) { } class Stand { - constructor(name, icao, callsign, remark = "") { + constructor(name, icao, callsign, remark = "", apronSize = 0) { this.name = name; this.icao = icao; this.callsign = callsign; this.remark = remark; + this.apronSize = apronSize; this.timestamp = Date.now(); } // Hash function for the Stand class key() { - return `${this.icao}:${this.name}`; + return this.apronSize > 0 + ? `${this.icao}:${this.name}:${this.callsign}` + : `${this.icao}:${this.name}`; } equals(other) { return ( this.icao === other.icao && this.name === other.name && - this.callsign === other.callsign + this.callsign === other.callsign && + this.apronSize === other.apronSize ); } @@ -64,6 +68,7 @@ class Stand { icao: this.icao, callsign: this.callsign, remark: this.remark, + apronSize: this.apronSize, timestamp: this.timestamp, }; } @@ -74,7 +79,6 @@ class StandRegistry { this.occupied = new Map(); // key -> Stand this.assigned = new Map(); // key -> Stand this.blocked = new Map(); // key -> Stand - this.apron = new Map(); // key -> Stand } addOccupied(stand) { @@ -101,31 +105,60 @@ class StandRegistry { this.blocked.delete(stand.key()); } - addApron(stand) { - this.apron.set(stand.key(), stand); - } - getApronOccupancyLevel(standName, icao) { - // Count how many stands with the same name/icao exist in apron - let count = 0; - for (const [key, apronStand] of this.apron.entries()) { - if (apronStand.name === standName && apronStand.icao === icao) { - count++; - } + // Count how many pilot have this stand assigned or occupied + let count = 0; + for (const stand of this.occupied.values()) { + if (stand.name === standName && stand.icao === icao && stand.apronSize > 0) { + count++; } - return count; - } - - removeApron(stand) { - this.apron.delete(stand.key()); + } + for (const stand of this.assigned.values()) { + if (stand.name === standName && stand.icao === icao && stand.apronSize > 0) { + count++; + } + } + return count; } isOccupied(icao, name) { - return this.occupied.has(`${icao}:${name}`); + // For non-apron stands, check simple key + const simpleOccupied = Array.from(this.occupied.values()).find( + s => s.icao === icao && s.name === name && s.apronSize === 0 + ); + if (simpleOccupied) return true; + + // For apron stands, check if capacity is reached + const apronStand = Array.from(this.occupied.values()).filter( + s => s.icao === icao && s.name === name && s.apronSize > 0 + ); + if (apronStand.length > 0) { + if(this.getApronOccupancyLevel(name, icao) >= apronStand[0].apronSize) { + return true; + } + } + + return false; } isAssigned(icao, name) { - return this.assigned.has(`${icao}:${name}`); + // For non-apron stands, check simple key + const simpleAssigned = Array.from(this.assigned.values()).find( + s => s.icao === icao && s.name === name && s.apronSize === 0 + ); + if (simpleAssigned) return true; + + // For apron stands, check if capacity is reached + const apronStand = Array.from(this.assigned.values()).filter( + s => s.icao === icao && s.name === name && s.apronSize > 0 + ); + if (apronStand.length > 0) { + if(this.getApronOccupancyLevel(name, icao) >= apronStand[0].apronSize) { + return true; + } + } + + return false; } isBlocked(icao, name) { @@ -144,10 +177,6 @@ class StandRegistry { return Array.from(this.blocked.values()); } - getAllApron() { - return Array.from(this.apron.values()); - } - clearExpired(predicateFn) { // e.g. remove old stands if needed for (const [key, stand] of this.occupied) { @@ -189,19 +218,6 @@ class StandRegistry { ); } } - for (const [key, stand] of this.apron) { - if (predicateFn(stand)) { - this.apron.delete(key); - warn( - `Clearing expired Apron stand ${stand.name} at ${stand.icao} for ${stand.callsign}`, - { - category: "Stand Management", - callsign: stand.callsign, - icao: stand.icao, - } - ); - } - } } } @@ -289,19 +305,30 @@ const isAircraftOnStand = async ( coords.lon ); - if (standDef.Apron && standDef.Apron.Coordinates && Array.isArray(standDef.Apron.Coordinates)) { + if ( + standDef.Apron && + standDef.Apron.Coordinates && + Array.isArray(standDef.Apron.Coordinates) + ) { // Check if aircraft is inside apron polygon const apronCoords = standDef.Apron.Coordinates.map((coordString) => { const coord = parseCoordinates(coordString); return coord ? { lat: coord.lat, lon: coord.lon } : null; - }).filter(c => c !== null); + }).filter((c) => c !== null); - if (isPointInPolygon({ lat: ac.latitude, lon: ac.longitude }, apronCoords)) { + if ( + isPointInPolygon({ lat: ac.latitude, lon: ac.longitude }, apronCoords) + ) { return standName; } } if (aircraftDist <= coords.radius) { - if (!ac.flight_plan || !ac.flight_plan.aircraft_short || ac.flight_plan.aircraft_short === "UNKNOWN" || ac.flight_plan.aircraft_short === "") { + if ( + !ac.flight_plan || + !ac.flight_plan.aircraft_short || + ac.flight_plan.aircraft_short === "UNKNOWN" || + ac.flight_plan.aircraft_short === "" + ) { if (ac.flight_plan) { warn( `Aircraft ${ac.callsign} on ground at ${ac.origin} has unknown type`, @@ -348,7 +375,10 @@ const isAircraftOnStand = async ( let bestPriority = Number.MAX_SAFE_INTEGER; for (const potentialStandName of potentialStands) { - const wingspan = getAircraftWingspan(config, ac.flight_plan.aircraft_short); + const wingspan = getAircraftWingspan( + config, + ac.flight_plan.aircraft_short + ); const aircraftCode = getAircraftCode(wingspan); const potentialStandDef = airportData.Stands[potentialStandName]; @@ -394,7 +424,9 @@ const blockStands = (standDef, icao, callsign) => { const blockedStand = new Stand( blockedStandName, icao || "UNKNOWN", - callsign + callsign, + "", + 0 ); registry.addBlocked(blockedStand); } @@ -410,8 +442,10 @@ function isPointInPolygon(point, polygon) { const xj = polygon[j].lon, yj = polygon[j].lat; - const x = point.lon, y = point.lat; - const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); + const x = point.lon, + y = point.lat; + const intersect = + yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; @@ -431,7 +465,12 @@ async function getAirportCoordinates(icao) { } async function calculateRemainingDistance(ac) { - if (!ac.flight_plan || !ac.flight_plan.arrival || !ac.latitude || !ac.longitude) { + if ( + !ac.flight_plan || + !ac.flight_plan.arrival || + !ac.latitude || + !ac.longitude + ) { return Number.MAX_SAFE_INTEGER; } const destCoords = await getAirportCoordinates(ac.flight_plan.arrival); @@ -458,7 +497,8 @@ async function isConcernedArrival(ac, config, airportSet) { return false; } ac.remainingDistance = await calculateRemainingDistance(ac); - if (ac.remainingDistance * 0.00053996 > config.max_distance) { // convert to nautical miles + if (ac.remainingDistance * 0.00053996 > config.max_distance) { + // convert to nautical miles return false; } return true; @@ -580,21 +620,18 @@ function assignStand(airportConfig, config, ac) { const blockedStands = registry .getAllBlocked() .filter((s) => s.callsign === ac.callsign); - const apronStands = registry - .getAllApron() - .find((s) => s.callsign === ac.callsign); - if (assignedStand || apronStands) { - if (assignedStand && (registry.isOccupied(ac.destination, assignedStand.name) || registry.isBlocked(ac.destination, assignedStand.name))) { + if (assignedStand) { + if ( + assignedStand && + (registry.isOccupied(ac.destination, assignedStand.name) || + registry.isBlocked(ac.destination, assignedStand.name)) + ) { registry.removeAssigned(assignedStand); } else { - if (apronStands) { - apronStands.timestamp = Date.now(); - } else { assignedStand.timestamp = Date.now(); for (const s of blockedStands) { s.timestamp = Date.now(); } - } return; } } @@ -602,7 +639,11 @@ function assignStand(airportConfig, config, ac) { const schengen = isSchengen(ac.origin, ac.destination); const wingspan = getAircraftWingspan(config, ac.flight_plan.aircraft_short); const code = getAircraftCode(wingspan); - const use = getAircraftUse(config, ac.callsign, ac.flight_plan.aircraft_short); + const use = getAircraftUse( + config, + ac.callsign, + ac.flight_plan.aircraft_short + ); const originPrefix = ac.origin.substring(0, 2).toUpperCase(); const compagnyPrefix = ac.callsign.substring(0, 3).toUpperCase(); @@ -633,7 +674,7 @@ function assignStand(airportConfig, config, ac) { } } if (standDef.Callsigns && Array.isArray(standDef.Callsigns)) { -const cs = (ac.callsign || "").toUpperCase(); + const cs = (ac.callsign || "").toUpperCase(); let match = false; // check prefixes from length 3 up to full callsign for (let len = 3; len <= cs.length; len++) { @@ -659,7 +700,10 @@ const cs = (ac.callsign || "").toUpperCase(); } } else { const apronSize = standDef.Apron.Size; - const currentApronOccupancy = registry.getApronOccupancyLevel(standName, airportConfig.ICAO); + const currentApronOccupancy = registry.getApronOccupancyLevel( + standName, + airportConfig.ICAO + ); if (currentApronOccupancy >= apronSize) { // Apron is full continue; @@ -704,18 +748,14 @@ const cs = (ac.callsign || "").toUpperCase(); const standName = Object.keys(airportConfig.Stands).find( (name) => airportConfig.Stands[name] === selectedStandDef ); - const stand = new Stand(standName, airportConfig.ICAO, ac.callsign); + const stand = new Stand(standName, airportConfig.ICAO, ac.callsign, "", selectedStandDef.Apron === undefined ? 0 : selectedStandDef.Apron.Size); info(`Assigning Stand ${standName} to ${ac.callsign}`, { category: "Assignation", callsign: ac.callsign, icao: airportConfig.ICAO, }); - if (selectedStandDef.apron === undefined) { - registry.addAssigned(stand); - blockStands(selectedStandDef, ac.destination, ac.callsign); - } else { - registry.addApron(stand); - } + registry.addAssigned(stand); + blockStands(selectedStandDef, ac.destination, ac.callsign); return; } warn(`No available stands found for ${ac.callsign} at ${ac.destination}`, { @@ -806,43 +846,45 @@ processDatafeed = async (aircrafts) => { const standDef = airportJson && airportJson.Stands && airportJson.Stands[ac.stand]; + if (!standDef) { + warn( + `Stand definition for stand ${ac.stand} not found at airport ${ac.origin}, skipping occupancy`, + { category: "Assignation", callsign: ac.callsign, icao: ac.origin } + ); + continue; + } + let aircraftCode = "UNKNOWN"; if ( - standDef && - (standDef.Apron === undefined) + ac.flight_plan && + ac.flight_plan.aircraft_short && + ac.flight_plan.aircraft_short !== "UNKNOWN" && + ac.flight_plan.aircraft_short !== "" ) { - let aircraftCode = "UNKNOWN"; - if (ac.flight_plan && ac.flight_plan.aircraft_short && ac.flight_plan.aircraft_short !== "UNKNOWN" && ac.flight_plan.aircraft_short !== "") { - aircraftCode = getAircraftCode( - getAircraftWingspan(config, ac.flight_plan.aircraft_short) - ); - } - let remark = ""; - if (standDef.Remark && typeof standDef.Remark === "object") { - // Iterate through all keys in the Remark object - for (const [codeList, remarkText] of Object.entries( - standDef.Remark - )) { - // Check if the aircraft code is in this key - if (codeList.includes(aircraftCode)) { - remark = remarkText; - break; - } + aircraftCode = getAircraftCode( + getAircraftWingspan(config, ac.flight_plan.aircraft_short) + ); + } + let remark = ""; + if (standDef.Remark && typeof standDef.Remark === "object") { + // Iterate through all keys in the Remark object + for (const [codeList, remarkText] of Object.entries(standDef.Remark)) { + // Check if the aircraft code is in this key + if (codeList.includes(aircraftCode)) { + remark = remarkText; + break; } } - const stand = new Stand( - ac.stand, - ac.origin || "UNKNOWN", - ac.callsign, - remark - ); - // Remove preceeding entry if any - registry.removeOccupied(stand); - registry.addOccupied(stand); - - blockStands(standDef, ac.origin, ac.callsign); - } else { - registry.addApron(new Stand(ac.stand, ac.origin, ac.callsign)); } + const stand = new Stand( + ac.stand, + ac.origin || "UNKNOWN", + ac.callsign, + remark, + standDef.Apron === undefined ? 0 : standDef.Apron.Size + ); + + registry.addOccupied(stand); + blockStands(standDef, ac.origin, ac.callsign); } } @@ -854,10 +896,10 @@ processDatafeed = async (aircrafts) => { ac.origin = ac.flight_plan.departure; ac.destination = ac.flight_plan.arrival; // Check Assignement conditions - if (!await isConcernedArrival(ac, config, airportSet)) { + if (!(await isConcernedArrival(ac, config, airportSet))) { continue; } - + // Aircraft meets requirements for stand assignment // Use cached config let airportConfig = airportConfigCache.get(ac.destination); @@ -896,28 +938,22 @@ const getGlobalOccupied = () => { async function assignStandToPilot(standName, icao, callsign, client) { // Remove any existing assignment const existingStand = registry - .getAllAssigned() - .filter((s) => s.callsign === callsign); + .getAllAssigned() + .filter((s) => s.callsign === callsign); existingStand.forEach((existingStand) => { registry.removeAssigned(existingStand); }); const blockedStands = registry - .getAllBlocked() - .filter((s) => s.callsign === callsign); + .getAllBlocked() + .filter((s) => s.callsign === callsign); blockedStands.forEach((s) => { registry.removeBlocked(s); }); - const apronStands = registry - .getAllApron() - .filter((s) => s.callsign === callsign); - apronStands.forEach((s) => { - registry.removeApron(s); - }); if (standName === "None") { info(`Removed stand assignment for ${callsign}, Requester: ${client}`, { category: "Manual Assign", callsign: callsign, - icao: icao + icao: icao, }); return { action: "free", @@ -930,7 +966,11 @@ async function assignStandToPilot(standName, icao, callsign, client) { const standDef = await airportService .getAirportConfig(icao) .then((airportConfig) => { - if (airportConfig && airportConfig.Stands && airportConfig.Stands[standName]) { + if ( + airportConfig && + airportConfig.Stands && + airportConfig.Stands[standName] + ) { return airportConfig.Stands[standName]; } return null; @@ -940,90 +980,69 @@ async function assignStandToPilot(standName, icao, callsign, client) { warn(`Stand ${standName} not found at ${icao}, Requester: ${client}`, { category: "Manual Assign", callsign: callsign, - icao: icao + icao: icao, }); return { - action: "not_found", - stand: standName, - callsign: callsign, - icao: icao, - message: `Stand ${standName} does not exist at ${icao}`, - }; -} + action: "not_found", + stand: standName, + callsign: callsign, + icao: icao, + message: `Stand ${standName} does not exist at ${icao}`, + }; + } - if (standDef.Apron === undefined) { - if (registry.isOccupied(icao, standName)) { - warn( - `Cannot assign stand ${standName} at ${icao} to ${callsign} - already occupied, Requester: ${client}`, - { category: "Manual Assign", callsign: callsign, icao: icao } - ); - return { - action: "occupied", - stand: standName, - callsign: callsign, - icao: icao, - message: `Stand ${standName} could not be assigned to ${callsign} as it is already occupied`, - }; - } - if (registry.isAssigned(icao, standName)) { - warn( - `Cannot assign stand ${standName} at ${icao} to ${callsign} - already assigned, Requester: ${client}`, - { category: "Manual Assign", callsign: callsign, icao: icao } - ); - return { - action: "assigned", - stand: standName, - callsign: callsign, - icao: icao, - message: `Stand ${standName} could not be assigned to ${callsign} as it is already assigned`, - }; - } - if (registry.isBlocked(icao, standName)) { - warn( - `Cannot assign stand ${standName} at ${icao} to ${callsign} - already blocked, Requester: ${client}`, - { category: "Manual Assign", callsign: callsign, icao: icao } - ); - return { - action: "blocked", - stand: standName, - callsign: callsign, - icao: icao, - message: `Stand ${standName} could not be assigned to ${callsign} as it is blocked`, - }; - } - const stand = new Stand(standName, icao, callsign); - registry.addAssigned(stand); - // Block stands - blockStands(standDef, icao, callsign); - info(`Manually assigned stand ${standName} at ${icao} to ${callsign}, Requester: ${client}`, { + if (registry.isOccupied(icao, standName)) { + warn( + `Cannot assign stand ${standName} at ${icao} to ${callsign} - already occupied, Requester: ${client}`, + { category: "Manual Assign", callsign: callsign, icao: icao } + ); + return { + action: "occupied", + stand: standName, + callsign: callsign, + icao: icao, + message: `Stand ${standName} could not be assigned to ${callsign} as it is already occupied`, + }; + } + if (registry.isAssigned(icao, standName)) { + warn( + `Cannot assign stand ${standName} at ${icao} to ${callsign} - already assigned, Requester: ${client}`, + { category: "Manual Assign", callsign: callsign, icao: icao } + ); + return { + action: "assigned", + stand: standName, + callsign: callsign, + icao: icao, + message: `Stand ${standName} could not be assigned to ${callsign} as it is already assigned`, + }; + } + if (registry.isBlocked(icao, standName)) { + warn( + `Cannot assign stand ${standName} at ${icao} to ${callsign} - already blocked, Requester: ${client}`, + { category: "Manual Assign", callsign: callsign, icao: icao } + ); + return { + action: "blocked", + stand: standName, + callsign: callsign, + icao: icao, + message: `Stand ${standName} could not be assigned to ${callsign} as it is blocked`, + }; + } + const stand = new Stand(standName, icao, callsign, "", standDef.Apron === undefined ? 0 : standDef.Apron.Size); + registry.addAssigned(stand); + // Block stands + blockStands(standDef, icao, callsign); + info( + `Manually assigned stand ${standName} at ${icao} to ${callsign}, Requester: ${client}`, + { category: "Manual Assign", callsign: callsign, icao: icao, - }); - } else { - const size = standDef.Apron.Size; - if (registry.getApronOccupancyLevel(standName, icao) >= size) { - warn( - `Cannot assign apron stand ${standName} at ${icao} to ${callsign} - apron full, Requester: ${client}`, - { category: "Manual Assign", callsign: callsign, icao: icao } - ); - return { - action: "full", - stand: standName, - callsign: callsign, - icao: icao, - message: `Apron ${standName} at ${icao} is full and cannot be assigned to ${callsign}`, - }; - } else { - const stand = new Stand(standName, icao, callsign); - registry.addApron(stand); - info(`Manually assigned apron stand ${standName} at ${icao} to ${callsign}, Requester: ${client}`, { - category: "Manual Assign", - callsign: callsign, - icao: icao, - }); } - } + ); + return { action: "assign", stand: standName, @@ -1048,10 +1067,10 @@ module.exports = { processDatafeed, assignStandToPilot, getGlobalOccupied, - getAllOccupied: registry.getAllOccupied.bind(registry), - getAllAssigned: registry.getAllAssigned.bind(registry), - getAllBlocked: registry.getAllBlocked.bind(registry), - isOccupied: registry.isOccupied.bind(registry), - isBlocked: registry.isBlocked.bind(registry), - isBlocked: registry.isBlocked.bind(registry), + getAllOccupied: () => registry.getAllOccupied(), + getAllAssigned: () => registry.getAllAssigned(), + getAllBlocked: () => registry.getAllBlocked(), + isOccupied: (icao, name) => registry.isOccupied(icao, name), + isAssigned: (icao, name) => registry.isAssigned(icao, name), + isBlocked: (icao, name) => registry.isBlocked(icao, name), }; diff --git a/viewer/script.js b/viewer/script.js index fabd178..3f4c078 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1,4 +1,4 @@ -const API_BASE_URL = "https://pintade.vatsim.fr/rampagent"; +const API_BASE_URL = ""; /* Set the width of the side navigation to 250px */ function openNav() { @@ -1103,8 +1103,6 @@ document.addEventListener("DOMContentLoaded", () => { }, 2000); }); -// Event listeners for filter changes are now set up inside DOMContentLoaded - // Navigation routing - wrapped to execute after DOM is ready (function () { function initNavigation() { @@ -1200,11 +1198,20 @@ function fetchOccupiedStands() { }) .then((stands) => { if (Array.isArray(stands)) { - // Store as ICAO-StandName format instead of just name - occupiedStands = stands.map((s) => ({ - id: s.icao + "-" + s.name, - callsign: s.callsign, - })); + // Group apron stands by ICAO-StandName, keep others as-is + const grouped = new Map(); + stands.forEach((s) => { + const id = s.icao + "-" + s.name; + if (s.apronSize > 0) { + if (!grouped.has(id)) { + grouped.set(id, { id, callsigns: [], isApron: true }); + } + grouped.get(id).callsigns.push(s.callsign); + } else { + grouped.set(id, { id, callsign: s.callsign, isApron: false }); + } + }); + occupiedStands = Array.from(grouped.values()); } }) .catch((err) => { @@ -1222,11 +1229,20 @@ function fetchAssignedStands() { }) .then((stands) => { if (Array.isArray(stands)) { - // Store as ICAO-StandName format instead of just name - assignedStands = stands.map((s) => ({ - id: s.icao + "-" + s.name, - callsign: s.callsign, - })); + // Group apron stands by ICAO-StandName, keep others as-is + const grouped = new Map(); + stands.forEach((s) => { + const id = s.icao + "-" + s.name; + if (s.apronSize > 0) { + if (!grouped.has(id)) { + grouped.set(id, { id, callsigns: [], isApron: true }); + } + grouped.get(id).callsigns.push(s.callsign); + } else { + grouped.set(id, { id, callsign: s.callsign, isApron: false }); + } + }); + assignedStands = Array.from(grouped.values()); } }) .catch((err) => { @@ -1244,11 +1260,20 @@ function fetchBlockedStands() { }) .then((stands) => { if (Array.isArray(stands)) { - // Store as ICAO-StandName format instead of just name - blockedStands = stands.map((s) => ({ - id: s.icao + "-" + s.name, - callsign: s.callsign, - })); + // Blocked stands are usually not aprons, but handle just in case + const grouped = new Map(); + stands.forEach((s) => { + const id = s.icao + "-" + s.name; + if (s.apronSize > 0) { + if (!grouped.has(id)) { + grouped.set(id, { id, callsigns: [], isApron: true }); + } + grouped.get(id).callsigns.push(s.callsign); + } else { + grouped.set(id, { id, callsign: s.callsign, isApron: false }); + } + }); + blockedStands = Array.from(grouped.values()); } }) .catch((err) => { @@ -1258,6 +1283,10 @@ function fetchBlockedStands() { function getStandColor(standName, apron) { // Now both standName and the arrays are in ICAO-StandName format + if (apron) { + return ["#4682B4", "#87CEEB"]; // steel blue border, sky blue fill (apron) + } + if (occupiedStands.some((s) => s.id === standName)) { return ["#B22222", "#FF6B6B"]; // dark red border, light red fill (occupied) } @@ -1270,10 +1299,6 @@ function getStandColor(standName, apron) { return ["#9c7c22ff", "#cdc54eff"]; // dark teal border, light teal fill (blocked) } - if (apron) { - return ["#4682B4", "#87CEEB"]; // steel blue border, sky blue fill (apron) - } - return ["#78BFA0", "#96CEB4"]; // darker green border, light green fill (default) } @@ -1552,16 +1577,41 @@ function createStandPopupContent(standId) { div.className = "stand-popup-content"; div.innerHTML = "

" + standId + "

"; - // Check occupied/assigned/blocked arrays for callsign + // Check occupied/assigned/blocked arrays for callsign(s) const occupied = occupiedStands.find((s) => s.id === standId); const assigned = assignedStands.find((s) => s.id === standId); const blocked = blockedStands.find((s) => s.id === standId); + if (occupied) { - div.innerHTML += `

Occupied by ${occupied.callsign}

`; + if (occupied.isApron && Array.isArray(occupied.callsigns)) { + div.innerHTML += `

Occupied (${occupied.callsigns.length}):

    `; + occupied.callsigns.forEach(cs => { + div.innerHTML += `
  • ${cs}
  • `; + }); + div.innerHTML += `
`; + } else { + div.innerHTML += `

Occupied by ${occupied.callsign}

`; + } } else if (assigned) { - div.innerHTML += `

Assigned to ${assigned.callsign}

`; + if (assigned.isApron && Array.isArray(assigned.callsigns)) { + div.innerHTML += `

Assigned (${assigned.callsigns.length}):

    `; + assigned.callsigns.forEach(cs => { + div.innerHTML += `
  • ${cs}
  • `; + }); + div.innerHTML += `
`; + } else { + div.innerHTML += `

Assigned to ${assigned.callsign}

`; + } } else if (blocked) { - div.innerHTML += `

Blocked by ${blocked.callsign}

`; + if (blocked.isApron && Array.isArray(blocked.callsigns)) { + div.innerHTML += `

Blocked by (${blocked.callsigns.length}):

    `; + blocked.callsigns.forEach(cs => { + div.innerHTML += `
  • ${cs}
  • `; + }); + div.innerHTML += `
`; + } else { + div.innerHTML += `

Blocked by ${blocked.callsign}

`; + } } else { div.innerHTML += `

Free

`; } @@ -2220,6 +2270,7 @@ setInterval(updateControllerNumber, 15000); // update every 15 seconds if (direction === "left" && right) { right.style.width = `${MAX_TRANSLATE}px`; right.style.opacity = "1"; + } setTimeout(() => { From 3c0172464181d4957e9581f79ba5bd0531c4126e Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 17 Nov 2025 18:06:04 +0100 Subject: [PATCH 27/30] Fixed map apron popup callsign list --- viewer/script.js | 50 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/viewer/script.js b/viewer/script.js index 3f4c078..1a24cdc 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1582,26 +1582,52 @@ function createStandPopupContent(standId) { const assigned = assignedStands.find((s) => s.id === standId); const blocked = blockedStands.find((s) => s.id === standId); - if (occupied) { - if (occupied.isApron && Array.isArray(occupied.callsigns)) { - div.innerHTML += `

Occupied (${occupied.callsigns.length}):

    `; - occupied.callsigns.forEach(cs => { + // For aprons, combine occupied and assigned callsigns + if (occupied && occupied.isApron && assigned && assigned.isApron) { + // Both occupied and assigned callsigns exist + const occupiedCallsigns = Array.isArray(occupied.callsigns) ? occupied.callsigns : []; + const assignedCallsigns = Array.isArray(assigned.callsigns) ? assigned.callsigns : []; + const totalCount = occupiedCallsigns.length + assignedCallsigns.length; + + div.innerHTML += `

    Aircraft (${totalCount}):

    `; + + if (occupiedCallsigns.length > 0) { + div.innerHTML += `

    Occupied (${occupiedCallsigns.length}):

      `; + occupiedCallsigns.forEach(cs => { div.innerHTML += `
    • ${cs}
    • `; }); div.innerHTML += `
    `; - } else { - div.innerHTML += `

    Occupied by ${occupied.callsign}

    `; } - } else if (assigned) { - if (assigned.isApron && Array.isArray(assigned.callsigns)) { - div.innerHTML += `

    Assigned (${assigned.callsigns.length}):

      `; - assigned.callsigns.forEach(cs => { + + if (assignedCallsigns.length > 0) { + div.innerHTML += `

      Assigned (${assignedCallsigns.length}):

        `; + assignedCallsigns.forEach(cs => { div.innerHTML += `
      • ${cs}
      • `; }); div.innerHTML += `
      `; - } else { - div.innerHTML += `

      Assigned to ${assigned.callsign}

      `; } + } else if (occupied && occupied.isApron) { + // Only occupied callsigns + const occupiedCallsigns = Array.isArray(occupied.callsigns) ? occupied.callsigns : []; + div.innerHTML += `

      Occupied (${occupiedCallsigns.length}):

        `; + occupiedCallsigns.forEach(cs => { + div.innerHTML += `
      • ${cs}
      • `; + }); + div.innerHTML += `
      `; + } else if (assigned && assigned.isApron) { + // Only assigned callsigns + const assignedCallsigns = Array.isArray(assigned.callsigns) ? assigned.callsigns : []; + div.innerHTML += `

      Assigned (${assignedCallsigns.length}):

        `; + assignedCallsigns.forEach(cs => { + div.innerHTML += `
      • ${cs}
      • `; + }); + div.innerHTML += `
      `; + } else if (occupied) { + // Non-apron occupied stand + div.innerHTML += `

      Occupied by ${occupied.callsign}

      `; + } else if (assigned) { + // Non-apron assigned stand + div.innerHTML += `

      Assigned to ${assigned.callsign}

      `; } else if (blocked) { if (blocked.isApron && Array.isArray(blocked.callsigns)) { div.innerHTML += `

      Blocked by (${blocked.callsigns.length}):

        `; From 7e47b089b45ff782f230d568481d275dad2ee300 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 17 Nov 2025 18:06:30 +0100 Subject: [PATCH 28/30] version is now 1.0.7 --- viewer/viewer.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewer/viewer.html b/viewer/viewer.html index 7e63506..75028ee 100644 --- a/viewer/viewer.html +++ b/viewer/viewer.html @@ -57,7 +57,7 @@

        Ramp Agent API

        Logs Configs Dashboard -

        Version 1.0.6

        +

        Version 1.0.7

From bf59a43ac402cd19437e15d2ba7ca44b9ffc528d Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 17 Nov 2025 18:27:44 +0100 Subject: [PATCH 29/30] Fixed circular apron map color --- viewer/script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewer/script.js b/viewer/script.js index 1a24cdc..c92e05b 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1718,7 +1718,7 @@ function loadMapData() { stand.circle = L.circle(stand.coords, { color: color[0], fillColor: color[1], - fillOpacity: 0.8, + fillOpacity: 0.5, radius: stand.radius, weight: 3, }).bindPopup(() => { From 0b691920463a0b2b87f3948e0e8f6032452ffbc3 Mon Sep 17 00:00:00 2001 From: AlexisBalzano <151195549+AlexisBalzano@users.noreply.github.com> Date: Tue, 18 Nov 2025 07:32:47 +0100 Subject: [PATCH 30/30] Update script.js --- viewer/script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewer/script.js b/viewer/script.js index c92e05b..b62f9dc 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -1,4 +1,4 @@ -const API_BASE_URL = ""; +const API_BASE_URL = "https://pintade.vatsim.fr/rampagent"; /* Set the width of the side navigation to 250px */ function openNav() {