diff --git a/application.properties.phase1 b/application.properties.phase1 new file mode 100644 index 0000000..569479d --- /dev/null +++ b/application.properties.phase1 @@ -0,0 +1,59 @@ +# Flight Spotlight Java Frontend Configuration +# Phase 1: Java Frontend Skeleton + Authentication + +# Server Configuration +server.port=${PORT:8080} + +# Application Name +spring.application.name=flight-spotlight-java + +# Vaadin Configuration +vaadin.servlet.production-mode=false +vaadin.whitelisted-packages=com.flightspotlight + +# OAuth2 / OIDC Configuration (User Authentication) +# These values should match the Node.js environment variables +spring.security.oauth2.client.registration.flight-passport.client-id=${CLIENT_ID} +spring.security.oauth2.client.registration.flight-passport.client-secret=${CLIENT_SECRET} +spring.security.oauth2.client.registration.flight-passport.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.flight-passport.redirect-uri=${SPOTLIGHT_BASE_URL}/login/oauth2/code/flight-passport +spring.security.oauth2.client.registration.flight-passport.scope=openid,profile + +spring.security.oauth2.client.provider.flight-passport.authorization-uri=${OIDC_DOMAIN}/authorize +spring.security.oauth2.client.provider.flight-passport.token-uri=${OIDC_DOMAIN}/token/ +spring.security.oauth2.client.provider.flight-passport.user-info-uri=${OIDC_DOMAIN}/userinfo/ +spring.security.oauth2.client.provider.flight-passport.jwk-set-uri=${OIDC_DOMAIN}/.well-known/jwks.json +spring.security.oauth2.client.provider.flight-passport.user-name-attribute=sub + +# Flight Passport M2M Token Configuration (for Flight Blender API calls) +flight.passport.m2m.client-id=${PASSPORT_BLENDER_CLIENT_ID} +flight.passport.m2m.client-secret=${PASSPORT_BLENDER_CLIENT_SECRET} +flight.passport.m2m.scope=${PASSPORT_BLENDER_SCOPE:blender.read blender.write} +flight.passport.m2m.audience=${PASSPORT_BLENDER_AUDIENCE} +flight.passport.m2m.token-url=${PASSPORT_URL}/oauth/token/ +flight.passport.m2m.token-cache-key=blender_passport_token +flight.passport.m2m.token-cache-ttl=3500 + +# Flight Blender API Configuration +flight.blender.base-url=${BLENDER_BASE_URL} +flight.blender.ping-endpoint=/ping +flight.blender.declarations-endpoint=/flight_declaration_ops/flight_declaration + +# Redis Configuration (for token caching) +# Note: REDIS_URL takes precedence if provided (format: rediss://host:port or redis://host:port) +# SSL is automatically detected from the URL scheme (rediss:// = SSL enabled) +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +# Cache Configuration +spring.cache.type=redis +spring.cache.redis.time-to-live=3500000 + +# Mapbox Configuration (for future phases) +mapbox.access-token=${MAPBOX_KEY} + +# Logging +logging.level.com.flightspotlight=DEBUG +logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.web=DEBUG diff --git a/java-frontend/.mvn/wrapper/maven-wrapper.jar b/java-frontend/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..cb28b0e Binary files /dev/null and b/java-frontend/.mvn/wrapper/maven-wrapper.jar differ diff --git a/java-frontend/.mvn/wrapper/maven-wrapper.properties b/java-frontend/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..10cf7bb --- /dev/null +++ b/java-frontend/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/java-frontend/mvnw.cmd b/java-frontend/mvnw.cmd new file mode 100644 index 0000000..2e4b889 --- /dev/null +++ b/java-frontend/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/java-frontend/pom.xml b/java-frontend/pom.xml new file mode 100644 index 0000000..bd2ee3f --- /dev/null +++ b/java-frontend/pom.xml @@ -0,0 +1,146 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.flightspotlight + flight-spotlight-java + 1.0.0-SNAPSHOT + Flight Spotlight Java Frontend + Flight Spotlight frontend rebuilt in Java using Spring Boot and Vaadin + + + 17 + 17 + 17 + 24.3.10 + UTF-8 + UTF-8 + + + + + + com.vaadin + vaadin-bom + ${vaadin.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-cache + + + + + com.vaadin + vaadin-spring-boot-starter + ${vaadin.version} + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + io.lettuce + lettuce-core + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + prepare-frontend + + + + + + + diff --git a/java-frontend/run-local.ps1 b/java-frontend/run-local.ps1 new file mode 100644 index 0000000..fc99cd1 --- /dev/null +++ b/java-frontend/run-local.ps1 @@ -0,0 +1,91 @@ +# Flight Spotlight Java Frontend - Local Run Script +# Loads .env file and runs the application + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Flight Spotlight Java Frontend" -ForegroundColor Cyan +Write-Host "Loading environment variables and starting..." -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Get the project root directory (parent of java-frontend) +$projectRoot = Split-Path -Parent $PSScriptRoot +$envFile = Join-Path $projectRoot ".env" + +# Load .env file +if (Test-Path $envFile) { + Write-Host "Loading environment variables from .env file..." -ForegroundColor Yellow + Get-Content $envFile | ForEach-Object { + if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)\s*$') { + $key = $matches[1].Trim() + $value = $matches[2].Trim() + # Remove quotes if present + if ($value -match '^"(.*)"$' -or $value -match "^'(.*)'$") { + $value = $matches[1] + } + [Environment]::SetEnvironmentVariable($key, $value, "Process") + Write-Host " Set: $key" -ForegroundColor Gray + } + } + Write-Host "✓ Environment variables loaded" -ForegroundColor Green +} else { + Write-Host "⚠ .env file not found at: $envFile" -ForegroundColor Yellow + Write-Host "Please ensure .env file exists in project root" -ForegroundColor Yellow + exit 1 +} + +# Override SPOTLIGHT_BASE_URL for local testing +$env:SPOTLIGHT_BASE_URL = "http://localhost:8080" +Write-Host " Override: SPOTLIGHT_BASE_URL = http://localhost:8080 (for local testing)" -ForegroundColor Gray + +# Clean REDIS_URL if it has quotes +if ($env:REDIS_URL) { + $env:REDIS_URL = $env:REDIS_URL.Trim('"').Trim("'") +} + +# Clean PASSPORT_BLENDER_SCOPE if it has quotes +if ($env:PASSPORT_BLENDER_SCOPE) { + $env:PASSPORT_BLENDER_SCOPE = $env:PASSPORT_BLENDER_SCOPE.Trim('"').Trim("'") +} + +Write-Host "" +Write-Host "Environment Variables Summary:" -ForegroundColor Yellow +Write-Host " CLIENT_ID: $($env:CLIENT_ID.Substring(0, [Math]::Min(20, $env:CLIENT_ID.Length)))..." -ForegroundColor Gray +Write-Host " OIDC_DOMAIN: $env:OIDC_DOMAIN" -ForegroundColor Gray +Write-Host " SPOTLIGHT_BASE_URL: $env:SPOTLIGHT_BASE_URL" -ForegroundColor Gray +Write-Host " BLENDER_BASE_URL: $env:BLENDER_BASE_URL" -ForegroundColor Gray +Write-Host " REDIS_URL: $($env:REDIS_URL.Substring(0, [Math]::Min(50, $env:REDIS_URL.Length)))..." -ForegroundColor Gray +Write-Host "" + +# Check Java +Write-Host "Checking Java..." -ForegroundColor Yellow +$javaVersion = java -version 2>&1 | Select-String "version" +if ($javaVersion) { + Write-Host "✓ $javaVersion" -ForegroundColor Green +} else { + Write-Host "✗ Java not found" -ForegroundColor Red + exit 1 +} + +# Build the project +Write-Host "" +Write-Host "Building the project..." -ForegroundColor Yellow +& .\mvnw.cmd clean compile -q +if ($LASTEXITCODE -ne 0) { + Write-Host "✗ Build failed" -ForegroundColor Red + exit 1 +} +Write-Host "✓ Build successful" -ForegroundColor Green + +# Run the application +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Starting application..." -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Application will be available at:" -ForegroundColor Yellow +Write-Host " http://localhost:8080" -ForegroundColor Green +Write-Host "" +Write-Host "Press Ctrl+C to stop the application" -ForegroundColor Yellow +Write-Host "" + +& .\mvnw.cmd spring-boot:run diff --git a/java-frontend/run-with-env.ps1 b/java-frontend/run-with-env.ps1 new file mode 100644 index 0000000..2804a4e --- /dev/null +++ b/java-frontend/run-with-env.ps1 @@ -0,0 +1,56 @@ +# Flight Spotlight Java Frontend - Run with .env file +# This script loads environment variables from .env file and starts the application + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Flight Spotlight Java - Starting..." -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Load .env file from parent directory +$envFile = Join-Path (Split-Path $PSScriptRoot -Parent) ".env" + +if (Test-Path $envFile) { + Write-Host "Loading environment variables from .env file..." -ForegroundColor Yellow + + Get-Content $envFile | ForEach-Object { + if ($_ -match '^([^#][^=]+)=(.*)$') { + $key = $matches[1].Trim() + $value = $matches[2].Trim() + + # Remove quotes if present + $value = $value -replace '^[""'']|[""'']$', '' + + # Set environment variable + [Environment]::SetEnvironmentVariable($key, $value, "Process") + Write-Host " * $key" -ForegroundColor Green + } + } + + Write-Host "" + Write-Host "Environment variables loaded successfully!" -ForegroundColor Green + Write-Host "" +} else { + Write-Host "Warning: .env file not found at: $envFile" -ForegroundColor Red + Write-Host "Please create a .env file with required environment variables." -ForegroundColor Red + exit 1 +} + +# Override SPOTLIGHT_BASE_URL for local testing +Write-Host "Setting SPOTLIGHT_BASE_URL to http://localhost:8080 for local testing..." -ForegroundColor Yellow +[Environment]::SetEnvironmentVariable("SPOTLIGHT_BASE_URL", "http://localhost:8080", "Process") +Write-Host "" + +# Add Maven to PATH +$env:PATH = "$env:USERPROFILE\.m2\wrapper\dists\apache-maven-3.9.9\977a63e90f436cd6ade95b4c0e10c20c\bin;" + $env:PATH + +# Navigate to script directory (java-frontend) +Set-Location -Path $PSScriptRoot + +# Start the application +Write-Host "Starting Spring Boot application..." -ForegroundColor Cyan +Write-Host "" +Write-Host "The application will be available at: http://localhost:8080" -ForegroundColor Green +Write-Host "Press Ctrl+C to stop the application" -ForegroundColor Yellow +Write-Host "" + +mvn spring-boot:run diff --git a/java-frontend/src/main/java/com/flightspotlight/FlightSpotlightApplication.java b/java-frontend/src/main/java/com/flightspotlight/FlightSpotlightApplication.java new file mode 100644 index 0000000..e85a97a --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/FlightSpotlightApplication.java @@ -0,0 +1,21 @@ +package com.flightspotlight; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; + +/** + * Main application class for Flight Spotlight Java Frontend. + * + * This application replaces the Node.js/Express frontend with a Java/Spring Boot/Vaadin implementation. + * + * Phase 1: Java Frontend Skeleton + Authentication + */ +@SpringBootApplication +@EnableCaching +public class FlightSpotlightApplication { + + public static void main(String[] args) { + SpringApplication.run(FlightSpotlightApplication.class, args); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/config/AppShellConfig.java b/java-frontend/src/main/java/com/flightspotlight/config/AppShellConfig.java new file mode 100644 index 0000000..4df4e56 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/config/AppShellConfig.java @@ -0,0 +1,19 @@ +package com.flightspotlight.config; + +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.Push; +import com.vaadin.flow.server.PWA; +import com.vaadin.flow.shared.communication.PushMode; + +/** + * Application shell configuration. + * + * Phase 5.2: Vaadin Push Integration + * + * Configures Vaadin Push (WebSocket) for server-initiated updates to clients. + * This enables real-time air traffic updates in the Spotlight view. + */ +@Push(PushMode.AUTOMATIC) +public class AppShellConfig implements AppShellConfigurator { + // Configuration is provided via annotations +} diff --git a/java-frontend/src/main/java/com/flightspotlight/config/OAuth2ClientConfig.java b/java-frontend/src/main/java/com/flightspotlight/config/OAuth2ClientConfig.java new file mode 100644 index 0000000..dc1f58d --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/config/OAuth2ClientConfig.java @@ -0,0 +1,26 @@ +package com.flightspotlight.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; + +/** + * OAuth2 Client configuration. + * + * This configuration is handled primarily through application.properties + * with the following properties: + * - spring.security.oauth2.client.registration.flight-passport.* + * - spring.security.oauth2.client.provider.flight-passport.* + * + * Phase 1.2: OAuth2 User Authentication + */ +@Configuration +public class OAuth2ClientConfig { + + // OAuth2 client configuration is done via application.properties + // Spring Boot auto-configures OAuth2ClientRegistrationRepository + // and OAuth2AuthorizedClientRepository based on properties + + // Additional custom configuration can be added here if needed +} diff --git a/java-frontend/src/main/java/com/flightspotlight/config/ObjectMapperConfig.java b/java-frontend/src/main/java/com/flightspotlight/config/ObjectMapperConfig.java new file mode 100644 index 0000000..57ea29c --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/config/ObjectMapperConfig.java @@ -0,0 +1,19 @@ +package com.flightspotlight.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ObjectMapper configuration for JSON parsing. + * + * Phase 1.3: M2M Token & Flight Blender API + */ +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/config/RedisConfig.java b/java-frontend/src/main/java/com/flightspotlight/config/RedisConfig.java new file mode 100644 index 0000000..8330453 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/config/RedisConfig.java @@ -0,0 +1,112 @@ +package com.flightspotlight.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.net.URI; +import java.time.Duration; + +/** + * Redis configuration for token caching. + * + * Supports both REDIS_URL format and individual host/port/password configuration. + * Token TTL is set to 3500 seconds (matching Node.js implementation). + * + * Phase 1.3: M2M Token & Flight Blender API + */ +@Configuration +@EnableCaching +public class RedisConfig { + + private static final Logger log = LoggerFactory.getLogger(RedisConfig.class); + private static final Duration TOKEN_TTL = Duration.ofSeconds(3500); + + @Value("${REDIS_URL:}") + private String redisUrl; + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + private boolean redisSsl = false; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + + // Parse REDIS_URL if provided (format: rediss://host:port or redis://host:port) + if (redisUrl != null && !redisUrl.isEmpty()) { + try { + URI uri = new URI(redisUrl); + config.setHostName(uri.getHost()); + config.setPort(uri.getPort()); + + // Check if SSL is enabled (rediss://) + if (uri.getScheme().equals("rediss")) { + redisSsl = true; + } + + // Parse password from URL if present (format: redis://:password@host:port) + if (uri.getUserInfo() != null && uri.getUserInfo().contains(":")) { + String[] userInfo = uri.getUserInfo().split(":"); + if (userInfo.length > 1) { + config.setPassword(userInfo[1]); + } + } + + log.info("Redis connection configured from REDIS_URL: {}:{}", uri.getHost(), uri.getPort()); + } catch (Exception e) { + log.warn("Failed to parse REDIS_URL, using individual properties: {}", e.getMessage()); + config.setHostName(redisHost); + config.setPort(redisPort); + if (redisPassword != null && !redisPassword.isEmpty()) { + config.setPassword(redisPassword); + } + } + } else { + // Use individual properties + config.setHostName(redisHost); + config.setPort(redisPort); + if (redisPassword != null && !redisPassword.isEmpty()) { + config.setPassword(redisPassword); + } + log.info("Redis connection configured from properties: {}:{}", redisHost, redisPort); + } + + LettuceConnectionFactory factory = new LettuceConnectionFactory(config); + factory.setValidateConnection(true); + return factory; + } + + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(TOKEN_TTL) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(cacheConfig) + .build(); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/config/RedisTemplateConfig.java b/java-frontend/src/main/java/com/flightspotlight/config/RedisTemplateConfig.java new file mode 100644 index 0000000..a4000d2 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/config/RedisTemplateConfig.java @@ -0,0 +1,30 @@ +package com.flightspotlight.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * RedisTemplate configuration for string-based operations. + * + * Used by PassportTokenService for token caching. + * + * Phase 1.3: M2M Token & Flight Blender API + */ +@Configuration +public class RedisTemplateConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/config/SecurityConfig.java b/java-frontend/src/main/java/com/flightspotlight/config/SecurityConfig.java new file mode 100644 index 0000000..0adc7e8 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.flightspotlight.config; + +import com.vaadin.flow.spring.security.VaadinWebSecurity; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.security.web.firewall.StrictHttpFirewall; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +/** + * Spring Security configuration for Flight Spotlight. + * + * Configures OAuth2 login with Flight Passport and Vaadin security. + * + * Phase 1.2: OAuth2 User Authentication + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig extends VaadinWebSecurity { + + @Override + protected void configure(HttpSecurity http) throws Exception { + // Allow public access to landing page, static resources, and OAuth2 endpoints + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/login", "/oauth2/**", "/login/oauth2/**", + "/images/**", "/css/**", "/js/**", "/favicon.ico").permitAll() + ); + + // Configure OAuth2 login before calling super + http.oauth2Login(oauth2 -> oauth2 + .defaultSuccessUrl("/authenticated", true) + ); + + // Call super to configure Vaadin security (must be called after oauth2Login) + super.configure(http); + + // Configure logout after super.configure() + http.logout(logout -> logout + .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) + .logoutSuccessUrl("/") + .invalidateHttpSession(true) + .clearAuthentication(true) + ); + } + + /** + * Configure HTTP firewall to allow semicolons in URLs (needed for jsessionid). + */ + @Bean + public HttpFirewall allowSemicolonHttpFirewall() { + StrictHttpFirewall firewall = new StrictHttpFirewall(); + firewall.setAllowSemicolon(true); + return firewall; + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/config/WebClientConfig.java b/java-frontend/src/main/java/com/flightspotlight/config/WebClientConfig.java new file mode 100644 index 0000000..ee06c0c --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/config/WebClientConfig.java @@ -0,0 +1,25 @@ +package com.flightspotlight.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * WebClient configuration for Flight Blender API calls. + * + * Phase 1.3: M2M Token & Flight Blender API + */ +@Configuration +public class WebClientConfig { + + @Bean + public WebClient.Builder webClientBuilder() { + return WebClient.builder(); + } + + @Bean + public WebClient flightBlenderWebClient(WebClient.Builder webClientBuilder) { + // Base URL will be set per request using application.properties value + return webClientBuilder.build(); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/service/FlightBlenderService.java b/java-frontend/src/main/java/com/flightspotlight/service/FlightBlenderService.java new file mode 100644 index 0000000..9bf633f --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/service/FlightBlenderService.java @@ -0,0 +1,298 @@ +package com.flightspotlight.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flightspotlight.model.FlightDeclaration; +import com.flightspotlight.model.FlightDeclarationRequest; +import com.flightspotlight.model.FlightDeclarationResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +/** + * Service for calling Flight Blender API endpoints. + * + * Uses M2M OAuth2 tokens from Flight Passport for authentication. + * + * Phase 1.3: M2M Token & Flight Blender API + */ +@Service +public class FlightBlenderService { + + private static final Logger log = LoggerFactory.getLogger(FlightBlenderService.class); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + + private final WebClient webClient; + private final PassportTokenService passportTokenService; + private final ObjectMapper objectMapper; + + @Value("${flight.blender.base-url}") + private String baseUrl; + + @Value("${flight.blender.ping-endpoint}") + private String pingEndpoint; + + public FlightBlenderService(WebClient.Builder webClientBuilder, + PassportTokenService passportTokenService, + ObjectMapper objectMapper) { + this.webClient = webClientBuilder + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + this.passportTokenService = passportTokenService; + this.objectMapper = objectMapper; + } + + /** + * Health check endpoint - verifies Flight Blender API is accessible. + * + * @return Response body as string + */ + public Mono ping() { + log.debug("Calling Flight Blender ping endpoint"); + + return passportTokenService.getPassportToken() + .flatMap(token -> webClient.get() + .uri(pingEndpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .doOnSuccess(response -> log.debug("Flight Blender ping successful")) + .doOnError(error -> log.error("Flight Blender ping failed: {}", error.getMessage()))) + .onErrorResume(error -> { + log.error("Error calling Flight Blender ping endpoint", error); + return Mono.error(new RuntimeException("Failed to call Flight Blender API", error)); + }); + } + + /** + * Get flight declarations from Flight Blender. + * + * @param startDate Start date (YYYY-MM-DD format) + * @param endDate End date (YYYY-MM-DD format) + * @param page Page number (default: 1) + * @return FlightDeclarationResponse with list of declarations + */ + public Mono getFlightDeclarations(String startDate, String endDate, Integer page) { + log.debug("Calling Flight Blender getFlightDeclarations: startDate={}, endDate={}, page={}", + startDate, endDate, page); + + String endpoint = String.format("%s?start_date=%s&end_date=%s&page=%d", + "/flight_declaration_ops/flight_declaration", startDate, endDate, page); + + return passportTokenService.getPassportToken() + .flatMap(token -> webClient.get() + .uri(endpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .map(response -> { + try { + FlightDeclarationResponse declarationResponse = + objectMapper.readValue(response, FlightDeclarationResponse.class); + log.debug("Flight declarations retrieved successfully: {} results", + declarationResponse.getResults() != null ? declarationResponse.getResults().size() : 0); + return declarationResponse; + } catch (Exception e) { + log.error("Error parsing flight declarations response: {}", e.getMessage()); + throw new RuntimeException("Failed to parse flight declarations", e); + } + }) + .doOnError(error -> log.error("Failed to retrieve flight declarations: {}", error.getMessage()))) + .onErrorResume(error -> { + log.error("Error calling Flight Blender getFlightDeclarations endpoint", error); + return Mono.error(new RuntimeException("Failed to retrieve flight declarations", error)); + }); + } + + /** + * Submit flight declaration to Flight Blender. + * + * Phase 3.2: Form Submission + * + * @param request Flight declaration request + * @return FlightDeclaration response + */ + public Mono submitFlightDeclaration(FlightDeclarationRequest request) { + log.debug("Submitting flight declaration: operator={}, type={}", + request.getOriginatingParty(), request.getTypeOfOperation()); + + String endpoint = "/flight_declaration_ops/set_flight_declaration"; + + return passportTokenService.getPassportToken() + .flatMap(token -> webClient.post() + .uri(endpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .bodyValue(request) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .map(response -> { + try { + FlightDeclaration declaration = + objectMapper.readValue(response, FlightDeclaration.class); + log.debug("Flight declaration submitted successfully: id={}", declaration.getId()); + return declaration; + } catch (Exception e) { + log.error("Error parsing flight declaration response: {}", e.getMessage()); + throw new RuntimeException("Failed to parse flight declaration response", e); + } + }) + .doOnError(error -> log.error("Failed to submit flight declaration: {}", error.getMessage()))) + .onErrorResume(error -> { + log.error("Error calling Flight Blender submitFlightDeclaration endpoint", error); + return Mono.error(new RuntimeException("Failed to submit flight declaration", error)); + }); + } + + /** + * Get single flight declaration by ID. + * + * Phase 3.3: Operation Status View + * + * @param uuid Flight declaration UUID + * @return FlightDeclaration + */ + public Mono getFlightDeclarationById(String uuid) { + log.debug("Getting flight declaration by ID: {}", uuid); + + String endpoint = String.format("/flight_declaration_ops/flight_declaration/%s", uuid); + + return passportTokenService.getPassportToken() + .flatMap(token -> webClient.get() + .uri(endpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .map(response -> { + try { + FlightDeclaration declaration = + objectMapper.readValue(response, FlightDeclaration.class); + log.debug("Flight declaration retrieved successfully: id={}", declaration.getId()); + return declaration; + } catch (Exception e) { + log.error("Error parsing flight declaration response: {}", e.getMessage()); + throw new RuntimeException("Failed to parse flight declaration", e); + } + }) + .doOnError(error -> log.error("Failed to retrieve flight declaration: {}", error.getMessage()))) + .onErrorResume(error -> { + log.error("Error calling Flight Blender getFlightDeclarationById endpoint", error); + return Mono.error(new RuntimeException("Failed to retrieve flight declaration", error)); + }); + } + + /** + * Approve or reject a flight declaration. + * + * Phase 4.1: Approval/Rejection Actions + * + * Based on Node.js routes/spotlight_noticeboard.js lines 450-488 + * + * @param uuid Flight declaration UUID + * @param isApproved true to approve, false to reject + * @param notes Reviewer notes + * @return Updated FlightDeclaration + */ + public Mono reviewFlightDeclaration(String uuid, boolean isApproved, String notes) { + log.debug("Reviewing flight declaration: uuid={}, approved={}", uuid, isApproved); + + String endpoint = String.format("/flight_declaration_ops/flight_declaration_review/%s", uuid); + + // Build request body + var requestBody = new java.util.HashMap(); + requestBody.put("is_approved", isApproved ? "1" : "0"); + if (notes != null && !notes.isEmpty()) { + requestBody.put("notes", notes); + } + + return passportTokenService.getPassportToken() + .flatMap(token -> webClient.put() + .uri(endpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .map(response -> { + try { + FlightDeclaration declaration = + objectMapper.readValue(response, FlightDeclaration.class); + log.debug("Flight declaration reviewed successfully: id={}, approved={}", + declaration.getId(), isApproved); + return declaration; + } catch (Exception e) { + log.error("Error parsing flight declaration review response: {}", e.getMessage()); + throw new RuntimeException("Failed to parse review response", e); + } + }) + .doOnError(error -> log.error("Failed to review flight declaration: {}", error.getMessage()))) + .onErrorResume(error -> { + log.error("Error calling Flight Blender reviewFlightDeclaration endpoint", error); + return Mono.error(new RuntimeException("Failed to review flight declaration", error)); + }); + } + + /** + * Update the state of a flight declaration. + * + * Phase 4.2: State Updates + * + * Based on Node.js routes/spotlight_noticeboard.js lines 490-528 + * + * @param uuid Flight declaration UUID + * @param newState New state (Accepted, Activated, Closed, Contingent, NonConforming) + * @param notes Optional notes about the state change + * @return Updated FlightDeclaration + */ + public Mono updateFlightDeclarationState(String uuid, String newState, String notes) { + log.debug("Updating flight declaration state: uuid={}, newState={}", uuid, newState); + + String endpoint = String.format("/flight_declaration_ops/flight_declaration_state/%s", uuid); + + // Build request body + var requestBody = new java.util.HashMap(); + requestBody.put("state", newState); + if (notes != null && !notes.isEmpty()) { + requestBody.put("notes", notes); + } + + return passportTokenService.getPassportToken() + .flatMap(token -> webClient.put() + .uri(endpoint) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .map(response -> { + try { + FlightDeclaration declaration = + objectMapper.readValue(response, FlightDeclaration.class); + log.debug("Flight declaration state updated successfully: id={}, state={}", + declaration.getId(), newState); + return declaration; + } catch (Exception e) { + log.error("Error parsing flight declaration state update response: {}", e.getMessage()); + throw new RuntimeException("Failed to parse state update response", e); + } + }) + .doOnError(error -> log.error("Failed to update flight declaration state: {}", error.getMessage()))) + .onErrorResume(error -> { + log.error("Error calling Flight Blender updateFlightDeclarationState endpoint", error); + return Mono.error(new RuntimeException("Failed to update flight declaration state", error)); + }); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/service/PassportTokenService.java b/java-frontend/src/main/java/com/flightspotlight/service/PassportTokenService.java new file mode 100644 index 0000000..c1db4d0 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/service/PassportTokenService.java @@ -0,0 +1,125 @@ +package com.flightspotlight.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * Service for managing M2M (Machine-to-Machine) OAuth2 tokens from Flight Passport. + * + * Implements OAuth2 Client Credentials flow to obtain tokens for Flight Blender API calls. + * Tokens are cached in Redis with 3500s TTL (matching Node.js implementation). + * + * Phase 1.3: M2M Token & Flight Blender API + */ +@Service +public class PassportTokenService { + + private static final Logger log = LoggerFactory.getLogger(PassportTokenService.class); + private static final String CACHE_KEY = "blender_passport_token"; + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + private final ValueOperations redisOps; + + @Value("${flight.passport.m2m.client-id}") + private String clientId; + + @Value("${flight.passport.m2m.client-secret}") + private String clientSecret; + + @Value("${flight.passport.m2m.scope}") + private String scope; + + @Value("${flight.passport.m2m.audience}") + private String audience; + + @Value("${flight.passport.m2m.token-url}") + private String tokenUrl; + + @Value("${PASSPORT_URL}") + private String passportBaseUrl; + + @Value("${flight.passport.m2m.token-cache-ttl:3500}") + private long tokenCacheTtl; + + public PassportTokenService(WebClient.Builder webClientBuilder, + ObjectMapper objectMapper, + RedisTemplate redisTemplate) { + this.webClient = webClientBuilder + .baseUrl(passportBaseUrl) + .build(); + this.objectMapper = objectMapper; + this.redisTemplate = redisTemplate; + this.redisOps = redisTemplate.opsForValue(); + } + + /** + * Get M2M access token from Flight Passport. + * Uses Client Credentials flow (OAuth2). + * Token is cached in Redis with 3500s TTL (matching Node.js implementation). + * + * @return Access token string + */ + public Mono getPassportToken() { + // Check Redis cache first + String cachedToken = redisOps.get(CACHE_KEY); + if (cachedToken != null) { + log.debug("Using cached M2M token from Redis"); + return Mono.just(cachedToken); + } + + log.debug("Requesting new M2M token from Flight Passport"); + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + formData.add("grant_type", "client_credentials"); + formData.add("scope", scope); + formData.add("audience", audience); + + return webClient.post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .bodyToMono(String.class) + .timeout(REQUEST_TIMEOUT) + .map(response -> { + try { + JsonNode jsonNode = objectMapper.readTree(response); + String accessToken = jsonNode.get("access_token").asText(); + + // Cache token in Redis with TTL (matching Node.js: 3500s) + redisOps.set(CACHE_KEY, accessToken, tokenCacheTtl, TimeUnit.SECONDS); + log.debug("Successfully obtained and cached M2M token (TTL: {}s)", tokenCacheTtl); + + return accessToken; + } catch (Exception e) { + log.error("Error parsing token response: {}", e.getMessage()); + throw new RuntimeException("Failed to parse token response", e); + } + }) + .doOnError(error -> log.error("Error obtaining M2M token: {}", error.getMessage())) + .onErrorResume(error -> { + log.error("Failed to obtain M2M token from Flight Passport", error); + return Mono.error(new RuntimeException("Failed to obtain M2M token", error)); + }); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/AuthenticatedView.java b/java-frontend/src/main/java/com/flightspotlight/view/AuthenticatedView.java new file mode 100644 index 0000000..7d9301f --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/AuthenticatedView.java @@ -0,0 +1,83 @@ +package com.flightspotlight.view; + +import com.flightspotlight.service.FlightBlenderService; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.H1; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import reactor.core.publisher.Mono; + +/** + * Authenticated landing page view. + * + * Displays user information after successful OAuth2 login. + * Includes Flight Blender API integration (Phase 1.3). + * + * Phase 1.2: OAuth2 User Authentication + * Phase 1.3: M2M Token & Flight Blender API + */ +@Route("authenticated") +@AnonymousAllowed // Will be secured by Spring Security +public class AuthenticatedView extends VerticalLayout { + + private final FlightBlenderService flightBlenderService; + private final Paragraph apiStatus; + + public AuthenticatedView(@AuthenticationPrincipal OidcUser user, + FlightBlenderService flightBlenderService) { + this.flightBlenderService = flightBlenderService; + + setSizeFull(); + setAlignItems(Alignment.CENTER); + setJustifyContentMode(JustifyContentMode.CENTER); + + H1 title = new H1("Flight Spotlight"); + H2 welcome = new H2("Welcome!"); + + Paragraph userInfo = new Paragraph(); + if (user != null) { + String email = user.getEmail(); + String name = user.getFullName(); + + userInfo.setText( + String.format("Logged in as: %s (%s)", + name != null ? name : "User", + email != null ? email : "No email") + ); + } else { + userInfo.setText("Not authenticated"); + } + + Paragraph status = new Paragraph("Phase 1: Java Frontend Skeleton + Authentication"); + + // Flight Blender API integration (Phase 1.3) + apiStatus = new Paragraph("Flight Blender API: Not tested yet"); + Button testApiButton = new Button("Test Flight Blender API", e -> testFlightBlenderApi()); + testApiButton.addClassName("primary"); + + add(title, welcome, userInfo, status, apiStatus, testApiButton); + } + + private void testFlightBlenderApi() { + apiStatus.setText("Testing Flight Blender API..."); + + flightBlenderService.ping() + .subscribe( + response -> { + getUI().ifPresent(ui -> ui.access(() -> + apiStatus.setText("Flight Blender API: ✅ Success! Response: " + response) + )); + }, + error -> { + getUI().ifPresent(ui -> ui.access(() -> + apiStatus.setText("Flight Blender API: ❌ Error: " + error.getMessage()) + )); + } + ); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/HomeView.java b/java-frontend/src/main/java/com/flightspotlight/view/HomeView.java new file mode 100644 index 0000000..3bede71 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/HomeView.java @@ -0,0 +1,68 @@ +package com.flightspotlight.view; + +import com.vaadin.flow.component.Html; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.dependency.StyleSheet; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; + +/** + * Landing page (home page) - accessible without authentication. + * Matches the design from the Node.js version at flight-spotlight.onrender.com + * + * Phase 1.2: OAuth2 User Authentication + */ +@Route("") +@AnonymousAllowed +@StyleSheet("https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css") +public class HomeView extends Div { + + public HomeView() { + addClassName("h-100"); + + // Create the HTML structure matching the original home.ejs + String htmlContent = """ +
+ + + + +
+
+
+ Flight Spotlight Logo +
+
+

Open airspace connectivity

+

With Flight Spotlight, you can see flights and drones in an airspace real-time and in compliance with standards. You can connect to a U-Space / UTM airspace for Network Remote ID information and live air-traffic. You can also integrate other data sources such as ADS-B feeds for showing non-drone traffic. In addition, it can display standards-compliant geofences for an area. Finally, it is compatible with different data sources to display flight information e.g. drone registration data, flight declarations and permissioning.

+
+
+ +
+
+
+
+ + +
+
+
+

+ See homepage for more information. © Openskies Aerial Technology Limited. +

+
+
+
+
+ """; + + add(new Html(htmlContent)); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/LaunchpadView.java b/java-frontend/src/main/java/com/flightspotlight/view/LaunchpadView.java new file mode 100644 index 0000000..c3ab9c6 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/LaunchpadView.java @@ -0,0 +1,502 @@ +package com.flightspotlight.view; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flightspotlight.component.GeoJsonUpload; +import com.flightspotlight.component.MapboxMap; +import com.flightspotlight.model.FlightDeclaration; +import com.flightspotlight.model.FlightDeclarationRequest; +import com.flightspotlight.service.FlightBlenderService; +import com.flightspotlight.validator.GeoJsonValidator; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.H6; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * Launchpad view - Flight declaration submission form. + * + * Phase 3.1: Launchpad Form + */ +@Route("launchpad") +@AnonymousAllowed +public class LaunchpadView extends VerticalLayout { + + private final FlightBlenderService flightBlenderService; + private final GeoJsonValidator geoJsonValidator; + private final ObjectMapper objectMapper; + private final String mapboxAccessToken; + private final String operators; + private final Integer defaultApproved; + + // Form fields + private final GeoJsonUpload geoJsonUpload; + private final TextField operatorNameField; + private final ComboBox operatorComboBox; + private final IntegerField altitudeField; + private final DatePicker operationDatePicker; + private final ComboBox startTimeComboBox; + private final ComboBox endTimeComboBox; + private final ComboBox operationTypeComboBox; + private final MapboxMap previewMap; + private final Button submitButton; + private final Paragraph statusMessage; + + private OidcUser currentUser; + + public LaunchpadView(FlightBlenderService flightBlenderService, + GeoJsonValidator geoJsonValidator, + ObjectMapper objectMapper, + @Value("${mapbox.access-token}") String mapboxAccessToken, + @Value("${operators:}") String operators, + @Value("${default.approved:0}") Integer defaultApproved, + @AuthenticationPrincipal OidcUser user) { + this.flightBlenderService = flightBlenderService; + this.geoJsonValidator = geoJsonValidator; + this.objectMapper = objectMapper; + this.mapboxAccessToken = mapboxAccessToken; + this.operators = operators; + this.defaultApproved = defaultApproved; + this.currentUser = user; + + setSizeFull(); + setPadding(true); + setSpacing(true); + + // Title + H2 title = new H2("Submit a Flight Plan"); + Paragraph description = new Paragraph("Use this form to submit a Flight Plan into the UTM system."); + + // Status message + statusMessage = new Paragraph(); + statusMessage.setVisible(false); + + // GeoJSON upload section + geoJsonUpload = new GeoJsonUpload(geoJsonValidator); + geoJsonUpload.setOnValidGeoJson(geoJson -> updatePreviewMap(geoJson)); + + // Operator name field or combo box + if (operators == null || operators.trim().isEmpty()) { + operatorNameField = new TextField("Operator Name"); + operatorNameField.setPlaceholder("e.g. Aerobridge Drones F521"); + operatorNameField.setRequiredIndicatorVisible(true); + operatorNameField.setWidth("100%"); + operatorComboBox = null; + } else { + operatorComboBox = new ComboBox<>("Operator Name"); + List operatorList = List.of(operators.split(",")); + operatorComboBox.setItems(operatorList); + operatorComboBox.setValue(operatorList.get(0)); + operatorComboBox.setRequiredIndicatorVisible(true); + operatorComboBox.setWidth("100%"); + operatorNameField = null; + } + + // Altitude field + altitudeField = new IntegerField("Altitude (meters AGL)"); + altitudeField.setPlaceholder("e.g. 100"); + altitudeField.setRequiredIndicatorVisible(true); + altitudeField.setMin(0); + altitudeField.setMax(4000); + altitudeField.setHelperText("Altitude in meters above ground level (0-4000)"); + altitudeField.setWidth("100%"); + + // Operation date picker + operationDatePicker = new DatePicker("Operation Date"); + operationDatePicker.setRequiredIndicatorVisible(true); + operationDatePicker.setMin(LocalDate.now()); + operationDatePicker.setMax(LocalDate.now().plusDays(7)); + operationDatePicker.setValue(LocalDate.now()); + operationDatePicker.setWidth("100%"); + + // Time slots + startTimeComboBox = new ComboBox<>("Intended Start Time"); + startTimeComboBox.setItems(generateTimeSlots()); + startTimeComboBox.setItemLabelGenerator(TimeSlot::getLabel); + startTimeComboBox.setRequiredIndicatorVisible(true); + startTimeComboBox.setWidth("100%"); + startTimeComboBox.addValueChangeListener(event -> { + if (event.getValue() != null) { + updateEndTimeOptions(event.getValue()); + } + }); + + endTimeComboBox = new ComboBox<>("Intended End Time"); + endTimeComboBox.setItems(generateTimeSlots()); + endTimeComboBox.setItemLabelGenerator(TimeSlot::getLabel); + endTimeComboBox.setRequiredIndicatorVisible(true); + endTimeComboBox.setWidth("100%"); + + // Operation type + operationTypeComboBox = new ComboBox<>("Operation Type"); + operationTypeComboBox.setItems(OperationType.values()); + operationTypeComboBox.setItemLabelGenerator(OperationType::getLabel); + operationTypeComboBox.setValue(OperationType.BVLOS); + operationTypeComboBox.setRequiredIndicatorVisible(true); + operationTypeComboBox.setWidth("100%"); + + // Preview map + previewMap = new MapboxMap(); + previewMap.setAccessToken(mapboxAccessToken); + previewMap.setCenter(-79.378161, 43.659752); // Toronto coordinates + previewMap.setZoom(2.0); + previewMap.setHeight("400px"); + previewMap.setWidth("100%"); + + // Submit button + submitButton = new Button("Submit Flight Plan"); + submitButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + submitButton.addClickListener(event -> handleSubmit()); + + // Layout assembly + HorizontalLayout timeLayout = new HorizontalLayout(startTimeComboBox, endTimeComboBox); + timeLayout.setWidth("100%"); + + VerticalLayout formLayout = new VerticalLayout( + geoJsonUpload, + operatorNameField != null ? operatorNameField : operatorComboBox, + altitudeField, + operationDatePicker, + timeLayout, + operationTypeComboBox, + submitButton, + statusMessage + ); + formLayout.setWidth("60%"); + + VerticalLayout mapPreviewLayout = new VerticalLayout( + new H6("Flight Plan Preview"), + previewMap + ); + mapPreviewLayout.setWidth("100%"); + + add(title, description, formLayout, mapPreviewLayout); + } + + /** + * Generate time slots from 3:00 AM to 10:00 PM in 15-minute intervals + */ + private List generateTimeSlots() { + List timeSlots = new ArrayList<>(); + + // 180 minutes = 3:00 AM, 1320 minutes = 10:00 PM + for (int minutes = 180; minutes <= 1320; minutes += 15) { + int hours = minutes / 60; + int mins = minutes % 60; + timeSlots.add(new TimeSlot(minutes, hours, mins)); + } + + return timeSlots; + } + + /** + * Update end time options based on selected start time + */ + private void updateEndTimeOptions(TimeSlot startTime) { + List allTimeSlots = generateTimeSlots(); + List validEndTimes = allTimeSlots.stream() + .filter(slot -> slot.getMinutes() > startTime.getMinutes()) + .toList(); + + endTimeComboBox.setItems(validEndTimes); + + // Select first valid end time + if (!validEndTimes.isEmpty() && endTimeComboBox.getValue() == null) { + endTimeComboBox.setValue(validEndTimes.get(0)); + } + } + + /** + * Update preview map with GeoJSON + */ + private void updatePreviewMap(String geoJson) { + try { + previewMap.clearGeoJson(); + previewMap.addGeoJson(geoJson); + } catch (Exception e) { + showError("Failed to update map preview: " + e.getMessage()); + } + } + + /** + * Handle form submission + */ + private void handleSubmit() { + // Validate form + List validationErrors = validateForm(); + if (!validationErrors.isEmpty()) { + showError("Please fix the following errors: " + String.join(", ", validationErrors)); + return; + } + + try { + // Build request + FlightDeclarationRequest request = buildFlightDeclarationRequest(); + + // Submit to Flight Blender + statusMessage.setText("Submitting flight declaration..."); + statusMessage.setVisible(true); + + flightBlenderService.submitFlightDeclaration(request) + .subscribe( + declaration -> { + // Success + getUI().ifPresent(ui -> ui.access(() -> { + showSuccess("Flight declaration submitted successfully!"); + + // Navigate to operation status page + ui.navigate("launchpad/operation-status/" + declaration.getId()); + })); + }, + error -> { + // Error + getUI().ifPresent(ui -> ui.access(() -> { + showError("Failed to submit flight declaration: " + error.getMessage()); + })); + } + ); + + } catch (Exception e) { + showError("Error submitting flight declaration: " + e.getMessage()); + } + } + + /** + * Validate form fields + */ + private List validateForm() { + List errors = new ArrayList<>(); + + // GeoJSON validation + if (!geoJsonUpload.isValid()) { + errors.add("Valid GeoJSON is required"); + } + + // Operator name validation + String operatorName = getOperatorName(); + if (operatorName == null || operatorName.trim().length() < 5 || operatorName.trim().length() > 50) { + errors.add("Operator name must be between 5 and 50 characters"); + } + + // Altitude validation + if (altitudeField.getValue() == null || altitudeField.getValue() < 0 || altitudeField.getValue() > 4000) { + errors.add("Altitude must be between 0 and 4000 meters"); + } + + // Date validation + if (operationDatePicker.getValue() == null) { + errors.add("Operation date is required"); + } + + // Time validation + if (startTimeComboBox.getValue() == null) { + errors.add("Start time is required"); + } + if (endTimeComboBox.getValue() == null) { + errors.add("End time is required"); + } + if (startTimeComboBox.getValue() != null && endTimeComboBox.getValue() != null) { + if (endTimeComboBox.getValue().getMinutes() <= startTimeComboBox.getValue().getMinutes()) { + errors.add("End time must be after start time"); + } + } + + // Operation type validation + if (operationTypeComboBox.getValue() == null) { + errors.add("Operation type is required"); + } + + return errors; + } + + /** + * Build Flight Declaration Request from form data + */ + private FlightDeclarationRequest buildFlightDeclarationRequest() throws Exception { + FlightDeclarationRequest request = new FlightDeclarationRequest(); + + // Date/time + LocalDate opDate = operationDatePicker.getValue(); + TimeSlot startTime = startTimeComboBox.getValue(); + TimeSlot endTime = endTimeComboBox.getValue(); + + LocalDateTime startDateTime = LocalDateTime.of(opDate, LocalTime.of(startTime.getHours(), startTime.getMins())); + LocalDateTime endDateTime = LocalDateTime.of(opDate, LocalTime.of(endTime.getHours(), endTime.getMins())); + + request.setStartDatetime(startDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + request.setEndDatetime(endDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + + // Operator and submission info + request.setOriginatingParty(getOperatorName()); + request.setSubmittedBy(currentUser != null ? currentUser.getEmail() : "unknown@example.com"); + + // Aircraft ID (default from Node.js code) + request.setAircraftId("83a5a039-8fa0-4780-bfae-ee7ba458af0c"); + + // Operation type + request.setTypeOfOperation(operationTypeComboBox.getValue().getValue()); + + // Approval status + request.setIsApproved(defaultApproved); + + // GeoJSON with altitude + String geoJsonString = geoJsonUpload.getGeoJson(); + JsonNode geoJsonNode = objectMapper.readTree(geoJsonString); + + FlightDeclarationRequest.GeoJsonWithAltitude geoJsonWithAltitude = + addAltitudeToGeoJson(geoJsonNode, altitudeField.getValue()); + + request.setFlightDeclarationGeoJson(geoJsonWithAltitude); + + return request; + } + + /** + * Add altitude properties to GeoJSON features + */ + private FlightDeclarationRequest.GeoJsonWithAltitude addAltitudeToGeoJson(JsonNode geoJson, Integer altitude) { + FlightDeclarationRequest.GeoJsonWithAltitude result = new FlightDeclarationRequest.GeoJsonWithAltitude(); + result.setType("FeatureCollection"); + + JsonNode featuresNode = geoJson.get("features"); + if (featuresNode != null && featuresNode.isArray()) { + List features = new ArrayList<>(); + + for (JsonNode featureNode : featuresNode) { + FlightDeclarationRequest.Feature feature = new FlightDeclarationRequest.Feature(); + feature.setType("Feature"); + feature.setGeometry(featureNode.get("geometry")); + + // Add altitude properties + FlightDeclarationRequest.FeatureProperties properties = new FlightDeclarationRequest.FeatureProperties(); + properties.setMinAltitude(new FlightDeclarationRequest.Altitude(altitude)); + properties.setMaxAltitude(new FlightDeclarationRequest.Altitude(altitude)); + feature.setProperties(properties); + + features.add(feature); + } + + result.setFeatures(features.toArray(new FlightDeclarationRequest.Feature[0])); + } + + return result; + } + + /** + * Get operator name from field or combo box + */ + private String getOperatorName() { + if (operatorNameField != null) { + return operatorNameField.getValue(); + } else if (operatorComboBox != null) { + return operatorComboBox.getValue(); + } + return null; + } + + /** + * Show error message + */ + private void showError(String message) { + Notification notification = Notification.show(message, 5000, Notification.Position.MIDDLE); + notification.addThemeVariants(NotificationVariant.LUMO_ERROR); + + statusMessage.setText(message); + statusMessage.getStyle().set("color", "red"); + statusMessage.setVisible(true); + } + + /** + * Show success message + */ + private void showSuccess(String message) { + Notification notification = Notification.show(message, 5000, Notification.Position.MIDDLE); + notification.addThemeVariants(NotificationVariant.LUMO_SUCCESS); + + statusMessage.setText(message); + statusMessage.getStyle().set("color", "green"); + statusMessage.setVisible(true); + } + + // Inner classes + + /** + * Time slot representation + */ + private static class TimeSlot { + private final int minutes; // Total minutes from midnight + private final int hours; + private final int mins; + + public TimeSlot(int minutes, int hours, int mins) { + this.minutes = minutes; + this.hours = hours; + this.mins = mins; + } + + public int getMinutes() { + return minutes; + } + + public int getHours() { + return hours; + } + + public int getMins() { + return mins; + } + + public String getLabel() { + String ampm = hours < 12 ? "AM" : "PM"; + int displayHours = hours % 12; + if (displayHours == 0) displayHours = 12; + return String.format("%d:%02d %s", displayHours, mins, ampm); + } + } + + /** + * Operation type enum + */ + private enum OperationType { + VLOS(1, "VLOS"), + BVLOS(2, "BVLOS"), + CREWED(3, "Crewed"); + + private final int value; + private final String label; + + OperationType(int value, String label) { + this.value = value; + this.label = label; + } + + public int getValue() { + return value; + } + + public String getLabel() { + return label; + } + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardGlobeView.java b/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardGlobeView.java new file mode 100644 index 0000000..e23b746 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardGlobeView.java @@ -0,0 +1,145 @@ +package com.flightspotlight.view; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flightspotlight.component.CesiumGlobe; +import com.flightspotlight.model.FlightDeclaration; +import com.flightspotlight.model.FlightDeclarationResponse; +import com.flightspotlight.service.FlightBlenderService; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Noticeboard globe view - displays flight declarations on a 3D Cesium globe. + * + * Phase 2.3: Noticeboard Globe View (Cesium) + */ +@Route("noticeboard/globe") +@AnonymousAllowed +public class NoticeboardGlobeView extends VerticalLayout { + + private final FlightBlenderService flightBlenderService; + private final String mapboxAccessToken; + private final ObjectMapper objectMapper; + private final DatePicker startDatePicker; + private final DatePicker endDatePicker; + private final Button searchButton; + private final CesiumGlobe cesiumGlobe; + private final Paragraph statusMessage; + + public NoticeboardGlobeView(FlightBlenderService flightBlenderService, + @Value("${mapbox.access-token}") String mapboxAccessToken, + ObjectMapper objectMapper, + @AuthenticationPrincipal OidcUser user) { + this.flightBlenderService = flightBlenderService; + this.mapboxAccessToken = mapboxAccessToken; + this.objectMapper = objectMapper; + + setSizeFull(); + setPadding(true); + setSpacing(true); + + // Title + H2 title = new H2("Flight Noticeboard - Globe View"); + add(title); + + // Date range picker + startDatePicker = new DatePicker("Start Date"); + endDatePicker = new DatePicker("End Date"); + searchButton = new Button("Show Schedule", e -> searchDeclarations()); + searchButton.addClassName("primary"); + + HorizontalLayout dateLayout = new HorizontalLayout(startDatePicker, endDatePicker, searchButton); + dateLayout.setAlignItems(Alignment.END); + dateLayout.setSpacing(true); + add(dateLayout); + + // Status message + statusMessage = new Paragraph(); + statusMessage.setVisible(false); + add(statusMessage); + + // Cesium globe + cesiumGlobe = new CesiumGlobe(); + cesiumGlobe.setMapboxAccessToken(mapboxAccessToken); + cesiumGlobe.setWidthFull(); + cesiumGlobe.setHeight("600px"); + add(cesiumGlobe); + } + + private void searchDeclarations() { + LocalDate startDate = startDatePicker.getValue(); + LocalDate endDate = endDatePicker.getValue(); + + if (startDate == null || endDate == null) { + statusMessage.setText("Please select both start and end dates."); + statusMessage.setVisible(true); + return; + } + + if (startDate.isAfter(endDate)) { + statusMessage.setText("Start date must be before end date."); + statusMessage.setVisible(true); + return; + } + + statusMessage.setText("Loading flight declarations..."); + statusMessage.setVisible(true); + cesiumGlobe.clearGeoJson(); + + String startDateStr = startDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + String endDateStr = endDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + + flightBlenderService.getFlightDeclarations(startDateStr, endDateStr, 1) + .subscribe( + response -> { + getUI().ifPresent(ui -> ui.access(() -> { + List declarations = response.getResults(); + if (declarations == null || declarations.isEmpty()) { + statusMessage.setText("No flight declarations found for the selected date range."); + statusMessage.setVisible(true); + return; + } + + statusMessage.setText( + String.format("Found %d flight declaration(s)", declarations.size()) + ); + statusMessage.setVisible(true); + + // Add GeoJSON from each declaration to globe + declarations.forEach(declaration -> { + if (declaration.getFlightDeclarationGeoJson() != null) { + try { + String geoJson = objectMapper.writeValueAsString( + declaration.getFlightDeclarationGeoJson() + ); + cesiumGlobe.addGeoJson(geoJson); + } catch (Exception e) { + // Skip invalid GeoJSON + } + } + }); + })); + }, + error -> { + getUI().ifPresent(ui -> ui.access(() -> { + statusMessage.setText("Error: " + error.getMessage()); + statusMessage.setVisible(true); + })); + } + ); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardMapView.java b/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardMapView.java new file mode 100644 index 0000000..8dcf0ea --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardMapView.java @@ -0,0 +1,147 @@ +package com.flightspotlight.view; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flightspotlight.component.MapboxMap; +import com.flightspotlight.model.FlightDeclaration; +import com.flightspotlight.model.FlightDeclarationResponse; +import com.flightspotlight.service.FlightBlenderService; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Noticeboard map view - displays flight declarations on a 2D Mapbox map. + * + * Phase 2.2: Noticeboard Map View (Mapbox) + */ +@Route("noticeboard/map") +@AnonymousAllowed +public class NoticeboardMapView extends VerticalLayout { + + private final FlightBlenderService flightBlenderService; + private final String mapboxAccessToken; + private final ObjectMapper objectMapper; + private final DatePicker startDatePicker; + private final DatePicker endDatePicker; + private final Button searchButton; + private final MapboxMap mapboxMap; + private final Paragraph statusMessage; + + public NoticeboardMapView(FlightBlenderService flightBlenderService, + @Value("${mapbox.access-token}") String mapboxAccessToken, + ObjectMapper objectMapper, + @AuthenticationPrincipal OidcUser user) { + this.flightBlenderService = flightBlenderService; + this.mapboxAccessToken = mapboxAccessToken; + this.objectMapper = objectMapper; + + setSizeFull(); + setPadding(true); + setSpacing(true); + + // Title + H2 title = new H2("Flight Noticeboard - Map View"); + add(title); + + // Date range picker + startDatePicker = new DatePicker("Start Date"); + endDatePicker = new DatePicker("End Date"); + searchButton = new Button("Show Schedule", e -> searchDeclarations()); + searchButton.addClassName("primary"); + + HorizontalLayout dateLayout = new HorizontalLayout(startDatePicker, endDatePicker, searchButton); + dateLayout.setAlignItems(Alignment.END); + dateLayout.setSpacing(true); + add(dateLayout); + + // Status message + statusMessage = new Paragraph(); + statusMessage.setVisible(false); + add(statusMessage); + + // Mapbox map + mapboxMap = new MapboxMap(); + mapboxMap.setAccessToken(mapboxAccessToken); + mapboxMap.setCenter(-79.378161, 43.659752); // Default center + mapboxMap.setZoom(2); + mapboxMap.setWidthFull(); + mapboxMap.setHeight("600px"); + add(mapboxMap); + } + + private void searchDeclarations() { + LocalDate startDate = startDatePicker.getValue(); + LocalDate endDate = endDatePicker.getValue(); + + if (startDate == null || endDate == null) { + statusMessage.setText("Please select both start and end dates."); + statusMessage.setVisible(true); + return; + } + + if (startDate.isAfter(endDate)) { + statusMessage.setText("Start date must be before end date."); + statusMessage.setVisible(true); + return; + } + + statusMessage.setText("Loading flight declarations..."); + statusMessage.setVisible(true); + mapboxMap.clearGeoJson(); + + String startDateStr = startDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + String endDateStr = endDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + + flightBlenderService.getFlightDeclarations(startDateStr, endDateStr, 1) + .subscribe( + response -> { + getUI().ifPresent(ui -> ui.access(() -> { + List declarations = response.getResults(); + if (declarations == null || declarations.isEmpty()) { + statusMessage.setText("No flight declarations found for the selected date range."); + statusMessage.setVisible(true); + return; + } + + statusMessage.setText( + String.format("Found %d flight declaration(s)", declarations.size()) + ); + statusMessage.setVisible(true); + + // Add GeoJSON from each declaration to map + declarations.forEach(declaration -> { + if (declaration.getFlightDeclarationGeoJson() != null) { + try { + String geoJson = objectMapper.writeValueAsString( + declaration.getFlightDeclarationGeoJson() + ); + mapboxMap.addGeoJson(geoJson); + } catch (Exception e) { + // Skip invalid GeoJSON + } + } + }); + })); + }, + error -> { + getUI().ifPresent(ui -> ui.access(() -> { + statusMessage.setText("Error: " + error.getMessage()); + statusMessage.setVisible(true); + })); + } + ); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardView.java b/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardView.java new file mode 100644 index 0000000..6b5d8a4 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/NoticeboardView.java @@ -0,0 +1,276 @@ +package com.flightspotlight.view; + +import com.flightspotlight.component.ApprovalDialog; +import com.flightspotlight.component.StateUpdateDialog; +import com.flightspotlight.model.FlightDeclaration; +import com.flightspotlight.model.FlightDeclarationResponse; +import com.flightspotlight.service.FlightBlenderService; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.data.renderer.ComponentRenderer; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * Noticeboard text view - displays flight declarations in a table/grid. + * + * Phase 2.1: Noticeboard Text View + * Phase 4.1: Approval/Rejection Actions + * Phase 4.2: State Updates + */ +@Route("noticeboard") +@AnonymousAllowed +public class NoticeboardView extends VerticalLayout { + + private final FlightBlenderService flightBlenderService; + private final DatePicker startDatePicker; + private final DatePicker endDatePicker; + private final Button searchButton; + private final Grid declarationsGrid; + private final Paragraph statusMessage; + private Integer currentPage = 1; + + public NoticeboardView(FlightBlenderService flightBlenderService, + @AuthenticationPrincipal OidcUser user) { + this.flightBlenderService = flightBlenderService; + + setSizeFull(); + setPadding(true); + setSpacing(true); + + // Title + H2 title = new H2("Flight Noticeboard"); + Paragraph subtitle = new Paragraph("Operator and Status details"); + add(title, subtitle); + + // Date range picker + startDatePicker = new DatePicker("Start Date"); + endDatePicker = new DatePicker("End Date"); + searchButton = new Button("Show Schedule", e -> searchDeclarations()); + searchButton.addClassName("primary"); + + HorizontalLayout dateLayout = new HorizontalLayout(startDatePicker, endDatePicker, searchButton); + dateLayout.setAlignItems(Alignment.END); + dateLayout.setSpacing(true); + add(dateLayout); + + // Status message + statusMessage = new Paragraph(); + statusMessage.setVisible(false); + add(statusMessage); + + // Declarations grid + declarationsGrid = new Grid<>(FlightDeclaration.class, false); + declarationsGrid.addColumn(FlightDeclaration::getOriginatingParty) + .setHeader("Originating Party") + .setSortable(true); + declarationsGrid.addColumn(FlightDeclaration::getOperationTypeString) + .setHeader("Operation Type") + .setSortable(true); + declarationsGrid.addColumn(declaration -> declaration.isApproved() ? "✓" : "✗") + .setHeader("Approved"); + declarationsGrid.addColumn(FlightDeclaration::getStateString) + .setHeader("Operational Intent State") + .setSortable(true); + declarationsGrid.addColumn(declaration -> { + if (declaration.getBounds() != null && !declaration.getBounds().isEmpty()) { + return "View"; + } + return "-"; + }).setHeader("Live View"); + + // Phase 4.1: Add Actions column with approve/reject buttons + declarationsGrid.addColumn(new ComponentRenderer<>(declaration -> { + Button reviewButton = new Button("Review"); + reviewButton.addThemeVariants(ButtonVariant.LUMO_SMALL); + reviewButton.addClickListener(e -> showApprovalDialog(declaration)); + + Button updateStateButton = new Button("Update State"); + updateStateButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + updateStateButton.addClickListener(e -> showStateUpdateDialog(declaration)); + + return new HorizontalLayout(reviewButton, updateStateButton); + })).setHeader("Actions").setWidth("300px"); + + declarationsGrid.setWidthFull(); + declarationsGrid.setHeight("600px"); + declarationsGrid.setItems(new ArrayList<>()); + add(declarationsGrid); + + // Pagination (simple - just show current page info for now) + Paragraph pageInfo = new Paragraph(); + pageInfo.setText("Page: " + currentPage); + add(pageInfo); + } + + private void searchDeclarations() { + LocalDate startDate = startDatePicker.getValue(); + LocalDate endDate = endDatePicker.getValue(); + + if (startDate == null || endDate == null) { + statusMessage.setText("Please select both start and end dates."); + statusMessage.setVisible(true); + declarationsGrid.setItems(new ArrayList<>()); + return; + } + + if (startDate.isAfter(endDate)) { + statusMessage.setText("Start date must be before end date."); + statusMessage.setVisible(true); + declarationsGrid.setItems(new ArrayList<>()); + return; + } + + statusMessage.setText("Loading flight declarations..."); + statusMessage.setVisible(true); + + String startDateStr = startDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + String endDateStr = endDate.format(DateTimeFormatter.ISO_LOCAL_DATE); + + flightBlenderService.getFlightDeclarations(startDateStr, endDateStr, currentPage) + .subscribe( + response -> { + getUI().ifPresent(ui -> ui.access(() -> { + List declarations = response.getResults(); + if (declarations == null) { + declarations = new ArrayList<>(); + } + + if (declarations.isEmpty()) { + statusMessage.setText("No flight declarations found for the selected date range."); + } else { + statusMessage.setText( + String.format("Found %d flight declaration(s)", declarations.size()) + ); + } + statusMessage.setVisible(true); + declarationsGrid.setItems(declarations); + })); + }, + error -> { + getUI().ifPresent(ui -> ui.access(() -> { + statusMessage.setText("Error: " + error.getMessage()); + statusMessage.setVisible(true); + declarationsGrid.setItems(new ArrayList<>()); + })); + } + ); + } + + /** + * Show approval dialog for a flight declaration. + * Phase 4.1: Approval/Rejection Actions + */ + private void showApprovalDialog(FlightDeclaration declaration) { + ApprovalDialog dialog = new ApprovalDialog( + declaration.getId(), + declaration.getOriginatingParty(), + (isApproved, notes) -> handleApproval(declaration, isApproved, notes) + ); + dialog.show(); + } + + /** + * Handle approval/rejection of a flight declaration. + * Phase 4.1: Approval/Rejection Actions + */ + private void handleApproval(FlightDeclaration declaration, boolean isApproved, String notes) { + statusMessage.setText("Processing review..."); + statusMessage.setVisible(true); + + flightBlenderService.reviewFlightDeclaration(declaration.getId(), isApproved, notes) + .subscribe( + updated -> { + getUI().ifPresent(ui -> ui.access(() -> { + Notification notification = Notification.show( + String.format("Flight declaration %s successfully", + isApproved ? "approved" : "rejected"), + 3000, + Notification.Position.TOP_CENTER + ); + notification.addThemeVariants( + isApproved ? NotificationVariant.LUMO_SUCCESS : NotificationVariant.LUMO_PRIMARY + ); + // Refresh the grid + searchDeclarations(); + })); + }, + error -> { + getUI().ifPresent(ui -> ui.access(() -> { + Notification notification = Notification.show( + "Error: " + error.getMessage(), + 5000, + Notification.Position.TOP_CENTER + ); + notification.addThemeVariants(NotificationVariant.LUMO_ERROR); + statusMessage.setText("Error processing review"); + })); + } + ); + } + + /** + * Show state update dialog for a flight declaration. + * Phase 4.2: State Updates + */ + private void showStateUpdateDialog(FlightDeclaration declaration) { + StateUpdateDialog dialog = new StateUpdateDialog( + declaration.getId(), + declaration.getStateString(), + (newState, notes) -> handleStateUpdate(declaration, newState, notes) + ); + dialog.show(); + } + + /** + * Handle state update of a flight declaration. + * Phase 4.2: State Updates + */ + private void handleStateUpdate(FlightDeclaration declaration, String newState, String notes) { + statusMessage.setText("Updating state..."); + statusMessage.setVisible(true); + + flightBlenderService.updateFlightDeclarationState(declaration.getId(), newState, notes) + .subscribe( + updated -> { + getUI().ifPresent(ui -> ui.access(() -> { + Notification notification = Notification.show( + String.format("State updated to: %s", newState), + 3000, + Notification.Position.TOP_CENTER + ); + notification.addThemeVariants(NotificationVariant.LUMO_SUCCESS); + // Refresh the grid + searchDeclarations(); + })); + }, + error -> { + getUI().ifPresent(ui -> ui.access(() -> { + Notification notification = Notification.show( + "Error: " + error.getMessage(), + 5000, + Notification.Position.TOP_CENTER + ); + notification.addThemeVariants(NotificationVariant.LUMO_ERROR); + statusMessage.setText("Error updating state"); + })); + } + ); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/OperationStatusView.java b/java-frontend/src/main/java/com/flightspotlight/view/OperationStatusView.java new file mode 100644 index 0000000..1579604 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/OperationStatusView.java @@ -0,0 +1,181 @@ +package com.flightspotlight.view; + +import com.flightspotlight.component.MapboxMap; +import com.flightspotlight.model.FlightDeclaration; +import com.flightspotlight.service.FlightBlenderService; +import com.vaadin.flow.component.html.*; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.BeforeEvent; +import com.vaadin.flow.router.HasUrlParameter; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** + * Operation Status view - displays single flight declaration details. + * + * Phase 3.3: Operation Status View + */ +@Route("launchpad/operation-status") +@AnonymousAllowed +public class OperationStatusView extends VerticalLayout implements HasUrlParameter { + + private final FlightBlenderService flightBlenderService; + private final String mapboxAccessToken; + private final H2 titleLabel; + private final Paragraph statusMessage; + private final VerticalLayout detailsLayout; + private final MapboxMap mapboxMap; + private OidcUser currentUser; + + public OperationStatusView(FlightBlenderService flightBlenderService, + @Value("${mapbox.access-token}") String mapboxAccessToken, + @AuthenticationPrincipal OidcUser user) { + this.flightBlenderService = flightBlenderService; + this.mapboxAccessToken = mapboxAccessToken; + this.currentUser = user; + + setSizeFull(); + setPadding(true); + setSpacing(true); + + // Title + titleLabel = new H2("Operation Status"); + + // Status message + statusMessage = new Paragraph(); + statusMessage.setVisible(false); + + // Details layout + detailsLayout = new VerticalLayout(); + detailsLayout.setSpacing(false); + detailsLayout.setPadding(false); + + // Map preview + mapboxMap = new MapboxMap(); + mapboxMap.setAccessToken(mapboxAccessToken); + mapboxMap.setCenter(-79.378161, 43.659752); + mapboxMap.setZoom(2.0); + mapboxMap.setHeight("400px"); + mapboxMap.setWidth("100%"); + + add(titleLabel, statusMessage, detailsLayout, new H6("Flight Path"), mapboxMap); + } + + @Override + public void setParameter(BeforeEvent event, String uuid) { + // Validate UUID format + if (uuid == null || !isValidUUID(uuid)) { + showError("Invalid operation ID format"); + return; + } + + // Load flight declaration + loadFlightDeclaration(uuid); + } + + /** + * Load flight declaration by UUID + */ + private void loadFlightDeclaration(String uuid) { + statusMessage.setText("Loading operation status..."); + statusMessage.setVisible(true); + + flightBlenderService.getFlightDeclarationById(uuid) + .subscribe( + declaration -> { + getUI().ifPresent(ui -> ui.access(() -> { + displayFlightDeclaration(declaration); + statusMessage.setVisible(false); + })); + }, + error -> { + getUI().ifPresent(ui -> ui.access(() -> { + showError("Failed to load operation: " + error.getMessage()); + })); + } + ); + } + + /** + * Display flight declaration details + */ + private void displayFlightDeclaration(FlightDeclaration declaration) { + detailsLayout.removeAll(); + + // Title update + titleLabel.setText("Flight Declaration: " + declaration.getId()); + + // Details + detailsLayout.add(createDetailRow("Operation ID:", declaration.getId())); + detailsLayout.add(createDetailRow("Operator:", declaration.getOriginatingParty())); + detailsLayout.add(createDetailRow("Submitted By:", declaration.getSubmittedBy())); + detailsLayout.add(createDetailRow("Start Time:", declaration.getStartDatetime())); + detailsLayout.add(createDetailRow("End Time:", declaration.getEndDatetime())); + detailsLayout.add(createDetailRow("Operation Type:", getOperationTypeLabel(declaration.getTypeOfOperation()))); + detailsLayout.add(createDetailRow("Approval Status:", + "1".equals(declaration.getIsApproved()) ? "Approved" : "Pending Approval")); + detailsLayout.add(createDetailRow("State:", declaration.getState() != null ? String.valueOf(declaration.getState()) : "N/A")); + + // Show GeoJSON on map if available + if (declaration.getFlightDeclarationGeoJson() != null) { + try { + String geoJsonString = declaration.getFlightDeclarationGeoJson().toString(); + mapboxMap.clearGeoJson(); + mapboxMap.addGeoJson(geoJsonString); + } catch (Exception e) { + showError("Failed to display flight path on map: " + e.getMessage()); + } + } + } + + /** + * Create a detail row with label and value + */ + private Div createDetailRow(String label, String value) { + Div row = new Div(); + row.getStyle().set("padding", "8px 0"); + + Span labelSpan = new Span(label); + labelSpan.getStyle() + .set("font-weight", "bold") + .set("margin-right", "8px"); + + Span valueSpan = new Span(value != null ? value : "N/A"); + + row.add(labelSpan, valueSpan); + return row; + } + + /** + * Get operation type label + */ + private String getOperationTypeLabel(Integer type) { + if (type == null) return "Unknown"; + switch (type) { + case 1: return "VLOS"; + case 2: return "BVLOS"; + case 3: return "Crewed"; + default: return "Unknown"; + } + } + + /** + * Validate UUID format + */ + private boolean isValidUUID(String uuid) { + String uuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"; + return uuid.matches(uuidPattern); + } + + /** + * Show error message + */ + private void showError(String message) { + statusMessage.setText(message); + statusMessage.getStyle().set("color", "red"); + statusMessage.setVisible(true); + } +} diff --git a/java-frontend/src/main/java/com/flightspotlight/view/SpotlightView.java b/java-frontend/src/main/java/com/flightspotlight/view/SpotlightView.java new file mode 100644 index 0000000..87ec534 --- /dev/null +++ b/java-frontend/src/main/java/com/flightspotlight/view/SpotlightView.java @@ -0,0 +1,145 @@ +package com.flightspotlight.view; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.BeforeEvent; +import com.vaadin.flow.router.HasUrlParameter; +import com.vaadin.flow.router.OptionalParameter; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.flightspotlight.component.CesiumGlobe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Spotlight view - real-time air traffic visualization. + * + * Phase 5.3: Spotlight View + * + * Displays real-time air traffic observations using Cesium.js. + * Integrates with Vaadin Push for server-initiated updates. + * + * Based on Node.js views/spotlight.ejs and routes/spotlight_noticeboard.js lines 224-302 + * + * URL Parameters: + * - lat: Latitude (optional, defaults to 28.5) + * - lng: Longitude (optional, defaults to 77.1) + * - view: View mode (optional: "3d" or "map") + */ +@Route("spotlight") +@AnonymousAllowed +public class SpotlightView extends VerticalLayout implements HasUrlParameter { + + private static final Logger log = LoggerFactory.getLogger(SpotlightView.class); + + private CesiumGlobe globe; + private Div statusDiv; + private double latitude = 28.5; // Default Delhi coordinates + private double longitude = 77.1; + private String viewMode = "3d"; + + public SpotlightView() { + setSizeFull(); + setPadding(false); + setSpacing(false); + + // Title + H2 title = new H2("Real-time Air Traffic Spotlight"); + title.getStyle().set("padding", "20px"); + add(title); + + // Status message + statusDiv = new Div(); + statusDiv.getStyle() + .set("padding", "10px 20px") + .set("background-color", "var(--lumo-primary-color-10pct)") + .set("border-left", "4px solid var(--lumo-primary-color)"); + statusDiv.setText("Initializing real-time tracking..."); + add(statusDiv); + + // Cesium globe + globe = new CesiumGlobe(); + globe.setSizeFull(); + add(globe); + + setFlexGrow(1, globe); + } + + @Override + public void setParameter(BeforeEvent event, @OptionalParameter String parameter) { + // Parse query parameters for lat/lng/view + var queryParams = event.getLocation().getQueryParameters(); + + if (queryParams.getParameters().containsKey("lat")) { + try { + latitude = Double.parseDouble( + queryParams.getParameters().get("lat").get(0) + ); + } catch (Exception e) { + log.warn("Invalid latitude parameter", e); + } + } + + if (queryParams.getParameters().containsKey("lng")) { + try { + longitude = Double.parseDouble( + queryParams.getParameters().get("lng").get(0) + ); + } catch (Exception e) { + log.warn("Invalid longitude parameter", e); + } + } + + if (queryParams.getParameters().containsKey("view")) { + viewMode = queryParams.getParameters().get("view").get(0); + } + + log.info("Spotlight view initialized: lat={}, lng={}, view={}", + latitude, longitude, viewMode); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + super.onAttach(attachEvent); + + // Set globe camera to requested coordinates + globe.setCameraPosition(longitude, latitude, 10000000); // 10,000km height for overview + + // Start scanning for observations + getUI().ifPresent(ui -> { + ui.access(() -> { + statusDiv.setText("Scanning for air traffic in the area... (60 second updates)"); + }); + }); + + // Note: Real-time updates would be pushed via SpotlightPushService + // which would poll Tile38 and push updates to this view + // For now, this is a placeholder for the full implementation + } + + /** + * Method to receive real-time updates from SpotlightPushService. + * Phase 5.2: Vaadin Push Integration + */ + public void updateObservations(String observationsJson) { + getUI().ifPresent(ui -> { + ui.access(() -> { + try { + // Parse observations and update globe + // globe.updateObservations(observationsJson); + statusDiv.setText("Last updated: " + + java.time.LocalDateTime.now().format( + java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss") + ) + ); + } catch (Exception e) { + log.error("Error updating observations", e); + statusDiv.setText("Error updating observations: " + e.getMessage()); + } + }); + }); + } +} diff --git a/java-frontend/src/main/resources/application.properties b/java-frontend/src/main/resources/application.properties new file mode 100644 index 0000000..569479d --- /dev/null +++ b/java-frontend/src/main/resources/application.properties @@ -0,0 +1,59 @@ +# Flight Spotlight Java Frontend Configuration +# Phase 1: Java Frontend Skeleton + Authentication + +# Server Configuration +server.port=${PORT:8080} + +# Application Name +spring.application.name=flight-spotlight-java + +# Vaadin Configuration +vaadin.servlet.production-mode=false +vaadin.whitelisted-packages=com.flightspotlight + +# OAuth2 / OIDC Configuration (User Authentication) +# These values should match the Node.js environment variables +spring.security.oauth2.client.registration.flight-passport.client-id=${CLIENT_ID} +spring.security.oauth2.client.registration.flight-passport.client-secret=${CLIENT_SECRET} +spring.security.oauth2.client.registration.flight-passport.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.flight-passport.redirect-uri=${SPOTLIGHT_BASE_URL}/login/oauth2/code/flight-passport +spring.security.oauth2.client.registration.flight-passport.scope=openid,profile + +spring.security.oauth2.client.provider.flight-passport.authorization-uri=${OIDC_DOMAIN}/authorize +spring.security.oauth2.client.provider.flight-passport.token-uri=${OIDC_DOMAIN}/token/ +spring.security.oauth2.client.provider.flight-passport.user-info-uri=${OIDC_DOMAIN}/userinfo/ +spring.security.oauth2.client.provider.flight-passport.jwk-set-uri=${OIDC_DOMAIN}/.well-known/jwks.json +spring.security.oauth2.client.provider.flight-passport.user-name-attribute=sub + +# Flight Passport M2M Token Configuration (for Flight Blender API calls) +flight.passport.m2m.client-id=${PASSPORT_BLENDER_CLIENT_ID} +flight.passport.m2m.client-secret=${PASSPORT_BLENDER_CLIENT_SECRET} +flight.passport.m2m.scope=${PASSPORT_BLENDER_SCOPE:blender.read blender.write} +flight.passport.m2m.audience=${PASSPORT_BLENDER_AUDIENCE} +flight.passport.m2m.token-url=${PASSPORT_URL}/oauth/token/ +flight.passport.m2m.token-cache-key=blender_passport_token +flight.passport.m2m.token-cache-ttl=3500 + +# Flight Blender API Configuration +flight.blender.base-url=${BLENDER_BASE_URL} +flight.blender.ping-endpoint=/ping +flight.blender.declarations-endpoint=/flight_declaration_ops/flight_declaration + +# Redis Configuration (for token caching) +# Note: REDIS_URL takes precedence if provided (format: rediss://host:port or redis://host:port) +# SSL is automatically detected from the URL scheme (rediss:// = SSL enabled) +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +# Cache Configuration +spring.cache.type=redis +spring.cache.redis.time-to-live=3500000 + +# Mapbox Configuration (for future phases) +mapbox.access-token=${MAPBOX_KEY} + +# Logging +logging.level.com.flightspotlight=DEBUG +logging.level.org.springframework.security=DEBUG +logging.level.org.springframework.web=DEBUG diff --git a/java-frontend/src/main/resources/static/images/favicon.png b/java-frontend/src/main/resources/static/images/favicon.png new file mode 100644 index 0000000..7607d0a Binary files /dev/null and b/java-frontend/src/main/resources/static/images/favicon.png differ diff --git a/java-frontend/src/main/resources/static/images/logo_transparent.png b/java-frontend/src/main/resources/static/images/logo_transparent.png new file mode 100644 index 0000000..10d7e31 Binary files /dev/null and b/java-frontend/src/main/resources/static/images/logo_transparent.png differ diff --git a/java-frontend/src/main/resources/static/images/logo_transparent_sm.png b/java-frontend/src/main/resources/static/images/logo_transparent_sm.png new file mode 100644 index 0000000..d6e8998 Binary files /dev/null and b/java-frontend/src/main/resources/static/images/logo_transparent_sm.png differ diff --git a/java-frontend/src/main/resources/static/images/spinner.gif b/java-frontend/src/main/resources/static/images/spinner.gif new file mode 100644 index 0000000..2d888c7 Binary files /dev/null and b/java-frontend/src/main/resources/static/images/spinner.gif differ