diff --git a/.github/workflows/build-module.yml b/.github/workflows/build-module.yml index 00bd4e34..9e543e00 100644 --- a/.github/workflows/build-module.yml +++ b/.github/workflows/build-module.yml @@ -11,12 +11,15 @@ on: - all - continuum-api-server - continuum-avro-schemas + - continuum-cloud-gateway - continuum-commons - continuum-knime-base - continuum-message-bridge - continuum-worker-springboot-starter - continuum-gradle-plugin - continuum-orchestration-service + - continuum-cluster-manager + - landing-page publish_to_maven: description: 'Publish artifacts to Maven Central' required: true diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml index 44f9a7aa..16ee7730 100644 --- a/.github/workflows/on-release.yml +++ b/.github/workflows/on-release.yml @@ -31,12 +31,21 @@ jobs: - module: continuum-api-server publish_to_maven: false publish_to_docker_hub: true + - module: continuum-cloud-gateway + publish_to_maven: false + publish_to_docker_hub: true - module: continuum-orchestration-service publish_to_maven: false publish_to_docker_hub: true - module: continuum-message-bridge publish_to_maven: false publish_to_docker_hub: true + - module: continuum-cluster-manager + publish_to_maven: false + publish_to_docker_hub: true + - module: landing-page + publish_to_maven: false + publish_to_docker_hub: true steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index ba2750b0..859d810f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ out/ ### Node.js ### **/.turbo/ **/node_modules/ +**/dist/ **/continuum-workbench/lib/ **/continuum-workbench-thin/lib/ **/workflow-editor-extension/lib/ diff --git a/continuum-api-server/src/main/kotlin/org/projectcontinuum/core/api/server/config/OpenApiConfig.kt b/continuum-api-server/src/main/kotlin/org/projectcontinuum/core/api/server/config/OpenApiConfig.kt new file mode 100644 index 00000000..514dd99c --- /dev/null +++ b/continuum-api-server/src/main/kotlin/org/projectcontinuum/core/api/server/config/OpenApiConfig.kt @@ -0,0 +1,16 @@ +package org.projectcontinuum.core.api.server.config + +import io.swagger.v3.oas.models.servers.Server +import org.springdoc.core.customizers.OpenApiCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig { + @Bean + fun relativeServerCustomizer() = OpenApiCustomizer { openApi -> + // Use a relative server URL so Swagger UI resolves API calls correctly + // whether accessed directly or through the cloud-gateway reverse proxy. + openApi.servers = listOf(Server().apply { url = "../" }) + } +} diff --git a/continuum-api-server/src/main/resources/application.yml b/continuum-api-server/src/main/resources/application.yml index e3b53b8d..aacd291d 100644 --- a/continuum-api-server/src/main/resources/application.yml +++ b/continuum-api-server/src/main/resources/application.yml @@ -28,3 +28,8 @@ continuum.core: endpoint: ${CONTINUUM_MINIO_ENDPOINT:http://localhost:39000} access-key: ${CONTINUUM_MINIO_USERNAME:minioadmin} secret-key: ${CONTINUUM_MINIO_PASSWORD:minioadmin} + +springdoc: + swagger-ui: + config-url: ../v3/api-docs/swagger-config + url: ../v3/api-docs diff --git a/continuum-cloud-gateway/build.gradle.kts b/continuum-cloud-gateway/build.gradle.kts new file mode 100644 index 00000000..fd66dd54 --- /dev/null +++ b/continuum-cloud-gateway/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + kotlin("jvm") version "2.1.0" + kotlin("plugin.spring") version "1.9.25" + id("org.springframework.boot") version "3.4.0" + id("io.spring.dependency-management") version "1.1.6" + id("com.google.cloud.tools.jib") version "3.4.1" +} + +group = "org.projectcontinuum.core" +description = "Continuum Cloud Gateway โ€” reverse proxy for workbench instances, scalable independently" +version = property("platformVersion").toString() + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-websocket") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // JSON + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + // Spring Cloud Gateway Server MVC (servlet-based reverse proxy) + implementation("org.springframework.cloud:spring-cloud-gateway-server-mvc") + + // Database (read-only access to workbench_instances for endpoint resolution) + implementation("org.springframework.boot:spring-boot-starter-data-jdbc") + implementation("org.postgresql:postgresql") + + // Test dependencies + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("com.h2database:h2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.1") + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +tasks.withType { + useJUnitPlatform() +} + +jib { + from { + image = "eclipse-temurin:21-jre" + } + + to { + image = "docker.io/projectcontinuum/${project.name.lowercase()}:${project.version}" + auth { + username = System.getenv("DOCKER_REPO_USERNAME") ?: "" + password = System.getenv("DOCKER_REPO_PASSWORD") ?: "" + } + } +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/App.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/App.kt new file mode 100644 index 00000000..a11c8428 --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/App.kt @@ -0,0 +1,11 @@ +package org.projectcontinuum.core.cloud.gateway + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class App + +fun main(args: Array) { + runApplication(*args) +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/CorsConfig.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/CorsConfig.kt new file mode 100644 index 00000000..ee3691f9 --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/CorsConfig.kt @@ -0,0 +1,21 @@ +package org.projectcontinuum.core.cloud.gateway.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class CorsConfig { + @Bean + fun corsConfigurer(): WebMvcConfigurer { + return object : WebMvcConfigurer { + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + } + } + } +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/GatewayConfig.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/GatewayConfig.kt new file mode 100644 index 00000000..453d7c7f --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/GatewayConfig.kt @@ -0,0 +1,113 @@ +package org.projectcontinuum.core.cloud.gateway.config + +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route +import org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.path +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.function.RouterFunction +import org.springframework.web.servlet.function.ServerRequest +import org.springframework.web.servlet.function.ServerResponse +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +// Spring Cloud Gateway Server MVC configuration. +// +// Uses a custom proxy handler instead of HandlerFunctions.http() to work around +// a Spring Cloud Gateway Server MVC bug where percent-encoded query parameters +// are decoded before proxying (e.g., %26 becomes &), which corrupts parameters +// containing special characters like "Aggregation & Grouping". +// See: https://github.com/spring-cloud/spring-cloud-gateway/issues/3082 +// +// The custom proxy() function reads the raw URI and query string directly from the +// servlet request (which preserves percent-encoding) and forwards them as-is to +// the backend using Java's HttpClient. +// +// The dynamic workbench proxy routing (/workbench/{instanceName}/open) +// is handled by WorkbenchProxyController and WorkbenchWebSocketProxyHandler. +@Configuration +class GatewayConfig( + @Value("\${CONTINUUM_API_SERVER_URL:http://localhost:8081}") + private val apiServerUrl: String, + + @Value("\${CONTINUUM_CLUSTER_MANAGER_URL:http://localhost:8082}") + private val clusterManagerUrl: String, +) { + + private val logger = LoggerFactory.getLogger(GatewayConfig::class.java) + + private val httpClient: HttpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(30)) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + + private val HOP_BY_HOP_HEADERS = setOf( + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", "host", "content-length" + ) + + @Bean + fun apiServerRoute(): RouterFunction = + route("api-server") + .route(path("/api-server/**")) { request -> proxy(request, apiServerUrl, "/api-server") } + .build() + + @Bean + fun clusterManagerRoute(): RouterFunction = + route("cluster-manager") + .route(path("/cluster-manager/**")) { request -> proxy(request, clusterManagerUrl, "/cluster-manager") } + .build() + + private fun proxy(request: ServerRequest, backendUrl: String, prefixToStrip: String): ServerResponse { + val servletRequest: HttpServletRequest = request.servletRequest() + + // Strip the prefix from the raw request URI to preserve percent-encoding + val rawUri = servletRequest.requestURI + val remainingPath = rawUri.removePrefix(prefixToStrip).ifEmpty { "/" } + val rawQuery = servletRequest.queryString?.let { "?$it" } ?: "" + val targetUrl = "$backendUrl$remainingPath$rawQuery" + + logger.debug("Proxying {} {} -> {}", servletRequest.method, rawUri, targetUrl) + + // Build the proxy request + val proxyBuilder = HttpRequest.newBuilder() + .uri(URI(targetUrl)) + .timeout(Duration.ofMinutes(5)) + + // Forward headers + val headerNames = servletRequest.headerNames + while (headerNames.hasMoreElements()) { + val name = headerNames.nextElement() + if (name.lowercase() in HOP_BY_HOP_HEADERS) continue + val values = servletRequest.getHeaders(name) + while (values.hasMoreElements()) { + proxyBuilder.header(name, values.nextElement()) + } + } + + // Set method and body + val bodyPublisher = when (servletRequest.method.uppercase()) { + "GET", "HEAD", "OPTIONS", "TRACE" -> HttpRequest.BodyPublishers.noBody() + else -> HttpRequest.BodyPublishers.ofByteArray(servletRequest.inputStream.readAllBytes()) + } + proxyBuilder.method(servletRequest.method.uppercase(), bodyPublisher) + + // Execute + val proxyResponse = httpClient.send(proxyBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + + // Build the response + val responseBuilder = ServerResponse.status(proxyResponse.statusCode()) + proxyResponse.headers().map().forEach { (name, values) -> + if (name.lowercase() !in HOP_BY_HOP_HEADERS) { + values.forEach { value -> responseBuilder.header(name, value) } + } + } + return responseBuilder.body(proxyResponse.body()) + } +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/WebSocketProxyConfig.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/WebSocketProxyConfig.kt new file mode 100644 index 00000000..064d1117 --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/config/WebSocketProxyConfig.kt @@ -0,0 +1,20 @@ +package org.projectcontinuum.core.cloud.gateway.config + +import org.projectcontinuum.core.cloud.gateway.service.WorkbenchWebSocketProxyHandler +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry + +@Configuration +@EnableWebSocket +class WebSocketProxyConfig( + private val webSocketProxyHandler: WorkbenchWebSocketProxyHandler +) : WebSocketConfigurer { + + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry + .addHandler(webSocketProxyHandler, "/workbench/*/open/**") + .setAllowedOrigins("*") + } +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/controller/WorkbenchProxyController.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/controller/WorkbenchProxyController.kt new file mode 100644 index 00000000..49e3d13c --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/controller/WorkbenchProxyController.kt @@ -0,0 +1,37 @@ +package org.projectcontinuum.core.cloud.gateway.controller + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.projectcontinuum.core.cloud.gateway.service.WorkbenchProxyService +import org.slf4j.LoggerFactory +import org.springframework.web.bind.annotation.* + +// Reverse proxy controller that routes requests from +// /workbench/{instanceName}/open to the actual workbench service endpoint. +// +// This controller: +// - Resolves the workbench instance's K8s service endpoint from the database +// - Validates the requesting user owns the workbench instance +// - Proxies HTTP requests (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) +// - Strips the /workbench/{instanceName}/open prefix from the path +// +// WebSocket connections are handled separately by WorkbenchWebSocketProxyHandler. +@RestController +@RequestMapping("/workbench/{instanceName}/open") +class WorkbenchProxyController( + private val workbenchProxyService: WorkbenchProxyService +) { + + private val logger = LoggerFactory.getLogger(WorkbenchProxyController::class.java) + + // Catch-all handler for all HTTP methods on /workbench/{instanceName}/open + @RequestMapping(value = ["", "/**"]) + fun proxyRequest( + @PathVariable instanceName: String, + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String, + request: HttpServletRequest, + response: HttpServletResponse + ) { + workbenchProxyService.proxyRequest(instanceName, userId, request, response) + } +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/entity/WorkbenchInstanceEntity.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/entity/WorkbenchInstanceEntity.kt new file mode 100644 index 00000000..03787430 --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/entity/WorkbenchInstanceEntity.kt @@ -0,0 +1,46 @@ +package org.projectcontinuum.core.cloud.gateway.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.annotation.Version +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant +import java.util.UUID + +@Table("workbench_instances") +data class WorkbenchInstanceEntity( + @Id + @Column("instance_id") + val instanceId: UUID, + @Column("instance_name") + val instanceName: String, + @Column("namespace") + val namespace: String, + @Column("user_id") + val userId: String, + @Column("status") + val status: String, + @Column("image") + val image: String, + @Column("cpu_request") + val cpuRequest: String = "500m", + @Column("cpu_limit") + val cpuLimit: String = "2", + @Column("memory_request") + val memoryRequest: String = "512Mi", + @Column("memory_limit") + val memoryLimit: String = "1Gi", + @Column("storage_size") + val storageSize: String = "5Gi", + @Column("storage_class_name") + val storageClassName: String? = null, + @Column("k8s_resources") + val k8sResources: String = "[]", + @Column("created_at") + val createdAt: Instant = Instant.now(), + @Column("updated_at") + val updatedAt: Instant = Instant.now(), + @Version + @Column("entity_version") + val entityVersion: Long? = null +) diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/exception/GlobalExceptionHandler.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/exception/GlobalExceptionHandler.kt new file mode 100644 index 00000000..02bf51aa --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/exception/GlobalExceptionHandler.kt @@ -0,0 +1,39 @@ +package org.projectcontinuum.core.cloud.gateway.exception + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MissingRequestHeaderException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice(basePackages = ["org.projectcontinuum.core.cloud.gateway.controller"]) +class GlobalExceptionHandler { + + private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + @ExceptionHandler(WorkbenchNotFoundException::class) + fun handleNotFound(ex: WorkbenchNotFoundException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(mapOf("error" to (ex.message ?: "Not found"))) + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to (ex.message ?: "Bad request"))) + } + + @ExceptionHandler(MissingRequestHeaderException::class) + fun handleMissingHeader(ex: MissingRequestHeaderException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to (ex.message ?: "Missing required header"))) + } + + @ExceptionHandler(Exception::class) + fun handleGenericError(ex: Exception): ResponseEntity> { + logger.error("Unexpected error", ex) + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(mapOf("error" to "Internal server error")) + } +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/exception/WorkbenchNotFoundException.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/exception/WorkbenchNotFoundException.kt new file mode 100644 index 00000000..89436e6a --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/exception/WorkbenchNotFoundException.kt @@ -0,0 +1,3 @@ +package org.projectcontinuum.core.cloud.gateway.exception + +class WorkbenchNotFoundException(message: String) : RuntimeException(message) diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/repository/WorkbenchInstanceRepository.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/repository/WorkbenchInstanceRepository.kt new file mode 100644 index 00000000..e4447a31 --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/repository/WorkbenchInstanceRepository.kt @@ -0,0 +1,12 @@ +package org.projectcontinuum.core.cloud.gateway.repository + +import org.projectcontinuum.core.cloud.gateway.entity.WorkbenchInstanceEntity +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import java.util.UUID + +interface WorkbenchInstanceRepository : CrudRepository { + + @Query("SELECT * FROM workbench_instances WHERE user_id = :userId AND instance_name = :instanceName AND status NOT IN ('DELETED', 'TERMINATING') LIMIT 1") + fun findByUserIdAndInstanceName(userId: String, instanceName: String): WorkbenchInstanceEntity? +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/service/WorkbenchProxyService.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/service/WorkbenchProxyService.kt new file mode 100644 index 00000000..22cf875c --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/service/WorkbenchProxyService.kt @@ -0,0 +1,158 @@ +package org.projectcontinuum.core.cloud.gateway.service + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.projectcontinuum.core.cloud.gateway.exception.WorkbenchNotFoundException +import org.projectcontinuum.core.cloud.gateway.repository.WorkbenchInstanceRepository +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import java.io.IOException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +/** + * Service responsible for proxying HTTP requests to workbench instances. + * + * Resolves the target workbench service endpoint from the database and forwards + * the request, stripping the /workbench/{instanceName}/open prefix. + */ +@Service +class WorkbenchProxyService( + private val repository: WorkbenchInstanceRepository +) { + + private val logger = LoggerFactory.getLogger(WorkbenchProxyService::class.java) + + private val httpClient: HttpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofSeconds(30)) + .followRedirects(HttpClient.Redirect.NEVER) + .build() + + // Headers that must not be forwarded hop-by-hop + private val HOP_BY_HOP_HEADERS = setOf( + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", + "host", "content-length" + ) + + /** + * Proxies an HTTP request to the target workbench instance. + * + * @param instanceName The workbench instance name + * @param userId The user ID from the x-continuum-user-id header + * @param request The incoming servlet request + * @param response The outgoing servlet response + */ + fun proxyRequest( + instanceName: String, + userId: String, + request: HttpServletRequest, + response: HttpServletResponse + ) { + // 1. Resolve the workbench instance and validate ownership + val entity = repository.findByUserIdAndInstanceName(userId, instanceName) + ?: throw WorkbenchNotFoundException("Workbench '$instanceName' not found for user '$userId'") + + if (entity.status != "RUNNING") { + response.sendError(HttpStatus.SERVICE_UNAVAILABLE.value(), "Workbench '$instanceName' is not running (status: ${entity.status})") + return + } + + // 2. Build the target URL + val serviceEndpoint = "wb-${entity.instanceId}-svc.${entity.namespace}.svc.cluster.local:8080" + val targetBaseUrl = "http://$serviceEndpoint" + + // Strip /workbench/{instanceName}/open from the request path + val prefixPath = "/workbench/$instanceName/open" + val requestUri = request.requestURI + val remainingPath = if (requestUri.startsWith(prefixPath)) { + requestUri.removePrefix(prefixPath) + } else { + requestUri + } + + // Ensure path starts with / + val targetPath = if (remainingPath.isEmpty() || !remainingPath.startsWith("/")) "/$remainingPath" else remainingPath + val queryString = request.queryString?.let { "?$it" } ?: "" + val targetUrl = "$targetBaseUrl$targetPath$queryString" + + logger.debug("Proxying {} {} -> {}", request.method, requestUri, targetUrl) + + try { + // 3. Build the proxy request + val proxyRequestBuilder = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .timeout(Duration.ofMinutes(5)) + + // Forward headers (excluding hop-by-hop) + val headerNames = request.headerNames + while (headerNames.hasMoreElements()) { + val headerName = headerNames.nextElement() + if (headerName.lowercase() in HOP_BY_HOP_HEADERS) continue + val values = request.getHeaders(headerName) + while (values.hasMoreElements()) { + proxyRequestBuilder.header(headerName, values.nextElement()) + } + } + + // Set the correct Host header for the target + proxyRequestBuilder.header("Host", serviceEndpoint) + + // Set the HTTP method and body + val bodyPublisher = when (request.method.uppercase()) { + "GET", "HEAD", "OPTIONS", "TRACE" -> HttpRequest.BodyPublishers.noBody() + else -> { + val body = request.inputStream.readAllBytes() + HttpRequest.BodyPublishers.ofByteArray(body) + } + } + proxyRequestBuilder.method(request.method.uppercase(), bodyPublisher) + + // 4. Execute the proxy request + val proxyResponse = httpClient.send( + proxyRequestBuilder.build(), + HttpResponse.BodyHandlers.ofInputStream() + ) + + // 5. Copy response status + response.status = proxyResponse.statusCode() + + // 6. Copy response headers (excluding hop-by-hop) + proxyResponse.headers().map().forEach { (name, values) -> + if (name.lowercase() !in HOP_BY_HOP_HEADERS) { + values.forEach { value -> + response.addHeader(name, value) + } + } + } + + // 7. Copy response body + proxyResponse.body().use { inputStream -> + inputStream.transferTo(response.outputStream) + } + response.flushBuffer() + + } catch (e: IOException) { + logger.error("Proxy IO error for workbench '{}': {}", instanceName, e.message) + if (!response.isCommitted) { + response.sendError(HttpStatus.BAD_GATEWAY.value(), "Failed to reach workbench instance: ${e.message}") + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + logger.error("Proxy request interrupted for workbench '{}'", instanceName) + if (!response.isCommitted) { + response.sendError(HttpStatus.GATEWAY_TIMEOUT.value(), "Proxy request interrupted") + } + } catch (e: Exception) { + logger.error("Unexpected proxy error for workbench '{}': {}", instanceName, e.message, e) + if (!response.isCommitted) { + response.sendError(HttpStatus.BAD_GATEWAY.value(), "Proxy error: ${e.message}") + } + } + } +} diff --git a/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/service/WorkbenchWebSocketProxyHandler.kt b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/service/WorkbenchWebSocketProxyHandler.kt new file mode 100644 index 00000000..d10273b6 --- /dev/null +++ b/continuum-cloud-gateway/src/main/kotlin/org/projectcontinuum/core/cloud/gateway/service/WorkbenchWebSocketProxyHandler.kt @@ -0,0 +1,184 @@ +package org.projectcontinuum.core.cloud.gateway.service + +import org.projectcontinuum.core.cloud.gateway.repository.WorkbenchInstanceRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.socket.BinaryMessage +import org.springframework.web.socket.CloseStatus +import org.springframework.web.socket.PongMessage +import org.springframework.web.socket.TextMessage +import org.springframework.web.socket.WebSocketSession +import org.springframework.web.socket.client.standard.StandardWebSocketClient +import org.springframework.web.socket.handler.AbstractWebSocketHandler +import java.net.URI +import java.util.concurrent.ConcurrentHashMap + +// WebSocket proxy handler that forwards WebSocket connections from +// /workbench/{instanceName}/open to the target workbench instance. +// +// For each incoming WebSocket connection, this handler: +// 1. Extracts the instanceName from the URI path +// 2. Validates the user owns the workbench instance +// 3. Resolves the target service endpoint +// 4. Establishes a WebSocket connection to the target +// 5. Bidirectionally forwards all messages between client and target +@Component +class WorkbenchWebSocketProxyHandler( + private val repository: WorkbenchInstanceRepository +) : AbstractWebSocketHandler() { + + private val logger = LoggerFactory.getLogger(WorkbenchWebSocketProxyHandler::class.java) + private val webSocketClient = StandardWebSocketClient() + + // Maps inbound session ID -> outbound (target) session + private val sessionMap = ConcurrentHashMap() + + override fun afterConnectionEstablished(session: WebSocketSession) { + val uri = session.uri ?: run { + session.close(CloseStatus.SERVER_ERROR.withReason("Missing URI")) + return + } + + // Extract instanceName from the path: /workbench/{instanceName}/open/... + val pathParts = uri.path.split("/") + // Expected: ["", "workbench", "{instanceName}", "open", ...] + if (pathParts.size < 4 || pathParts[2].isBlank()) { + session.close(CloseStatus.BAD_DATA.withReason("Invalid path: cannot extract instanceName")) + return + } + val instanceName = pathParts[2] + + // Extract userId from handshake headers + val userId = session.handshakeHeaders.getFirst("x-continuum-user-id") ?: "anonymous" + + // Resolve workbench instance + val entity = repository.findByUserIdAndInstanceName(userId, instanceName) + if (entity == null) { + logger.warn("WebSocket proxy: workbench '{}' not found for user '{}'", instanceName, userId) + session.close(CloseStatus.POLICY_VIOLATION.withReason("Workbench not found")) + return + } + + if (entity.status != "RUNNING") { + logger.warn("WebSocket proxy: workbench '{}' is not running (status: {})", instanceName, entity.status) + session.close(CloseStatus.SERVICE_RESTARTED.withReason("Workbench is not running")) + return + } + + // Build target WebSocket URL + val serviceEndpoint = "wb-${entity.instanceId}-svc.${entity.namespace}.svc.cluster.local:8080" + val prefixPath = "/workbench/$instanceName/open" + val remainingPath = uri.path.removePrefix(prefixPath).let { + if (it.isEmpty() || !it.startsWith("/")) "/$it" else it + } + val query = uri.rawQuery?.let { "?$it" } ?: "" + val targetWsUrl = "ws://$serviceEndpoint$remainingPath$query" + + logger.debug("WebSocket proxy: {} -> {}", uri, targetWsUrl) + + try { + // Create a handler for the outbound (target) WebSocket session + val targetHandler = TargetWebSocketHandler(session) + + // Connect to target + val targetSession = webSocketClient + .execute(targetHandler, null, URI.create(targetWsUrl)) + .get() // blocking connect + + sessionMap[session.id] = targetSession + logger.info("WebSocket proxy established: client={} -> target={}", session.id, targetSession.id) + } catch (e: Exception) { + logger.error("WebSocket proxy: failed to connect to target '{}': {}", targetWsUrl, e.message, e) + session.close(CloseStatus.SERVER_ERROR.withReason("Failed to connect to workbench")) + } + } + + override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { + val targetSession = sessionMap[session.id] + if (targetSession != null && targetSession.isOpen) { + targetSession.sendMessage(message) + } + } + + override fun handleBinaryMessage(session: WebSocketSession, message: BinaryMessage) { + val targetSession = sessionMap[session.id] + if (targetSession != null && targetSession.isOpen) { + targetSession.sendMessage(message) + } + } + + override fun handlePongMessage(session: WebSocketSession, message: PongMessage) { + val targetSession = sessionMap[session.id] + if (targetSession != null && targetSession.isOpen) { + targetSession.sendMessage(message) + } + } + + override fun handleTransportError(session: WebSocketSession, exception: Throwable) { + logger.error("WebSocket proxy transport error for session {}: {}", session.id, exception.message) + val targetSession = sessionMap.remove(session.id) + if (targetSession != null && targetSession.isOpen) { + try { + targetSession.close(CloseStatus.SERVER_ERROR) + } catch (_: Exception) { /* ignore */ } + } + } + + override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { + logger.debug("WebSocket proxy: client session {} closed with status {}", session.id, status) + val targetSession = sessionMap.remove(session.id) + if (targetSession != null && targetSession.isOpen) { + try { + targetSession.close(status) + } catch (_: Exception) { /* ignore */ } + } + } + + override fun supportsPartialMessages(): Boolean = true + + // Handler for the outbound WebSocket connection to the target workbench instance. + // Forwards all messages received from the target back to the client. + private inner class TargetWebSocketHandler( + private val clientSession: WebSocketSession + ) : AbstractWebSocketHandler() { + + override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { + if (clientSession.isOpen) { + clientSession.sendMessage(message) + } + } + + override fun handleBinaryMessage(session: WebSocketSession, message: BinaryMessage) { + if (clientSession.isOpen) { + clientSession.sendMessage(message) + } + } + + override fun handlePongMessage(session: WebSocketSession, message: PongMessage) { + if (clientSession.isOpen) { + clientSession.sendMessage(message) + } + } + + override fun handleTransportError(session: WebSocketSession, exception: Throwable) { + logger.error("WebSocket proxy: target transport error: {}", exception.message) + if (clientSession.isOpen) { + try { + clientSession.close(CloseStatus.SERVER_ERROR) + } catch (_: Exception) { /* ignore */ } + } + } + + override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { + logger.debug("WebSocket proxy: target session closed with status {}", status) + sessionMap.remove(clientSession.id) + if (clientSession.isOpen) { + try { + clientSession.close(status) + } catch (_: Exception) { /* ignore */ } + } + } + + override fun supportsPartialMessages(): Boolean = true + } +} diff --git a/continuum-cloud-gateway/src/main/resources/application.yml b/continuum-cloud-gateway/src/main/resources/application.yml new file mode 100644 index 00000000..60823326 --- /dev/null +++ b/continuum-cloud-gateway/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring.application.name: continuum-cloud-gateway +server: + port: 8080 + error: + include-message: always + include-stacktrace: on_param +spring: + datasource: + url: ${CONTINUUM_DB_URL:jdbc:postgresql://localhost:35432/continuum} + username: ${CONTINUUM_DB_USERNAME:continuum_owner} + password: ${CONTINUUM_DB_PASSWORD:continuum-test-password} + driver-class-name: org.postgresql.Driver + sql: + init: + mode: always + +logging: + level: + org.projectcontinuum.core.cloud.gateway.service.WorkbenchProxyService: DEBUG + org.projectcontinuum.core.cloud.gateway.service.WorkbenchWebSocketProxyHandler: DEBUG + org.springframework.cloud.gateway: DEBUG diff --git a/continuum-cloud-gateway/src/main/resources/schema.sql b/continuum-cloud-gateway/src/main/resources/schema.sql new file mode 100644 index 00000000..7766113f --- /dev/null +++ b/continuum-cloud-gateway/src/main/resources/schema.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS workbench_instances ( + instance_id UUID PRIMARY KEY, + instance_name VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL DEFAULT 'default', + user_id VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + image VARCHAR(512) NOT NULL, + cpu_request VARCHAR(50) NOT NULL DEFAULT '500m', + cpu_limit VARCHAR(50) NOT NULL DEFAULT '2', + memory_request VARCHAR(50) NOT NULL DEFAULT '512Mi', + memory_limit VARCHAR(50) NOT NULL DEFAULT '1Gi', + storage_size VARCHAR(50) NOT NULL DEFAULT '5Gi', + storage_class_name VARCHAR(255), + k8s_resources TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + entity_version BIGINT NOT NULL DEFAULT 0 +); + +-- Partial unique index to prevent duplicate active workbenches per user (PostgreSQL-specific) +CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_active_workbench + ON workbench_instances (user_id, instance_name) + WHERE status NOT IN ('DELETED', 'TERMINATING'); + +-- Index for fast lookups by user_id and instance_name +CREATE INDEX IF NOT EXISTS idx_workbench_user_instance + ON workbench_instances (user_id, instance_name); + +-- Index for listing workbenches by user (excludes deleted) +CREATE INDEX IF NOT EXISTS idx_workbench_user_status + ON workbench_instances (user_id, status); diff --git a/continuum-cluster-manager/build.gradle.kts b/continuum-cluster-manager/build.gradle.kts new file mode 100644 index 00000000..e133f6a1 --- /dev/null +++ b/continuum-cluster-manager/build.gradle.kts @@ -0,0 +1,136 @@ +import com.github.gradle.node.npm.task.NpmTask + +plugins { + kotlin("jvm") version "2.1.0" + kotlin("plugin.spring") version "1.9.25" + id("org.springframework.boot") version "3.4.0" + id("io.spring.dependency-management") version "1.1.6" + id("com.google.cloud.tools.jib") version "3.4.1" + id("com.github.node-gradle.node") version "3.2.1" +} + +group = "org.projectcontinuum.core" +description = "Continuum cluster management API Server โ€” REST API for managing and monitoring continuum cluster resources" +version = property("platformVersion").toString() + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // Swagger-UI Dependencies + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4") + + // Kubernetes client + implementation("io.fabric8:kubernetes-client:7.1.0") + + // Template engine for K8s manifests + implementation("org.freemarker:freemarker:2.3.34") + + // JSON + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + // Database + implementation("org.springframework.boot:spring-boot-starter-data-jdbc") + implementation("org.postgresql:postgresql") + + // Test dependencies + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1") + testImplementation("io.fabric8:kubernetes-server-mock:7.1.0") + testImplementation("io.fabric8:mockwebserver:7.1.0") + testImplementation("com.h2database:h2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +tasks.withType { + useJUnitPlatform() +} + +// ============================================================================ +// Frontend build tasks (uses node-gradle plugin to auto-download Node.js) +// ============================================================================ +node { + version.set("22.12.0") + download.set(true) + nodeProjectDir.set(file("frontend")) +} + +val frontendDist = file("frontend/dist") + +val frontendInstall by tasks.registering(NpmTask::class) { + description = "Install frontend npm dependencies" + group = "frontend" + args.set(listOf("install")) + inputs.file(file("frontend/package.json")) + inputs.file(file("frontend/package-lock.json")) + outputs.dir(file("frontend/node_modules")) +} + +val frontendBuild by tasks.registering(NpmTask::class) { + description = "Build the frontend (Vite + React)" + group = "frontend" + dependsOn(frontendInstall) + args.set(listOf("run", "build")) + inputs.dir(file("frontend/src")) + inputs.file(file("frontend/index.html")) + inputs.file(file("frontend/vite.config.ts")) + inputs.file(file("frontend/tsconfig.json")) + inputs.file(file("frontend/tailwind.config.js")) + inputs.file(file("frontend/postcss.config.js")) + outputs.dir(frontendDist) +} + +val copyFrontend by tasks.registering(Copy::class) { + description = "Copy built frontend into Spring Boot static resources" + group = "frontend" + dependsOn(frontendBuild) + from(frontendDist) + into(layout.buildDirectory.dir("resources/main/static")) +} + +tasks.named("processResources") { + dependsOn(copyFrontend) +} + +val frontendClean by tasks.registering(Delete::class) { + description = "Clean frontend build output" + group = "frontend" + delete(frontendDist) +} + +tasks.named("clean") { + dependsOn(frontendClean) +} + +jib { + from { + image = "eclipse-temurin:21-jre" + } + + to { + image = "docker.io/projectcontinuum/${project.name.lowercase()}:${project.version}" + auth { + username = System.getenv("DOCKER_REPO_USERNAME") ?: "" + password = System.getenv("DOCKER_REPO_PASSWORD") ?: "" + } + } +} diff --git a/continuum-cluster-manager/frontend/index.html b/continuum-cluster-manager/frontend/index.html new file mode 100644 index 00000000..71a68bc7 --- /dev/null +++ b/continuum-cluster-manager/frontend/index.html @@ -0,0 +1,35 @@ + + + + + + + + Continuum Workbench Management + + + + + + + +
+ + + + diff --git a/continuum-cluster-manager/frontend/package-lock.json b/continuum-cluster-manager/frontend/package-lock.json new file mode 100644 index 00000000..449c1f25 --- /dev/null +++ b/continuum-cluster-manager/frontend/package-lock.json @@ -0,0 +1,2875 @@ +{ + "name": "workbench-management-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workbench-management-ui", + "version": "0.1.0", + "dependencies": { + "framer-motion": "^11.15.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/continuum-cluster-manager/frontend/package.json b/continuum-cluster-manager/frontend/package.json new file mode 100644 index 00000000..c301234f --- /dev/null +++ b/continuum-cluster-manager/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "workbench-management-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "framer-motion": "^11.15.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } +} diff --git a/continuum-cluster-manager/frontend/postcss.config.js b/continuum-cluster-manager/frontend/postcss.config.js new file mode 100644 index 00000000..1d926516 --- /dev/null +++ b/continuum-cluster-manager/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + diff --git a/continuum-cluster-manager/frontend/public/Logo.png b/continuum-cluster-manager/frontend/public/Logo.png new file mode 100644 index 00000000..1b24e033 Binary files /dev/null and b/continuum-cluster-manager/frontend/public/Logo.png differ diff --git a/continuum-cluster-manager/frontend/src/App.tsx b/continuum-cluster-manager/frontend/src/App.tsx new file mode 100644 index 00000000..6fd59030 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/App.tsx @@ -0,0 +1,11 @@ +import { Routes, Route } from 'react-router-dom'; +import { WorkbenchListPage } from './pages'; + +export default function App() { + return ( + + } /> + + ); +} + diff --git a/continuum-cluster-manager/frontend/src/api/workbench.ts b/continuum-cluster-manager/frontend/src/api/workbench.ts new file mode 100644 index 00000000..0eee88b7 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/api/workbench.ts @@ -0,0 +1,147 @@ +import type { + WorkbenchCreateRequest, + WorkbenchUpdateRequest, + WorkbenchResponse, + DockerHubTag, +} from '../types/api'; +import { SERVICE_BASE } from '../basePath'; + +const API_BASE = `${SERVICE_BASE}/api/v1/workbench`; + +const getHeaders = (): HeadersInit => ({ + 'Content-Type': 'application/json', + // Note: x-continuum-user-id is injected by the boundary service (OAuth2 Proxy) + // Do not set it here +}); + +class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = 'ApiError'; + } +} + +async function handleResponse(response: Response): Promise { + if (!response.ok) { + const errorText = await response.text(); + throw new ApiError(response.status, errorText || `HTTP ${response.status}`); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json(); +} + +export const workbenchApi = { + /** + * Create a new workbench instance + */ + async create(request: WorkbenchCreateRequest): Promise { + const response = await fetch(API_BASE, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(request), + }); + return handleResponse(response); + }, + + /** + * Get the status of a specific workbench + */ + async getStatus(instanceName: string): Promise { + const response = await fetch(`${API_BASE}/${encodeURIComponent(instanceName)}`, { + method: 'GET', + headers: getHeaders(), + }); + return handleResponse(response); + }, + + /** + * List all workbenches for the current user + */ + async list(): Promise { + const response = await fetch(API_BASE, { + method: 'GET', + headers: getHeaders(), + }); + return handleResponse(response); + }, + + /** + * Delete a workbench + */ + async delete(instanceName: string): Promise { + const response = await fetch(`${API_BASE}/${encodeURIComponent(instanceName)}`, { + method: 'DELETE', + headers: getHeaders(), + }); + return handleResponse(response); + }, + + /** + * Suspend a workbench (keep data, stop compute) + */ + async suspend(instanceName: string): Promise { + const response = await fetch(`${API_BASE}/${encodeURIComponent(instanceName)}/suspend`, { + method: 'PUT', + headers: getHeaders(), + }); + return handleResponse(response); + }, + + /** + * Resume a suspended workbench + */ + async resume(instanceName: string): Promise { + const response = await fetch(`${API_BASE}/${encodeURIComponent(instanceName)}/resume`, { + method: 'PUT', + headers: getHeaders(), + }); + return handleResponse(response); + }, + + /** + * Update a workbench's configuration + */ + async update(instanceName: string, request: WorkbenchUpdateRequest): Promise { + const response = await fetch(`${API_BASE}/${encodeURIComponent(instanceName)}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify(request), + }); + return handleResponse(response); + }, + + /** + * Check if the workbench UI is ready by probing its index.html + * Returns true if the response is 200, false otherwise. + */ + async checkReady(instanceName: string): Promise { + try { + const response = await fetch( + `/workbench/${encodeURIComponent(instanceName)}/open/index.html`, + { method: 'GET', redirect: 'manual' }, + ); + return response.status === 200; + } catch { + return false; + } + }, + + /** + * Fetch available Docker Hub image tags for the workbench image + */ + async getAvailableTags(): Promise { + const response = await fetch(`${API_BASE}/tags`, { + method: 'GET', + headers: getHeaders(), + }); + return handleResponse(response); + }, +}; + +export { ApiError }; + diff --git a/continuum-cluster-manager/frontend/src/basePath.ts b/continuum-cluster-manager/frontend/src/basePath.ts new file mode 100644 index 00000000..42d3aee0 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/basePath.ts @@ -0,0 +1,14 @@ +// Detect the service prefix from the current URL so the app works +// both when served directly at /ui/ and behind a gateway prefix like /cluster-manager/ui/. +const match = window.location.pathname.match(/^(.*?)\/ui(\/|$)/); + +// The gateway prefix before /ui (e.g., '/cluster-manager' or '') +export const SERVICE_BASE = match?.[1] ?? ''; + +// The full base path for React Router (e.g., '/cluster-manager/ui' or '/ui') +export const UI_BASE = `${SERVICE_BASE}/ui`; + +// Resolve a path relative to the UI base (e.g., assetPath('Logo.png') => '/cluster-manager/ui/Logo.png') +export function assetPath(path: string): string { + return `${UI_BASE}/${path}`; +} diff --git a/continuum-cluster-manager/frontend/src/components/Button.tsx b/continuum-cluster-manager/frontend/src/components/Button.tsx new file mode 100644 index 00000000..8054451d --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/Button.tsx @@ -0,0 +1,68 @@ +import { type ReactNode } from 'react'; +import { motion } from 'framer-motion'; + +interface ButtonProps { + children: ReactNode; + onClick?: () => void; + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + loading?: boolean; + type?: 'button' | 'submit' | 'reset'; + className?: string; +} + +const variantStyles = { + primary: 'bg-accent text-on-accent hover:bg-accent/90 focus:ring-accent', + secondary: 'border border-divider bg-card text-fg hover:border-accent/40 focus:ring-accent', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-700 dark:hover:bg-red-600', + ghost: 'text-fg-muted hover:text-fg hover:bg-surface focus:ring-accent', +}; + +const sizeStyles = { + sm: 'px-3 py-1.5 text-xs', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', +}; + +export function Button({ + children, + onClick, + variant = 'primary', + size = 'md', + disabled = false, + loading = false, + type = 'button', + className = '', +}: ButtonProps) { + return ( + + {loading && ( + + + + + )} + {children} + + ); +} + diff --git a/continuum-cluster-manager/frontend/src/components/CreateWorkbenchModal.tsx b/continuum-cluster-manager/frontend/src/components/CreateWorkbenchModal.tsx new file mode 100644 index 00000000..c82d6f16 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/CreateWorkbenchModal.tsx @@ -0,0 +1,515 @@ +import { useState, useEffect, useRef } from 'react'; +import { Modal } from './Modal'; +import { Button } from './Button'; +import { DEFAULT_IMAGE_TAG, WORKBENCH_IMAGE_REPOSITORY } from '../types/api'; +import type { WorkbenchCreateRequest, ResourceSpec, DockerHubTag } from '../types/api'; +import { workbenchApi } from '../api/workbench'; + +interface CreateWorkbenchModalProps { + isOpen: boolean; + onClose: () => void; + onCreate: (request: WorkbenchCreateRequest) => Promise; +} + +// โ”€โ”€ Preset sizes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +interface SizePreset { + label: string; + description: string; + icon: string; + resources: ResourceSpec; +} + +const SIZE_PRESETS: SizePreset[] = [ + { + label: 'Small', + description: 'Light tasks & exploration', + icon: '๐ŸŸข', + resources: { + cpuRequest: '500m', cpuLimit: '1', + memoryRequest: '512Mi', memoryLimit: '1Gi', + storageSize: '5Gi', storageClassName: null, + }, + }, + { + label: 'Medium', + description: 'General workflow development', + icon: '๐Ÿ”ต', + resources: { + cpuRequest: '1', cpuLimit: '2', + memoryRequest: '1Gi', memoryLimit: '2Gi', + storageSize: '10Gi', storageClassName: null, + }, + }, + { + label: 'Large', + description: 'Data-intensive workloads', + icon: '๐ŸŸฃ', + resources: { + cpuRequest: '2', cpuLimit: '4', + memoryRequest: '2Gi', memoryLimit: '4Gi', + storageSize: '20Gi', storageClassName: null, + }, + }, + { + label: 'XL', + description: 'Heavy compute & large datasets', + icon: '๐ŸŸ ', + resources: { + cpuRequest: '4', cpuLimit: '8', + memoryRequest: '4Gi', memoryLimit: '8Gi', + storageSize: '50Gi', storageClassName: null, + }, + }, +]; + +// โ”€โ”€ Slider step options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const CPU_OPTIONS = ['250m', '500m', '1', '2', '4', '8']; +const MEMORY_OPTIONS = ['256Mi', '512Mi', '1Gi', '2Gi', '4Gi', '8Gi', '16Gi']; +const STORAGE_OPTIONS = ['1Gi', '2Gi', '5Gi', '10Gi', '20Gi', '50Gi', '100Gi']; + +function formatCpu(val: string): string { + if (val.endsWith('m')) return `${parseInt(val)}m`; + return `${val} core${val === '1' ? '' : 's'}`; +} + +function formatLabel(val: string): string { + return val; +} + +// โ”€โ”€ Stepped Slider Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +interface SteppedSliderProps { + id: string; + label: string; + options: string[]; + value: string; + onChange: (val: string) => void; + formatValue?: (val: string) => string; + icon: React.ReactNode; + color: string; +} + +function SteppedSlider({ id, label, options, value, onChange, formatValue = formatLabel, icon, color }: SteppedSliderProps) { + const idx = options.indexOf(value); + const currentIdx = idx >= 0 ? idx : 0; + const pct = options.length > 1 ? (currentIdx / (options.length - 1)) * 100 : 0; + + return ( +
+
+ + + {formatValue(value)} + +
+
+ onChange(options[parseInt(e.target.value)])} + className="slider-input w-full cursor-pointer" + style={{ + background: `linear-gradient(to right, var(--slider-fill, #7c5cfc) ${pct}%, var(--slider-track, #e2e2e2) ${pct}%)`, + }} + /> +
+ {options.map((opt, i) => ( + + {opt} + + ))} +
+
+
+ ); +} + +// โ”€โ”€ Main Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +export function CreateWorkbenchModal({ isOpen, onClose, onCreate }: CreateWorkbenchModalProps) { + const [instanceName, setInstanceName] = useState(''); + const [imageTag, setImageTag] = useState(DEFAULT_IMAGE_TAG); + const [resources, setResources] = useState(SIZE_PRESETS[0].resources); + const [selectedPreset, setSelectedPreset] = useState(0); + const [showAdvanced, setShowAdvanced] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Tag dropdown state + const [availableTags, setAvailableTags] = useState([]); + const [tagsLoading, setTagsLoading] = useState(false); + const [tagsError, setTagsError] = useState(false); + const [showTagDropdown, setShowTagDropdown] = useState(false); + const dropdownRef = useRef(null); + + const filteredTags = availableTags.filter( + (tag) => tag.name.toLowerCase().includes(imageTag.toLowerCase()) + ); + + // Fetch tags when modal opens + useEffect(() => { + if (isOpen) { + setTagsLoading(true); + setTagsError(false); + workbenchApi.getAvailableTags() + .then((tags) => setAvailableTags(tags)) + .catch(() => setTagsError(true)) + .finally(() => setTagsLoading(false)); + } + }, [isOpen]); + + // Close dropdown on outside click + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowTagDropdown(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!instanceName.trim()) { + setError('Instance name is required'); + return; + } + + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(instanceName)) { + setError('Instance name must be lowercase, start and end with alphanumeric characters, and can contain hyphens'); + return; + } + + setLoading(true); + setError(null); + + try { + await onCreate({ + instanceName: instanceName.trim(), + image: `${WORKBENCH_IMAGE_REPOSITORY}:${imageTag}`, + resources, + }); + + setInstanceName(''); + setImageTag(DEFAULT_IMAGE_TAG); + setResources(SIZE_PRESETS[0].resources); + setSelectedPreset(0); + setShowAdvanced(false); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create workbench'); + } finally { + setLoading(false); + } + }; + + const selectPreset = (index: number) => { + setSelectedPreset(index); + setResources({ ...SIZE_PRESETS[index].resources }); + setShowAdvanced(false); + }; + + const updateResource = (key: keyof ResourceSpec, value: string) => { + setResources(prev => ({ ...prev, [key]: value })); + setSelectedPreset(-1); // deselect preset when manually changed + }; + + return ( + +
+ {error && ( +
+ {error} +
+ )} + + {/* Instance Name */} +
+ + setInstanceName(e.target.value.toLowerCase())} + placeholder="my-workbench" + className="mt-1 w-full rounded-lg border border-divider bg-base px-3 py-2 text-fg placeholder:text-fg-muted/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + required + /> +

+ Lowercase letters, numbers, and hyphens only +

+
+ + {/* Image */} +
+ +
+
+ + {WORKBENCH_IMAGE_REPOSITORY}: + + { + setImageTag(e.target.value); + setShowTagDropdown(true); + }} + onFocus={() => setShowTagDropdown(true)} + placeholder={tagsLoading ? 'Loading...' : DEFAULT_IMAGE_TAG} + className="w-[80%] bg-base px-3 py-2 text-fg placeholder:text-fg-muted/50 focus:outline-none" + autoComplete="off" + /> + {tagsLoading && ( + + + + + + + )} +
+ {showTagDropdown && filteredTags.length > 0 && ( +
+ {filteredTags.map((tag) => ( + + ))} +
+ )} +
+ {tagsError && ( +

+ Could not load available tags. You can type a tag manually. +

+ )} +
+ + {/* Size Presets */} +
+ +
+ {SIZE_PRESETS.map((preset, i) => ( + + ))} +
+
+ + {/* Advanced Toggle */} + + + {/* Advanced Sliders */} + {showAdvanced && ( +
+ {/* CPU */} +
+

CPU

+ updateResource('cpuRequest', v)} + formatValue={formatCpu} + icon={ + + + + } + color="bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" + /> + updateResource('cpuLimit', v)} + formatValue={formatCpu} + icon={ + + + + } + color="bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" + /> +
+ +
+ + {/* Memory */} +
+

Memory

+ updateResource('memoryRequest', v)} + icon={ + + + + } + color="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" + /> + updateResource('memoryLimit', v)} + icon={ + + + + } + color="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" + /> +
+ +
+ + {/* Storage */} +
+

Storage

+ updateResource('storageSize', v)} + icon={ + + + + } + color="bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300" + /> +
+ + updateResource('storageClassName', e.target.value || null as unknown as string)} + placeholder="default" + className="mt-1 w-full rounded-lg border border-divider bg-base px-3 py-2 text-sm text-fg placeholder:text-fg-muted/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+ + {/* Resource Summary */} +
+

Summary

+
+
+
{resources.cpuLimit}
+
CPU cores
+
+
+
{resources.memoryLimit}
+
Memory
+
+
+
{resources.storageSize}
+
Storage
+
+
+
+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} + diff --git a/continuum-cluster-manager/frontend/src/components/EmptyState.tsx b/continuum-cluster-manager/frontend/src/components/EmptyState.tsx new file mode 100644 index 00000000..38908aa1 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/EmptyState.tsx @@ -0,0 +1,68 @@ +import { motion, useReducedMotion } from 'framer-motion'; + +export function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { + const reducedMotion = useReducedMotion() ?? false; + + return ( + + {/* Illustration */} +
+ + {/* Monitor */} + + + + {/* Monitor stand */} + + + {/* Code lines on screen */} + + + + + + {/* Plus sign */} + + + + + +
+ +

No Workbenches Yet

+

+ Create your first Continuum workbench to start building visual workflows. + Each workbench is an isolated environment with persistent storage. +

+ + + + + + Create Your First Workbench + +
+ ); +} + diff --git a/continuum-cluster-manager/frontend/src/components/ErrorState.tsx b/continuum-cluster-manager/frontend/src/components/ErrorState.tsx new file mode 100644 index 00000000..04e4a14a --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/ErrorState.tsx @@ -0,0 +1,31 @@ +import { Button } from './Button'; + +interface ErrorStateProps { + message: string; + onRetry: () => void; +} + +export function ErrorState({ message, onRetry }: ErrorStateProps) { + return ( +
+
+ + + +
+

+ Failed to Load Workbenches +

+

+ {message} +

+ +
+ ); +} + diff --git a/continuum-cluster-manager/frontend/src/components/Footer.tsx b/continuum-cluster-manager/frontend/src/components/Footer.tsx new file mode 100644 index 00000000..8a7d3a2f --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/Footer.tsx @@ -0,0 +1,41 @@ +import { assetPath } from '../basePath'; + +export function Footer() { + return ( +
+
+
+
+ Continuum logo + Continuum Workbench Manager +
+ + +
+ +
+ Apache 2.0 License · © {new Date().getFullYear()} Project Continuum contributors. +
+
+
+ ); +} + + diff --git a/continuum-cluster-manager/frontend/src/components/Header.tsx b/continuum-cluster-manager/frontend/src/components/Header.tsx new file mode 100644 index 00000000..d270016a --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/Header.tsx @@ -0,0 +1,85 @@ +import { motion, AnimatePresence } from 'framer-motion'; +import { useTheme } from '../hooks/useTheme'; +import { assetPath } from '../basePath'; + +const NAV_LINKS: Array<{ label: string; href: string; external?: boolean }> = [ + { label: 'Workbenches', href: '/' }, + { label: 'Documentation', href: 'https://github.com/projectcontinuum/continuum-platform-core', external: true }, +]; + +export function Header() { + const { isDark, toggle } = useTheme(); + + return ( +
+ +
+ ); +} + + + diff --git a/continuum-cluster-manager/frontend/src/components/LoadingState.tsx b/continuum-cluster-manager/frontend/src/components/LoadingState.tsx new file mode 100644 index 00000000..800236f8 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/LoadingState.tsx @@ -0,0 +1,34 @@ +import { motion } from 'framer-motion'; + +export function LoadingState() { + return ( +
+ + + + + + +

Loading workbenches...

+
+ ); +} + diff --git a/continuum-cluster-manager/frontend/src/components/Modal.tsx b/continuum-cluster-manager/frontend/src/components/Modal.tsx new file mode 100644 index 00000000..81abdb0c --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/Modal.tsx @@ -0,0 +1,80 @@ +import { type ReactNode, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + size?: 'sm' | 'md' | 'lg'; +} + +const sizeStyles = { + sm: 'max-w-md', + md: 'max-w-lg', + lg: 'max-w-2xl', +}; + +export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) { + // Close on escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = ''; + }; + }, [isOpen, onClose]); + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Modal */} +
+ + {/* Header */} +
+

{title}

+ +
+ + {/* Content */} + {children} +
+
+ + )} +
+ ); +} + diff --git a/continuum-cluster-manager/frontend/src/components/StatusBadge.tsx b/continuum-cluster-manager/frontend/src/components/StatusBadge.tsx new file mode 100644 index 00000000..339fc999 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,39 @@ +import { motion } from 'framer-motion'; +import type { WorkbenchStatus } from '../types/api'; + +interface StatusBadgeProps { + status: WorkbenchStatus; + showPulse?: boolean; +} + +const STATUS_CONFIG: Record = { + RUNNING: { label: 'Running', className: 'status-running' }, + PENDING: { label: 'Pending', className: 'status-pending' }, + SUSPENDED: { label: 'Suspended', className: 'status-suspended' }, + FAILED: { label: 'Failed', className: 'status-failed' }, + TERMINATING: { label: 'Terminating', className: 'status-terminating' }, + DELETED: { label: 'Deleted', className: 'status-deleted' }, + UNKNOWN: { label: 'Unknown', className: 'status-unknown' }, +}; + +export function StatusBadge({ status, showPulse = true }: StatusBadgeProps) { + const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN; + const isPulsing = showPulse && (status === 'PENDING' || status === 'TERMINATING'); + + return ( + + {isPulsing && ( + + )} + {status === 'RUNNING' && ( + + )} + {config.label} + + ); +} + diff --git a/continuum-cluster-manager/frontend/src/components/WorkbenchCard.tsx b/continuum-cluster-manager/frontend/src/components/WorkbenchCard.tsx new file mode 100644 index 00000000..63d7b991 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/WorkbenchCard.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import type { WorkbenchResponse } from '../types/api'; +import { useWorkbenchReadiness } from '../hooks/useWorkbenchReadiness'; +import { StatusBadge } from './StatusBadge'; +import { Button } from './Button'; +import { Modal } from './Modal'; + +interface WorkbenchCardProps { + workbench: WorkbenchResponse; + onSuspend: (instanceName: string) => Promise; + onResume: (instanceName: string) => Promise; + onDelete: (instanceName: string) => Promise; + onOpen: (workbench: WorkbenchResponse) => void; +} + +export function WorkbenchCard({ workbench, onSuspend, onResume, onDelete, onOpen }: WorkbenchCardProps) { + const [loading, setLoading] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [expanded, setExpanded] = useState(false); + + const isRunning = workbench.status === 'RUNNING'; + const isSuspended = workbench.status === 'SUSPENDED'; + const isPending = workbench.status === 'PENDING'; + + // Poll readiness only when the workbench is running + const { ready, checking } = useWorkbenchReadiness(workbench.instanceName, isRunning); + + const canSuspend = isRunning; + const canResume = isSuspended; + const canDelete = !isPending; + const canOpen = isRunning && ready; + + const handleAction = async (action: string, handler: () => Promise) => { + setLoading(action); + try { + await handler(); + } finally { + setLoading(null); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + + return ( + <> + + {/* Header */} +
+
+

{workbench.instanceName}

+

+ Created {formatDate(workbench.createdAt)} +

+
+ +
+ + {/* Key Details */} +
+
+ Image: + + {workbench.image.includes(':') ? workbench.image.split(':').pop() : workbench.image.split('/').pop()} + +
+
+ Resources: + + {workbench.resources.cpuRequest} CPU, {workbench.resources.memoryRequest} RAM + +
+
+ Storage: + {workbench.resources.storageSize} +
+
+ + {/* Expandable Details */} + + + + {expanded && ( + +
+
+ Instance ID: + + {workbench.instanceId} + +
+
+ Namespace: + {workbench.namespace} +
+
+ CPU Limit: + {workbench.resources.cpuLimit} +
+
+ Memory Limit: + {workbench.resources.memoryLimit} +
+ {workbench.serviceEndpoint && ( +
+ Service Endpoint: + {workbench.serviceEndpoint} +
+ )} +
+ Updated: + {formatDate(workbench.updatedAt)} +
+
+
+ )} +
+ + {/* Actions */} +
+ {isRunning && ( + + )} + + {canSuspend && ( + + )} + + {canResume && ( + + )} + + {canDelete && ( + + )} +
+
+ + {/* Delete Confirmation Modal */} + { setShowDeleteConfirm(false); setDeleteConfirmText(''); }} + title="Delete Workbench" + size="sm" + > +
+

+ Are you sure you want to delete {workbench.instanceName}? + This action cannot be undone and all data will be permanently lost. +

+
+ + setDeleteConfirmText(e.target.value)} + placeholder={workbench.instanceName} + className="w-full rounded-lg border border-divider bg-surface px-3 py-2 text-sm text-fg placeholder:text-fg-muted/40 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + autoFocus + /> +
+
+ + +
+
+
+ + ); +} diff --git a/continuum-cluster-manager/frontend/src/components/index.ts b/continuum-cluster-manager/frontend/src/components/index.ts new file mode 100644 index 00000000..7b73ac4c --- /dev/null +++ b/continuum-cluster-manager/frontend/src/components/index.ts @@ -0,0 +1,11 @@ +export { Header } from './Header'; +export { Footer } from './Footer'; +export { Button } from './Button'; +export { Modal } from './Modal'; +export { StatusBadge } from './StatusBadge'; +export { WorkbenchCard } from './WorkbenchCard'; +export { CreateWorkbenchModal } from './CreateWorkbenchModal'; +export { EmptyState } from './EmptyState'; +export { LoadingState } from './LoadingState'; +export { ErrorState } from './ErrorState'; + diff --git a/continuum-cluster-manager/frontend/src/hooks/useTheme.ts b/continuum-cluster-manager/frontend/src/hooks/useTheme.ts new file mode 100644 index 00000000..a39b0cbf --- /dev/null +++ b/continuum-cluster-manager/frontend/src/hooks/useTheme.ts @@ -0,0 +1,19 @@ +import { useState, useCallback } from 'react'; + +export function useTheme() { + const [isDark, setIsDark] = useState( + () => document.documentElement.classList.contains('dark'), + ); + + const toggle = useCallback(() => { + setIsDark((prev) => { + const next = !prev; + document.documentElement.classList.toggle('dark', next); + localStorage.setItem('theme', next ? 'dark' : 'light'); + return next; + }); + }, []); + + return { isDark, toggle }; +} + diff --git a/continuum-cluster-manager/frontend/src/hooks/useWorkbenchReadiness.ts b/continuum-cluster-manager/frontend/src/hooks/useWorkbenchReadiness.ts new file mode 100644 index 00000000..4d004fc2 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/hooks/useWorkbenchReadiness.ts @@ -0,0 +1,66 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { workbenchApi } from '../api/workbench'; + +const POLL_INTERVAL_MS = 3000; // poll every 3 seconds + +/** + * Polls `{instanceName}/open/index.html` until it returns 200. + * Only starts polling when the workbench status is RUNNING. + * + * Returns `ready` (boolean) and `checking` (true while still polling). + */ +export function useWorkbenchReadiness( + instanceName: string, + isRunning: boolean, +): { ready: boolean; checking: boolean } { + const [ready, setReady] = useState(false); + const [checking, setChecking] = useState(false); + const timerRef = useRef | null>(null); + const mountedRef = useRef(true); + + const poll = useCallback(async () => { + if (!mountedRef.current) return; + try { + const ok = await workbenchApi.checkReady(instanceName); + if (!mountedRef.current) return; + if (ok) { + setReady(true); + setChecking(false); + return; + } + } catch { + // ignore โ€“ we'll retry + } + // schedule next poll + if (mountedRef.current) { + timerRef.current = setTimeout(poll, POLL_INTERVAL_MS); + } + }, [instanceName]); + + useEffect(() => { + mountedRef.current = true; + + if (!isRunning) { + // Not running โ†’ reset state, don't poll + setReady(false); + setChecking(false); + return; + } + + // Start polling + setReady(false); + setChecking(true); + poll(); + + return () => { + mountedRef.current = false; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [isRunning, poll]); + + return { ready, checking }; +} + diff --git a/continuum-cluster-manager/frontend/src/hooks/useWorkbenches.ts b/continuum-cluster-manager/frontend/src/hooks/useWorkbenches.ts new file mode 100644 index 00000000..30653935 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/hooks/useWorkbenches.ts @@ -0,0 +1,89 @@ +import { useState, useCallback, useEffect } from 'react'; +import type { WorkbenchResponse, WorkbenchCreateRequest, WorkbenchUpdateRequest } from '../types/api'; +import { workbenchApi, ApiError } from '../api/workbench'; + +interface UseWorkbenchesResult { + workbenches: WorkbenchResponse[]; + loading: boolean; + error: string | null; + refresh: () => Promise; + createWorkbench: (request: WorkbenchCreateRequest) => Promise; + deleteWorkbench: (instanceName: string) => Promise; + suspendWorkbench: (instanceName: string) => Promise; + resumeWorkbench: (instanceName: string) => Promise; + updateWorkbench: (instanceName: string, request: WorkbenchUpdateRequest) => Promise; +} + +export function useWorkbenches(): UseWorkbenchesResult { + const [workbenches, setWorkbenches] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await workbenchApi.list(); + // Filter out deleted workbenches and sort by createdAt descending + const activeWorkbenches = data + .filter(wb => wb.status !== 'DELETED') + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + setWorkbenches(activeWorkbenches); + } catch (err) { + const message = err instanceof ApiError + ? `API Error (${err.status}): ${err.message}` + : err instanceof Error + ? err.message + : 'An unknown error occurred'; + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const createWorkbench = useCallback(async (request: WorkbenchCreateRequest) => { + const result = await workbenchApi.create(request); + await refresh(); + return result; + }, [refresh]); + + const deleteWorkbench = useCallback(async (instanceName: string) => { + await workbenchApi.delete(instanceName); + await refresh(); + }, [refresh]); + + const suspendWorkbench = useCallback(async (instanceName: string) => { + const result = await workbenchApi.suspend(instanceName); + await refresh(); + return result; + }, [refresh]); + + const resumeWorkbench = useCallback(async (instanceName: string) => { + const result = await workbenchApi.resume(instanceName); + await refresh(); + return result; + }, [refresh]); + + const updateWorkbench = useCallback(async (instanceName: string, request: WorkbenchUpdateRequest) => { + const result = await workbenchApi.update(instanceName, request); + await refresh(); + return result; + }, [refresh]); + + return { + workbenches, + loading, + error, + refresh, + createWorkbench, + deleteWorkbench, + suspendWorkbench, + resumeWorkbench, + updateWorkbench, + }; +} + diff --git a/continuum-cluster-manager/frontend/src/index.css b/continuum-cluster-manager/frontend/src/index.css new file mode 100644 index 00000000..46691d8e --- /dev/null +++ b/continuum-cluster-manager/frontend/src/index.css @@ -0,0 +1,147 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ---- Light theme (default) โ€” matches Continuum Light ---- */ +:root { + --c-bg: 245 245 245; /* #F5F5F5 editor.background */ + --c-surface: 237 237 245; /* #EDEDF5 activityBar.background */ + --c-card: 255 255 255; /* #FFFFFF white cards */ + --c-fg: 51 56 77; /* #33384D dark text */ + --c-fg-muted: 108 108 108; /* #6C6C6C secondary text */ + --c-accent: 112 86 151; /* #705697 button.background */ + --c-purple: 151 105 220; /* #9769DC focusBorder */ + --c-highlight: 146 115 194; /* #9273C2 section headers */ + --c-divider: 210 210 220; /* #D2D2DC borders */ + --c-overlay: 0 0 0; /* black base for overlays */ + --c-on-accent: 255 255 255; /* white text on accent buttons */ + --svg-accent: #705697; + --svg-purple: #9769dc; + --gradient-from: #705697; + --gradient-to: #9273c2; +} + +/* ---- Dark theme โ€” matches Continuum Dark ---- */ +.dark { + --c-bg: 41 45 62; /* #292D3E activityBar.background */ + --c-surface: 54 60 80; /* #363C50 editor.background */ + --c-card: 54 60 80; /* #363C50 cards */ + --c-fg: 238 231 231; /* #EEE7E7 editor.foreground */ + --c-fg-muted: 165 152 184; /* #A598B8 input.foreground */ + --c-accent: 196 168 255; /* #C4A8FF activityBar.foreground */ + --c-purple: 112 86 151; /* #705697 button.background */ + --c-highlight: 146 115 194; /* #9273C2 section headers */ + --c-divider: 69 74 94; /* #454A5E selection */ + --c-overlay: 255 255 255; /* white base for overlays */ + --c-on-accent: 41 45 62; /* dark text on accent buttons */ + --svg-accent: #C4A8FF; + --svg-purple: #705697; + --gradient-from: #C4A8FF; + --gradient-to: #9273c2; +} + +html { + scroll-behavior: smooth; +} + +@layer utilities { + .glow-accent { + box-shadow: 0 0 30px rgb(var(--c-accent) / 0.25); + } + + .glow-purple { + box-shadow: 0 0 30px rgb(var(--c-purple) / 0.25); + } + + .text-gradient { + background: linear-gradient(135deg, var(--gradient-from), var(--gradient-to)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } +} + +/* Status badge colors */ +.status-running { + @apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400; +} + +.status-pending { + @apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400; +} + +.status-suspended { + @apply bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400; +} + +.status-failed { + @apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400; +} + +.status-terminating { + @apply bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400; +} + +.status-deleted { + @apply bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400; +} + +.status-unknown { + @apply bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400; +} + +/* ---- Slider styles ---- */ +:root { + --slider-fill: #7c5cfc; + --slider-track: #e2e2e2; +} + +.dark { + --slider-fill: #a78bfa; + --slider-track: #3a3f54; +} + +.slider-input { + -webkit-appearance: none; + appearance: none; + height: 6px; + border-radius: 9999px; + outline: none; +} + +.slider-input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--slider-fill); + border: 2px solid white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + cursor: pointer; + transition: transform 0.15s ease; +} + +.slider-input::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.slider-input::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--slider-fill); + border: 2px solid white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + cursor: pointer; + transition: transform 0.15s ease; +} + +.slider-input::-moz-range-thumb:hover { + transform: scale(1.2); +} + +.slider-input:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.3); +} + diff --git a/continuum-cluster-manager/frontend/src/main.tsx b/continuum-cluster-manager/frontend/src/main.tsx new file mode 100644 index 00000000..11dea08a --- /dev/null +++ b/continuum-cluster-manager/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import { UI_BASE } from './basePath'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); + diff --git a/continuum-cluster-manager/frontend/src/pages/WorkbenchListPage.tsx b/continuum-cluster-manager/frontend/src/pages/WorkbenchListPage.tsx new file mode 100644 index 00000000..8366b505 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/pages/WorkbenchListPage.tsx @@ -0,0 +1,248 @@ +import { useState, useCallback } from 'react'; +import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; +import { useWorkbenches } from '../hooks/useWorkbenches'; +import { + Header, + Footer, + Button, + WorkbenchCard, + CreateWorkbenchModal, + EmptyState, + LoadingState, + ErrorState, +} from '../components'; +import type { WorkbenchResponse } from '../types/api'; + +const fadeInUp = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, +}; + +export function WorkbenchListPage() { + const reducedMotion = useReducedMotion() ?? false; + const [showCreateModal, setShowCreateModal] = useState(false); + const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + const { + workbenches, + loading, + error, + refresh, + createWorkbench, + deleteWorkbench, + suspendWorkbench, + resumeWorkbench, + } = useWorkbenches(); + + const showNotification = useCallback((type: 'success' | 'error', message: string) => { + setNotification({ type, message }); + setTimeout(() => setNotification(null), 5000); + }, []); + + const handleCreate = async (request: Parameters[0]) => { + try { + await createWorkbench(request); + showNotification('success', `Workbench "${request.instanceName}" created successfully`); + } catch (err) { + showNotification('error', err instanceof Error ? err.message : 'Failed to create workbench'); + throw err; + } + }; + + const handleSuspend = async (instanceName: string) => { + try { + await suspendWorkbench(instanceName); + showNotification('success', `Workbench "${instanceName}" suspended`); + } catch (err) { + showNotification('error', err instanceof Error ? err.message : 'Failed to suspend workbench'); + } + }; + + const handleResume = async (instanceName: string) => { + try { + await resumeWorkbench(instanceName); + showNotification('success', `Workbench "${instanceName}" resumed`); + } catch (err) { + showNotification('error', err instanceof Error ? err.message : 'Failed to resume workbench'); + } + }; + + const handleDelete = async (instanceName: string) => { + try { + await deleteWorkbench(instanceName); + showNotification('success', `Workbench "${instanceName}" deleted`); + } catch (err) { + showNotification('error', err instanceof Error ? err.message : 'Failed to delete workbench'); + } + }; + + const handleOpen = (workbench: WorkbenchResponse) => { + if (workbench.serviceEndpoint) { + // Open workbench in a new tab via the cluster-manager reverse proxy + const externalUrl = `/workbench/${workbench.instanceName}/open/#/workspace`; + window.open(externalUrl, '_blank'); + } + }; + + const runningCount = workbenches.filter(wb => wb.status === 'RUNNING').length; + const suspendedCount = workbenches.filter(wb => wb.status === 'SUSPENDED').length; + + return ( +
+
+ +
+ {/* Hero Section */} +
+
+ +

+ Workbench Manager +

+

+ Create and manage your Continuum workbench instances. Each workbench is an isolated + environment with persistent storage for building visual workflows. +

+
+ + {/* Stats */} + {!loading && !error && workbenches.length > 0 && ( + +
+ + {runningCount} + + Running +
+
+ + {suspendedCount} + + Suspended +
+
+ + {workbenches.length} + + Total +
+
+ )} +
+
+ + {/* Workbench Grid */} +
+
+ {/* Actions Bar */} +
+
+ + +
+
+ + {/* Content */} + {loading && } + + {error && } + + {!loading && !error && workbenches.length === 0 && ( + setShowCreateModal(true)} /> + )} + + {!loading && !error && workbenches.length > 0 && ( + + + {workbenches.map((workbench) => ( + + ))} + + + )} +
+
+
+ +
+ + {/* Create Modal */} + setShowCreateModal(false)} + onCreate={handleCreate} + /> + + {/* Notification Toast */} + + {notification && ( + + {notification.type === 'success' ? ( + + + + ) : ( + + + + )} + {notification.message} + + + )} + +
+ ); +} + diff --git a/continuum-cluster-manager/frontend/src/pages/index.ts b/continuum-cluster-manager/frontend/src/pages/index.ts new file mode 100644 index 00000000..901b7b1e --- /dev/null +++ b/continuum-cluster-manager/frontend/src/pages/index.ts @@ -0,0 +1,2 @@ +export { WorkbenchListPage } from './WorkbenchListPage'; + diff --git a/continuum-cluster-manager/frontend/src/types/api.ts b/continuum-cluster-manager/frontend/src/types/api.ts new file mode 100644 index 00000000..4d6f49b7 --- /dev/null +++ b/continuum-cluster-manager/frontend/src/types/api.ts @@ -0,0 +1,64 @@ +// API types matching the backend models + +export interface ResourceSpec { + cpuRequest: string; + cpuLimit: string; + memoryRequest: string; + memoryLimit: string; + storageSize: string; + storageClassName: string | null; +} + +export interface WorkbenchCreateRequest { + instanceName: string; + resources?: Partial; + image?: string; +} + +export interface WorkbenchUpdateRequest { + resources?: Partial; + image?: string; +} + +export interface WorkbenchResponse { + instanceId: string; + instanceName: string; + namespace: string; + userId: string; + status: WorkbenchStatus; + image: string; + resources: ResourceSpec; + serviceEndpoint: string | null; + createdAt: string; + updatedAt: string; +} + +export type WorkbenchStatus = + | 'PENDING' + | 'RUNNING' + | 'FAILED' + | 'SUSPENDED' + | 'UNKNOWN' + | 'TERMINATING' + | 'DELETED'; + +// Default values for new workbench +export const DEFAULT_RESOURCES: ResourceSpec = { + cpuRequest: '500m', + cpuLimit: '2', + memoryRequest: '512Mi', + memoryLimit: '1Gi', + storageSize: '5Gi', + storageClassName: null, +}; + +export const WORKBENCH_IMAGE_REPOSITORY = 'projectcontinuum/continuum-workbench'; +export const DEFAULT_IMAGE_TAG = 'latest'; +export const DEFAULT_IMAGE = `${WORKBENCH_IMAGE_REPOSITORY}:${DEFAULT_IMAGE_TAG}`; + +export interface DockerHubTag { + name: string; + lastUpdated: string | null; + fullSize: number | null; +} + diff --git a/continuum-cluster-manager/frontend/tailwind.config.js b/continuum-cluster-manager/frontend/tailwind.config.js new file mode 100644 index 00000000..8b9b6364 --- /dev/null +++ b/continuum-cluster-manager/frontend/tailwind.config.js @@ -0,0 +1,31 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + base: 'rgb(var(--c-bg) / )', + surface: 'rgb(var(--c-surface) / )', + card: 'rgb(var(--c-card) / )', + fg: 'rgb(var(--c-fg) / )', + 'fg-muted': 'rgb(var(--c-fg-muted) / )', + accent: 'rgb(var(--c-accent) / )', + purple: 'rgb(var(--c-purple) / )', + highlight: 'rgb(var(--c-highlight) / )', + divider: 'rgb(var(--c-divider) / )', + overlay: 'rgb(var(--c-overlay) / )', + 'on-accent': 'rgb(var(--c-on-accent) / )', + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + animation: { + 'spin-slow': 'spin 20s linear infinite', + 'pulse-soft': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + }, + }, + plugins: [], +}; + diff --git a/continuum-cluster-manager/frontend/tsconfig.json b/continuum-cluster-manager/frontend/tsconfig.json new file mode 100644 index 00000000..da77efdc --- /dev/null +++ b/continuum-cluster-manager/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} + diff --git a/continuum-cluster-manager/frontend/tsconfig.node.json b/continuum-cluster-manager/frontend/tsconfig.node.json new file mode 100644 index 00000000..41cdb7d5 --- /dev/null +++ b/continuum-cluster-manager/frontend/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} + diff --git a/continuum-cluster-manager/frontend/vite.config.ts b/continuum-cluster-manager/frontend/vite.config.ts new file mode 100644 index 00000000..446ad755 --- /dev/null +++ b/continuum-cluster-manager/frontend/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + base: './', + build: { + outDir: 'dist', + }, + server: { + port: 5173, + proxy: { + // Proxy API requests to the cluster-manager backend during development + '/api': { + target: process.env.CLUSTER_MANAGER_URL || 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +}); diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/App.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/App.kt new file mode 100644 index 00000000..9d07d8a7 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/App.kt @@ -0,0 +1,14 @@ +package org.projectcontinuum.core.cluster.manager + +import org.projectcontinuum.core.cluster.manager.config.WorkbenchProperties +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.runApplication + +@SpringBootApplication +@EnableConfigurationProperties(WorkbenchProperties::class) +class App + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/CorsConfig.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/CorsConfig.kt new file mode 100644 index 00000000..5ac58ef3 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/CorsConfig.kt @@ -0,0 +1,32 @@ +package org.projectcontinuum.core.cluster.manager.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class CorsConfig { + @Bean + fun corsConfigurer(): WebMvcConfigurer { + return object : WebMvcConfigurer { + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + } + + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry.addResourceHandler("/ui/**") + .addResourceLocations("classpath:/static/") + } + + override fun addViewControllers(registry: ViewControllerRegistry) { + registry.addRedirectViewController("/", "/ui/") + } + } + } +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/FreemarkerConfig.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/FreemarkerConfig.kt new file mode 100644 index 00000000..9c3d6fbe --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/FreemarkerConfig.kt @@ -0,0 +1,19 @@ +package org.projectcontinuum.core.cluster.manager.config + +import freemarker.template.Configuration +import freemarker.template.TemplateExceptionHandler +import org.springframework.context.annotation.Bean + +@org.springframework.context.annotation.Configuration +class FreemarkerConfig { + + @Bean + fun freemarkerConfiguration(): Configuration { + val cfg = Configuration(Configuration.VERSION_2_3_34) + cfg.setClassLoaderForTemplateLoading(this::class.java.classLoader, "/templates") + cfg.defaultEncoding = "UTF-8" + cfg.templateExceptionHandler = TemplateExceptionHandler.RETHROW_HANDLER + cfg.logTemplateExceptions = false + return cfg + } +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/KubernetesClientConfig.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/KubernetesClientConfig.kt new file mode 100644 index 00000000..620dc0ea --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/KubernetesClientConfig.kt @@ -0,0 +1,33 @@ +package org.projectcontinuum.core.cluster.manager.config + +import io.fabric8.kubernetes.client.KubernetesClient +import io.fabric8.kubernetes.client.KubernetesClientBuilder +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class KubernetesClientConfig( + @param:Value("\${continuum.core.cluster-manager.kubernetes.master-url:}") + private val masterUrl: String, + @param:Value("\${continuum.core.cluster-manager.kubernetes.kubeconfig:}") + private val kubeconfig: String +) { + + @Bean + fun kubernetesClient(): KubernetesClient { + val builder = KubernetesClientBuilder() + val configBuilder = io.fabric8.kubernetes.client.ConfigBuilder() + + if (masterUrl.isNotBlank()) { + configBuilder.withMasterUrl(masterUrl) + } + if (kubeconfig.isNotBlank()) { + configBuilder.withAutoOAuthToken(null) + System.setProperty("kubeconfig", kubeconfig) + } + + builder.withConfig(configBuilder.build()) + return builder.build() + } +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/OpenApiConfig.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/OpenApiConfig.kt new file mode 100644 index 00000000..45d6f766 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/OpenApiConfig.kt @@ -0,0 +1,16 @@ +package org.projectcontinuum.core.cluster.manager.config + +import io.swagger.v3.oas.models.servers.Server +import org.springdoc.core.customizers.OpenApiCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig { + @Bean + fun relativeServerCustomizer() = OpenApiCustomizer { openApi -> + // Use a relative server URL so Swagger UI resolves API calls correctly + // whether accessed directly or through the cloud-gateway reverse proxy. + openApi.servers = listOf(Server().apply { url = "../" }) + } +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/WorkbenchProperties.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/WorkbenchProperties.kt new file mode 100644 index 00000000..dd90d3f3 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/config/WorkbenchProperties.kt @@ -0,0 +1,11 @@ +package org.projectcontinuum.core.cluster.manager.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "continuum.core.cluster-manager.workbench") +data class WorkbenchProperties( + val defaultImage: String = "projectcontinuum/continuum-workbench:latest", + val namespace: String = "default", + val imageRepository: String = "projectcontinuum/continuum-workbench" +) + diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/controller/UiFallbackController.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/controller/UiFallbackController.kt new file mode 100644 index 00000000..cb2713e5 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/controller/UiFallbackController.kt @@ -0,0 +1,17 @@ +package org.projectcontinuum.core.cluster.manager.controller + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping + +// SPA fallback controller for the management UI. +// Forwards any /ui/ sub-route that doesn't match a static file to index.html +// so that React Router can handle client-side routing. +@Controller +class UiFallbackController { + + @GetMapping("/ui", "/ui/", "/ui/{path:[^\\.]*}", "/ui/{path:[^\\.]*}/{subpath:[^\\.]*}") + fun forwardToUi(): String { + return "forward:/ui/index.html" + } +} + diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/controller/WorkbenchController.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/controller/WorkbenchController.kt new file mode 100644 index 00000000..cc5d0a79 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/controller/WorkbenchController.kt @@ -0,0 +1,88 @@ +package org.projectcontinuum.core.cluster.manager.controller + +import org.projectcontinuum.core.cluster.manager.model.WorkbenchCreateRequest +import org.projectcontinuum.core.cluster.manager.model.WorkbenchResponse +import org.projectcontinuum.core.cluster.manager.model.WorkbenchUpdateRequest +import org.projectcontinuum.core.cluster.manager.service.DockerHubService +import org.projectcontinuum.core.cluster.manager.service.DockerHubTag +import org.projectcontinuum.core.cluster.manager.service.WorkbenchService +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/workbench") +class WorkbenchController( + private val workbenchService: WorkbenchService, + private val dockerHubService: DockerHubService +) { + + @GetMapping("/tags") + fun getAvailableTags(): ResponseEntity> { + val tags = dockerHubService.getAvailableTags() + return ResponseEntity.ok(tags) + } + + @PostMapping + fun createWorkbench( + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String, + @RequestBody request: WorkbenchCreateRequest + ): ResponseEntity { + val response = workbenchService.createWorkbench(userId, request) + return ResponseEntity.status(HttpStatus.CREATED).body(response) + } + + @GetMapping("/{instanceName}") + fun getWorkbenchStatus( + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String, + @PathVariable instanceName: String + ): ResponseEntity { + val response = workbenchService.getWorkbenchStatus(userId, instanceName) + return ResponseEntity.ok(response) + } + + @DeleteMapping("/{instanceName}") + fun deleteWorkbench( + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String, + @PathVariable instanceName: String + ): ResponseEntity { + workbenchService.deleteWorkbench(userId, instanceName) + return ResponseEntity.noContent().build() + } + + @GetMapping + fun listWorkbenches( + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String + ): ResponseEntity> { + val response = workbenchService.listWorkbenches(userId) + return ResponseEntity.ok(response) + } + + @PutMapping("/{instanceName}/suspend") + fun suspendWorkbench( + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String, + @PathVariable instanceName: String + ): ResponseEntity { + val response = workbenchService.suspendWorkbench(userId, instanceName) + return ResponseEntity.ok(response) + } + + @PutMapping("/{instanceName}/resume") + fun resumeWorkbench( + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String, + @PathVariable instanceName: String + ): ResponseEntity { + val response = workbenchService.resumeWorkbench(userId, instanceName) + return ResponseEntity.ok(response) + } + + @PutMapping("/{instanceName}") + fun updateWorkbench( + @RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String, + @PathVariable instanceName: String, + @RequestBody request: WorkbenchUpdateRequest + ): ResponseEntity { + val response = workbenchService.updateWorkbench(userId, instanceName, request) + return ResponseEntity.ok(response) + } +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/entity/WorkbenchInstanceEntity.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/entity/WorkbenchInstanceEntity.kt new file mode 100644 index 00000000..50eb847d --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/entity/WorkbenchInstanceEntity.kt @@ -0,0 +1,46 @@ +package org.projectcontinuum.core.cluster.manager.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.annotation.Version +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.Instant +import java.util.UUID + +@Table("workbench_instances") +data class WorkbenchInstanceEntity( + @Id + @Column("instance_id") + val instanceId: UUID, + @Column("instance_name") + val instanceName: String, + @Column("namespace") + val namespace: String, + @Column("user_id") + val userId: String, + @Column("status") + val status: String, + @Column("image") + val image: String, + @Column("cpu_request") + val cpuRequest: String = "500m", + @Column("cpu_limit") + val cpuLimit: String = "2", + @Column("memory_request") + val memoryRequest: String = "512Mi", + @Column("memory_limit") + val memoryLimit: String = "1Gi", + @Column("storage_size") + val storageSize: String = "5Gi", + @Column("storage_class_name") + val storageClassName: String? = null, + @Column("k8s_resources") + val k8sResources: String = "[]", + @Column("created_at") + val createdAt: Instant = Instant.now(), + @Column("updated_at") + val updatedAt: Instant = Instant.now(), + @Version + @Column("entity_version") + val entityVersion: Long? = null +) diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/exception/GlobalExceptionHandler.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/exception/GlobalExceptionHandler.kt new file mode 100644 index 00000000..1db5e7a6 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/exception/GlobalExceptionHandler.kt @@ -0,0 +1,55 @@ +package org.projectcontinuum.core.cluster.manager.exception + +import io.fabric8.kubernetes.client.KubernetesClientException +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MissingRequestHeaderException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.client.RestClientException + +@RestControllerAdvice(basePackages = ["org.projectcontinuum.core.cluster.manager.controller"]) +class GlobalExceptionHandler { + + private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + @ExceptionHandler(WorkbenchNotFoundException::class) + fun handleNotFound(ex: WorkbenchNotFoundException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(mapOf("error" to (ex.message ?: "Not found"))) + } + + @ExceptionHandler(KubernetesClientException::class) + fun handleKubernetesError(ex: KubernetesClientException): ResponseEntity> { + logger.error("Kubernetes client error", ex) + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(mapOf("error" to "Kubernetes operation failed: ${ex.message}")) + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to (ex.message ?: "Bad request"))) + } + + @ExceptionHandler(MissingRequestHeaderException::class) + fun handleMissingHeader(ex: MissingRequestHeaderException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(mapOf("error" to (ex.message ?: "Missing required header"))) + } + + @ExceptionHandler(RestClientException::class) + fun handleRestClientError(ex: RestClientException): ResponseEntity> { + logger.error("External API call failed", ex) + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(mapOf("error" to "Failed to fetch data from external service")) + } + + @ExceptionHandler(Exception::class) + fun handleGenericError(ex: Exception): ResponseEntity> { + logger.error("Unexpected error", ex) + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(mapOf("error" to "Internal server error")) + } +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/exception/WorkbenchNotFoundException.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/exception/WorkbenchNotFoundException.kt new file mode 100644 index 00000000..64b8b893 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/exception/WorkbenchNotFoundException.kt @@ -0,0 +1,3 @@ +package org.projectcontinuum.core.cluster.manager.exception + +class WorkbenchNotFoundException(message: String) : RuntimeException(message) diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/ResourceSpec.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/ResourceSpec.kt new file mode 100644 index 00000000..3241d437 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/ResourceSpec.kt @@ -0,0 +1,10 @@ +package org.projectcontinuum.core.cluster.manager.model + +data class ResourceSpec( + val cpuRequest: String = "500m", + val cpuLimit: String = "2", + val memoryRequest: String = "512Mi", + val memoryLimit: String = "1Gi", + val storageSize: String = "5Gi", + val storageClassName: String? = null +) diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchCreateRequest.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchCreateRequest.kt new file mode 100644 index 00000000..b881cf14 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchCreateRequest.kt @@ -0,0 +1,7 @@ +package org.projectcontinuum.core.cluster.manager.model + +data class WorkbenchCreateRequest( + val instanceName: String, + val resources: ResourceSpec = ResourceSpec(), + val image: String = "projectcontinuum/continuum-workbench:0.0.5" +) diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchResponse.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchResponse.kt new file mode 100644 index 00000000..08f581ac --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchResponse.kt @@ -0,0 +1,17 @@ +package org.projectcontinuum.core.cluster.manager.model + +import java.time.Instant +import java.util.UUID + +data class WorkbenchResponse( + val instanceId: UUID, + val instanceName: String, + val namespace: String, + val userId: String, + val status: String, + val image: String, + val resources: ResourceSpec, + val serviceEndpoint: String?, + val createdAt: Instant, + val updatedAt: Instant +) diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchStatus.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchStatus.kt new file mode 100644 index 00000000..3037211d --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchStatus.kt @@ -0,0 +1,11 @@ +package org.projectcontinuum.core.cluster.manager.model + +enum class WorkbenchStatus { + PENDING, + RUNNING, + FAILED, + SUSPENDED, + UNKNOWN, + TERMINATING, + DELETED +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchUpdateRequest.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchUpdateRequest.kt new file mode 100644 index 00000000..b12ee00a --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchUpdateRequest.kt @@ -0,0 +1,6 @@ +package org.projectcontinuum.core.cluster.manager.model + +data class WorkbenchUpdateRequest( + val resources: ResourceSpec? = null, + val image: String? = null +) diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/repository/WorkbenchInstanceRepository.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/repository/WorkbenchInstanceRepository.kt new file mode 100644 index 00000000..e612d370 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/repository/WorkbenchInstanceRepository.kt @@ -0,0 +1,18 @@ +package org.projectcontinuum.core.cluster.manager.repository + +import org.projectcontinuum.core.cluster.manager.entity.WorkbenchInstanceEntity +import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.repository.CrudRepository +import java.util.UUID + +interface WorkbenchInstanceRepository : CrudRepository { + + @Query("SELECT * FROM workbench_instances WHERE user_id = :userId AND instance_name = :instanceName AND status NOT IN ('DELETED', 'TERMINATING') LIMIT 1") + fun findByUserIdAndInstanceName(userId: String, instanceName: String): WorkbenchInstanceEntity? + + @Query("SELECT * FROM workbench_instances WHERE user_id = :userId AND status NOT IN ('DELETED', 'TERMINATING')") + fun findByUserId(userId: String): List + + @Query("SELECT * FROM workbench_instances WHERE user_id = :userId AND namespace = :namespace AND status NOT IN ('DELETED', 'TERMINATING')") + fun findByUserIdAndNamespace(userId: String, namespace: String): List +} diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/service/DockerHubService.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/service/DockerHubService.kt new file mode 100644 index 00000000..6b90492f --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/service/DockerHubService.kt @@ -0,0 +1,110 @@ +package org.projectcontinuum.core.cluster.manager.service + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import org.projectcontinuum.core.cluster.manager.config.WorkbenchProperties +import org.slf4j.LoggerFactory +import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestTemplate +import java.time.Duration +import java.time.Instant + +@Service +class DockerHubService( + restTemplateBuilder: RestTemplateBuilder, + private val workbenchProperties: WorkbenchProperties +) { + + private val logger = LoggerFactory.getLogger(DockerHubService::class.java) + + private val restTemplate: RestTemplate = restTemplateBuilder + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(10)) + .build() + + @Volatile + private var cachedTags: CachedResult? = null + + companion object { + private const val DOCKER_HUB_BASE = "https://hub.docker.com/v2/repositories" + private val CACHE_TTL = Duration.ofMinutes(5) + } + + fun getAvailableTags(): List { + cachedTags?.let { cached -> + if (Duration.between(cached.fetchedAt, Instant.now()) < CACHE_TTL) { + logger.debug("Returning cached Docker Hub tags ({} tags)", cached.tags.size) + return cached.tags + } + } + + return fetchAndCacheTags() + } + + @Synchronized + private fun fetchAndCacheTags(): List { + // Double-check after acquiring lock + cachedTags?.let { cached -> + if (Duration.between(cached.fetchedAt, Instant.now()) < CACHE_TTL) { + return cached.tags + } + } + + val repository = workbenchProperties.imageRepository + val url = "$DOCKER_HUB_BASE/$repository/tags/?page_size=100&ordering=last_updated" + + logger.info("Fetching Docker Hub tags from: {}", url) + + try { + val response = restTemplate.getForObject(url, DockerHubTagsResponse::class.java) + ?: throw RuntimeException("Received null response from Docker Hub") + + val tags = response.results.map { result -> + DockerHubTag( + name = result.name, + lastUpdated = result.lastUpdated, + fullSize = result.fullSize + ) + } + + cachedTags = CachedResult(tags = tags, fetchedAt = Instant.now()) + logger.info("Cached {} Docker Hub tags for repository '{}'", tags.size, repository) + return tags + } catch (ex: RestClientException) { + logger.error("Failed to fetch Docker Hub tags for repository '{}'", repository, ex) + // Return stale cache if available, otherwise propagate error + cachedTags?.let { return it.tags } + throw RuntimeException("Failed to fetch Docker Hub tags: ${ex.message}", ex) + } + } + + // โ”€โ”€ DTOs for Docker Hub response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class DockerHubTagsResponse( + val count: Int, + val next: String?, + val previous: String?, + val results: List + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class DockerHubTagResult( + val name: String, + @JsonProperty("last_updated") val lastUpdated: String?, + @JsonProperty("full_size") val fullSize: Long? + ) + + private data class CachedResult( + val tags: List, + val fetchedAt: Instant + ) +} + +data class DockerHubTag( + val name: String, + val lastUpdated: String?, + val fullSize: Long? +) diff --git a/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/service/WorkbenchService.kt b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/service/WorkbenchService.kt new file mode 100644 index 00000000..2ebf1096 --- /dev/null +++ b/continuum-cluster-manager/src/main/kotlin/org/projectcontinuum/core/cluster/manager/service/WorkbenchService.kt @@ -0,0 +1,603 @@ +package org.projectcontinuum.core.cluster.manager.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import freemarker.template.Configuration +import io.fabric8.kubernetes.api.model.HasMetadata +import io.fabric8.kubernetes.client.KubernetesClient +import org.projectcontinuum.core.cluster.manager.config.WorkbenchProperties +import org.projectcontinuum.core.cluster.manager.entity.WorkbenchInstanceEntity +import org.projectcontinuum.core.cluster.manager.exception.WorkbenchNotFoundException +import org.projectcontinuum.core.cluster.manager.model.* +import org.projectcontinuum.core.cluster.manager.repository.WorkbenchInstanceRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.io.StringWriter +import java.time.Instant +import java.util.UUID + +@Service +class WorkbenchService( + private val repository: WorkbenchInstanceRepository, + private val kubernetesClient: KubernetesClient, + private val freemarkerConfig: Configuration, + private val transactionTemplate: TransactionTemplate, + private val workbenchProperties: WorkbenchProperties +) { + + private val logger = LoggerFactory.getLogger(WorkbenchService::class.java) + private val objectMapper = jacksonObjectMapper() + + /** + * Audit operation types for workbench lifecycle events + */ + private enum class AuditOperation { + CREATE, DELETE, SUSPEND, RESUME, UPDATE, GET_STATUS, LIST + } + + /** + * Logs an audit event for workbench operations. + * Format: AUDIT | operation=X | userId=Y | instanceId=Z | instanceName=W | status=S | details={...} + */ + private fun logAudit( + operation: AuditOperation, + userId: String, + instanceId: UUID? = null, + instanceName: String? = null, + status: String = "SUCCESS", + details: Map = emptyMap() + ) { + val detailsJson = if (details.isNotEmpty()) objectMapper.writeValueAsString(details) else "{}" + logger.info( + "AUDIT | operation={} | userId={} | instanceId={} | instanceName={} | status={} | details={}", + operation.name, + userId, + instanceId?.toString() ?: "N/A", + instanceName ?: "N/A", + status, + detailsJson + ) + } + + fun createWorkbench(userId: String, request: WorkbenchCreateRequest): WorkbenchResponse { + logger.info("AUDIT | operation=CREATE | userId={} | instanceName={} | status=INITIATED", userId, request.instanceName) + + val existing = repository.findByUserIdAndInstanceName(userId, request.instanceName) + val activeStatuses = setOf(WorkbenchStatus.RUNNING.name, WorkbenchStatus.SUSPENDED.name) + if (existing != null && existing.status in activeStatuses) { + logAudit( + operation = AuditOperation.CREATE, + userId = userId, + instanceName = request.instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench already exists") + ) + throw IllegalArgumentException("Workbench '${request.instanceName}' already exists for user '$userId'") + } + + val instanceId = UUID.randomUUID() + val now = Instant.now() + val namespace = workbenchProperties.namespace + + val entity = WorkbenchInstanceEntity( + instanceId = instanceId, + instanceName = request.instanceName, + namespace = namespace, + userId = userId, + status = WorkbenchStatus.PENDING.name, + image = request.image, + cpuRequest = request.resources.cpuRequest, + cpuLimit = request.resources.cpuLimit, + memoryRequest = request.resources.memoryRequest, + memoryLimit = request.resources.memoryLimit, + storageSize = request.resources.storageSize, + storageClassName = request.resources.storageClassName, + createdAt = now, + updatedAt = now + ) + + val templateModel = buildTemplateModel(entity) + val k8sResourceIds = mutableListOf() + + try { + // First, create all K8s resources + val pvcYaml = renderTemplate("pvc.ftl", templateModel) + applyYaml(pvcYaml, namespace) + k8sResourceIds.add("persistentvolumeclaim/wb-${instanceId}-pvc") + + val deploymentYaml = renderTemplate("deployment.ftl", templateModel) + applyYaml(deploymentYaml, namespace) + k8sResourceIds.add("deployment/wb-${instanceId}-deployment") + + val serviceYaml = renderTemplate("service.ftl", templateModel) + applyYaml(serviceYaml, namespace) + k8sResourceIds.add("service/wb-${instanceId}-svc") + + // Only save to DB after all K8s resources are successfully created + val savedEntity = transactionTemplate.execute { + val entityToSave = entity.copy( + status = WorkbenchStatus.RUNNING.name, + k8sResources = objectMapper.writeValueAsString(k8sResourceIds), + updatedAt = Instant.now() + ) + repository.save(entityToSave) + }!! + + logAudit( + operation = AuditOperation.CREATE, + userId = userId, + instanceId = instanceId, + instanceName = request.instanceName, + status = "SUCCESS", + details = mapOf( + "namespace" to namespace, + "image" to request.image, + "cpuRequest" to request.resources.cpuRequest, + "memoryRequest" to request.resources.memoryRequest, + "storageSize" to request.resources.storageSize + ) + ) + + return toResponse(savedEntity) + } catch (ex: Exception) { + logger.error("Failed to create K8s resources for workbench $instanceId, rolling back", ex) + logAudit( + operation = AuditOperation.CREATE, + userId = userId, + instanceId = instanceId, + instanceName = request.instanceName, + status = "FAILED", + details = mapOf("reason" to (ex.message ?: "Unknown error")) + ) + // Rollback K8s resources that were created + rollbackK8sResources(k8sResourceIds, namespace) + throw ex + } + } + + fun getWorkbenchStatus(userId: String, instanceName: String): WorkbenchResponse { + val entity = repository.findByUserIdAndInstanceName(userId, instanceName) + ?: run { + logAudit( + operation = AuditOperation.GET_STATUS, + userId = userId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench not found") + ) + throw WorkbenchNotFoundException("Workbench '$instanceName' not found for user '$userId'") + } + + val refreshedEntity = refreshStatusFromK8s(entity) + + logAudit( + operation = AuditOperation.GET_STATUS, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "SUCCESS", + details = mapOf("workbenchStatus" to refreshedEntity.status) + ) + + return toResponse(refreshedEntity) + } + + fun deleteWorkbench(userId: String, instanceName: String) { + logger.info("AUDIT | operation=DELETE | userId={} | instanceName={} | status=INITIATED", userId, instanceName) + + val entity = repository.findByUserIdAndInstanceName(userId, instanceName) + ?: run { + logAudit( + operation = AuditOperation.DELETE, + userId = userId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench not found") + ) + throw WorkbenchNotFoundException("Workbench '$instanceName' not found for user '$userId'") + } + + // First, delete K8s resources + try { + deleteK8sResourcesByLabel(entity.instanceId.toString(), entity.namespace) + } catch (ex: Exception) { + logger.error("Failed to delete K8s resources for workbench ${entity.instanceId}", ex) + logAudit( + operation = AuditOperation.DELETE, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Failed to delete K8s resources: ${ex.message}") + ) + throw ex + } + + // Only update DB after K8s resources are successfully deleted + transactionTemplate.execute { + val deletedEntity = entity.copy( + status = WorkbenchStatus.DELETED.name, + updatedAt = Instant.now() + ) + repository.save(deletedEntity) + } + + logAudit( + operation = AuditOperation.DELETE, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "SUCCESS", + details = mapOf("previousStatus" to entity.status) + ) + } + + @Transactional(readOnly = true) + fun listWorkbenches(userId: String): List { + val entities = repository.findByUserId(userId) + + logAudit( + operation = AuditOperation.LIST, + userId = userId, + status = "SUCCESS", + details = mapOf("count" to entities.size) + ) + + return entities.map { toResponse(it) } + } + + fun suspendWorkbench(userId: String, instanceName: String): WorkbenchResponse { + logger.info("AUDIT | operation=SUSPEND | userId={} | instanceName={} | status=INITIATED", userId, instanceName) + + val entity = repository.findByUserIdAndInstanceName(userId, instanceName) + ?: run { + logAudit( + operation = AuditOperation.SUSPEND, + userId = userId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench not found") + ) + throw WorkbenchNotFoundException("Workbench '$instanceName' not found for user '$userId'") + } + + if (entity.status == WorkbenchStatus.SUSPENDED.name) { + logAudit( + operation = AuditOperation.SUSPEND, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench already suspended") + ) + throw IllegalArgumentException("Workbench '$instanceName' is already suspended") + } + + // First, suspend K8s resources (delete deployment and service, keep PVC) + try { + suspendK8sResources(entity.instanceId.toString(), entity.namespace) + } catch (ex: Exception) { + logger.error("Failed to suspend K8s resources for workbench ${entity.instanceId}", ex) + logAudit( + operation = AuditOperation.SUSPEND, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Failed to suspend K8s resources: ${ex.message}") + ) + throw ex + } + + // Only update DB after K8s resources are successfully suspended + val suspendedEntity = transactionTemplate.execute { + val entityToSave = entity.copy( + status = WorkbenchStatus.SUSPENDED.name, + updatedAt = Instant.now() + ) + repository.save(entityToSave) + }!! + + logAudit( + operation = AuditOperation.SUSPEND, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "SUCCESS", + details = mapOf("previousStatus" to entity.status) + ) + + return toResponse(suspendedEntity) + } + + fun resumeWorkbench(userId: String, instanceName: String): WorkbenchResponse { + logger.info("AUDIT | operation=RESUME | userId={} | instanceName={} | status=INITIATED", userId, instanceName) + + val entity = repository.findByUserIdAndInstanceName(userId, instanceName) + ?: run { + logAudit( + operation = AuditOperation.RESUME, + userId = userId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench not found") + ) + throw WorkbenchNotFoundException("Workbench '$instanceName' not found for user '$userId'") + } + + if (entity.status != WorkbenchStatus.SUSPENDED.name) { + logAudit( + operation = AuditOperation.RESUME, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench is not suspended", "currentStatus" to entity.status) + ) + throw IllegalArgumentException("Workbench '$instanceName' is not suspended") + } + + val templateModel = buildTemplateModel(entity) + + // First, recreate K8s resources + try { + val deploymentYaml = renderTemplate("deployment.ftl", templateModel) + applyYaml(deploymentYaml, entity.namespace) + + val serviceYaml = renderTemplate("service.ftl", templateModel) + applyYaml(serviceYaml, entity.namespace) + } catch (ex: Exception) { + logger.error("Failed to resume K8s resources for workbench ${entity.instanceId}, rolling back", ex) + logAudit( + operation = AuditOperation.RESUME, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Failed to resume K8s resources: ${ex.message}") + ) + // Rollback any partially created resources + try { + suspendK8sResources(entity.instanceId.toString(), entity.namespace) + } catch (rollbackEx: Exception) { + logger.error("Failed to rollback K8s resources during resume failure", rollbackEx) + } + throw ex + } + + // Only update DB after K8s resources are successfully created + val resumedEntity = transactionTemplate.execute { + val entityToSave = entity.copy( + status = WorkbenchStatus.RUNNING.name, + updatedAt = Instant.now() + ) + repository.save(entityToSave) + }!! + + logAudit( + operation = AuditOperation.RESUME, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "SUCCESS", + details = mapOf("previousStatus" to entity.status) + ) + + return toResponse(resumedEntity) + } + + fun updateWorkbench(userId: String, instanceName: String, request: WorkbenchUpdateRequest): WorkbenchResponse { + logger.info("AUDIT | operation=UPDATE | userId={} | instanceName={} | status=INITIATED", userId, instanceName) + + val entity = repository.findByUserIdAndInstanceName(userId, instanceName) + ?: run { + logAudit( + operation = AuditOperation.UPDATE, + userId = userId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Workbench not found") + ) + throw WorkbenchNotFoundException("Workbench '$instanceName' not found for user '$userId'") + } + + val updatedEntity = entity.copy( + image = request.image ?: entity.image, + cpuRequest = request.resources?.cpuRequest ?: entity.cpuRequest, + cpuLimit = request.resources?.cpuLimit ?: entity.cpuLimit, + memoryRequest = request.resources?.memoryRequest ?: entity.memoryRequest, + memoryLimit = request.resources?.memoryLimit ?: entity.memoryLimit, + storageSize = request.resources?.storageSize ?: entity.storageSize, + storageClassName = request.resources?.storageClassName ?: entity.storageClassName, + updatedAt = Instant.now() + ) + + // Build change details for audit + val changes = buildMap { + request.image?.let { if (it != entity.image) put("image", mapOf("from" to entity.image, "to" to it)) } + request.resources?.cpuRequest?.let { if (it != entity.cpuRequest) put("cpuRequest", mapOf("from" to entity.cpuRequest, "to" to it)) } + request.resources?.cpuLimit?.let { if (it != entity.cpuLimit) put("cpuLimit", mapOf("from" to entity.cpuLimit, "to" to it)) } + request.resources?.memoryRequest?.let { if (it != entity.memoryRequest) put("memoryRequest", mapOf("from" to entity.memoryRequest, "to" to it)) } + request.resources?.memoryLimit?.let { if (it != entity.memoryLimit) put("memoryLimit", mapOf("from" to entity.memoryLimit, "to" to it)) } + request.resources?.storageSize?.let { if (it != entity.storageSize) put("storageSize", mapOf("from" to entity.storageSize, "to" to it)) } + } + + val templateModel = buildTemplateModel(updatedEntity) + + // First, update K8s resources + try { + val deploymentYaml = renderTemplate("deployment.ftl", templateModel) + applyYaml(deploymentYaml, updatedEntity.namespace) + + val serviceYaml = renderTemplate("service.ftl", templateModel) + applyYaml(serviceYaml, updatedEntity.namespace) + } catch (ex: Exception) { + logger.error("Failed to update K8s resources for workbench ${entity.instanceId}, rolling back", ex) + logAudit( + operation = AuditOperation.UPDATE, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "FAILED", + details = mapOf("reason" to "Failed to update K8s resources: ${ex.message}") + ) + // Rollback by reapplying old configuration + try { + val oldTemplateModel = buildTemplateModel(entity) + val oldDeploymentYaml = renderTemplate("deployment.ftl", oldTemplateModel) + applyYaml(oldDeploymentYaml, entity.namespace) + val oldServiceYaml = renderTemplate("service.ftl", oldTemplateModel) + applyYaml(oldServiceYaml, entity.namespace) + } catch (rollbackEx: Exception) { + logger.error("Failed to rollback K8s resources during update failure", rollbackEx) + } + throw ex + } + + // Only save to DB after K8s resources are successfully updated + val savedEntity = transactionTemplate.execute { + repository.save(updatedEntity) + }!! + + logAudit( + operation = AuditOperation.UPDATE, + userId = userId, + instanceId = entity.instanceId, + instanceName = instanceName, + status = "SUCCESS", + details = if (changes.isNotEmpty()) mapOf("changes" to changes) else emptyMap() + ) + + return toResponse(savedEntity) + } + + private fun refreshStatusFromK8s(entity: WorkbenchInstanceEntity): WorkbenchInstanceEntity { + return try { + val deployment = kubernetesClient.apps().deployments() + .inNamespace(entity.namespace) + .withName("wb-${entity.instanceId}-deployment") + .get() + + val newStatus = if (deployment == null) { + WorkbenchStatus.UNKNOWN.name + } else { + val readyReplicas = deployment.status?.readyReplicas ?: 0 + if (readyReplicas > 0) WorkbenchStatus.RUNNING.name else WorkbenchStatus.PENDING.name + } + + if (newStatus != entity.status) { + val updated = entity.copy(status = newStatus, updatedAt = Instant.now()) + repository.save(updated) + updated + } else { + entity + } + } catch (ex: Exception) { + logger.warn("Could not refresh K8s status for workbench ${entity.instanceId}", ex) + entity + } + } + + private fun rollbackK8sResources(resourceIds: List, namespace: String) { + for (resourceId in resourceIds.reversed()) { + try { + val parts = resourceId.split("/") + if (parts.size != 2) continue + val (kind, name) = parts + when (kind) { + "persistentvolumeclaim" -> kubernetesClient.persistentVolumeClaims() + .inNamespace(namespace).withName(name).delete() + "deployment" -> kubernetesClient.apps().deployments() + .inNamespace(namespace).withName(name).delete() + "service" -> kubernetesClient.services() + .inNamespace(namespace).withName(name).delete() + } + logger.info("Rolled back K8s resource: $resourceId") + } catch (ex: Exception) { + logger.warn("Failed to rollback K8s resource: $resourceId", ex) + } + } + } + + private fun deleteK8sResourcesByLabel(instanceId: String, namespace: String) { + val labels = mapOf( + "instance-id" to instanceId, + "app" to "continuum-workbench", + "managed-by" to "continuum-cluster-manager" + ) + + kubernetesClient.apps().deployments() + .inNamespace(namespace).withLabels(labels).delete() + kubernetesClient.services() + .inNamespace(namespace).withLabels(labels).delete() + kubernetesClient.persistentVolumeClaims() + .inNamespace(namespace).withLabels(labels).delete() + } + + private fun suspendK8sResources(instanceId: String, namespace: String) { + val labels = mapOf( + "instance-id" to instanceId, + "app" to "continuum-workbench", + "managed-by" to "continuum-cluster-manager" + ) + + kubernetesClient.apps().deployments() + .inNamespace(namespace).withLabels(labels).delete() + kubernetesClient.services() + .inNamespace(namespace).withLabels(labels).delete() + } + + private fun renderTemplate(templateName: String, model: Map): String { + val template = freemarkerConfig.getTemplate(templateName) + val writer = StringWriter() + template.process(model, writer) + return writer.toString() + } + + @Suppress("DEPRECATION") + private fun applyYaml(yaml: String, namespace: String) { + val resources: List = kubernetesClient.load(yaml.byteInputStream()).items() + for (resource in resources) { + kubernetesClient.resource(resource) + .inNamespace(namespace) + .createOrReplace() + } + } + + private fun buildTemplateModel(entity: WorkbenchInstanceEntity): Map { + return mapOf( + "instanceId" to entity.instanceId.toString(), + "namespace" to entity.namespace, + "image" to entity.image, + "cpuRequest" to entity.cpuRequest, + "cpuLimit" to entity.cpuLimit, + "memoryRequest" to entity.memoryRequest, + "memoryLimit" to entity.memoryLimit, + "storageSize" to entity.storageSize, + "storageClassName" to (entity.storageClassName ?: "") + ) + } + + private fun toResponse(entity: WorkbenchInstanceEntity): WorkbenchResponse { + return WorkbenchResponse( + instanceId = entity.instanceId, + instanceName = entity.instanceName, + namespace = entity.namespace, + userId = entity.userId, + status = entity.status, + image = entity.image, + resources = ResourceSpec( + cpuRequest = entity.cpuRequest, + cpuLimit = entity.cpuLimit, + memoryRequest = entity.memoryRequest, + memoryLimit = entity.memoryLimit, + storageSize = entity.storageSize, + storageClassName = entity.storageClassName + ), + serviceEndpoint = "wb-${entity.instanceId}-svc.${entity.namespace}.svc.cluster.local:8080", + createdAt = entity.createdAt, + updatedAt = entity.updatedAt + ) + } +} diff --git a/continuum-cluster-manager/src/main/resources/application.yml b/continuum-cluster-manager/src/main/resources/application.yml new file mode 100644 index 00000000..115e8a7d --- /dev/null +++ b/continuum-cluster-manager/src/main/resources/application.yml @@ -0,0 +1,31 @@ +spring.application.name: continuum-cluster-manager +server: + port: 8080 + error: + include-message: always + include-stacktrace: on_param +spring: + datasource: + url: ${CONTINUUM_DB_URL:jdbc:postgresql://localhost:35432/continuum} + username: ${CONTINUUM_DB_USERNAME:continuum_owner} + password: ${CONTINUUM_DB_PASSWORD:continuum-test-password} + driver-class-name: org.postgresql.Driver + sql: + init: + mode: always + +continuum.core: + cluster-manager: + kubernetes: + master-url: ${KUBERNETES_MASTER_URL:} + kubeconfig: ${KUBECONFIG:} + workbench: + default-image: ${WORKBENCH_DEFAULT_IMAGE:projectcontinuum/continuum-workbench:0.0.5} + namespace: ${WORKBENCH_NAMESPACE:default} + image-repository: ${WORKBENCH_IMAGE_REPOSITORY:projectcontinuum/continuum-workbench} + +springdoc: + packages-to-scan: org.projectcontinuum.core.cluster.manager.controller + swagger-ui: + config-url: ../v3/api-docs/swagger-config + url: ../v3/api-docs diff --git a/continuum-cluster-manager/src/main/resources/schema.sql b/continuum-cluster-manager/src/main/resources/schema.sql new file mode 100644 index 00000000..c2966a51 --- /dev/null +++ b/continuum-cluster-manager/src/main/resources/schema.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS workbench_instances ( + instance_id UUID PRIMARY KEY, + instance_name VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL DEFAULT 'default', + user_id VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + image VARCHAR(512) NOT NULL, + cpu_request VARCHAR(50) NOT NULL DEFAULT '500m', + cpu_limit VARCHAR(50) NOT NULL DEFAULT '2', + memory_request VARCHAR(50) NOT NULL DEFAULT '512Mi', + memory_limit VARCHAR(50) NOT NULL DEFAULT '1Gi', + storage_size VARCHAR(50) NOT NULL DEFAULT '5Gi', + storage_class_name VARCHAR(255), + k8s_resources TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + entity_version BIGINT NOT NULL DEFAULT 0 +); + +-- Partial unique index to prevent duplicate active workbenches per user (PostgreSQL-specific) +CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_active_workbench + ON workbench_instances (user_id, instance_name) + WHERE status NOT IN ('DELETED', 'TERMINATING'); + +-- Index for fast lookups by user_id and instance_name +CREATE INDEX IF NOT EXISTS idx_workbench_user_instance + ON workbench_instances (user_id, instance_name); + +-- Index for listing workbenches by user (excludes deleted) +CREATE INDEX IF NOT EXISTS idx_workbench_user_status + ON workbench_instances (user_id, status); + diff --git a/continuum-cluster-manager/src/main/resources/templates/deployment.ftl b/continuum-cluster-manager/src/main/resources/templates/deployment.ftl new file mode 100644 index 00000000..b7936f0f --- /dev/null +++ b/continuum-cluster-manager/src/main/resources/templates/deployment.ftl @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wb-${instanceId}-deployment + namespace: ${namespace} + labels: + app: continuum-workbench + instance-id: "${instanceId}" + managed-by: continuum-cluster-manager +spec: + replicas: 1 + selector: + matchLabels: + app: continuum-workbench + instance-id: "${instanceId}" + template: + metadata: + labels: + app: continuum-workbench + instance-id: "${instanceId}" + managed-by: continuum-cluster-manager + spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: theia + image: ${image} + ports: + - containerPort: 8080 + resources: + requests: + cpu: "${cpuRequest}" + memory: "${memoryRequest}" + limits: + cpu: "${cpuLimit}" + memory: "${memoryLimit}" + volumeMounts: + - name: workspace-storage + mountPath: /workspace + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + volumes: + - name: workspace-storage + persistentVolumeClaim: + claimName: wb-${instanceId}-pvc diff --git a/continuum-cluster-manager/src/main/resources/templates/pvc.ftl b/continuum-cluster-manager/src/main/resources/templates/pvc.ftl new file mode 100644 index 00000000..a867739f --- /dev/null +++ b/continuum-cluster-manager/src/main/resources/templates/pvc.ftl @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wb-${instanceId}-pvc + namespace: ${namespace} + labels: + app: continuum-workbench + instance-id: "${instanceId}" + managed-by: continuum-cluster-manager +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: ${storageSize} +<#if storageClassName?has_content> + storageClassName: ${storageClassName} + diff --git a/continuum-cluster-manager/src/main/resources/templates/service.ftl b/continuum-cluster-manager/src/main/resources/templates/service.ftl new file mode 100644 index 00000000..b7df4ddd --- /dev/null +++ b/continuum-cluster-manager/src/main/resources/templates/service.ftl @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: wb-${instanceId}-svc + namespace: ${namespace} + labels: + app: continuum-workbench + instance-id: "${instanceId}" + managed-by: continuum-cluster-manager +spec: + type: ClusterIP + selector: + app: continuum-workbench + instance-id: "${instanceId}" + ports: + - protocol: TCP + port: 8080 + targetPort: 8080 diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/controller/WorkbenchControllerTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/controller/WorkbenchControllerTest.kt new file mode 100644 index 00000000..04e30171 --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/controller/WorkbenchControllerTest.kt @@ -0,0 +1,533 @@ +package org.projectcontinuum.core.cluster.manager.controller + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.fabric8.kubernetes.client.KubernetesClientException +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.projectcontinuum.core.cluster.manager.exception.WorkbenchNotFoundException +import org.projectcontinuum.core.cluster.manager.model.ResourceSpec +import org.projectcontinuum.core.cluster.manager.model.WorkbenchResponse +import org.projectcontinuum.core.cluster.manager.model.WorkbenchStatus +import org.projectcontinuum.core.cluster.manager.service.DockerHubService +import org.projectcontinuum.core.cluster.manager.service.DockerHubTag +import org.projectcontinuum.core.cluster.manager.service.WorkbenchService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import java.time.Instant +import java.util.UUID + +@WebMvcTest(WorkbenchController::class) +class WorkbenchControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @MockitoBean + private lateinit var workbenchService: WorkbenchService + + @MockitoBean + private lateinit var dockerHubService: DockerHubService + + private val objectMapper = jacksonObjectMapper() + + private fun sampleResponse( + instanceName: String = "my-workbench", + status: String = WorkbenchStatus.RUNNING.name, + namespace: String = "default", + userId: String = "user-1" + ) = WorkbenchResponse( + instanceId = UUID.randomUUID(), + instanceName = instanceName, + namespace = namespace, + userId = userId, + status = status, + image = "theiaide/theia:latest", + resources = ResourceSpec(), + serviceEndpoint = "wb-test-svc.$namespace.svc.cluster.local:8080", + createdAt = Instant.now(), + updatedAt = Instant.now() + ) + + // โ”€โ”€ POST /api/v1/workbench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `POST creates workbench and returns 201`() { + val response = sampleResponse() + whenever(workbenchService.createWorkbench(eq("user-1"), any())).thenReturn(response) + + mockMvc.perform( + post("/api/v1/workbench") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"instanceName": "my-workbench"}""") + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.instanceName").value("my-workbench")) + .andExpect(jsonPath("$.status").value("RUNNING")) + } + + @Test + fun `POST without user-id header defaults to anonymous`() { + val response = sampleResponse(userId = "anonymous") + whenever(workbenchService.createWorkbench(eq("anonymous"), any())).thenReturn(response) + + mockMvc.perform( + post("/api/v1/workbench") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"instanceName": "my-workbench"}""") + ) + .andExpect(status().isCreated) + } + + @Test + fun `POST returns 400 when workbench already exists`() { + whenever(workbenchService.createWorkbench(eq("user-1"), any())) + .thenThrow(IllegalArgumentException("Workbench 'my-workbench' already exists for user 'user-1'")) + + mockMvc.perform( + post("/api/v1/workbench") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"instanceName": "my-workbench"}""") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("Workbench 'my-workbench' already exists for user 'user-1'")) + } + + @Test + fun `POST returns 201 with full resource spec`() { + val response = sampleResponse() + whenever(workbenchService.createWorkbench(eq("user-1"), any())).thenReturn(response) + + mockMvc.perform( + post("/api/v1/workbench") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{ + "instanceName": "my-workbench", + "image": "theiaide/theia:next", + "resources": { + "cpuRequest": "1", + "cpuLimit": "4", + "memoryRequest": "1Gi", + "memoryLimit": "4Gi", + "storageSize": "10Gi", + "storageClassName": "fast-ssd" + } + }""") + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.instanceName").value("my-workbench")) + } + + @Test + fun `POST returns 500 when K8s client fails`() { + whenever(workbenchService.createWorkbench(eq("user-1"), any())) + .thenThrow(KubernetesClientException("connection refused")) + + mockMvc.perform( + post("/api/v1/workbench") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"instanceName": "my-workbench"}""") + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.error").value("Kubernetes operation failed: connection refused")) + } + + @Test + fun `POST returns 500 on unexpected exception`() { + whenever(workbenchService.createWorkbench(eq("user-1"), any())) + .thenThrow(RuntimeException("unexpected")) + + mockMvc.perform( + post("/api/v1/workbench") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"instanceName": "my-workbench"}""") + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.error").value("Internal server error")) + } + + // โ”€โ”€ GET /api/v1/workbench/{instanceName} โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `GET instance returns workbench status`() { + val response = sampleResponse() + whenever(workbenchService.getWorkbenchStatus("user-1", "my-workbench")).thenReturn(response) + + mockMvc.perform( + get("/api/v1/workbench/my-workbench") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.instanceName").value("my-workbench")) + .andExpect(jsonPath("$.status").value("RUNNING")) + } + + @Test + fun `GET instance returns 404 when not found`() { + whenever(workbenchService.getWorkbenchStatus("user-1", "missing")) + .thenThrow(WorkbenchNotFoundException("Workbench 'missing' not found for user 'user-1'")) + + mockMvc.perform( + get("/api/v1/workbench/missing") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.error").value("Workbench 'missing' not found for user 'user-1'")) + } + + @Test + fun `GET instance without user-id header defaults to anonymous`() { + val response = sampleResponse(userId = "anonymous") + whenever(workbenchService.getWorkbenchStatus("anonymous", "my-workbench")).thenReturn(response) + + mockMvc.perform( + get("/api/v1/workbench/my-workbench") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.userId").value("anonymous")) + } + + @Test + fun `GET instance returns response with all fields`() { + val instanceId = UUID.randomUUID() + val now = Instant.parse("2026-01-15T10:30:00Z") + val response = WorkbenchResponse( + instanceId = instanceId, + instanceName = "full-wb", + namespace = "staging", + userId = "user-1", + status = WorkbenchStatus.PENDING.name, + image = "theiaide/theia:1.0", + resources = ResourceSpec( + cpuRequest = "1", + cpuLimit = "4", + memoryRequest = "1Gi", + memoryLimit = "4Gi", + storageSize = "20Gi", + storageClassName = "fast-ssd" + ), + serviceEndpoint = "wb-$instanceId-svc.staging.svc.cluster.local:8080", + createdAt = now, + updatedAt = now + ) + whenever(workbenchService.getWorkbenchStatus("user-1", "full-wb")).thenReturn(response) + + mockMvc.perform( + get("/api/v1/workbench/full-wb") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.instanceId").value(instanceId.toString())) + .andExpect(jsonPath("$.instanceName").value("full-wb")) + .andExpect(jsonPath("$.namespace").value("staging")) + .andExpect(jsonPath("$.userId").value("user-1")) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.image").value("theiaide/theia:1.0")) + .andExpect(jsonPath("$.resources.cpuRequest").value("1")) + .andExpect(jsonPath("$.resources.cpuLimit").value("4")) + .andExpect(jsonPath("$.resources.memoryRequest").value("1Gi")) + .andExpect(jsonPath("$.resources.memoryLimit").value("4Gi")) + .andExpect(jsonPath("$.resources.storageSize").value("20Gi")) + .andExpect(jsonPath("$.resources.storageClassName").value("fast-ssd")) + .andExpect(jsonPath("$.serviceEndpoint").exists()) + } + + // โ”€โ”€ DELETE /api/v1/workbench/{instanceName} โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `DELETE returns 204`() { + mockMvc.perform( + delete("/api/v1/workbench/my-workbench") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isNoContent) + + verify(workbenchService).deleteWorkbench("user-1", "my-workbench") + } + + @Test + fun `DELETE returns 404 when workbench not found`() { + whenever(workbenchService.deleteWorkbench("user-1", "missing")) + .thenThrow(WorkbenchNotFoundException("Workbench 'missing' not found for user 'user-1'")) + + mockMvc.perform( + delete("/api/v1/workbench/missing") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.error").exists()) + } + + @Test + fun `DELETE without user-id header defaults to anonymous`() { + mockMvc.perform( + delete("/api/v1/workbench/my-workbench") + ) + .andExpect(status().isNoContent) + + verify(workbenchService).deleteWorkbench("anonymous", "my-workbench") + } + + // โ”€โ”€ GET /api/v1/workbench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `GET list returns workbenches for user`() { + val responses = listOf(sampleResponse("wb-1"), sampleResponse("wb-2")) + whenever(workbenchService.listWorkbenches("user-1")).thenReturn(responses) + + mockMvc.perform( + get("/api/v1/workbench") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].instanceName").value("wb-1")) + .andExpect(jsonPath("$[1].instanceName").value("wb-2")) + } + + @Test + fun `GET list returns empty array when no workbenches exist`() { + whenever(workbenchService.listWorkbenches("user-1")).thenReturn(emptyList()) + + mockMvc.perform( + get("/api/v1/workbench") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(0)) + } + + @Test + fun `GET list without user-id header defaults to anonymous`() { + whenever(workbenchService.listWorkbenches("anonymous")).thenReturn(emptyList()) + + mockMvc.perform( + get("/api/v1/workbench") + ) + .andExpect(status().isOk) + + verify(workbenchService).listWorkbenches("anonymous") + } + + // โ”€โ”€ PUT /api/v1/workbench/{instanceName} โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `PUT updates workbench config`() { + val response = sampleResponse() + whenever(workbenchService.updateWorkbench(eq("user-1"), eq("my-workbench"), any())) + .thenReturn(response) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"image": "theiaide/theia:next"}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.instanceName").value("my-workbench")) + } + + @Test + fun `PUT returns 404 when workbench not found`() { + whenever(workbenchService.updateWorkbench(eq("user-1"), eq("missing"), any())) + .thenThrow(WorkbenchNotFoundException("Workbench 'missing' not found for user 'user-1'")) + + mockMvc.perform( + put("/api/v1/workbench/missing") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"image": "theiaide/theia:next"}""") + ) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.error").exists()) + } + + @Test + fun `PUT with resource spec updates`() { + val response = sampleResponse() + whenever(workbenchService.updateWorkbench(eq("user-1"), eq("my-workbench"), any())) + .thenReturn(response) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench") + .header("x-continuum-user-id", "user-1") + .contentType(MediaType.APPLICATION_JSON) + .content("""{ + "resources": { + "cpuRequest": "2", + "cpuLimit": "8", + "memoryRequest": "2Gi", + "memoryLimit": "8Gi", + "storageSize": "50Gi" + } + }""") + ) + .andExpect(status().isOk) + } + + @Test + fun `PUT without user-id header defaults to anonymous`() { + val response = sampleResponse(userId = "anonymous") + whenever(workbenchService.updateWorkbench(eq("anonymous"), eq("my-workbench"), any())) + .thenReturn(response) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"image": "theiaide/theia:next"}""") + ) + .andExpect(status().isOk) + + verify(workbenchService).updateWorkbench(eq("anonymous"), eq("my-workbench"), any()) + } + + // โ”€โ”€ PUT /api/v1/workbench/{instanceName}/suspend โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `PUT suspend returns 200 with suspended response`() { + val response = sampleResponse(status = WorkbenchStatus.SUSPENDED.name) + whenever(workbenchService.suspendWorkbench("user-1", "my-workbench")).thenReturn(response) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench/suspend") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.instanceName").value("my-workbench")) + .andExpect(jsonPath("$.status").value("SUSPENDED")) + } + + @Test + fun `PUT suspend returns 404 when workbench not found`() { + whenever(workbenchService.suspendWorkbench("user-1", "missing")) + .thenThrow(WorkbenchNotFoundException("Workbench 'missing' not found for user 'user-1'")) + + mockMvc.perform( + put("/api/v1/workbench/missing/suspend") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.error").exists()) + } + + @Test + fun `PUT suspend returns 400 when already suspended`() { + whenever(workbenchService.suspendWorkbench("user-1", "my-workbench")) + .thenThrow(IllegalArgumentException("Workbench 'my-workbench' is already suspended")) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench/suspend") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("Workbench 'my-workbench' is already suspended")) + } + + @Test + fun `PUT suspend without user-id header defaults to anonymous`() { + val response = sampleResponse(userId = "anonymous", status = WorkbenchStatus.SUSPENDED.name) + whenever(workbenchService.suspendWorkbench("anonymous", "my-workbench")).thenReturn(response) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench/suspend") + ) + .andExpect(status().isOk) + + verify(workbenchService).suspendWorkbench("anonymous", "my-workbench") + } + + // โ”€โ”€ PUT /api/v1/workbench/{instanceName}/resume โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `PUT resume returns 200 with running response`() { + val response = sampleResponse(status = WorkbenchStatus.RUNNING.name) + whenever(workbenchService.resumeWorkbench("user-1", "my-workbench")).thenReturn(response) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench/resume") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.instanceName").value("my-workbench")) + .andExpect(jsonPath("$.status").value("RUNNING")) + } + + @Test + fun `PUT resume returns 404 when workbench not found`() { + whenever(workbenchService.resumeWorkbench("user-1", "missing")) + .thenThrow(WorkbenchNotFoundException("Workbench 'missing' not found for user 'user-1'")) + + mockMvc.perform( + put("/api/v1/workbench/missing/resume") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.error").exists()) + } + + @Test + fun `PUT resume returns 400 when workbench is not suspended`() { + whenever(workbenchService.resumeWorkbench("user-1", "my-workbench")) + .thenThrow(IllegalArgumentException("Workbench 'my-workbench' is not suspended")) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench/resume") + .header("x-continuum-user-id", "user-1") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.error").value("Workbench 'my-workbench' is not suspended")) + } + + @Test + fun `PUT resume without user-id header defaults to anonymous`() { + val response = sampleResponse(userId = "anonymous", status = WorkbenchStatus.RUNNING.name) + whenever(workbenchService.resumeWorkbench("anonymous", "my-workbench")).thenReturn(response) + + mockMvc.perform( + put("/api/v1/workbench/my-workbench/resume") + ) + .andExpect(status().isOk) + + verify(workbenchService).resumeWorkbench("anonymous", "my-workbench") + } + + // โ”€โ”€ GET /api/v1/workbench/tags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `GET tags returns available Docker Hub tags`() { + val tags = listOf( + DockerHubTag("0.0.5", "2024-01-01T00:00:00Z", 123456L), + DockerHubTag("latest", "2024-01-02T00:00:00Z", 654321L) + ) + whenever(dockerHubService.getAvailableTags()).thenReturn(tags) + + mockMvc.perform(get("/api/v1/workbench/tags")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].name").value("0.0.5")) + .andExpect(jsonPath("$[0].lastUpdated").value("2024-01-01T00:00:00Z")) + .andExpect(jsonPath("$[1].name").value("latest")) + } + + @Test + fun `GET tags returns 500 when Docker Hub is unreachable`() { + whenever(dockerHubService.getAvailableTags()) + .thenThrow(RuntimeException("Failed to fetch Docker Hub tags")) + + mockMvc.perform(get("/api/v1/workbench/tags")) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.error").value("Internal server error")) + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/entity/WorkbenchInstanceEntityTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/entity/WorkbenchInstanceEntityTest.kt new file mode 100644 index 00000000..bea12b68 --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/entity/WorkbenchInstanceEntityTest.kt @@ -0,0 +1,153 @@ +package org.projectcontinuum.core.cluster.manager.entity + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.projectcontinuum.core.cluster.manager.model.WorkbenchStatus +import java.time.Instant +import java.util.UUID + +class WorkbenchInstanceEntityTest { + + @Test + fun `entity has correct defaults for resource fields`() { + val entity = WorkbenchInstanceEntity( + instanceId = UUID.randomUUID(), + instanceName = "test-wb", + namespace = "default", + userId = "user-1", + status = WorkbenchStatus.PENDING.name, + image = "theiaide/theia:latest" + ) + + assertEquals("500m", entity.cpuRequest) + assertEquals("2", entity.cpuLimit) + assertEquals("512Mi", entity.memoryRequest) + assertEquals("1Gi", entity.memoryLimit) + assertEquals("5Gi", entity.storageSize) + assertNull(entity.storageClassName) + assertEquals("[]", entity.k8sResources) + assertNull(entity.entityVersion) + } + + @Test + fun `entity copy preserves all fields when none overridden`() { + val original = WorkbenchInstanceEntity( + instanceId = UUID.randomUUID(), + instanceName = "test-wb", + namespace = "staging", + userId = "user-1", + status = WorkbenchStatus.RUNNING.name, + image = "theiaide/theia:latest", + cpuRequest = "1", + cpuLimit = "4", + memoryRequest = "2Gi", + memoryLimit = "8Gi", + storageSize = "20Gi", + storageClassName = "fast-ssd", + k8sResources = "[\"deployment/test\"]", + createdAt = Instant.parse("2026-01-01T00:00:00Z"), + updatedAt = Instant.parse("2026-01-01T00:00:00Z") + ) + + val copy = original.copy() + + assertEquals(original, copy) + assertEquals(original.instanceId, copy.instanceId) + assertEquals(original.storageClassName, copy.storageClassName) + assertEquals(original.k8sResources, copy.k8sResources) + } + + @Test + fun `entity copy with status change only changes status`() { + val original = WorkbenchInstanceEntity( + instanceId = UUID.randomUUID(), + instanceName = "test-wb", + namespace = "default", + userId = "user-1", + status = WorkbenchStatus.RUNNING.name, + image = "theiaide/theia:latest" + ) + + val updated = original.copy(status = WorkbenchStatus.DELETED.name) + + assertEquals(WorkbenchStatus.DELETED.name, updated.status) + assertEquals(original.instanceId, updated.instanceId) + assertEquals(original.instanceName, updated.instanceName) + assertEquals(original.image, updated.image) + } + + @Test + fun `entity with custom storage class name`() { + val entity = WorkbenchInstanceEntity( + instanceId = UUID.randomUUID(), + instanceName = "test-wb", + namespace = "default", + userId = "user-1", + status = WorkbenchStatus.PENDING.name, + image = "theiaide/theia:latest", + storageClassName = "gp3" + ) + + assertEquals("gp3", entity.storageClassName) + } + + @Test + fun `entity equality is based on all fields`() { + val id = UUID.randomUUID() + val now = Instant.now() + + val entity1 = WorkbenchInstanceEntity( + instanceId = id, + instanceName = "test", + namespace = "default", + userId = "user-1", + status = "RUNNING", + image = "image:latest", + createdAt = now, + updatedAt = now + ) + + val entity2 = WorkbenchInstanceEntity( + instanceId = id, + instanceName = "test", + namespace = "default", + userId = "user-1", + status = "RUNNING", + image = "image:latest", + createdAt = now, + updatedAt = now + ) + + assertEquals(entity1, entity2) + assertEquals(entity1.hashCode(), entity2.hashCode()) + } + + @Test + fun `entities with different ids are not equal`() { + val now = Instant.now() + + val entity1 = WorkbenchInstanceEntity( + instanceId = UUID.randomUUID(), + instanceName = "test", + namespace = "default", + userId = "user-1", + status = "RUNNING", + image = "image:latest", + createdAt = now, + updatedAt = now + ) + + val entity2 = WorkbenchInstanceEntity( + instanceId = UUID.randomUUID(), + instanceName = "test", + namespace = "default", + userId = "user-1", + status = "RUNNING", + image = "image:latest", + createdAt = now, + updatedAt = now + ) + + assertNotEquals(entity1, entity2) + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/exception/GlobalExceptionHandlerTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/exception/GlobalExceptionHandlerTest.kt new file mode 100644 index 00000000..3953529d --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/exception/GlobalExceptionHandlerTest.kt @@ -0,0 +1,79 @@ +package org.projectcontinuum.core.cluster.manager.exception + +import io.fabric8.kubernetes.client.KubernetesClientException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.bind.MissingRequestHeaderException + +class GlobalExceptionHandlerTest { + + private val handler = GlobalExceptionHandler() + + @Test + fun `handleNotFound returns 404 with error message`() { + val ex = WorkbenchNotFoundException("Workbench 'test' not found for user 'user-1'") + val response = handler.handleNotFound(ex) + + assertEquals(HttpStatus.NOT_FOUND, response.statusCode) + assertEquals("Workbench 'test' not found for user 'user-1'", response.body!!["error"]) + } + + @Test + fun `handleKubernetesError returns 500 with K8s error details`() { + val ex = KubernetesClientException("connection refused") + val response = handler.handleKubernetesError(ex) + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) + assertEquals("Kubernetes operation failed: connection refused", response.body!!["error"]) + } + + @Test + fun `handleBadRequest returns 400 with error message`() { + val ex = IllegalArgumentException("Workbench 'wb' already exists for user 'user-1'") + val response = handler.handleBadRequest(ex) + + assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) + assertEquals("Workbench 'wb' already exists for user 'user-1'", response.body!!["error"]) + } + + @Test + fun `handleBadRequest returns default message when exception message is null`() { + val ex = IllegalArgumentException() + val response = handler.handleBadRequest(ex) + + assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) + assertEquals("Bad request", response.body!!["error"]) + } + + @Test + fun `handleMissingHeader returns 400 with header name`() { + val method = String::class.java.getMethod("toString") + val coreParam = org.springframework.core.MethodParameter(method, -1) + val ex = MissingRequestHeaderException("x-continuum-user-id", coreParam) + val response = handler.handleMissingHeader(ex) + + assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) + assertTrue(response.body!!["error"]!!.contains("x-continuum-user-id")) + } + + @Test + fun `handleGenericError returns 500 with generic message`() { + val ex = RuntimeException("something went wrong") + val response = handler.handleGenericError(ex) + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) + assertEquals("Internal server error", response.body!!["error"]) + } + + @Test + fun `handleGenericError does not leak exception details`() { + val ex = RuntimeException("sensitive database connection string: jdbc://...") + val response = handler.handleGenericError(ex) + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) + // Should NOT contain the sensitive message + assertEquals("Internal server error", response.body!!["error"]) + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/ResourceSpecTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/ResourceSpecTest.kt new file mode 100644 index 00000000..2edba3e4 --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/ResourceSpecTest.kt @@ -0,0 +1,65 @@ +package org.projectcontinuum.core.cluster.manager.model + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ResourceSpecTest { + + @Test + fun `default values are correct`() { + val spec = ResourceSpec() + + assertEquals("500m", spec.cpuRequest) + assertEquals("2", spec.cpuLimit) + assertEquals("512Mi", spec.memoryRequest) + assertEquals("1Gi", spec.memoryLimit) + assertEquals("5Gi", spec.storageSize) + assertNull(spec.storageClassName) + } + + @Test + fun `custom values are preserved`() { + val spec = ResourceSpec( + cpuRequest = "4", + cpuLimit = "8", + memoryRequest = "8Gi", + memoryLimit = "16Gi", + storageSize = "100Gi", + storageClassName = "premium-ssd" + ) + + assertEquals("4", spec.cpuRequest) + assertEquals("8", spec.cpuLimit) + assertEquals("8Gi", spec.memoryRequest) + assertEquals("16Gi", spec.memoryLimit) + assertEquals("100Gi", spec.storageSize) + assertEquals("premium-ssd", spec.storageClassName) + } + + @Test + fun `copy with partial overrides`() { + val original = ResourceSpec(cpuRequest = "1", storageClassName = "standard") + val modified = original.copy(cpuRequest = "2") + + assertEquals("2", modified.cpuRequest) + assertEquals("standard", modified.storageClassName) + assertEquals(original.cpuLimit, modified.cpuLimit) + } + + @Test + fun `equality works correctly`() { + val spec1 = ResourceSpec(cpuRequest = "1") + val spec2 = ResourceSpec(cpuRequest = "1") + + assertEquals(spec1, spec2) + assertEquals(spec1.hashCode(), spec2.hashCode()) + } + + @Test + fun `inequality when different values`() { + val spec1 = ResourceSpec(cpuRequest = "1") + val spec2 = ResourceSpec(cpuRequest = "2") + + assertNotEquals(spec1, spec2) + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchCreateRequestTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchCreateRequestTest.kt new file mode 100644 index 00000000..a5ad1019 --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchCreateRequestTest.kt @@ -0,0 +1,46 @@ +package org.projectcontinuum.core.cluster.manager.model + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WorkbenchCreateRequestTest { + + @Test + fun `default values are correct`() { + val request = WorkbenchCreateRequest(instanceName = "test-wb") + + assertEquals("test-wb", request.instanceName) + assertEquals("projectcontinuum/continuum-workbench:0.0.5", request.image) + assertEquals(ResourceSpec(), request.resources) + } + + @Test + fun `custom values override defaults`() { + val resources = ResourceSpec(cpuRequest = "4") + val request = WorkbenchCreateRequest( + instanceName = "custom-wb", + resources = resources, + image = "theiaide/theia:v2" + ) + + assertEquals("custom-wb", request.instanceName) + assertEquals("theiaide/theia:v2", request.image) + assertEquals("4", request.resources.cpuRequest) + } + + @Test + fun `equality based on all fields`() { + val req1 = WorkbenchCreateRequest(instanceName = "wb") + val req2 = WorkbenchCreateRequest(instanceName = "wb") + + assertEquals(req1, req2) + } + + @Test + fun `inequality when different instance names`() { + val req1 = WorkbenchCreateRequest(instanceName = "wb-1") + val req2 = WorkbenchCreateRequest(instanceName = "wb-2") + + assertNotEquals(req1, req2) + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchResponseTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchResponseTest.kt new file mode 100644 index 00000000..3be5df84 --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchResponseTest.kt @@ -0,0 +1,69 @@ +package org.projectcontinuum.core.cluster.manager.model + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.UUID + +class WorkbenchResponseTest { + + @Test + fun `response holds all provided values`() { + val instanceId = UUID.randomUUID() + val now = Instant.now() + + val response = WorkbenchResponse( + instanceId = instanceId, + instanceName = "test-wb", + namespace = "staging", + userId = "user-1", + status = WorkbenchStatus.RUNNING.name, + image = "theiaide/theia:latest", + resources = ResourceSpec(cpuRequest = "2"), + serviceEndpoint = "wb-$instanceId-svc.staging.svc.cluster.local:8080", + createdAt = now, + updatedAt = now + ) + + assertEquals(instanceId, response.instanceId) + assertEquals("test-wb", response.instanceName) + assertEquals("staging", response.namespace) + assertEquals("user-1", response.userId) + assertEquals("RUNNING", response.status) + assertEquals("theiaide/theia:latest", response.image) + assertEquals("2", response.resources.cpuRequest) + assertEquals("wb-$instanceId-svc.staging.svc.cluster.local:8080", response.serviceEndpoint) + assertEquals(now, response.createdAt) + assertEquals(now, response.updatedAt) + } + + @Test + fun `response allows null service endpoint`() { + val response = WorkbenchResponse( + instanceId = UUID.randomUUID(), + instanceName = "test-wb", + namespace = "default", + userId = "user-1", + status = "FAILED", + image = "theiaide/theia:latest", + resources = ResourceSpec(), + serviceEndpoint = null, + createdAt = Instant.now(), + updatedAt = Instant.now() + ) + + assertNull(response.serviceEndpoint) + } + + @Test + fun `response equality works`() { + val id = UUID.randomUUID() + val now = Instant.now() + + val r1 = WorkbenchResponse(id, "wb", "ns", "u", "RUNNING", "img", ResourceSpec(), "ep", now, now) + val r2 = WorkbenchResponse(id, "wb", "ns", "u", "RUNNING", "img", ResourceSpec(), "ep", now, now) + + assertEquals(r1, r2) + assertEquals(r1.hashCode(), r2.hashCode()) + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchStatusTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchStatusTest.kt new file mode 100644 index 00000000..86d02460 --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchStatusTest.kt @@ -0,0 +1,42 @@ +package org.projectcontinuum.core.cluster.manager.model + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WorkbenchStatusTest { + + @Test + fun `all expected statuses exist`() { + val statuses = WorkbenchStatus.entries.map { it.name } + assertTrue(statuses.contains("PENDING")) + assertTrue(statuses.contains("RUNNING")) + assertTrue(statuses.contains("FAILED")) + assertTrue(statuses.contains("SUSPENDED")) + assertTrue(statuses.contains("UNKNOWN")) + assertTrue(statuses.contains("TERMINATING")) + assertTrue(statuses.contains("DELETED")) + } + + @Test + fun `enum has exactly 6 values`() { + assertEquals(7, WorkbenchStatus.entries.size) + } + + @Test + fun `valueOf returns correct enum for valid names`() { + assertEquals(WorkbenchStatus.PENDING, WorkbenchStatus.valueOf("PENDING")) + assertEquals(WorkbenchStatus.RUNNING, WorkbenchStatus.valueOf("RUNNING")) + assertEquals(WorkbenchStatus.FAILED, WorkbenchStatus.valueOf("FAILED")) + assertEquals(WorkbenchStatus.SUSPENDED, WorkbenchStatus.valueOf("SUSPENDED")) + assertEquals(WorkbenchStatus.UNKNOWN, WorkbenchStatus.valueOf("UNKNOWN")) + assertEquals(WorkbenchStatus.TERMINATING, WorkbenchStatus.valueOf("TERMINATING")) + assertEquals(WorkbenchStatus.DELETED, WorkbenchStatus.valueOf("DELETED")) + } + + @Test + fun `valueOf throws for invalid name`() { + assertThrows(IllegalArgumentException::class.java) { + WorkbenchStatus.valueOf("INVALID") + } + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchUpdateRequestTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchUpdateRequestTest.kt new file mode 100644 index 00000000..4c9a8a8c --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/model/WorkbenchUpdateRequestTest.kt @@ -0,0 +1,43 @@ +package org.projectcontinuum.core.cluster.manager.model + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WorkbenchUpdateRequestTest { + + @Test + fun `default values are null`() { + val request = WorkbenchUpdateRequest() + + assertNull(request.resources) + assertNull(request.image) + } + + @Test + fun `image only update`() { + val request = WorkbenchUpdateRequest(image = "theiaide/theia:v2") + + assertEquals("theiaide/theia:v2", request.image) + assertNull(request.resources) + } + + @Test + fun `resources only update`() { + val resources = ResourceSpec(cpuRequest = "4") + val request = WorkbenchUpdateRequest(resources = resources) + + assertNull(request.image) + assertEquals("4", request.resources!!.cpuRequest) + } + + @Test + fun `both image and resources update`() { + val request = WorkbenchUpdateRequest( + image = "theiaide/theia:v3", + resources = ResourceSpec(memoryLimit = "16Gi") + ) + + assertEquals("theiaide/theia:v3", request.image) + assertEquals("16Gi", request.resources!!.memoryLimit) + } +} diff --git a/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/service/WorkbenchServiceTest.kt b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/service/WorkbenchServiceTest.kt new file mode 100644 index 00000000..ac29ef88 --- /dev/null +++ b/continuum-cluster-manager/src/test/kotlin/org/projectcontinuum/core/cluster/manager/service/WorkbenchServiceTest.kt @@ -0,0 +1,714 @@ +package org.projectcontinuum.core.cluster.manager.service + +import freemarker.template.Configuration +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder +import io.fabric8.kubernetes.api.model.apps.DeploymentStatusBuilder +import io.fabric8.kubernetes.client.KubernetesClient +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient +import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.projectcontinuum.core.cluster.manager.config.WorkbenchProperties +import org.projectcontinuum.core.cluster.manager.entity.WorkbenchInstanceEntity +import org.projectcontinuum.core.cluster.manager.exception.WorkbenchNotFoundException +import org.projectcontinuum.core.cluster.manager.model.* +import org.projectcontinuum.core.cluster.manager.repository.WorkbenchInstanceRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.support.TransactionTemplate +import java.time.Instant +import java.util.UUID + +@SpringBootTest +@EnableKubernetesMockClient(crud = true) +@ActiveProfiles("test") +class WorkbenchServiceTest { + + @Autowired + private lateinit var repository: WorkbenchInstanceRepository + + @Autowired + private lateinit var transactionTemplate: TransactionTemplate + + lateinit var client: KubernetesClient + lateinit var server: KubernetesMockServer + + private lateinit var service: WorkbenchService + private lateinit var workbenchProperties: WorkbenchProperties + + @BeforeEach + fun setUp() { + repository.deleteAll() + + val freemarkerConfig = Configuration(Configuration.VERSION_2_3_34) + freemarkerConfig.setClassLoaderForTemplateLoading(this::class.java.classLoader, "/templates") + freemarkerConfig.defaultEncoding = "UTF-8" + + workbenchProperties = WorkbenchProperties( + defaultImage = "projectcontinuum/continuum-workbench:latest", + namespace = "default" + ) + + service = WorkbenchService(repository, client, freemarkerConfig, transactionTemplate, workbenchProperties) + } + + private fun createSampleEntity( + instanceName: String = "test-wb", + namespace: String = "default", + userId: String = "user-1", + status: String = WorkbenchStatus.RUNNING.name, + instanceId: UUID = UUID.randomUUID() + ): WorkbenchInstanceEntity { + val now = Instant.now() + return WorkbenchInstanceEntity( + instanceId = instanceId, + instanceName = instanceName, + namespace = namespace, + userId = userId, + status = status, + image = "theiaide/theia:latest", + createdAt = now, + updatedAt = now + ) + } + + // โ”€โ”€ createWorkbench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `createWorkbench saves entity and creates K8s resources`() { + val request = WorkbenchCreateRequest( + instanceName = "test-wb", + resources = ResourceSpec(), + image = "theiaide/theia:latest" + ) + + val response = service.createWorkbench("user-1", request) + + assertEquals("test-wb", response.instanceName) + assertEquals("user-1", response.userId) + assertEquals("default", response.namespace) + assertEquals(WorkbenchStatus.RUNNING.name, response.status) + assertNotNull(response.instanceId) + assertNotNull(response.serviceEndpoint) + + val saved = repository.findByUserIdAndInstanceName("user-1", "test-wb") + assertNotNull(saved) + assertEquals(response.instanceId, saved!!.instanceId) + + val deployments = client.apps().deployments().inNamespace("default") + .withLabel("instance-id", response.instanceId.toString()).list().items + assertEquals(1, deployments.size) + + val services = client.services().inNamespace("default") + .withLabel("instance-id", response.instanceId.toString()).list().items + assertEquals(1, services.size) + + val pvcs = client.persistentVolumeClaims().inNamespace("default") + .withLabel("instance-id", response.instanceId.toString()).list().items + assertEquals(1, pvcs.size) + } + + @Test + fun `createWorkbench throws when workbench with same name already exists`() { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "dup-wb")) + + val exception = assertThrows { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "dup-wb")) + } + assertTrue(exception.message!!.contains("already exists")) + } + + @Test + fun `createWorkbench with custom resources sets correct values`() { + val customResources = ResourceSpec( + cpuRequest = "2", + cpuLimit = "4", + memoryRequest = "2Gi", + memoryLimit = "8Gi", + storageSize = "50Gi", + storageClassName = "fast-ssd" + ) + val request = WorkbenchCreateRequest( + instanceName = "custom-wb", + resources = customResources, + image = "theiaide/theia:custom" + ) + + val response = service.createWorkbench("user-1", request) + + assertEquals("custom-wb", response.instanceName) + assertEquals("default", response.namespace) // Uses configured namespace + assertEquals("theiaide/theia:custom", response.image) + assertEquals("2", response.resources.cpuRequest) + assertEquals("4", response.resources.cpuLimit) + assertEquals("2Gi", response.resources.memoryRequest) + assertEquals("8Gi", response.resources.memoryLimit) + assertEquals("50Gi", response.resources.storageSize) + assertEquals("fast-ssd", response.resources.storageClassName) + } + + @Test + fun `createWorkbench with default request values uses defaults`() { + val request = WorkbenchCreateRequest(instanceName = "default-wb") + + val response = service.createWorkbench("user-1", request) + + assertEquals("default", response.namespace) + assertEquals("projectcontinuum/continuum-workbench:0.0.5", response.image) + assertEquals("500m", response.resources.cpuRequest) + assertEquals("2", response.resources.cpuLimit) + assertEquals("512Mi", response.resources.memoryRequest) + assertEquals("1Gi", response.resources.memoryLimit) + assertEquals("5Gi", response.resources.storageSize) + assertNull(response.resources.storageClassName) + } + + @Test + fun `createWorkbench generates unique instanceId`() { + val response1 = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "wb-a")) + val response2 = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "wb-b")) + + assertNotEquals(response1.instanceId, response2.instanceId) + } + + @Test + fun `createWorkbench generates correct service endpoint format`() { + val request = WorkbenchCreateRequest(instanceName = "ep-wb") + val response = service.createWorkbench("user-1", request) + + val expectedEndpoint = "wb-${response.instanceId}-svc.default.svc.cluster.local:8080" + assertEquals(expectedEndpoint, response.serviceEndpoint) + } + + @Test + fun `createWorkbench allows re-creation for different users with same instance name`() { + val response1 = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "shared-name")) + val response2 = service.createWorkbench("user-2", WorkbenchCreateRequest(instanceName = "shared-name")) + + assertNotEquals(response1.instanceId, response2.instanceId) + assertEquals("user-1", response1.userId) + assertEquals("user-2", response2.userId) + } + + @Test + fun `createWorkbench sets timestamps on entity`() { + val beforeCreate = Instant.now() + val request = WorkbenchCreateRequest(instanceName = "ts-wb") + val response = service.createWorkbench("user-1", request) + + val entity = repository.findById(response.instanceId).get() + assertFalse(entity.createdAt.isBefore(beforeCreate)) + assertFalse(entity.updatedAt.isBefore(beforeCreate)) + } + + @Test + fun `createWorkbench uses configured namespace`() { + val request = WorkbenchCreateRequest(instanceName = "ns-test-wb") + val response = service.createWorkbench("user-1", request) + + assertEquals(workbenchProperties.namespace, response.namespace) + } + + // โ”€โ”€ getWorkbenchStatus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `getWorkbenchStatus returns response for existing workbench`() { + val instanceId = UUID.randomUUID() + val entity = createSampleEntity(instanceName = "status-wb", instanceId = instanceId) + repository.save(entity) + + val response = service.getWorkbenchStatus("user-1", "status-wb") + + assertEquals("status-wb", response.instanceName) + assertEquals("user-1", response.userId) + } + + @Test + fun `getWorkbenchStatus throws WorkbenchNotFoundException for missing workbench`() { + assertThrows { + service.getWorkbenchStatus("user-1", "nonexistent") + } + } + + @Test + fun `getWorkbenchStatus refreshes status from K8s when deployment has ready replicas`() { + val instanceId = UUID.randomUUID() + val entity = createSampleEntity( + instanceName = "refresh-wb", + instanceId = instanceId, + status = WorkbenchStatus.PENDING.name + ) + repository.save(entity) + + // Create a deployment in K8s with ready replicas + val deployment = DeploymentBuilder() + .withNewMetadata() + .withName("wb-$instanceId-deployment") + .withNamespace("default") + .endMetadata() + .withStatus(DeploymentStatusBuilder().withReadyReplicas(1).build()) + .build() + client.apps().deployments().inNamespace("default").resource(deployment).create() + + val response = service.getWorkbenchStatus("user-1", "refresh-wb") + assertEquals(WorkbenchStatus.RUNNING.name, response.status) + } + + @Test + fun `getWorkbenchStatus sets PENDING when deployment has zero ready replicas`() { + val instanceId = UUID.randomUUID() + val entity = createSampleEntity( + instanceName = "pending-wb", + instanceId = instanceId, + status = WorkbenchStatus.RUNNING.name + ) + repository.save(entity) + + // Create a deployment with 0 ready replicas + val deployment = DeploymentBuilder() + .withNewMetadata() + .withName("wb-$instanceId-deployment") + .withNamespace("default") + .endMetadata() + .withStatus(DeploymentStatusBuilder().withReadyReplicas(0).build()) + .build() + client.apps().deployments().inNamespace("default").resource(deployment).create() + + val response = service.getWorkbenchStatus("user-1", "pending-wb") + assertEquals(WorkbenchStatus.PENDING.name, response.status) + } + + @Test + fun `getWorkbenchStatus sets UNKNOWN when no deployment found`() { + val instanceId = UUID.randomUUID() + val entity = createSampleEntity( + instanceName = "unknown-wb", + instanceId = instanceId, + status = WorkbenchStatus.RUNNING.name + ) + repository.save(entity) + + // No K8s deployment exists for this workbench + val response = service.getWorkbenchStatus("user-1", "unknown-wb") + assertEquals(WorkbenchStatus.UNKNOWN.name, response.status) + } + + @Test + fun `getWorkbenchStatus does not update when status unchanged`() { + val instanceId = UUID.randomUUID() + val entity = createSampleEntity( + instanceName = "unchanged-wb", + instanceId = instanceId, + status = WorkbenchStatus.UNKNOWN.name + ) + repository.save(entity) + val versionAfterSave = repository.findById(instanceId).get().entityVersion + + // No deployment means UNKNOWN - same as current status + val response = service.getWorkbenchStatus("user-1", "unchanged-wb") + assertEquals(WorkbenchStatus.UNKNOWN.name, response.status) + + // Verify entity version didn't change (no unnecessary save) + val dbEntity = repository.findById(instanceId).get() + assertEquals(versionAfterSave, dbEntity.entityVersion) + } + + // โ”€โ”€ deleteWorkbench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `deleteWorkbench soft-deletes DB record and removes K8s resources`() { + val request = WorkbenchCreateRequest(instanceName = "delete-wb") + val created = service.createWorkbench("user-1", request) + + service.deleteWorkbench("user-1", "delete-wb") + + // Should not appear in active queries + assertNull(repository.findByUserIdAndInstanceName("user-1", "delete-wb")) + + // But record still exists in DB with DELETED status + val dbRecord = repository.findById(created.instanceId) + assertTrue(dbRecord.isPresent) + assertEquals("DELETED", dbRecord.get().status) + + val deployments = client.apps().deployments().inNamespace("default") + .withLabel("instance-id", created.instanceId.toString()).list().items + assertEquals(0, deployments.size) + } + + @Test + fun `deleteWorkbench throws WorkbenchNotFoundException for missing workbench`() { + assertThrows { + service.deleteWorkbench("user-1", "nonexistent") + } + } + + @Test + fun `deleteWorkbench removes all K8s resources - deployment, service, and pvc`() { + val created = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "full-del-wb")) + val instanceId = created.instanceId.toString() + + // Verify resources exist before delete + assertEquals(1, client.apps().deployments().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(1, client.services().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(1, client.persistentVolumeClaims().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + + service.deleteWorkbench("user-1", "full-del-wb") + + // Verify all resources are gone + assertEquals(0, client.apps().deployments().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(0, client.services().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(0, client.persistentVolumeClaims().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + } + + @Test + fun `deleted workbenches are excluded from list queries`() { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "keep-wb")) + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "remove-wb")) + + assertEquals(2, service.listWorkbenches("user-1").size) + + service.deleteWorkbench("user-1", "remove-wb") + + val remaining = service.listWorkbenches("user-1") + assertEquals(1, remaining.size) + assertEquals("keep-wb", remaining[0].instanceName) + } + + // โ”€โ”€ listWorkbenches โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `listWorkbenches returns all workbenches for user`() { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "wb-1")) + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "wb-2")) + service.createWorkbench("user-2", WorkbenchCreateRequest(instanceName = "wb-3")) + + val user1Workbenches = service.listWorkbenches("user-1") + assertEquals(2, user1Workbenches.size) + + val user2Workbenches = service.listWorkbenches("user-2") + assertEquals(1, user2Workbenches.size) + } + + @Test + fun `listWorkbenches returns empty list for unknown user`() { + val workbenches = service.listWorkbenches("unknown-user") + assertTrue(workbenches.isEmpty()) + } + + @Test + fun `listWorkbenches returns correct data in responses`() { + val request = WorkbenchCreateRequest( + instanceName = "detail-wb", + image = "theiaide/theia:v2", + resources = ResourceSpec(cpuRequest = "1", memoryRequest = "2Gi") + ) + service.createWorkbench("user-1", request) + + val results = service.listWorkbenches("user-1") + assertEquals(1, results.size) + + val wb = results[0] + assertEquals("detail-wb", wb.instanceName) + assertEquals("default", wb.namespace) + assertEquals("user-1", wb.userId) + assertEquals("theiaide/theia:v2", wb.image) + assertEquals("1", wb.resources.cpuRequest) + assertEquals("2Gi", wb.resources.memoryRequest) + assertNotNull(wb.instanceId) + assertNotNull(wb.serviceEndpoint) + assertNotNull(wb.createdAt) + assertNotNull(wb.updatedAt) + } + + // โ”€โ”€ updateWorkbench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `updateWorkbench updates image and resources`() { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "update-wb")) + + val updateRequest = WorkbenchUpdateRequest( + image = "theiaide/theia:next", + resources = ResourceSpec(cpuRequest = "1", memoryRequest = "1Gi") + ) + val updated = service.updateWorkbench("user-1", "update-wb", updateRequest) + + assertEquals("theiaide/theia:next", updated.image) + assertEquals("1", updated.resources.cpuRequest) + assertEquals("1Gi", updated.resources.memoryRequest) + } + + @Test + fun `updateWorkbench throws WorkbenchNotFoundException for missing workbench`() { + assertThrows { + service.updateWorkbench("user-1", "nonexistent", WorkbenchUpdateRequest()) + } + } + + @Test + fun `updateWorkbench with only image preserves existing resources`() { + val originalResources = ResourceSpec( + cpuRequest = "2", + cpuLimit = "4", + memoryRequest = "2Gi", + memoryLimit = "4Gi", + storageSize = "10Gi", + storageClassName = "standard" + ) + service.createWorkbench("user-1", WorkbenchCreateRequest( + instanceName = "partial-wb", + resources = originalResources + )) + + val updated = service.updateWorkbench( + "user-1", "partial-wb", + WorkbenchUpdateRequest(image = "theiaide/theia:v3") + ) + + assertEquals("theiaide/theia:v3", updated.image) + assertEquals("2", updated.resources.cpuRequest) + assertEquals("4", updated.resources.cpuLimit) + assertEquals("2Gi", updated.resources.memoryRequest) + assertEquals("4Gi", updated.resources.memoryLimit) + assertEquals("10Gi", updated.resources.storageSize) + assertEquals("standard", updated.resources.storageClassName) + } + + @Test + fun `updateWorkbench with only resources preserves existing image`() { + service.createWorkbench("user-1", WorkbenchCreateRequest( + instanceName = "res-only-wb", + image = "theiaide/theia:original" + )) + + val updated = service.updateWorkbench( + "user-1", "res-only-wb", + WorkbenchUpdateRequest(resources = ResourceSpec(cpuRequest = "4")) + ) + + assertEquals("theiaide/theia:original", updated.image) + assertEquals("4", updated.resources.cpuRequest) + } + + @Test + fun `updateWorkbench with empty request preserves all existing values`() { + val original = service.createWorkbench("user-1", WorkbenchCreateRequest( + instanceName = "noop-wb", + image = "theiaide/theia:original" + )) + + val updated = service.updateWorkbench( + "user-1", "noop-wb", + WorkbenchUpdateRequest() + ) + + assertEquals(original.image, updated.image) + assertEquals(original.resources.cpuRequest, updated.resources.cpuRequest) + assertEquals(original.resources.cpuLimit, updated.resources.cpuLimit) + assertEquals(original.resources.memoryRequest, updated.resources.memoryRequest) + assertEquals(original.resources.memoryLimit, updated.resources.memoryLimit) + assertEquals(original.resources.storageSize, updated.resources.storageSize) + } + + @Test + fun `updateWorkbench persists changes to database`() { + val created = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "persist-wb")) + + service.updateWorkbench( + "user-1", "persist-wb", + WorkbenchUpdateRequest(image = "theiaide/theia:updated", resources = ResourceSpec(cpuRequest = "8")) + ) + + val entity = repository.findById(created.instanceId).get() + assertEquals("theiaide/theia:updated", entity.image) + assertEquals("8", entity.cpuRequest) + } + + @Test + fun `updateWorkbench reapplies K8s deployment and service`() { + val created = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "k8s-upd-wb")) + + service.updateWorkbench( + "user-1", "k8s-upd-wb", + WorkbenchUpdateRequest(image = "theiaide/theia:v2") + ) + + // Verify deployment still exists after update + val deployments = client.apps().deployments().inNamespace("default") + .withLabel("instance-id", created.instanceId.toString()).list().items + assertEquals(1, deployments.size) + + // Verify service still exists after update + val services = client.services().inNamespace("default") + .withLabel("instance-id", created.instanceId.toString()).list().items + assertEquals(1, services.size) + } + + // โ”€โ”€ suspendWorkbench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `suspendWorkbench sets status to SUSPENDED and removes deployment and service but keeps PVC`() { + val created = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "suspend-wb")) + val instanceId = created.instanceId.toString() + + // Verify all 3 resources exist before suspend + assertEquals(1, client.apps().deployments().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(1, client.services().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(1, client.persistentVolumeClaims().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + + val response = service.suspendWorkbench("user-1", "suspend-wb") + + assertEquals(WorkbenchStatus.SUSPENDED.name, response.status) + assertEquals("suspend-wb", response.instanceName) + + // Deployment and Service should be deleted + assertEquals(0, client.apps().deployments().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(0, client.services().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + + // PVC should still exist + assertEquals(1, client.persistentVolumeClaims().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + } + + @Test + fun `suspendWorkbench persists SUSPENDED status in database`() { + val created = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "suspend-persist-wb")) + + service.suspendWorkbench("user-1", "suspend-persist-wb") + + val entity = repository.findById(created.instanceId).get() + assertEquals(WorkbenchStatus.SUSPENDED.name, entity.status) + } + + @Test + fun `suspendWorkbench throws WorkbenchNotFoundException for missing workbench`() { + assertThrows { + service.suspendWorkbench("user-1", "nonexistent") + } + } + + @Test + fun `suspendWorkbench throws IllegalArgumentException when already suspended`() { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "already-suspended-wb")) + service.suspendWorkbench("user-1", "already-suspended-wb") + + val exception = assertThrows { + service.suspendWorkbench("user-1", "already-suspended-wb") + } + assertTrue(exception.message!!.contains("already suspended")) + } + + @Test + fun `suspended workbenches still appear in list queries`() { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "active-wb")) + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "to-suspend-wb")) + + service.suspendWorkbench("user-1", "to-suspend-wb") + + val workbenches = service.listWorkbenches("user-1") + assertEquals(2, workbenches.size) + + val suspendedWb = workbenches.first { it.instanceName == "to-suspend-wb" } + assertEquals(WorkbenchStatus.SUSPENDED.name, suspendedWb.status) + } + + // โ”€โ”€ resumeWorkbench โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + fun `resumeWorkbench re-creates deployment and service and sets status to RUNNING`() { + val created = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "resume-wb")) + val instanceId = created.instanceId.toString() + + service.suspendWorkbench("user-1", "resume-wb") + + // Verify deployment and service are gone after suspend + assertEquals(0, client.apps().deployments().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(0, client.services().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + + val response = service.resumeWorkbench("user-1", "resume-wb") + + assertEquals(WorkbenchStatus.RUNNING.name, response.status) + assertEquals("resume-wb", response.instanceName) + + // Deployment and Service should be re-created + assertEquals(1, client.apps().deployments().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + assertEquals(1, client.services().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + + // PVC should still exist + assertEquals(1, client.persistentVolumeClaims().inNamespace("default") + .withLabel("instance-id", instanceId).list().items.size) + } + + @Test + fun `resumeWorkbench persists RUNNING status in database`() { + val created = service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "resume-persist-wb")) + service.suspendWorkbench("user-1", "resume-persist-wb") + + service.resumeWorkbench("user-1", "resume-persist-wb") + + val entity = repository.findById(created.instanceId).get() + assertEquals(WorkbenchStatus.RUNNING.name, entity.status) + } + + @Test + fun `resumeWorkbench throws WorkbenchNotFoundException for missing workbench`() { + assertThrows { + service.resumeWorkbench("user-1", "nonexistent") + } + } + + @Test + fun `resumeWorkbench throws IllegalArgumentException when workbench is not suspended`() { + service.createWorkbench("user-1", WorkbenchCreateRequest(instanceName = "running-wb")) + + val exception = assertThrows { + service.resumeWorkbench("user-1", "running-wb") + } + assertTrue(exception.message!!.contains("is not suspended")) + } + + @Test + fun `full suspend and resume cycle preserves workbench data`() { + val customResources = ResourceSpec( + cpuRequest = "2", + cpuLimit = "4", + memoryRequest = "2Gi", + memoryLimit = "4Gi", + storageSize = "20Gi" + ) + val created = service.createWorkbench("user-1", WorkbenchCreateRequest( + instanceName = "cycle-wb", + image = "theiaide/theia:custom", + resources = customResources + )) + + service.suspendWorkbench("user-1", "cycle-wb") + val resumed = service.resumeWorkbench("user-1", "cycle-wb") + + assertEquals(created.instanceId, resumed.instanceId) + assertEquals("theiaide/theia:custom", resumed.image) + assertEquals("2", resumed.resources.cpuRequest) + assertEquals("4", resumed.resources.cpuLimit) + assertEquals("2Gi", resumed.resources.memoryRequest) + assertEquals("4Gi", resumed.resources.memoryLimit) + assertEquals("20Gi", resumed.resources.storageSize) + assertEquals(WorkbenchStatus.RUNNING.name, resumed.status) + } +} diff --git a/continuum-cluster-manager/src/test/resources/application.yml b/continuum-cluster-manager/src/test/resources/application.yml new file mode 100644 index 00000000..8955ec69 --- /dev/null +++ b/continuum-cluster-manager/src/test/resources/application.yml @@ -0,0 +1,20 @@ +spring.application.name: continuum-cluster-manager-test +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH + username: sa + password: + driver-class-name: org.h2.Driver + sql: + init: + mode: always + schema-locations: classpath:schema-h2.sql + +continuum.core: + cluster-manager: + kubernetes: + master-url: "" + workbench: + default-image: theiaide/theia:latest + namespace: default + image-repository: projectcontinuum/continuum-workbench diff --git a/continuum-cluster-manager/src/test/resources/schema-h2.sql b/continuum-cluster-manager/src/test/resources/schema-h2.sql new file mode 100644 index 00000000..0e449ddd --- /dev/null +++ b/continuum-cluster-manager/src/test/resources/schema-h2.sql @@ -0,0 +1,31 @@ +-- H2-compatible schema for tests (mirrors main schema.sql without PostgreSQL-specific features) +CREATE TABLE IF NOT EXISTS workbench_instances ( + instance_id UUID PRIMARY KEY, + instance_name VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL DEFAULT 'default', + user_id VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + image VARCHAR(512) NOT NULL, + cpu_request VARCHAR(50) NOT NULL DEFAULT '500m', + cpu_limit VARCHAR(50) NOT NULL DEFAULT '2', + memory_request VARCHAR(50) NOT NULL DEFAULT '512Mi', + memory_limit VARCHAR(50) NOT NULL DEFAULT '1Gi', + storage_size VARCHAR(50) NOT NULL DEFAULT '5Gi', + storage_class_name VARCHAR(255), + k8s_resources TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + entity_version BIGINT NOT NULL DEFAULT 0 +); + +-- Note: Partial unique index (WHERE clause) not supported in H2 +-- The duplicate prevention is handled in application code (WorkbenchService) + +-- Index for fast lookups by user_id and instance_name +CREATE INDEX IF NOT EXISTS idx_workbench_user_instance + ON workbench_instances (user_id, instance_name); + +-- Index for listing workbenches by user (excludes deleted) +CREATE INDEX IF NOT EXISTS idx_workbench_user_status + ON workbench_instances (user_id, status); + diff --git a/gradle.properties b/gradle.properties index c896c4b3..5bb2b5fd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ repoName=projectcontinuum/Continuum -platformVersion=0.0.10 \ No newline at end of file +platformVersion=0.0.11 \ No newline at end of file diff --git a/landing-page/.gitignore b/landing-page/.gitignore new file mode 100644 index 00000000..de82ed50 --- /dev/null +++ b/landing-page/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +dist-server/ +.env +.env.local +.env.*.local +*.log +.DS_Store + diff --git a/landing-page/Dockerfile b/landing-page/Dockerfile new file mode 100644 index 00000000..4c51c7ed --- /dev/null +++ b/landing-page/Dockerfile @@ -0,0 +1,43 @@ +FROM node:22-alpine AS builder +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy source files +COPY . . + +# Build frontend (Vite) and server (TypeScript) +RUN npm run build:all + +# Production image +FROM node:22-alpine AS production +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Copy package files for production dependencies +COPY package.json package-lock.json ./ +RUN npm ci --only=production + +# Copy built frontend +COPY --from=builder /app/dist ./dist + +# Copy built server +COPY --from=builder /app/dist-server ./dist-server + +# Switch to non-root user +USER nodejs + +# Expose port +EXPOSE 8080 + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=8080 + +# Start the Express server +CMD ["node", "dist-server/index.js"] diff --git a/landing-page/README.md b/landing-page/README.md new file mode 100644 index 00000000..ad96f11e --- /dev/null +++ b/landing-page/README.md @@ -0,0 +1,243 @@ +# Continuum Platform Landing Page + +A sign-in page for the Continuum Platform with SSO authentication support, powered by Express.js and React. + +## Features + +- ๐Ÿ” **SSO Authentication** - Support for Google, GitHub, Microsoft, and LinkedIn OAuth via Keycloak +- ๐ŸŒ“ **Dark/Light Mode** - Automatic theme detection with manual toggle +- ๐Ÿ“ฑ **Fully Responsive** - Optimized for all screen sizes +- โšก **Express.js Backend** - Handles OAuth2 flow with IdP hints for direct identity provider login +- ๐ŸŽจ **Continuum Branding** - Consistent with the platform's visual identity + +## Architecture + +The landing page uses an Express.js server to: +1. Serve the static React frontend +2. Handle the OAuth2 authentication flow with OAuth2 Proxy and Keycloak +3. Use direct Keycloak redirects with `kc_idp_hint` for IdP selection + +### Auth Flow (with IdP selection) + +``` +User clicks "Sign in with Google" + โ†“ +GET /auth/start?idp=google (Express server) + โ†“ sets _auth_redirect cookie +Redirect directly to Keycloak with kc_idp_hint=google + โ†“ Keycloak skips login page +User authenticates with Google + โ†“ +Google redirects to Keycloak + โ†“ +Keycloak redirects to Landing Page /auth/keycloak-callback + โ†“ User now has Keycloak session (but no OAuth2 Proxy session yet) +Redirect to OAuth2 Proxy /oauth2/start + โ†“ OAuth2 Proxy redirects to Keycloak + โ†“ Keycloak sees user is already authenticated (SSO) + โ†“ Keycloak immediately returns auth code +OAuth2 Proxy validates code, sets session cookie + โ†“ +OAuth2 Proxy redirects to Landing Page /auth/complete + โ†“ reads _auth_redirect cookie +Redirect to originally requested URL + โ†“ +User is authenticated โœ… +``` + +### Auth Flow (email/password login) + +``` +User clicks "Sign in with Email" + โ†“ +GET /auth/start (no idp param) + โ†“ +Redirect to OAuth2 Proxy /oauth2/start + โ†“ +OAuth2 Proxy redirects to Keycloak login page + โ†“ +User enters credentials + โ†“ +(standard OAuth2 flow continues...) +``` + +### Keycloak Client Configuration + +The Keycloak client must have these redirect URIs configured: + +- `https://auth./oauth2/callback` - OAuth2 Proxy callback +- `https://continuum./auth/keycloak-callback` - Landing page callback (for direct IdP flows) + +## Tech Stack + +- **Express.js** - Backend server for auth flow handling +- **React 18** - Frontend UI library +- **TypeScript** - Type safety +- **Tailwind CSS** - Utility-first CSS framework +- **Framer Motion** - Smooth animations +- **Vite** - Build tool and dev server + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- npm or yarn + +### Installation + +```bash +# Navigate to the landing-page directory +cd continuum-platform-core/landing-page + +# Install dependencies +npm install + +# Start development server (frontend only) +npm run dev + +# Start Express server in development +npm run dev:server +``` + +The Vite dev server runs at `http://localhost:3000`. +The Express server runs at `http://localhost:8080`. + +### Building for Production + +```bash +# Build both frontend and server +npm run build:all + +# Start production server +npm start +``` + +## Project Structure + +``` +landing-page/ +โ”œโ”€โ”€ public/ +โ”‚ โ””โ”€โ”€ Logo.png # Continuum logo +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ components/ +โ”‚ โ”‚ โ””โ”€โ”€ AuthModal.tsx # SSO authentication component +โ”‚ โ”œโ”€โ”€ App.tsx # Main app component +โ”‚ โ”œโ”€โ”€ index.css # Global styles & theme +โ”‚ โ””โ”€โ”€ main.tsx # Entry point +โ”œโ”€โ”€ server/ +โ”‚ โ”œโ”€โ”€ index.ts # Express server with auth routes +โ”‚ โ””โ”€โ”€ tsconfig.json # Server TypeScript config +โ”œโ”€โ”€ dist/ # Built frontend (Vite output) +โ”œโ”€โ”€ dist-server/ # Built server (TypeScript output) +โ”œโ”€โ”€ Dockerfile # Multi-stage Docker build +โ”œโ”€โ”€ package.json +โ”œโ”€โ”€ tailwind.config.js +โ”œโ”€โ”€ tsconfig.json +โ””โ”€โ”€ vite.config.ts +``` + +## Environment Variables + +The Express server accepts the following environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | Server port | `8080` | +| `NODE_ENV` | Environment mode | `development` | +| `KEYCLOAK_URL` | Keycloak base URL | `https://keycloak.192.168.49.2.nip.io` | +| `KEYCLOAK_REALM` | Keycloak realm name | `continuum` | +| `KEYCLOAK_CLIENT_ID` | OAuth2 client ID | `continuum` | +| `PUBLIC_AUTH_URL` | Public OAuth2 Proxy URL | `https://auth.192.168.49.2.nip.io` | +| `PUBLIC_APP_URL` | Public landing page URL | `https://continuum.192.168.49.2.nip.io` | +| `COOKIE_DOMAIN` | Cookie domain for cross-subdomain auth | `.192.168.49.2.nip.io` | + +## API Endpoints + +### Auth Routes + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/auth/start?idp=&rd=` | GET | Start OAuth flow with optional IdP hint | +| `/auth/?rd=` | GET | Direct IdP login shortcut | +| `/auth/keycloak-callback` | GET | Callback from direct Keycloak auth | +| `/auth/complete` | GET | Final callback after OAuth2 Proxy session established | +| `/auth/signout?rd=` | GET | Sign out and clear session | + +### Utility Routes + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check endpoint | +| `/api/auth/config` | GET | Get auth configuration for frontend | + +## SSO Configuration + +The landing page includes SSO buttons for: +- Google +- GitHub +- Microsoft +- LinkedIn + +### Keycloak Setup + +1. Create identity providers in Keycloak for each SSO provider +2. Use the provider alias (e.g., `google`, `github`) as the `kc_idp_hint` +3. Configure the OAuth2 client with proper redirect URIs + +## Theming + +The landing page uses CSS custom properties for theming, matching the Continuum brand colors. + +### Light Theme +```css +:root { + --c-bg: 245 245 245; + --c-accent: 112 86 151; + /* ... */ +} +``` + +### Dark Theme +```css +.dark { + --c-bg: 41 45 62; + --c-accent: 196 168 255; + /* ... */ +} +``` + +## Adding New SSO Providers + +1. Add the provider to Keycloak as an identity provider +2. Edit `src/components/AuthModal.tsx` and add a new entry to `SSO_PROVIDERS`: + +```typescript +{ + id: 'provider-id', + name: 'Provider Name', + keycloakIdpHint: 'keycloak-idp-alias', + icon: , + color: 'hover:bg-color-50 dark:hover:bg-color-900/10', +} +``` + +## Docker + +Build and run the Docker image: + +```bash +# Build +docker build -t landing-page . + +# Run +docker run -p 8080:8080 \ + -e KEYCLOAK_URL=https://keycloak.example.com \ + -e PUBLIC_AUTH_URL=https://auth.example.com \ + -e PUBLIC_APP_URL=https://app.example.com \ + landing-page +``` + +## License + +Apache 2.0 - See the main repository LICENSE file. diff --git a/landing-page/build.gradle.kts b/landing-page/build.gradle.kts new file mode 100644 index 00000000..a2e652ae --- /dev/null +++ b/landing-page/build.gradle.kts @@ -0,0 +1,55 @@ +import com.github.gradle.node.npm.task.NpmTask + +plugins { + id("com.github.node-gradle.node") version "3.2.1" +} + +// Read version and name from package.json +val packageJsonFile = file("package.json") +val packageJson = groovy.json.JsonSlurper().parseText(packageJsonFile.readText()) as Map<*, *> +version = property("platformVersion").toString() + +node { + version.set("22.12.0") // Specify the Node.js version + download.set(true) // Automatically download and install Node.js +} + + +tasks.register("build") { + dependsOn("npmInstall") + args.set(listOf("run", "build")) +} + +tasks.register("clean") { + delete("dist") +} + +tasks.named("build") { + dependsOn("clean") +} + +tasks.register("run") { + args.set(listOf("run", "dev")) +} + +tasks.register("publish") { + description = "Publish the built application to JFrog Artifactory" + group = "Publishing tasks" + // don't publish yet +} + +tasks.register("jib") { + description = "Docker build and push to Docker Hub" + group = "Jib tasks" + val dockerRepoOwner = (System.getenv("GITHUB_REPOSITORY_OWNER") ?: property("repoOwner").toString()).lowercase() + val dockerRepoName = "docker.io/$dockerRepoOwner/${project.name.lowercase()}" + val imageName = "$dockerRepoName:${project.version}" + val latestImageName = "$dockerRepoName:latest" + commandLine("bash", "-c", + "docker build -t $imageName -t $latestImageName . --progress=plain && " + + "docker login docker.io --username ${System.getenv("DOCKER_REPO_USERNAME")} --password ${System.getenv("DOCKER_REPO_PASSWORD")} && " + + "docker push $imageName && " + + "docker push $latestImageName" + ) +} + diff --git a/landing-page/index.html b/landing-page/index.html new file mode 100644 index 00000000..c69667b3 --- /dev/null +++ b/landing-page/index.html @@ -0,0 +1,29 @@ + + + + + + + Sign In | Continuum Platform + + + + + + + +
+ + + + diff --git a/landing-page/nginx.conf b/landing-page/nginx.conf new file mode 100644 index 00000000..d20922e9 --- /dev/null +++ b/landing-page/nginx.conf @@ -0,0 +1,41 @@ +# Run as non-root user +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Use /tmp for cache directories (writable by non-root) + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback - redirect all requests to index.html + location / { + try_files $uri $uri/ /index.html; + } + } +} + diff --git a/landing-page/package-lock.json b/landing-page/package-lock.json new file mode 100644 index 00000000..686c2d5d --- /dev/null +++ b/landing-page/package-lock.json @@ -0,0 +1,4321 @@ +{ + "name": "landing-page", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "landing-page", + "version": "0.1.0", + "dependencies": { + "cookie-parser": "^1.4.6", + "express": "^4.21.0", + "framer-motion": "^11.15.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.7", + "@types/express": "^4.17.21", + "@types/node": "^25.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "tsx": "^4.19.0", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/landing-page/package.json b/landing-page/package.json new file mode 100644 index 00000000..48321b5f --- /dev/null +++ b/landing-page/package.json @@ -0,0 +1,37 @@ +{ + "name": "landing-page", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:server": "tsx watch server/index.ts", + "build": "tsc && vite build", + "build:server": "tsc -p server/tsconfig.json", + "build:all": "npm run build && npm run build:server", + "start": "node dist-server/index.js", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "cookie-parser": "^1.4.6", + "express": "^4.21.0", + "framer-motion": "^11.15.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.7", + "@types/express": "^4.17.21", + "@types/node": "^25.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "tsx": "^4.19.0", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } +} diff --git a/landing-page/postcss.config.js b/landing-page/postcss.config.js new file mode 100644 index 00000000..1d926516 --- /dev/null +++ b/landing-page/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + diff --git a/landing-page/public/Logo.png b/landing-page/public/Logo.png new file mode 100644 index 00000000..1b24e033 Binary files /dev/null and b/landing-page/public/Logo.png differ diff --git a/landing-page/server/index.ts b/landing-page/server/index.ts new file mode 100644 index 00000000..7e238525 --- /dev/null +++ b/landing-page/server/index.ts @@ -0,0 +1,328 @@ +import express, { Request, Response, NextFunction } from 'express'; +import cookieParser from 'cookie-parser'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 8080; + +// Configuration from environment variables +const config = { + // Keycloak URLs + keycloakUrl: process.env.KEYCLOAK_URL || 'https://keycloak.192.168.49.2.nip.io', + keycloakRealm: process.env.KEYCLOAK_REALM || 'continuum', + keycloakClientId: process.env.KEYCLOAK_CLIENT_ID || 'continuum', + // Public URLs + publicAuthUrl: process.env.PUBLIC_AUTH_URL || 'https://auth.192.168.49.2.nip.io', + publicAppUrl: process.env.PUBLIC_APP_URL || 'https://continuum.192.168.49.2.nip.io', + // Cookie domain (for cross-subdomain cookies) + cookieDomain: process.env.COOKIE_DOMAIN || '.192.168.49.2.nip.io', +}; + +// Middleware +app.use(cookieParser()); + +// Serve static files from the dist directory +const distPath = path.join(__dirname, '..', 'dist'); +app.use(express.static(distPath, { + // Cache static assets + maxAge: '1y', + immutable: true, +})); + +// ============================================================================ +// Auth Flow Routes +// ============================================================================ + +/** + * Start the OAuth2 authentication flow + * + * For IdP logins (Google, GitHub, etc.): + * 1. Redirect directly to Keycloak with kc_idp_hint + * 2. After Keycloak auth, redirect to a protected endpoint + * 3. OAuth2 Proxy sees user is authenticated in Keycloak (SSO) and sets session + * + * For email login (no IdP hint): + * 1. Redirect to OAuth2 Proxy which shows Keycloak's login page + * + * GET /auth/start?idp=google&rd=/dashboard + * + * Query params: + * - idp: (optional) Identity provider hint (google, github, microsoft, linkedin) + * - rd: (optional) URL to redirect to after successful auth (default: /) + */ +app.get('/auth/start', (req: Request, res: Response) => { + const idpHint = req.query.idp as string | undefined; + const redirectAfterAuth = req.query.rd as string || '/'; + + // Store redirect URL in cookie for after auth completes + res.cookie('_auth_redirect', redirectAfterAuth, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 10 * 60 * 1000, // 10 minutes + sameSite: 'lax', + path: '/', + domain: config.cookieDomain, + }); + + if (idpHint) { + // For IdP logins, redirect directly to Keycloak with kc_idp_hint + // After Keycloak authenticates via the IdP, it will redirect to our callback + // which then goes through OAuth2 Proxy to get the session cookie + const keycloakAuthUrl = buildKeycloakDirectAuthUrl(idpHint); + console.log(`[auth/start] IdP: ${idpHint}, Redirecting directly to Keycloak`); + res.redirect(keycloakAuthUrl); + } else { + // For email login, go through OAuth2 Proxy which will show Keycloak's login page + const oauth2StartUrl = `${config.publicAuthUrl}/oauth2/start?rd=${encodeURIComponent(config.publicAppUrl + '/auth/complete')}`; + console.log(`[auth/start] No IdP, Redirecting to OAuth2 Proxy`); + res.redirect(oauth2StartUrl); + } +}); + +/** + * Build Keycloak direct authorization URL with kc_idp_hint + * + * This redirects to Keycloak which then redirects to the IdP. + * After IdP authentication, Keycloak redirects back to OUR callback, + * not OAuth2 Proxy's callback. + * + * The redirect_uri points to our /auth/keycloak-callback endpoint. + * From there, we'll trigger OAuth2 Proxy which will do instant SSO + * since the user is already authenticated in Keycloak. + */ +function buildKeycloakDirectAuthUrl(idpHint: string): string { + // Redirect back to our server, NOT OAuth2 Proxy + // We'll handle establishing the OAuth2 Proxy session in the callback + const redirectUri = `${config.publicAppUrl}/auth/keycloak-callback`; + + const params = new URLSearchParams({ + client_id: config.keycloakClientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile email', + kc_idp_hint: idpHint, + }); + + return `${config.keycloakUrl}/realms/${config.keycloakRealm}/protocol/openid-connect/auth?${params.toString()}`; +} + +/** + * Keycloak callback - after direct Keycloak auth + * + * This is called after Keycloak authenticates the user via an IdP. + * The user now has a valid Keycloak session. + * + * We redirect to OAuth2 Proxy's /oauth2/start endpoint. + * Since the user is already authenticated in Keycloak (SSO), + * OAuth2 Proxy will instantly get a code and set the session cookie. + * + * GET /auth/keycloak-callback?code=xxx&session_state=xxx + */ +app.get('/auth/keycloak-callback', (req: Request, res: Response) => { + // Ignore the code - we don't process it ourselves + // The user now has an active Keycloak session + + // Redirect to OAuth2 Proxy to establish its session + // This will be instant because of SSO with Keycloak + const oauth2StartUrl = `${config.publicAuthUrl}/oauth2/start?rd=${encodeURIComponent(config.publicAppUrl + '/auth/complete')}`; + + console.log('[auth/keycloak-callback] User authenticated in Keycloak, establishing OAuth2 Proxy session via SSO'); + res.redirect(oauth2StartUrl); +}); + +/** + * Auth complete - final step after OAuth2 Proxy sets the session + * + * OAuth2 Proxy redirects here after successfully setting the session cookie. + * We read the original redirect URL and send the user there. + * + * GET /auth/complete + */ +app.get('/auth/complete', (req: Request, res: Response) => { + // Get the redirect URL from cookie (set during /auth/start) + const redirectUrl = req.cookies?._auth_redirect || '/'; + + // Clear the redirect cookie + res.clearCookie('_auth_redirect', { + path: '/', + domain: config.cookieDomain, + }); + + console.log(`[auth/complete] Session established, redirecting to: ${redirectUrl}`); + res.redirect(redirectUrl); +}); + +/** + * Direct IdP login shortcut + * + * GET /auth/:provider + * GET /auth/google + * GET /auth/github + */ +app.get('/auth/:provider', (req: Request, res: Response) => { + const provider = req.params.provider; + const redirectAfterAuth = req.query.rd as string || '/'; + + // Skip if it's a known route + if (['start', 'complete', 'signout', 'keycloak-callback'].includes(provider)) { + return res.status(404).json({ error: 'Not found' }); + } + + // Validate provider + const validProviders = ['google', 'github', 'microsoft', 'linkedin', 'email']; + if (!validProviders.includes(provider)) { + return res.status(400).json({ + error: 'Invalid provider', + validProviders + }); + } + + // Store redirect URL + res.cookie('_auth_redirect', redirectAfterAuth, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 10 * 60 * 1000, + sameSite: 'lax', + path: '/', + domain: config.cookieDomain, + }); + + if (provider === 'email') { + // For email login, go through OAuth2 Proxy + const oauth2StartUrl = `${config.publicAuthUrl}/oauth2/start?rd=${encodeURIComponent(config.publicAppUrl + '/auth/complete')}`; + console.log(`[auth/email] Redirecting to OAuth2 Proxy`); + res.redirect(oauth2StartUrl); + } else { + // For IdP logins, redirect directly to Keycloak + const keycloakAuthUrl = buildKeycloakDirectAuthUrl(provider); + console.log(`[auth/${provider}] Redirecting directly to Keycloak`); + res.redirect(keycloakAuthUrl); + } +}); + +/** + * Sign out - clear OAuth2 Proxy session and Keycloak session + * + * GET /auth/signout?rd=/ + */ +app.get('/auth/signout', (req: Request, res: Response) => { + const redirectAfterLogout = req.query.rd as string || '/'; + + // Redirect to OAuth2 Proxy's sign out endpoint + const oauth2SignoutUrl = `${config.publicAuthUrl}/oauth2/sign_out?rd=${encodeURIComponent(config.publicAppUrl + redirectAfterLogout)}`; + + console.log(`[auth/signout] Redirecting to OAuth2 Proxy signout`); + res.redirect(oauth2SignoutUrl); +}); + +// ============================================================================ +// Health check endpoint +// ============================================================================ +app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// ============================================================================ +// API endpoint to get auth configuration (for the frontend) +// ============================================================================ +app.get('/api/auth/config', (_req: Request, res: Response) => { + res.json({ + authStartUrl: '/auth/start', + authSignoutUrl: '/auth/signout', + providers: ['google', 'github', 'microsoft', 'linkedin'], + }); +}); + +// ============================================================================ +// API endpoint to get current user info +// ============================================================================ +// This endpoint proxies to OAuth2 Proxy's userinfo endpoint to get the +// authenticated user's information (name, email, picture, etc.) + +interface OIDCUserInfo { + email?: string; + name?: string; + picture?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + sub?: string; +} + +app.get('/api/auth/userinfo', async (req: Request, res: Response) => { + try { + // Forward the request to OAuth2 Proxy's userinfo endpoint + // OAuth2 Proxy will validate the session cookie and return user info + const userinfoUrl = `${config.publicAuthUrl}/oauth2/userinfo`; + + // Forward cookies to OAuth2 Proxy + const cookies = req.headers.cookie || ''; + + const response = await fetch(userinfoUrl, { + headers: { + 'Cookie': cookies, + }, + }); + + if (!response.ok) { + // User is not authenticated + return res.status(401).json({ authenticated: false }); + } + + const userinfo = await response.json() as OIDCUserInfo; + + // Return user info with authenticated flag + res.json({ + authenticated: true, + user: { + email: userinfo.email, + name: userinfo.name || userinfo.preferred_username || userinfo.email?.split('@')[0], + picture: userinfo.picture, + preferredUsername: userinfo.preferred_username, + givenName: userinfo.given_name, + familyName: userinfo.family_name, + }, + }); + } catch (error) { + console.error('[api/auth/userinfo] Error fetching user info:', error); + res.status(401).json({ authenticated: false }); + } +}); + +// ============================================================================ +// SPA Fallback - serve index.html for all other routes +// ============================================================================ +app.get('*', (_req: Request, res: Response) => { + res.sendFile(path.join(distPath, 'index.html')); +}); + +// Error handling middleware +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error('Error:', err); + res.status(500).json({ error: 'Internal Server Error' }); +}); + +app.listen(PORT, () => { + console.log(`๐Ÿš€ Landing page server running on port ${PORT}`); + console.log(`๐Ÿ“‹ Configuration:`); + console.log(` - Keycloak URL: ${config.keycloakUrl}`); + console.log(` - Keycloak Realm: ${config.keycloakRealm}`); + console.log(` - Public Auth URL: ${config.publicAuthUrl}`); + console.log(` - Public App URL: ${config.publicAppUrl}`); + console.log(` - Cookie Domain: ${config.cookieDomain}`); +}); + +export default app; + + + + + + + + + diff --git a/landing-page/server/tsconfig.json b/landing-page/server/tsconfig.json new file mode 100644 index 00000000..cca382c9 --- /dev/null +++ b/landing-page/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "../dist-server", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["./**/*.ts"], + "exclude": ["node_modules"] +} + diff --git a/landing-page/src/App.tsx b/landing-page/src/App.tsx new file mode 100644 index 00000000..ae40016d --- /dev/null +++ b/landing-page/src/App.tsx @@ -0,0 +1,267 @@ +import { useState, useCallback, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import AuthModal from './components/AuthModal'; +import WelcomeScreen, { UserInfo } from './components/WelcomeScreen'; + +function useTheme() { + const [isDark, setIsDark] = useState( + () => document.documentElement.classList.contains('dark'), + ); + + const toggle = useCallback(() => { + setIsDark((prev) => { + const next = !prev; + document.documentElement.classList.toggle('dark', next); + localStorage.setItem('theme', next ? 'dark' : 'light'); + return next; + }); + }, []); + + return { isDark, toggle }; +} + +function useMousePosition() { + const [mousePosition, setMousePosition] = useState({ x: 0.5, y: 0.5 }); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + // Normalize mouse position to 0-1 range + setMousePosition({ + x: e.clientX / window.innerWidth, + y: e.clientY / window.innerHeight, + }); + }; + + window.addEventListener('mousemove', handleMouseMove); + return () => window.removeEventListener('mousemove', handleMouseMove); + }, []); + + return mousePosition; +} + +// Hook to check authentication status and get user info +function useAuth() { + const [isLoading, setIsLoading] = useState(true); + const [user, setUser] = useState(null); + + const checkAuth = useCallback(async () => { + try { + const response = await fetch('/api/auth/userinfo'); + if (response.ok) { + const data = await response.json(); + if (data.authenticated && data.user) { + setUser(data.user); + } else { + setUser(null); + } + } else { + setUser(null); + } + } catch (error) { + console.error('Error checking auth:', error); + setUser(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + const signOut = useCallback(() => { + window.location.href = '/auth/signout'; + }, []); + + return { isLoading, user, isAuthenticated: !!user, signOut, checkAuth }; +} + +export default function App() { + const [authMode, setAuthMode] = useState<'signin' | 'signup'>('signin'); + const { isDark, toggle } = useTheme(); + const mousePosition = useMousePosition(); + const { isLoading, user, isAuthenticated, signOut } = useAuth(); + + // Calculate light position relative to center (for shadow effect) + const lightOffsetX = (mousePosition.x - 0.5) * 100; // -50 to 50 + const lightOffsetY = (mousePosition.y - 0.5) * 100; // -50 to 50 + + // Shadow offset (opposite direction of light) - increased multiplier for more visible effect + const shadowX = -lightOffsetX * 0.5; + const shadowY = -lightOffsetY * 0.5; + + // Glow position (same direction as light) + const glowX = lightOffsetX * 0.6; + const glowY = lightOffsetY * 0.6; + + // Show loading state + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+ + {/* Main content - centered sign in or welcome */} +
+
+ {/* Static background ambient */} +
+
+
+
+ + +
+ Continuum +
+

+ {isAuthenticated ? ( + <>Welcome to Continuum + ) : ( + <>Welcome to Continuum + )} +

+

+ {isAuthenticated + ? 'You are signed in and ready to go' + : 'Sign in to access the visual workflow platform'} +

+
+ + {/* Card with dynamic shadow */} + + {/* Dynamic glow behind card based on light position */} + + + {/* Card with dynamic shadow */} + + {isAuthenticated && user ? ( + + ) : ( + {}} + mode={authMode} + onSwitchMode={setAuthMode} + embedded={true} + /> + )} + + + + {!isAuthenticated && ( + + By signing in, you agree to our{' '} + Terms of Service + {' '}and{' '} + Privacy Policy + + )} +
+
+ + {/* Footer */} +
+

ยฉ {new Date().getFullYear()} Project Continuum ยท Apache 2.0 License

+
+
+ ); +} diff --git a/landing-page/src/components/AuthModal.tsx b/landing-page/src/components/AuthModal.tsx new file mode 100644 index 00000000..ab4579fb --- /dev/null +++ b/landing-page/src/components/AuthModal.tsx @@ -0,0 +1,284 @@ +import { useState } from 'react'; +import { motion } from 'framer-motion'; + +interface AuthModalProps { + isOpen: boolean; + onClose: () => void; + mode: 'signin' | 'signup'; + onSwitchMode: (mode: 'signin' | 'signup') => void; + embedded?: boolean; +} + +// Auth configuration - using server-side routes for OAuth flow +const AUTH_CONFIG = { + // Server-side auth start endpoint - handles the OAuth2 flow with IdP hints + authStartUrl: '/auth/start', + // Direct IdP login endpoint (alternative approach) + authIdpUrl: '/auth/idp', +}; + +// SSO Provider configurations +const SSO_PROVIDERS = [ + { + id: 'google', + name: 'Google', + // The IdP alias configured in Keycloak + keycloakIdpHint: 'google', + icon: ( + + + + + + + ), + color: 'hover:bg-red-50 dark:hover:bg-red-900/10', + }, + { + id: 'github', + name: 'GitHub', + keycloakIdpHint: 'github', + icon: ( + + + + ), + color: 'hover:bg-gray-100 dark:hover:bg-gray-800', + }, + { + id: 'microsoft', + name: 'Microsoft', + keycloakIdpHint: 'microsoft', + icon: ( + + + + + + + ), + color: 'hover:bg-blue-50 dark:hover:bg-blue-900/10', + }, + { + id: 'linkedin', + name: 'LinkedIn', + keycloakIdpHint: 'linkedin', + icon: ( + + + + ), + color: 'hover:bg-blue-50 dark:hover:bg-blue-900/10', + }, +]; + +/** + * Build the server-side auth URL with the IdP hint + * The Express server will handle the OAuth2 flow with proper state management + * @param idpHint - The Keycloak identity provider alias (e.g., 'google', 'github') + */ +function buildAuthUrl(idpHint?: string): string { + if (idpHint) { + // Use server-side route that handles the OAuth2 flow with IdP hints + return `${AUTH_CONFIG.authStartUrl}?idp=${encodeURIComponent(idpHint)}`; + } + // No IdP hint - let Keycloak show its login page + // This goes through the direct IdP endpoint for the default flow + return `${AUTH_CONFIG.authIdpUrl}/keycloak`; +} + +export default function AuthModal({ isOpen, onClose, mode, onSwitchMode, embedded = false }: AuthModalProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + if (!isOpen && !embedded) return null; + + const handleSSOLogin = async (provider: typeof SSO_PROVIDERS[0]) => { + setIsLoading(true); + setError(null); + + try { + // Build the auth URL with the IdP hint + // The Express server will handle the OAuth2 flow + const authUrl = buildAuthUrl(provider.keycloakIdpHint); + + console.log(`Initiating SSO login with ${provider.name}`, { authUrl }); + + // Redirect to our Express server's auth endpoint + // The server will handle the OAuth2 Proxy -> Keycloak flow with proper state + window.location.href = authUrl; + } catch (err) { + setError('Failed to initiate sign-in. Please try again.'); + setIsLoading(false); + } + }; + + // Handle sign in with email/password (Keycloak's built-in form) + const handleEmailSignIn = () => { + setIsLoading(true); + // No IdP hint - redirect to server which will trigger Keycloak's login form + window.location.href = buildAuthUrl(); + }; + + // Embedded content (no modal wrapper) + const content = ( +
+ {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* SSO Buttons */} +
+ {SSO_PROVIDERS.map((provider) => ( + + ))} +
+ + {/* Divider */} +
+
+
+
+
+ or +
+
+ + {/* Email/Password Sign In (Keycloak form) */} + + + {/* Loading overlay */} + {isLoading && ( +
+ + + + + Redirecting to sign in... +
+ )} + + {/* Switch mode link */} +

+ {mode === 'signup' ? ( + <> + Already have an account?{' '} + + + ) : ( + <> + Don't have an account?{' '} + + + )} +

+
+ ); + + // If embedded, return content directly without modal wrapper + if (embedded) { + return content; + } + + // Modal wrapper for non-embedded use + return ( + <> + {/* Backdrop */} + + + {/* Modal */} +
+ + {/* Header */} +
+ + +
+
+ Continuum +
+

+ {mode === 'signup' ? 'Create your account' : 'Welcome back'} +

+

+ {mode === 'signup' + ? 'Get started with Continuum' + : 'Sign in to continue to Continuum'} +

+
+
+ + {content} +
+
+ + ); +} diff --git a/landing-page/src/components/WelcomeScreen.tsx b/landing-page/src/components/WelcomeScreen.tsx new file mode 100644 index 00000000..aee88f93 --- /dev/null +++ b/landing-page/src/components/WelcomeScreen.tsx @@ -0,0 +1,114 @@ +import { motion } from 'framer-motion'; + +export interface UserInfo { + email: string; + name: string; + picture?: string; + preferredUsername?: string; + givenName?: string; + familyName?: string; +} + +interface WelcomeScreenProps { + user: UserInfo; + onSignOut: () => void; +} + +export default function WelcomeScreen({ user, onSignOut }: WelcomeScreenProps) { + // Get initials for fallback avatar + const getInitials = (name: string) => { + const parts = name.split(' '); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); + }; + + const initials = getInitials(user.name || user.email); + + return ( +
+ {/* Profile Picture */} + + {user.picture ? ( + {user.name} + ) : ( +
+ {initials} +
+ )} +
+ + {/* Welcome Message */} + +

+ Welcome back, {user.givenName || user.name.split(' ')[0]}! +

+

{user.email}

+
+ + {/* Action Buttons */} + + {/* Open Workbench Button */} + + + + + Open Workbench + + + {/* View Dashboard Button */} + + + + + View Dashboard + + + {/* Divider */} +
+
+
+
+
+ + {/* Sign Out Button */} + + +
+ ); +} + diff --git a/landing-page/src/index.css b/landing-page/src/index.css new file mode 100644 index 00000000..e0588778 --- /dev/null +++ b/landing-page/src/index.css @@ -0,0 +1,139 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ---- Light theme (default) โ€” matches Continuum Light ---- */ +:root { + --c-bg: 245 245 245; /* #F5F5F5 editor.background */ + --c-surface: 237 237 245; /* #EDEDF5 activityBar.background */ + --c-card: 255 255 255; /* #FFFFFF white cards */ + --c-fg: 51 56 77; /* #33384D dark text */ + --c-fg-muted: 108 108 108; /* #6C6C6C secondary text */ + --c-accent: 112 86 151; /* #705697 button.background */ + --c-purple: 151 105 220; /* #9769DC focusBorder */ + --c-highlight: 146 115 194; /* #9273C2 section headers */ + --c-divider: 210 210 220; /* #D2D2DC borders */ + --c-overlay: 0 0 0; /* black base for overlays */ + --c-on-accent: 255 255 255; /* white text on accent buttons */ + --svg-accent: #705697; + --svg-purple: #9769dc; + --gradient-from: #705697; + --gradient-to: #9273c2; +} + +/* ---- Dark theme โ€” matches Continuum Dark ---- */ +.dark { + --c-bg: 41 45 62; /* #292D3E activityBar.background */ + --c-surface: 54 60 80; /* #363C50 editor.background */ + --c-card: 54 60 80; /* #363C50 cards */ + --c-fg: 238 231 231; /* #EEE7E7 editor.foreground */ + --c-fg-muted: 165 152 184; /* #A598B8 input.foreground */ + --c-accent: 196 168 255; /* #C4A8FF activityBar.foreground */ + --c-purple: 112 86 151; /* #705697 button.background */ + --c-highlight: 146 115 194; /* #9273C2 section headers */ + --c-divider: 69 74 94; /* #454A5E selection */ + --c-overlay: 255 255 255; /* white base for overlays */ + --c-on-accent: 41 45 62; /* dark text on accent buttons */ + --svg-accent: #C4A8FF; + --svg-purple: #705697; + --gradient-from: #C4A8FF; + --gradient-to: #9273c2; +} + +html { + scroll-behavior: smooth; +} + +@layer utilities { + .glow-accent { + box-shadow: 0 0 30px rgb(var(--c-accent) / 0.25); + } + + .glow-purple { + box-shadow: 0 0 30px rgb(var(--c-purple) / 0.25); + } + + .text-gradient { + background: linear-gradient(135deg, var(--gradient-from), var(--gradient-to)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .bg-gradient-radial { + background: radial-gradient(circle at 50% 50%, var(--gradient-from) 0%, transparent 70%); + } +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgb(var(--c-surface)); +} + +::-webkit-scrollbar-thumb { + background: rgb(var(--c-divider)); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--c-fg-muted)); +} + +/* Focus styles */ +*:focus-visible { + outline: 2px solid rgb(var(--c-accent)); + outline-offset: 2px; +} + +/* Button hover effects */ +.btn-primary { + @apply bg-accent text-on-accent px-6 py-3 rounded-full font-semibold transition-all duration-200; +} + +.btn-primary:hover { + @apply bg-accent/90 shadow-lg; + transform: translateY(-2px); +} + +.btn-secondary { + @apply border border-divider text-fg px-6 py-3 rounded-full font-semibold transition-all duration-200; +} + +.btn-secondary:hover { + @apply border-accent/40 bg-overlay/5; +} + +/* Card styles */ +.card { + @apply bg-card rounded-2xl border border-divider p-6 transition-all duration-300; +} + +.card:hover { + @apply shadow-lg; + transform: translateY(-4px); +} + +/* SSO Button styles */ +.sso-btn { + @apply flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg border border-divider bg-card font-medium transition-all duration-200; +} + +.sso-btn:hover { + @apply border-accent/40 bg-overlay/5 shadow-md; +} + +/* Modal backdrop */ +.modal-backdrop { + @apply fixed inset-0 bg-black/50 backdrop-blur-sm z-40; +} + +/* Modal container */ +.modal-container { + @apply fixed inset-0 flex items-center justify-center z-50 p-4; +} + diff --git a/landing-page/src/main.tsx b/landing-page/src/main.tsx new file mode 100644 index 00000000..dfacde09 --- /dev/null +++ b/landing-page/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/landing-page/src/vite-env.d.ts b/landing-page/src/vite-env.d.ts new file mode 100644 index 00000000..ed772106 --- /dev/null +++ b/landing-page/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/landing-page/tailwind.config.js b/landing-page/tailwind.config.js new file mode 100644 index 00000000..6153b6aa --- /dev/null +++ b/landing-page/tailwind.config.js @@ -0,0 +1,43 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + base: 'rgb(var(--c-bg) / )', + surface: 'rgb(var(--c-surface) / )', + card: 'rgb(var(--c-card) / )', + fg: 'rgb(var(--c-fg) / )', + 'fg-muted': 'rgb(var(--c-fg-muted) / )', + accent: 'rgb(var(--c-accent) / )', + purple: 'rgb(var(--c-purple) / )', + highlight: 'rgb(var(--c-highlight) / )', + divider: 'rgb(var(--c-divider) / )', + overlay: 'rgb(var(--c-overlay) / )', + 'on-accent': 'rgb(var(--c-on-accent) / )', + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + animation: { + 'spin-slow': 'spin 20s linear infinite', + 'fade-in': 'fadeIn 0.5s ease-out', + 'slide-up': 'slideUp 0.5s ease-out', + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { opacity: '0', transform: 'translateY(20px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + }, + }, + }, + plugins: [], +}; + diff --git a/landing-page/tsconfig.json b/landing-page/tsconfig.json new file mode 100644 index 00000000..4ac5034a --- /dev/null +++ b/landing-page/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/landing-page/tsconfig.node.json b/landing-page/tsconfig.node.json new file mode 100644 index 00000000..41cdb7d5 --- /dev/null +++ b/landing-page/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} + diff --git a/landing-page/vite.config.ts b/landing-page/vite.config.ts new file mode 100644 index 00000000..b629ee88 --- /dev/null +++ b/landing-page/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + base: '/', + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, + server: { + port: 3000, + open: true, + }, +}); + diff --git a/settings.gradle.kts b/settings.gradle.kts index ff987675..af62995b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,3 +7,6 @@ include(":continuum-worker-springboot-starter") include(":continuum-api-server") include(":continuum-orchestration-service") include(":continuum-gradle-plugin") +include(":continuum-cluster-manager") +include(":continuum-cloud-gateway") +include(":landing-page")