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/ 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 6f92405..94db5ee 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,12 +90,13 @@ 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" }); } }; 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(); @@ -111,7 +112,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" }); } }; @@ -120,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" }); @@ -151,7 +152,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"); @@ -202,7 +203,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" }); } }; @@ -239,7 +240,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; } } @@ -257,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: { @@ -355,74 +358,9 @@ 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"); } -}; - -// 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); - 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); - 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); - 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); - 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); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; +}; \ No newline at end of file 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/index.js b/index.js index 805c681..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(); @@ -66,9 +67,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); @@ -85,11 +87,13 @@ app.use("/api/airports", airportRoutes); // API endpoint to get stats (call service and return JSON) app.use("/api/stats", statRoutes); -// Register routes -app.use("/debug", express.static(path.join(__dirname, "viewer"))); +// 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 5c36c52..9872059 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -7,16 +7,9 @@ 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); -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/occupancyService.js b/services/occupancyService.js index 6481b25..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,20 +105,60 @@ class StandRegistry { this.blocked.delete(stand.key()); } - addApron(stand) { - this.apron.set(stand.key(), stand); - } - - removeApron(stand) { - this.apron.delete(stand.key()); + getApronOccupancyLevel(standName, icao) { + // 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++; + } + } + 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) { @@ -133,16 +177,12 @@ 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) { if (predicateFn(stand)) { this.occupied.delete(key); - warn( + info( `Clearing expired occupied stand ${stand.name} at ${stand.icao} for ${stand.callsign}`, { category: "Stand Management", @@ -155,7 +195,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 +208,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", @@ -178,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, - } - ); - } - } } } @@ -278,8 +305,30 @@ 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 || + !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`, @@ -326,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]; @@ -372,13 +424,33 @@ const blockStands = (standDef, icao, callsign) => { const blockedStand = new Stand( blockedStandName, icao || "UNKNOWN", - callsign + callsign, + "", + 0 ); registry.addBlocked(blockedStand); } } }; +function isPointInPolygon(point, polygon) { + // Ray casting algorithm for point-in-polygon + let inside = false; + 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 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; +} + async function getAirportCoordinates(icao) { const airport = await airportService.getAirportConfig(icao); if (!airport || !airport.Coordinates) { @@ -393,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); @@ -420,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; @@ -542,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; } } @@ -564,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(); @@ -595,11 +674,21 @@ 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; } } - if (standDef.Apron === undefined || standDef.Apron === false) { + if (standDef.Apron === undefined) { if (registry.isOccupied(ac.destination, standName)) { continue; } @@ -609,6 +698,16 @@ function assignStand(airportConfig, config, ac) { 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); } @@ -649,18 +748,14 @@ function assignStand(airportConfig, config, ac) { 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 || selectedStandDef.apron === false) { - 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}`, { @@ -751,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 || standDef.Apron === false) + 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); } } @@ -799,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); @@ -841,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", @@ -875,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; @@ -885,67 +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 || standDef.Apron === false) { - 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`, - }; - } + 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`, + }; } - const stand = new Stand(standName, icao, callsign); + 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, - }); + info( + `Manually assigned stand ${standName} at ${icao} to ${callsign}, Requester: ${client}`, + { + category: "Manual Assign", + callsign: callsign, + icao: icao, + } + ); + return { action: "assign", stand: standName, @@ -970,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/services/redisService.js b/services/redisService.js index 6933e8d..81ec69c 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); @@ -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 93ce53f..b62f9dc 100644 --- a/viewer/script.js +++ b/viewer/script.js @@ -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"); } } @@ -1102,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() { @@ -1115,10 +1114,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 @@ -1198,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) => { @@ -1220,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) => { @@ -1242,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) => { @@ -1256,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) } @@ -1268,15 +1299,11 @@ 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) } // 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 +1349,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 @@ -1553,16 +1577,67 @@ function createStandPopupContent(standId) { div.className = "stand-popup-content"; div.innerHTML = "
Aircraft (${totalCount}):
`; + + if (occupiedCallsigns.length > 0) { + div.innerHTML += `Occupied (${occupiedCallsigns.length}):
Assigned (${assignedCallsigns.length}):
Occupied (${occupiedCallsigns.length}):
Assigned (${assignedCallsigns.length}):
Occupied by ${occupied.callsign}
`; } else if (assigned) { + // Non-apron assigned stand 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 by ${blocked.callsign}
`; + } } else { div.innerHTML += `Free
`; } @@ -1581,7 +1656,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) && @@ -1599,15 +1673,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.5, + weight: 2, + 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.5, + radius: stand.radius, + weight: 3, + }).bindPopup(() => { + return createStandPopupContent(stand.name); + }); + } stand.label = L.marker(stand.coords, { interactive: false, @@ -1617,7 +1734,11 @@ function loadMapData() { }), }); - stand.circle.addTo(map); + if (stand.circle) { + stand.circle.addTo(map); + } else if (stand.polygon) { + stand.polygon.addTo(map); + } }); } }) @@ -1666,7 +1787,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, { @@ -1724,16 +1845,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; } } @@ -1744,7 +1865,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; } @@ -1755,21 +1881,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(); } @@ -1779,25 +1909,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 `