diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f22ed2b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,113 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Rust and dashboard + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + CARGO_TERM_COLOR: always + CARGO_BUILD_JOBS: 2 + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/dashboard/package-lock.json + - name: Install protobuf compiler + run: | + sudo apt-get update + sudo apt-get install --yes protobuf-compiler + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal --component rustfmt --component clippy + rustup default stable + - name: Check Rust formatting + run: cargo fmt --all -- --check + - name: Install dashboard dependencies + working-directory: web/dashboard + run: npm ci + - name: Test dashboard events and state + working-directory: web/dashboard + run: npm test + - name: Build dashboard + working-directory: web/dashboard + run: npm run build + - name: Check embedded dashboard assets + run: | + git diff --exit-code -- services/dashboard/assets/react + test -z "$(git ls-files --others --exclude-standard services/dashboard/assets/react)" + - name: Check independent service builds + run: | + for package in devcloud-s3 devcloud-gcs devcloud-bigquery devcloud-dynamodb devcloud-redshift devcloud-applicationautoscaling; do + cargo check --locked -p "$package" + done + - name: Run Rust tests + run: cargo test --locked --workspace --no-fail-fast + - name: Build CLI for browser test + run: cargo build --locked -p devcloud-orchestrator + - name: Install browser + working-directory: web/dashboard + run: npx playwright install --with-deps chromium + - name: Test dashboard in Chromium + working-directory: web/dashboard + run: npm run test:browser + - name: Upload browser verification + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: dashboard-browser + path: target/dashboard-browser + if-no-files-found: ignore + retention-days: 30 + - name: Check Rust lints + run: cargo clippy --locked --workspace --all-targets + + audit: + name: Dependency audit + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/dashboard/package-lock.json + - name: Audit dashboard dependencies + working-directory: web/dashboard + run: | + npm ci --ignore-scripts + npm audit + - name: Audit Rust dependencies + env: + AUDIT_VERSION: '0.22.2' + AUDIT_SHA256: 7fb9497f8594b389e5fce5ef9b92db08432996895b2e0c5a0167a69ed445c428 + run: | + archive="$RUNNER_TEMP/cargo-audit.tgz" + curl --fail --silent --show-error --location \ + "https://github.com/rustsec/rustsec/releases/download/cargo-audit/v$AUDIT_VERSION/cargo-audit-x86_64-unknown-linux-musl-v$AUDIT_VERSION.tgz" \ + --output "$archive" + printf '%s %s\n' "$AUDIT_SHA256" "$archive" | sha256sum --check + tar -xzf "$archive" -C "$RUNNER_TEMP" + "$RUNNER_TEMP/cargo-audit-x86_64-unknown-linux-musl-v$AUDIT_VERSION/cargo-audit" audit diff --git a/Cargo.lock b/Cargo.lock index 374ec1e..b5ecf0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "async-stream" @@ -453,7 +453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -568,9 +568,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -721,7 +721,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1085,7 +1085,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1252,7 +1252,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/README.md b/README.md index c036a8e..c5151f4 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,9 @@ Legend: | Dashboard inbox | Yes | Inspect messages and raw source. | | Delete messages | Yes | Single-message and clear-all paths are available through dashboard API. | | Outbound relay | No | devcloud is an inbox emulator, not an SMTP relay. | -| SMTP AUTH | No | Default `auth.smtp.mode` is `off`. | +| SMTP AUTH | Partial | `PLAIN` and `LOGIN` are available when `auth.smtp.mode` is not `off`; strict mode validates supplied credentials. `MAIL FROM` before AUTH remains accepted. The default mode is `off`. | +| HTTP API authentication | Partial | Strict mode protects the introspection/control API with the configured SMTP Basic credentials; the dashboard forwards those credentials for reads and mutations. | +| Browser guards | Yes | Direct introspection/control HTTP endpoints reject public DNS Host names and cross-site mutations, using the Dashboard API host allowlist. SDK/CLI requests without Origin remain supported. | | TLS / STARTTLS | No | Local plaintext development endpoint only. | | IMAP / POP3 | No | Not implemented. | @@ -347,6 +349,8 @@ Legend: | IAM/ACLs, retention, lifecycle | No | Not implemented. | | Pub/Sub notifications, signed URLs | No | Not implemented. | +Completed resumable uploads retain a small metadata receipt and notification marker in the shared object-store root until the workspace is reset; these contain no uploaded body and let retries return the original result after restarts, overwrites, or deletions. + ### BigQuery-Compatible REST API | Feature | Status | Notes | @@ -441,12 +445,14 @@ Pub/Sub dashboard actions are available under `/dashboard/pubsub`: | Feature | Status | Notes | | --- | --- | --- | | Redis wire endpoint | Yes | Uses a real `redis-server` child process in managed mode; devcloud does not re-implement RESP. | -| External Redis mode | Yes | `services.redis.mode: external` validates `externalUrl` with `PING` and exposes the same dashboard surface. | +| External Redis mode | Yes | `services.redis.mode: external` accepts `redis://[:password@]host[:port][/db]`, validates `AUTH`/`SELECT`/`PING`, and uses that target for dashboard commands. Credentials are omitted from the displayed endpoint. TLS and non-default ACL users are not supported. | | Managed persistence | Yes | Runtime data is kept under `.devcloud/data/redis` by default. | | String/hash/list/set/zset inspection | Partial | `/dashboard/redis` lists keys with SCAN and shows type-specific previews. | | Command runner | Partial | Dashboard commands are restricted to the Redis allowlist in `docs/design-redis-compat.md`. | | Destructive actions | Partial | Key delete, expire, and guarded `FLUSHDB` are supported; `FLUSHALL` and unsafe commands are rejected. | | Redis AUTH | Partial | Relaxed mode does not set `requirepass`; strict mode passes the configured password to managed Redis. | +| HTTP API authentication | Partial | Strict mode protects the Redis control API with the configured Redis Basic credentials; the dashboard forwards those credentials for reads and mutations. | +| Browser guards | Yes | Direct introspection/control HTTP endpoints reject public DNS Host names and cross-site mutations, using the Dashboard API host allowlist. SDK/CLI requests without Origin remain supported. | | Cluster, Sentinel, modules, RedisJSON, RediSearch | No | Out of local compatibility scope. | ### AWS Lambda-Compatible API @@ -501,11 +507,44 @@ Pub/Sub dashboard actions are available under `/dashboard/pubsub`: ### Local build and test +Install a current stable Rust toolchain, Node.js 22 or later, and the Protocol +Buffers compiler (`protoc`, including its standard `.proto` files). Pub/Sub's +build script uses `protoc`; on Debian/Ubuntu, install `protobuf-compiler`. + +```bash +cd web/dashboard +npm ci +npm test +npm run build +cd ../.. +cargo build --locked --workspace +cargo test --locked --workspace +``` + +The dashboard build writes the assets embedded by the Rust dashboard crate. +Commit the generated bundle with dashboard changes. `npm test` exercises the +event relay frame contract, reconnects, resynchronization, subscription cleanup, +and stale React responses without requiring a browser. + +To exercise the built CLI and dashboard together in Chromium: + ```bash -cargo build --workspace -cargo test --workspace +cargo build --locked -p devcloud-orchestrator +cd web/dashboard +npx playwright install --with-deps chromium +npm run test:browser ``` +The browser test starts an isolated GCS workspace and verifies that provider +mutations appear through the live event stream, including a snapshot refresh +after a disconnected interval. Its screenshot and result are written to +`target/dashboard-browser/` and uploaded as a CI artifact. + +Pull requests run `.github/workflows/ci.yml`: Rust formatting, individual service +builds, workspace tests and Clippy, dashboard tests and bundle consistency, +the Chromium smoke test, and Rust/npm dependency audits. Individual builds catch missing Cargo features that +can otherwise be hidden by workspace-wide feature unification. + Service acceptance gates and E2E scripts are run locally because they require service-specific external tooling (`awscli-local`, `postgres` server binaries, `aws` CLI, etc.). ### Manual acceptance gates (per service) diff --git a/orchestrator/src/config.rs b/orchestrator/src/config.rs index 945eba9..acb4bb2 100644 --- a/orchestrator/src/config.rs +++ b/orchestrator/src/config.rs @@ -20,7 +20,7 @@ use std::collections::HashMap; use std::fs; use std::io::{self, BufRead}; -use std::path::{PathBuf, MAIN_SEPARATOR}; +use std::path::{Component, Path, PathBuf, MAIN_SEPARATOR}; // --------------------------------------------------------------------------- // Config struct types (legacy config.rs lines ~12-274) @@ -1723,21 +1723,47 @@ pub fn default_config_yaml(cfg: &Config) -> String { // InitWorkspace / ResetWorkspace (legacy config.rs lines ~513-637) // --------------------------------------------------------------------------- -/// legacy `validateStoragePath`: path must be under `.devcloud`. +/// Storage operations must stay inside the workspace's `.devcloud` directory. +/// Check components instead of a string prefix: `PathBuf` does not resolve +/// `..`, and an intermediate symbolic link could redirect a reset elsewhere. fn validate_storage_path(path: &str) -> io::Result<()> { - let clean = clean_path(path); - let prefix = format!(".devcloud{MAIN_SEPARATOR}"); - if clean == ".devcloud" || clean.starts_with(&prefix) { - return Ok(()); + let invalid = || { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("storage.path must be under .devcloud without parent traversal: {path}"), + ) + }; + let mut components = Path::new(path) + .components() + .filter(|component| *component != Component::CurDir); + if components.next() != Some(Component::Normal(".devcloud".as_ref())) + || components.any(|component| !matches!(component, Component::Normal(_))) + { + return Err(invalid()); } - Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("storage.path must be under .devcloud: {path}"), - )) + + let mut ancestor = PathBuf::new(); + for component in Path::new(path).components() { + ancestor.push(component.as_os_str()); + match fs::symlink_metadata(&ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("storage.path must not follow symbolic links: {path}"), + )); + } + Ok(_) => {} + // Descendants cannot exist before this directory is created. + Err(err) if err.kind() == io::ErrorKind::NotFound => break, + Err(err) => return Err(err), + } + } + Ok(()) } /// legacy `ensureFile`: write `data` only if `path` does not already exist. fn ensure_file(path: &str, data: &[u8]) -> io::Result<()> { + validate_storage_path(path)?; match fs::metadata(path) { Ok(_) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound => fs::write(path, data), @@ -1746,6 +1772,8 @@ fn ensure_file(path: &str, data: &[u8]) -> io::Result<()> { } fn mkdir_all(path: &str, ctx: &str) -> io::Result<()> { + validate_storage_path(path) + .map_err(|err| io::Error::new(err.kind(), format!("{ctx}: {err}")))?; fs::create_dir_all(path).map_err(|err| io::Error::new(err.kind(), format!("{ctx}: {err}"))) } diff --git a/orchestrator/src/services/dashboard.rs b/orchestrator/src/services/dashboard.rs index 4ee3f95..d738eae 100644 --- a/orchestrator/src/services/dashboard.rs +++ b/orchestrator/src/services/dashboard.rs @@ -30,21 +30,11 @@ fn join(path: &str, sub: &str) -> String { Path::new(path).join(sub).to_string_lossy().into_owned() } -/// Mirrors legacy `redisEndpointForDisplay`: display-only metadata. +/// Display-only metadata, rebuilt without the external URL's credentials. fn redis_display_endpoint(cfg: &AppConfig) -> String { - let fallback = format!("redis://127.0.0.1:{}", cfg.server.redis_port); - let mode = cfg.services.redis.mode.trim().to_lowercase(); - let is_external = mode == "external" - || (mode.is_empty() && !cfg.services.redis.external_url.trim().is_empty()); - if !is_external { - return fallback; - } - let raw = cfg.services.redis.external_url.trim(); - if raw.is_empty() { - fallback - } else { - raw.to_string() - } + super::redis::connection_target(cfg) + .map(|target| target.display_endpoint()) + .unwrap_or_else(|_| "invalid Redis configuration".to_string()) } fn build_config(cfg: &AppConfig) -> Config { @@ -53,53 +43,62 @@ fn build_config(cfg: &AppConfig) -> Config { addr: format!("127.0.0.1:{}", cfg.server.dashboard_port), event_relay_endpoint: format!("ws://127.0.0.1:{}", cfg.server.event_relay_port), - sqs_base: http_ep(cfg.server.sqs_port), + // Empty forwarding bases mark disabled services in both the registry + // and API handlers. Display endpoints retain their configured ports. + sqs_base: enabled_ep(cfg.services.sqs.enabled, cfg.server.sqs_port), + sqs_endpoint: http_ep(cfg.server.sqs_port), sqs_region: cfg.services.sqs.region.clone(), sqs_auth_mode: cfg.auth.sqs.mode.clone(), sqs_storage_path: join(s, "sqs"), - mail_base: http_ep(cfg.server.mail_http_port), + mail_base: enabled_ep(cfg.services.mail.enabled, cfg.server.mail_http_port), mail_endpoint: format!("smtp://127.0.0.1:{}", cfg.server.smtp_port), mail_storage_path: join(s, "mail"), mail_disabled: !cfg.services.mail.enabled, + mail_auth_mode: cfg.auth.smtp.mode.clone(), + mail_username: cfg.auth.smtp.username.clone(), + mail_password: cfg.auth.smtp.password.clone(), - s3_base: http_ep(cfg.server.s3_port), + s3_base: enabled_ep(cfg.services.s3.enabled, cfg.server.s3_port), s3_endpoint: http_ep(cfg.server.s3_port), s3_storage_path: join(s, "s3"), - gcs_base: http_ep(cfg.server.gcs_port), + gcs_base: enabled_ep(cfg.services.gcs.enabled, cfg.server.gcs_port), gcs_endpoint: http_ep(cfg.server.gcs_port), gcs_storage_path: join(s, "gcs"), - dynamodb_base: http_ep(cfg.server.dynamodb_port), + dynamodb_base: enabled_ep(cfg.services.dynamodb.enabled, cfg.server.dynamodb_port), dynamodb_endpoint: http_ep(cfg.server.dynamodb_port), dynamodb_storage_path: join(s, "dynamodb"), - bigquery_base: http_ep(cfg.server.bigquery_port), + bigquery_base: enabled_ep(cfg.services.bigquery.enabled, cfg.server.bigquery_port), bigquery_endpoint: http_ep(cfg.server.bigquery_port), bigquery_storage_path: join(s, "bigquery"), - redshift_base: http_ep(cfg.server.redshift_api_port), + redshift_base: enabled_ep(cfg.services.redshift.enabled, cfg.server.redshift_api_port), redshift_sql_endpoint: format!("127.0.0.1:{}", cfg.server.redshift_port), redshift_endpoint: http_ep(cfg.server.redshift_api_port), redshift_storage_path: scoped_data_dir(s, &cfg.services.redshift.data_dir, "redshift"), - redis_base: http_ep(cfg.server.redis_http_port), + redis_base: enabled_ep(cfg.services.redis.enabled, cfg.server.redis_http_port), redis_endpoint: redis_display_endpoint(cfg), redis_storage_path: scoped_data_dir(s, &cfg.services.redis.data_dir, "redis"), redis_enabled: cfg.services.redis.enabled, + redis_auth_mode: cfg.auth.redis.mode.clone(), + redis_password: cfg.auth.redis.password.clone(), - pubsub_base: http_ep(cfg.server.pubsub_rest_port), + pubsub_base: enabled_ep(cfg.services.pubsub.enabled, cfg.server.pubsub_rest_port), pubsub_endpoint: http_ep(cfg.server.pubsub_rest_port), pubsub_storage_path: default_string(&cfg.services.pubsub.data_dir, &join(s, "pubsub")), - app_auto_scaling_base: http_ep(cfg.server.app_auto_scaling_port), + app_auto_scaling_base: enabled_ep( + cfg.services.app_auto_scaling.enabled, + cfg.server.app_auto_scaling_port, + ), app_auto_scaling_endpoint: http_ep(cfg.server.app_auto_scaling_port), app_auto_scaling_storage_path: join(s, "applicationautoscaling"), app_auto_scaling_region: cfg.services.app_auto_scaling.region.clone(), - // Empty base == disabled: the registry/status handlers report the - // service off instead of forwarding to a port nothing listens on. lambda_base: enabled_ep(cfg.services.lambda.enabled, cfg.server.lambda_port), lambda_endpoint: http_ep(cfg.server.lambda_port), lambda_storage_path: join(s, "lambda"), @@ -134,6 +133,107 @@ pub async fn run(cfg: &AppConfig, shutdown: impl Future) -> Result< mod tests { use super::*; + fn get(path: &str) -> http::Request { + http::Request { + method: "GET".to_string(), + path: path.to_string(), + raw_path: path.to_string(), + query: String::new(), + headers: std::collections::HashMap::new(), + body: Vec::new(), + } + } + + #[tokio::test] + async fn single_service_selection_disables_other_registry_entries_and_apis() { + let services = [ + ("mail", "mail", "/api/messages"), + ("s3", "s3", "/api/s3/buckets"), + ("gcs", "gcs", "/api/gcs/buckets"), + ("dynamodb", "dynamodb", "/api/dynamodb/tables"), + ("bigquery", "bigquery", "/api/bigquery/projects"), + ("redshift", "redshift", "/api/redshift/clusters"), + ("redis", "redis", "/api/redis/keys"), + ("sqs", "sqs", "/api/sqs/queues"), + ("pubsub", "pubsub", "/api/pubsub/topics"), + ( + "appautoscaling", + "applicationautoscaling", + "/api/applicationautoscaling/scalable-targets", + ), + ("lambda", "lambda", "/api/lambda/functions"), + ("cloudrun", "cloudrun", "/api/cloudrun/services"), + ]; + let mut covered: Vec<_> = services.iter().map(|(name, _, _)| *name).collect(); + covered.sort(); + assert_eq!(covered, crate::config::service_names()); + + for (selected, selected_id, _) in services { + let cfg = crate::config::apply_service_selection( + &AppConfig::default(), + &[selected.to_string()], + ) + .unwrap(); + let dashboard = build_config(&cfg); + let response = http::route(&dashboard, &get("/api/services")).await; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + let registry = body["services"].as_array().unwrap(); + assert_eq!(registry.len(), services.len()); + let running: Vec<_> = registry + .iter() + .filter(|service| service["status"] == "running") + .map(|service| service["id"].as_str().unwrap()) + .collect(); + assert_eq!(running, [selected_id], "devcloud up {selected}"); + + for (name, id, path) in services { + let entry = registry.iter().find(|service| service["id"] == id).unwrap(); + assert!( + !entry["endpoint"].as_str().unwrap().is_empty(), + "{id} must retain its display endpoint" + ); + if name != selected { + assert_eq!(entry["status"], "disabled", "devcloud up {selected}: {id}"); + let response = http::route(&dashboard, &get(path)).await; + assert_eq!( + response.status, + 503, + "devcloud up {selected}: disabled {path} must not forward: {}", + String::from_utf8_lossy(&response.body) + ); + } + } + } + } + + #[tokio::test] + async fn disabled_sqs_preserves_custom_endpoint_in_registry_and_status() { + let mut cfg = AppConfig::default(); + cfg.services.sqs.enabled = false; + cfg.server.sqs_port = 29324; + let dashboard = build_config(&cfg); + + let response = http::route(&dashboard, &get("/api/services")).await; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + let sqs = body["services"] + .as_array() + .unwrap() + .iter() + .find(|service| service["id"] == "sqs") + .unwrap(); + assert_eq!(sqs["status"], "disabled"); + assert_eq!(sqs["endpoint"], "http://127.0.0.1:29324"); + + let response = http::route(&dashboard, &get("/api/sqs/status")).await; + assert_eq!(response.status, 200); + let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(body["status"], "disabled"); + assert_eq!(body["running"], false); + assert_eq!(body["endpoint"], "http://127.0.0.1:29324"); + } + #[test] fn build_config_uses_distinct_s3_and_gcs_storage_paths() { let cfg = AppConfig::default(); diff --git a/orchestrator/src/services/redis.rs b/orchestrator/src/services/redis.rs index a9ddb3c..dd4ab90 100644 --- a/orchestrator/src/services/redis.rs +++ b/orchestrator/src/services/redis.rs @@ -18,20 +18,172 @@ use crate::services::util::scoped_data_dir; /// legacy `redisMode`: explicit mode wins; else external when an external URL is set; /// else managed. fn redis_mode(cfg: &AppConfig) -> String { - let mode = cfg.services.redis.mode.trim().to_lowercase(); + let mode = config_text(&cfg.services.redis.mode).to_lowercase(); if !mode.is_empty() { return mode; } - if !cfg.services.redis.external_url.trim().is_empty() { + if !config_text(&cfg.services.redis.external_url).is_empty() { return "external".to_string(); } "managed".to_string() } +/// One resolved upstream, shared by lifecycle, control-plane wiring and display. +/// The upstream password is independent of the control HTTP Basic password. +pub struct ConnectionTarget { + pub mode: String, + pub addr: String, + pub password: String, + pub database: i64, +} + +impl ConnectionTarget { + pub fn display_endpoint(&self) -> String { + if self.mode == "external" { + format!("redis://{}/{}", self.addr, self.database) + } else { + format!("redis://{}", self.addr) + } + } +} + +fn config_text(value: &str) -> &str { + let value = value.trim(); + value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .unwrap_or(value) +} + +pub fn connection_target(cfg: &AppConfig) -> Result { + match redis_mode(cfg).as_str() { + "external" => parse_external_url(config_text(&cfg.services.redis.external_url)), + "managed" => Ok(ConnectionTarget { + mode: "managed".into(), + addr: format!("127.0.0.1:{}", cfg.server.redis_port), + password: if cfg.auth.redis.mode.trim().eq_ignore_ascii_case("strict") { + cfg.auth.redis.password.clone() + } else { + String::new() + }, + database: 0, + }), + _ => Err("redis: services.redis.mode must be managed or external".into()), + } +} + +/// Redis's supported local URL form: redis://[:password@]host[:port][/db]. +/// Non-default ACL users and TLS are outside this emulator's Redis contract; +/// reject them explicitly rather than connecting with different credentials. +fn parse_external_url(raw: &str) -> Result { + let invalid = |reason: &str| format!("redis: invalid services.redis.externalUrl: {reason}"); + let rest = raw + .strip_prefix("redis://") + .ok_or_else(|| invalid("expected a redis:// URL"))?; + if rest.contains(['?', '#']) { + return Err(invalid("query parameters and fragments are unsupported")); + } + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + let database = if path.is_empty() { + 0 + } else { + if !path.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid("database must be a non-negative integer")); + } + path.parse::() + .map_err(|_| invalid("database is out of range"))? + }; + let (authority, password) = match authority.rsplit_once('@') { + Some((userinfo, authority)) => { + let (username, password) = userinfo + .split_once(':') + .ok_or_else(|| invalid("credentials must use :password@host"))?; + let username = decode_url_component(username) + .ok_or_else(|| invalid("credentials contain an invalid percent escape"))?; + if !username.is_empty() && username != "default" { + return Err(invalid("only the default Redis ACL user is supported")); + } + let password = decode_url_component(password) + .ok_or_else(|| invalid("credentials contain an invalid percent escape"))?; + (authority, password) + } + None => (authority, String::new()), + }; + let (host, port) = if let Some(rest) = authority.strip_prefix('[') { + let (host, suffix) = rest + .split_once(']') + .ok_or_else(|| invalid("IPv6 hosts must be enclosed in brackets"))?; + host.parse::() + .map_err(|_| invalid("invalid IPv6 host"))?; + let port = if suffix.is_empty() { + "6379" + } else { + suffix + .strip_prefix(':') + .ok_or_else(|| invalid("invalid host or port"))? + }; + (format!("[{host}]"), port) + } else { + let (host, port) = authority.split_once(':').unwrap_or((authority, "6379")); + if host.is_empty() + || host.chars().any(|c| { + c.is_whitespace() || c.is_control() || matches!(c, '@' | '[' | ']' | '\\' | '%') + }) + { + return Err(invalid("invalid host")); + } + (host.to_string(), port) + }; + let port = port + .parse::() + .ok() + .filter(|p| *p != 0) + .ok_or_else(|| invalid("port must be an integer between 1 and 65535"))?; + Ok(ConnectionTarget { + mode: "external".into(), + addr: format!("{host}:{port}"), + password, + database, + }) +} + +fn decode_url_component(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let high = (*bytes.get(i + 1)? as char).to_digit(16)?; + let low = (*bytes.get(i + 2)? as char).to_digit(16)?; + output.push((high * 16 + low) as u8); + i += 3; + } else { + output.push(bytes[i]); + i += 1; + } + } + String::from_utf8(output).ok() +} + pub async fn run(cfg: &AppConfig, shutdown: impl Future) -> Result<(), String> { // External mode: the orchestrator does not own a redis process; the control // surface talks to the user-supplied URL. Nothing to manage here. - if redis_mode(cfg) == "external" { + let target = connection_target(cfg)?; + if target.mode == "external" { + let result = devcloud_redis_control::validate_upstream( + &target.addr, + &target.password, + target.database, + ) + .await; + result.map_err(|error| { + let error = if target.password.is_empty() { + error + } else { + error.replace(&target.password, "redacted") + }; + format!("redis: validate external Redis connection: {error}") + })?; shutdown.await; return Ok(()); } @@ -55,6 +207,7 @@ pub async fn run(cfg: &AppConfig, shutdown: impl Future) -> Result< let mut child = match Command::new(&rc.binary) .args(&args) + .kill_on_drop(true) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -80,9 +233,256 @@ pub async fn run(cfg: &AppConfig, shutdown: impl Future) -> Result< Err(e) => Err(format!("redis: wait for redis-server: {e}")), }, _ = shutdown => { - let _ = child.start_kill(); - let _ = child.wait().await; - Ok(()) + devcloud_redis::stop_child(&mut child) + .await + .map_err(|e| format!("redis: stop redis-server: {e}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_target_uses_url_credentials_and_database() { + let mut cfg = AppConfig::default(); + cfg.services.redis.mode = "external".into(); + cfg.services.redis.external_url = "\"redis://default:p%40ss%3Aword@[::1]:6380/3\"".into(); + cfg.auth.redis.mode = "strict".into(); + cfg.auth.redis.password = "control-only-secret".into(); + let target = connection_target(&cfg).unwrap(); + assert_eq!(target.addr, "[::1]:6380"); + assert_eq!(target.password, "p@ss:word"); + assert_eq!(target.database, 3); + assert_eq!(target.display_endpoint(), "redis://[::1]:6380/3"); + + cfg.services.redis.mode.clear(); + cfg.services.redis.external_url = "redis://localhost".into(); + let target = connection_target(&cfg).unwrap(); + assert_eq!(target.mode, "external"); + assert_eq!(target.addr, "localhost:6379"); + assert!(target.password.is_empty()); + assert_eq!(target.database, 0); + } + + #[test] + fn external_target_rejects_invalid_urls_without_disclosing_credentials() { + for raw in [ + "", + "https://:secret@example.test:6379", + "rediss://:secret@example.test:6379", + "redis://:secret@", + "redis://:secret@host:0", + "redis://:secret@host:65536", + "redis://:secret@host/-1", + "redis://:secret@host/9223372036854775808", + "redis://:secret@host/0?db=1", + "redis://:secret@host/0#fragment", + "redis://other:secret@host/0", + "redis://:secret%あ@host/0", + "redis://:secret%zz@host/0", + "redis://:secret@[not-an-ip]/0", + ] { + let error = match parse_external_url(raw) { + Ok(_) => panic!("invalid external URL was accepted"), + Err(error) => error, + }; + assert!(!error.contains("secret"), "credential leaked in error"); + } + } + + #[test] + fn managed_target_auth_matches_the_managed_server() { + let mut cfg = AppConfig::default(); + cfg.auth.redis.password = "control-secret".into(); + let target = connection_target(&cfg).unwrap(); + assert_eq!(target.addr, "127.0.0.1:16379"); + assert!( + target.password.is_empty(), + "relaxed Redis has no requirepass" + ); + cfg.auth.redis.mode = "strict".into(); + assert_eq!(connection_target(&cfg).unwrap().password, "control-secret"); + cfg.services.redis.mode = "typo".into(); + assert!(connection_target(&cfg).is_err()); + } + + #[cfg(unix)] + mod lifecycle { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::Duration; + use tokio::sync::oneshot; + + extern "C" { + fn kill(pid: i32, signal: i32) -> i32; + } + + struct FakeRedis { + root: PathBuf, + binary: PathBuf, + } + + impl FakeRedis { + fn new(term_handler: &str) -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "devcloud-redis-lifecycle-{}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + let binary = root.join("redis-server"); + let body = if term_handler.is_empty() { + // Ignored signals survive exec, retaining this PID without + // busy-looping throughout the five-second shutdown grace. + "exec sleep 30" + } else { + "while :; do :; done" + }; + fs::write( + &binary, + format!( + "#!/bin/sh\ntrap '{term_handler}' TERM\nprintf '%s\\n' \"$$\" > \"$0.pid\"\n{body}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap(); + Self { root, binary } + } + + fn config(&self) -> AppConfig { + let mut cfg = AppConfig::default(); + cfg.storage.path = self.root.join("data").to_string_lossy().into_owned(); + cfg.services.redis.binary_path = self.binary.to_string_lossy().into_owned(); + cfg + } + + fn marker(&self, name: &str) -> PathBuf { + self.binary.with_extension(name) + } + + async fn ready(&self) -> i32 { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(pid) = fs::read_to_string(self.marker("pid")) + .ok() + .and_then(|value| value.trim().parse::().ok()) + { + return pid; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("fake redis never became ready") + } + + async fn assert_reaped(&self, pid: i32) { + tokio::time::timeout(Duration::from_secs(3), async { + // SAFETY: signal 0 only checks the process we just spawned. + while unsafe { kill(pid, 0) } == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("managed redis child was not stopped and reaped"); + // Disarm emergency cleanup once the PID is no longer ours. + fs::remove_file(self.marker("pid")).unwrap(); + } + } + + impl Drop for FakeRedis { + fn drop(&mut self) { + if let Some(pid) = fs::read_to_string(self.marker("pid")) + .ok() + .and_then(|value| value.trim().parse::().ok()) + { + // SAFETY: emergency cleanup of this test's child after a + // failed assertion; successful checks remove the PID file. + unsafe { kill(pid, 9) }; + } + let _ = fs::remove_dir_all(&self.root); + } + } + + #[tokio::test] + async fn managed_shutdown_sends_sigterm_and_reaps() { + let fake = FakeRedis::new("printf saved > \"$0.saved\"; exit 0"); + let cfg = fake.config(); + let (tx, rx) = oneshot::channel(); + let mut running = Box::pin(run(&cfg, async { + let _ = rx.await; + })); + let pid = tokio::select! { + pid = fake.ready() => pid, + result = &mut running => panic!("redis exited before readiness: {result:?}"), + }; + tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(3), running) + .await + .expect("graceful redis shutdown timed out") + .unwrap(); + assert_eq!(fs::read(fake.marker("saved")).unwrap(), b"saved"); + fake.assert_reaped(pid).await; + } + + #[tokio::test] + async fn managed_shutdown_kills_and_reaps_a_term_ignoring_child() { + let fake = FakeRedis::new(""); + let cfg = fake.config(); + let (tx, rx) = oneshot::channel(); + let mut running = Box::pin(run(&cfg, async { + let _ = rx.await; + })); + let pid = tokio::select! { + pid = fake.ready() => pid, + result = &mut running => panic!("redis exited before readiness: {result:?}"), + }; + tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(8), running) + .await + .expect("unresponsive redis blocked shutdown") + .unwrap(); + fake.assert_reaped(pid).await; + } + + #[tokio::test] + async fn cancelling_managed_redis_stops_and_reaps_its_child() { + // Check both cancellation during service operation and while its + // SIGTERM grace period is in progress. + for during_shutdown in [false, true] { + let fake = FakeRedis::new("printf received > \"$0.term\""); + let cfg = fake.config(); + let (tx, rx) = oneshot::channel(); + let mut running = Box::pin(run(&cfg, async { + let _ = rx.await; + })); + let pid = tokio::select! { + pid = fake.ready() => pid, + result = &mut running => panic!("redis exited before readiness: {result:?}"), + }; + if during_shutdown { + tx.send(()).unwrap(); + let received_term = async { + while !fake.marker("term").exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }; + tokio::select! { + result = tokio::time::timeout(Duration::from_secs(3), received_term) => { + result.expect("redis never received SIGTERM"); + } + result = &mut running => panic!("redis exited during its grace period: {result:?}"), + } + } + drop(running); + fake.assert_reaped(pid).await; + } } } } diff --git a/orchestrator/src/supervisor.rs b/orchestrator/src/supervisor.rs index 6179135..8c0e96c 100644 --- a/orchestrator/src/supervisor.rs +++ b/orchestrator/src/supervisor.rs @@ -45,6 +45,14 @@ pub async fn shutdown_future(mut rx: watch::Receiver) { /// Starts all enabled services and blocks until they all exit (clean shutdown) /// or the first one errors. Returns the first service error, if any. pub async fn run(cfg: Config) -> Result<(), String> { + // Resolve external Redis before launching tasks, so invalid URL settings + // cannot partially start the daemon or silently select the managed port. + let redis_target = cfg + .services + .redis + .enabled + .then(|| services::redis::connection_target(&cfg)) + .transpose()?; let (tx, rx) = watch::channel(false); let mut set: JoinSet> = JoinSet::new(); @@ -123,7 +131,7 @@ pub async fn run(cfg: Config) -> Result<(), String> { let sd = shutdown_future(rx.clone()); set.spawn(async move { services::redshift::run(&c, sd).await }); } - if cfg.services.redis.enabled { + if let Some(redis_target) = redis_target { let c = cfg.clone(); let sd = shutdown_future(rx.clone()); set.spawn(async move { services::redis::run(&c, sd).await }); @@ -132,23 +140,14 @@ pub async fn run(cfg: Config) -> Result<(), String> { // in-process even when the upstream redis-server is absent (reads/mutations // surface 502), mirroring the legacy listener which always runs when redis is // enabled. Feeds redis mutation events into the same relay channel. - let redis_mode = { - let m = cfg.services.redis.mode.trim().to_lowercase(); - if !m.is_empty() { - m - } else if !cfg.services.redis.external_url.trim().is_empty() { - "external".to_string() - } else { - "managed".to_string() - } - }; let rc_cfg = devcloud_redis_control::Config { http_addr: format!("127.0.0.1:{}", cfg.server.redis_http_port), - redis_addr: format!("127.0.0.1:{}", cfg.server.redis_port), - redis_password: cfg.auth.redis.password.clone(), + redis_addr: redis_target.addr, + redis_password: redis_target.password, control_auth_mode: cfg.auth.redis.mode.clone(), control_password: cfg.auth.redis.password.clone(), - mode: redis_mode, + mode: redis_target.mode, + initial_db: redis_target.database, database_count: 0, event_sink: Some(event_tx.clone()), }; diff --git a/orchestrator/tests/redis_external.rs b/orchestrator/tests/redis_external.rs new file mode 100644 index 0000000..6d1056a --- /dev/null +++ b/orchestrator/tests/redis_external.rs @@ -0,0 +1,253 @@ +#![cfg(unix)] + +use std::fs; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; + +struct Workspace { + root: PathBuf, + child: Option, +} + +impl Drop for Workspace { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = fs::remove_dir_all(&self.root); + } +} + +async fn command(reader: &mut BufReader) -> Option> { + let mut line = String::new(); + reader.read_line(&mut line).await.ok()?; + let count: usize = line.strip_prefix('*')?.trim().parse().ok()?; + let mut args = Vec::new(); + for _ in 0..count { + line.clear(); + reader.read_line(&mut line).await.ok()?; + let len: usize = line.strip_prefix('$')?.trim().parse().ok()?; + let mut bytes = vec![0; len + 2]; + reader.read_exact(&mut bytes).await.ok()?; + if &bytes[len..] != b"\r\n" { + return None; + } + args.push(String::from_utf8(bytes[..len].to_vec()).ok()?); + } + Some(args) +} + +fn bulk(value: &str) -> String { + format!("${}\r\n{value}\r\n", value.len()) +} + +async fn fake_redis(stream: TcpStream, wrong_auth: Arc, config_allowed: bool) { + let mut stream = BufReader::new(stream); + let mut authenticated = false; + let mut db = 0; + while let Some(args) = command(&mut stream).await { + let reply = match args.first().map(String::as_str) { + Some("AUTH") => { + authenticated = args.get(1).map(String::as_str) == Some("upstream-secret"); + if authenticated { + "+OK\r\n".into() + } else { + wrong_auth.store(true, Ordering::SeqCst); + "-WRONGPASS invalid credentials\r\n".into() + } + } + _ if !authenticated => "-NOAUTH authentication required\r\n".into(), + Some("PING") => "+PONG\r\n".into(), + Some("SELECT") => { + db = args[1].parse().unwrap(); + "+OK\r\n".into() + } + Some("CONFIG") if config_allowed => { + format!("*2\r\n{}{}", bulk("databases"), bulk("16")) + } + Some("CONFIG") => "-NOPERM this user cannot run CONFIG\r\n".into(), + Some("INFO") => bulk(&format!( + "redis_version:7.0.0\r\nconnected_clients:2\r\nused_memory_human:1M\r\ndb{db}:keys=1,expires=0\r\n" + )), + Some("GET") => bulk(&format!("database-{db}")), + _ => "-ERR unsupported test command\r\n".into(), + }; + if stream.get_mut().write_all(reply.as_bytes()).await.is_err() { + break; + } + } +} + +async fn http(port: u16, method: &str, path: &str, body: &str) -> Result<(u16, Value), String> { + let exchange = async { + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .await + .map_err(|e| e.to_string())?; + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: localhost:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(request.as_bytes()) + .await + .map_err(|e| e.to_string())?; + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .map_err(|e| e.to_string())?; + let end = response + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or("missing response head")?; + let status = String::from_utf8_lossy(&response[..end]) + .split_whitespace() + .nth(1) + .ok_or("missing response status")? + .parse() + .map_err(|_| "invalid response status")?; + let body = serde_json::from_slice(&response[end + 4..]) + .map_err(|e| format!("invalid response JSON: {e}"))?; + Ok((status, body)) + }; + tokio::time::timeout(Duration::from_secs(2), exchange) + .await + .map_err(|_| "HTTP request timed out".to_string())? +} + +#[tokio::test] +async fn external_redis_dashboard_uses_its_url_password_port_and_database() { + check_external_redis(3, true, 16).await; +} + +#[tokio::test] +async fn external_redis_without_config_permission_accepts_a_database_above_the_default_range() { + check_external_redis(20, false, 21).await; +} + +async fn check_external_redis(database: i64, config_allowed: bool, expected_database_count: i64) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream_port = listener.local_addr().unwrap().port(); + let wrong_auth = Arc::new(AtomicBool::new(false)); + let observed = Arc::clone(&wrong_auth); + let (stop, stopped) = tokio::sync::oneshot::channel(); + let backend = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + tokio::pin!(stopped); + loop { + tokio::select! { + _ = &mut stopped => break, + connection = listener.accept() => { + let (stream, _) = connection.unwrap(); + connections.spawn(fake_redis(stream, Arc::clone(&observed), config_allowed)); + } + } + } + connections.abort_all(); + while connections.join_next().await.is_some() {} + }); + + let reserved: Vec<_> = (0..4) + .map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap()) + .collect(); + let ports: Vec<_> = reserved + .iter() + .map(|s| s.local_addr().unwrap().port()) + .collect(); + let (dashboard, relay, control, managed) = (ports[0], ports[1], ports[2], ports[3]); + let root = std::env::temp_dir().join(format!( + "devcloud-redis-external-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join(".devcloud")).unwrap(); + fs::write( + root.join(".devcloud/config.yaml"), + format!( + "server:\n dashboardPort: {dashboard}\n eventRelayPort: {relay}\n redisHttpPort: {control}\n redisPort: {managed}\nauth:\n redis:\n mode: strict\n password: control-only-secret\nservices:\n redis:\n mode: external\n externalUrl: redis://:upstream%2Dsecret@127.0.0.1:{upstream_port}/{database}\n binaryPath: /redis-must-not-be-started\n" + ), + ) + .unwrap(); + let mut workspace = Workspace { root, child: None }; + let log = fs::File::create(workspace.root.join("stderr.log")).unwrap(); + drop(reserved); + workspace.child = Some( + Command::new(env!("CARGO_BIN_EXE_devcloud")) + .args(["up", "redis"]) + .current_dir(&workspace.root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .unwrap(), + ); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let status = loop { + if let Ok((200, body)) = http(dashboard, "GET", "/api/redis/status", "").await { + break body; + } + assert!( + tokio::time::Instant::now() < deadline, + "external Redis never became ready: {}", + fs::read_to_string(workspace.root.join("stderr.log")).unwrap() + ); + tokio::time::sleep(Duration::from_millis(25)).await; + }; + assert_eq!(status["mode"], "external"); + assert_eq!(status["address"], format!("127.0.0.1:{upstream_port}")); + assert_eq!(status["currentDB"], database); + assert_eq!(status["databaseCount"], expected_database_count); + assert_eq!(status["currentDBKeys"], 1); + // The selected DB must also remain available in the normal UI switcher, + // whose range comes from databaseCount rather than the external URL. + for db in [0, database] { + let (status, result) = http( + dashboard, + "POST", + "/api/redis/select-db", + &json!({"db": db}).to_string(), + ) + .await + .unwrap(); + assert_eq!(status, 200); + assert_eq!(result["currentDB"], db); + } + let (status, result) = http( + dashboard, + "POST", + "/api/redis/command", + &json!({"command": "GET", "args": ["probe"]}).to_string(), + ) + .await + .unwrap(); + assert_eq!(status, 200); + assert_eq!(result["rows"], json!([format!("database-{database}")])); + assert!(!wrong_auth.load(Ordering::SeqCst)); + let (_, registry) = http(dashboard, "GET", "/api/services", "").await.unwrap(); + let registry = registry.to_string(); + for credential in [ + "upstream-secret", + "upstream%2Dsecret", + "control-only-secret", + ] { + assert!( + !registry.contains(credential), + "registry exposed a credential" + ); + } + // Dropping the child guard also keeps assertion failures from leaving a daemon. + drop(workspace); + let _ = stop.send(()); + backend.await.unwrap(); +} diff --git a/orchestrator/tests/workspace_safety.rs b/orchestrator/tests/workspace_safety.rs new file mode 100644 index 0000000..91a88a4 --- /dev/null +++ b/orchestrator/tests/workspace_safety.rs @@ -0,0 +1,168 @@ +//! Exercise reset through the CLI so filesystem validation runs against an +//! isolated workspace without changing the test process's current directory. + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_WORKSPACE: AtomicU64 = AtomicU64::new(0); + +struct Workspace(PathBuf); + +impl Workspace { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "devcloud-reset-safety-{}-{}", + std::process::id(), + NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(root.join(".devcloud/data")).unwrap(); + fs::create_dir_all(root.join("outside/target")).unwrap(); + fs::write(root.join(".devcloud/data/keep"), b"stored data").unwrap(); + fs::write(root.join("outside/target/keep"), b"unrelated data").unwrap(); + Self(root) + } + + fn run(&self, command: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_devcloud")) + .arg(command) + .current_dir(&self.0) + .output() + .unwrap() + } + + fn assert_rejected_without_changes(&self, config: &str) { + fs::write(self.0.join(".devcloud/config.yaml"), config).unwrap(); + let result = self.run("reset"); + assert!( + !result.status.success(), + "reset accepted unsafe configuration: {config}" + ); + assert_eq!( + fs::read(self.0.join("outside/target/keep")).unwrap(), + b"unrelated data" + ); + assert_eq!( + fs::read(self.0.join(".devcloud/data/keep")).unwrap(), + b"stored data", + "every configured path must be validated before the first deletion" + ); + assert_eq!( + fs::read_to_string(self.0.join(".devcloud/config.yaml")).unwrap(), + config + ); + } +} + +impl Drop for Workspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn reset_rejects_parent_traversal_in_every_storage_setting() { + for config in [ + "storage:\n path: .devcloud/../outside/target\n", + "services:\n pubsub:\n dataDir: .devcloud/../outside/target\n", + "services:\n pubsub:\n messageDataDir: .devcloud/../outside/target\n", + "services:\n redshift:\n dataDir: ../../outside/target\n", + "services:\n redis:\n dataDir: ../../outside/target\n", + ] { + Workspace::new().assert_rejected_without_changes(config); + } +} + +#[cfg(unix)] +#[test] +fn reset_rejects_intermediate_symlinks_before_deleting_any_storage() { + for config in [ + "storage:\n path: .devcloud/link/target\n", + "services:\n pubsub:\n dataDir: .devcloud/link/target\n", + "services:\n pubsub:\n messageDataDir: .devcloud/link/target\n", + "services:\n redshift:\n dataDir: .devcloud/link/target\n", + "services:\n redis:\n dataDir: .devcloud/link/target\n", + ] { + let workspace = Workspace::new(); + std::os::unix::fs::symlink("../outside", workspace.0.join(".devcloud/link")).unwrap(); + workspace.assert_rejected_without_changes(config); + } +} + +#[cfg(unix)] +#[test] +fn init_rejects_a_symlinked_workspace_root() { + let workspace = Workspace::new(); + fs::remove_dir_all(workspace.0.join(".devcloud")).unwrap(); + std::os::unix::fs::symlink("outside", workspace.0.join(".devcloud")).unwrap(); + let result = workspace.run("init"); + assert!(!result.status.success()); + assert!(!workspace.0.join("outside/data").exists()); + assert!(!workspace.0.join("outside/config.yaml").exists()); + assert_eq!( + fs::read(workspace.0.join("outside/target/keep")).unwrap(), + b"unrelated data" + ); +} + +#[cfg(unix)] +#[test] +fn init_does_not_write_through_a_symlinked_service_directory() { + let workspace = Workspace::new(); + std::os::unix::fs::symlink( + "../../outside/target", + workspace.0.join(".devcloud/data/mail"), + ) + .unwrap(); + let result = workspace.run("init"); + assert!(!result.status.success()); + assert!(!workspace.0.join("outside/target/index.json").exists()); + assert_eq!( + fs::read(workspace.0.join("outside/target/keep")).unwrap(), + b"unrelated data" + ); +} + +#[cfg(unix)] +#[test] +fn init_does_not_write_through_a_dangling_state_file_symlink() { + let workspace = Workspace::new(); + fs::create_dir_all(workspace.0.join(".devcloud/data/mail")).unwrap(); + std::os::unix::fs::symlink( + "../../../outside/target/index.json", + workspace.0.join(".devcloud/data/mail/index.json"), + ) + .unwrap(); + let result = workspace.run("init"); + assert!(!result.status.success()); + assert!(!workspace.0.join("outside/target/index.json").exists()); + assert_eq!( + fs::read(workspace.0.join("outside/target/keep")).unwrap(), + b"unrelated data" + ); +} + +#[test] +fn reset_recreates_valid_storage_and_preserves_configuration() { + let workspace = Workspace::new(); + let config = "storage:\n path: ./.devcloud//data\n"; + fs::write(workspace.0.join(".devcloud/config.yaml"), config).unwrap(); + let result = workspace.run("reset"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(!workspace.0.join(".devcloud/data/keep").exists()); + assert!(workspace.0.join(".devcloud/data/s3/buckets").is_dir()); + assert!(workspace.0.join(".devcloud/data/mail/index.json").is_file()); + assert_eq!( + fs::read_to_string(workspace.0.join(".devcloud/config.yaml")).unwrap(), + config + ); + assert_eq!( + fs::read(workspace.0.join("outside/target/keep")).unwrap(), + b"unrelated data" + ); +} diff --git a/scripts/mail-e2e.sh b/scripts/mail-e2e.sh index 353ca86..098cee8 100755 --- a/scripts/mail-e2e.sh +++ b/scripts/mail-e2e.sh @@ -159,6 +159,8 @@ def request(path, method="GET"): req = urllib.request.Request(f"{base_url}{path}", method=method) with urllib.request.urlopen(req, timeout=5) as response: data = response.read() + if response.status == 204: + return None content_type = response.headers.get("Content-Type", "") if "application/json" in content_type: return json.loads(data.decode("utf-8")) diff --git a/services/applicationautoscaling/Cargo.toml b/services/applicationautoscaling/Cargo.toml index 8b79df8..2c4b04e 100644 --- a/services/applicationautoscaling/Cargo.toml +++ b/services/applicationautoscaling/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" # A hand-rolled HTTP/1.1 layer on plain tokio keeps the dependency surface tiny # (matching the legacy service's stdlib-only ethos) and avoids the axum→url→icu # transitive chain that fails to resolve on this toolchain. -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "time"] } serde = { version = "1", features = ["derive"] } # Default serde_json Map is a BTreeMap (sorted keys), matching legacy map JSON # marshaling — do NOT enable preserve_order, which would switch to insertion order. diff --git a/services/applicationautoscaling/src/http.rs b/services/applicationautoscaling/src/http.rs index b9e61e9..b605ec1 100644 --- a/services/applicationautoscaling/src/http.rs +++ b/services/applicationautoscaling/src/http.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::server::{error_outcome, Outcome, Server}; @@ -90,11 +90,14 @@ async fn handle_conn(mut stream: TcpStream, server: Arc) -> std::io::Res /// Reads one HTTP/1.1 request: request line + headers (until CRLFCRLF) then a /// `Content-Length`-bounded body. Returns `None` on clean EOF or malformed head. -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -121,14 +124,31 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let key = k.trim().to_ascii_lowercase(); + if matches!(key.as_str(), "content-length" | "transfer-encoding") + && headers.contains_key(&key) + { + return Ok(None); + } + headers.insert(key, v.trim().to_string()); } } - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + // This transport supports fixed-length request bodies only. Never + // dispatch an unsupported transfer coding as an empty request body. + if headers.contains_key("transfer-encoding") { + return Ok(None); + } + let content_length: usize = match headers.get("content-length") { + None => 0, + Some(value) if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => { + match value.parse() { + Ok(length) => length, + Err(_) => return Ok(None), + } + } + Some(_) => return Ok(None), + }; if content_length > MAX_BODY_BYTES { return Ok(None); } @@ -137,7 +157,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -300,3 +320,61 @@ pub fn serve_for_test( body: json, } } + +#[cfg(test)] +mod request_framing_tests { + use super::*; + + #[tokio::test] + async fn incomplete_fixed_length_body_is_rejected() { + // A complete JSON value is still an incomplete HTTP message. It must + // never reach a mutating handler after the client disconnects early. + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 9\r\n\r\n{}"[..]; + assert!(read_request(&mut wire).await.unwrap().is_none()); + } + + #[tokio::test] + async fn invalid_content_length_is_rejected() { + for value in ["bad", "-1", "+2", "", "18446744073709551616"] { + let wire = format!("POST / HTTP/1.1\r\nContent-Length: {value}\r\n\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{value:?}" + ); + } + } + + #[tokio::test] + async fn ambiguous_or_unsupported_body_framing_is_rejected() { + for headers in [ + "Transfer-Encoding: gzip\r\n", + "Transfer-Encoding: xchunked\r\n", + "Transfer-Encoding: chunked, gzip\r\n", + "Content-Length: 9\r\nContent-Length: 2\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ] { + let wire = format!("POST / HTTP/1.1\r\n{headers}\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{headers:?}" + ); + } + } + + #[tokio::test] + async fn complete_fixed_length_body_is_preserved() { + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn oversized_header_is_rejected_even_when_terminator_arrives() { + let wire = format!( + "POST / HTTP/1.1\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ); + assert!(read_request(&mut wire.as_bytes()).await.unwrap().is_none()); + } +} diff --git a/services/applicationautoscaling/src/sigv4.rs b/services/applicationautoscaling/src/sigv4.rs index d12851b..17560ce 100644 --- a/services/applicationautoscaling/src/sigv4.rs +++ b/services/applicationautoscaling/src/sigv4.rs @@ -299,8 +299,11 @@ fn url_decode(s: &str) -> String { while i < bytes.len() { match bytes[i] { b'%' if i + 2 < bytes.len() => { - if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { - out.push(b); + if let (Some(hi), Some(lo)) = ( + (bytes[i + 1] as char).to_digit(16), + (bytes[i + 2] as char).to_digit(16), + ) { + out.push((hi * 16 + lo) as u8); i += 3; continue; } @@ -350,3 +353,20 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { } diff == 0 } + +#[cfg(test)] +mod percent_decode_tests { + use super::*; + + #[test] + fn malformed_percent_escapes_next_to_unicode_do_not_panic() { + for value in ["%あ", "%aあ", "猫%猫", "%é", "%💾", "%", "%2", "%zz"] { + assert_eq!(url_decode(value), value); + } + } + + #[test] + fn valid_percent_escapes_and_plus_are_preserved() { + assert_eq!(url_decode("%E7%8C%AB+%2B%2F"), "猫 +/"); + } +} diff --git a/services/bigquery/Cargo.toml b/services/bigquery/Cargo.toml index f2d49b5..6adeb10 100644 --- a/services/bigquery/Cargo.toml +++ b/services/bigquery/Cargo.toml @@ -20,4 +20,4 @@ serde = { version = "1", features = ["derive"] } # `json.RawMessage` for insertAll row values (preserves number literals like # `1.50` byte-for-byte in the streaming-buffer JSONL). serde_json = { version = "1", features = ["raw_value"] } -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "time", "sync"] } diff --git a/services/bigquery/src/http.rs b/services/bigquery/src/http.rs index 98d3bb6..782f15f 100644 --- a/services/bigquery/src/http.rs +++ b/services/bigquery/src/http.rs @@ -9,7 +9,7 @@ use std::future::Future; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::responses::ApiResponse; @@ -70,11 +70,14 @@ async fn handle_conn(mut stream: TcpStream, server: Arc) -> std::io::Res write_response(&mut stream, response).await } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -100,6 +103,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut authorization = String::new(); let mut content_type = String::new(); let mut content_length: usize = 0; + let mut has_content_length = false; let mut chunked = false; for line in lines { if let Some((k, v)) = line.split_once(':') { @@ -107,15 +111,33 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result match k.trim().to_ascii_lowercase().as_str() { "authorization" => authorization = value.to_string(), "content-type" => content_type = value.to_string(), - "content-length" => content_length = value.parse().unwrap_or(0), + "content-length" => { + if has_content_length { + return Ok(None); + } + has_content_length = true; + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { + return Ok(None); + } + content_length = match value.parse() { + Ok(length) => length, + Err(_) => return Ok(None), + }; + } "transfer-encoding" => { - chunked = value.to_ascii_lowercase().contains("chunked"); + if chunked || !value.eq_ignore_ascii_case("chunked") { + return Ok(None); + } + chunked = true; } _ => {} } } } + if chunked && has_content_length { + return Ok(None); + } let mut body = buf[header_end + 4..].to_vec(); if chunked { body = read_chunked_body(stream, body).await?; @@ -126,7 +148,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -143,40 +165,119 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result })) } -async fn read_chunked_body(stream: &mut TcpStream, mut buf: Vec) -> std::io::Result> { +async fn read_chunked_body( + stream: &mut (impl AsyncRead + Unpin), + mut buf: Vec, +) -> std::io::Result> { let mut out = Vec::new(); + let mut framing_bytes = 0usize; let mut tmp = [0u8; 4096]; - let mut pos = 0; loop { - let line_end = loop { - if let Some(i) = find_subslice(&buf[pos..], b"\r\n") { - break pos + i; + let line = read_chunk_line(stream, &mut buf, MAX_HEADER_BYTES).await?; + framing_bytes += line.len() + 2; + // One-byte chunks use five framing bytes per data byte. Reserve that + // legal overhead separately from the additional extension budget. + if framing_bytes > 5 * out.len() + MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk framing too large", + )); + } + let size_hex = line.split(|b| *b == b';').next().unwrap_or_default(); + if size_hex.is_empty() || !size_hex.iter().all(u8::is_ascii_hexdigit) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid chunk size", + )); + } + // ASCII validation above makes this conversion infallible. Reject a + // huge declaration before reading its payload or adding byte offsets. + let size = + usize::from_str_radix(std::str::from_utf8(size_hex).unwrap(), 16).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid chunk size") + })?; + if size > MAX_BODY_BYTES - out.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request body too large", + )); + } + if size == 0 { + // The zero chunk is followed by a complete, bounded trailer + // section, ending in an empty line. EOF is never a terminator. + let mut remaining = MAX_HEADER_BYTES; + loop { + let trailer = read_chunk_line(stream, &mut buf, remaining).await?; + remaining -= trailer.len() + 2; + framing_bytes += trailer.len() + 2; + if framing_bytes > 5 * out.len() + MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk framing too large", + )); + } + if trailer.is_empty() { + return Ok(out); + } } + } + // `size` is now bounded by MAX_BODY_BYTES, so the addition is safe. + while buf.len() < size + 2 { let n = stream.read(&mut tmp).await?; if n == 0 { - return Ok(out); + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete chunk body", + )); } buf.extend_from_slice(&tmp[..n]); - }; - let size_line = String::from_utf8_lossy(&buf[pos..line_end]).into_owned(); - let size_hex = size_line.split(';').next().unwrap_or("").trim(); - let size = usize::from_str_radix(size_hex, 16).unwrap_or(0); - pos = line_end + 2; - if size == 0 { - return Ok(out); } - while buf.len() < pos + size + 2 { - let n = stream.read(&mut tmp).await?; - if n == 0 { - return Ok(out); + if &buf[size..size + 2] != b"\r\n" { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid chunk terminator", + )); + } + out.extend_from_slice(&buf[..size]); + framing_bytes += 2; + // Discard consumed framing as well as data, so many small chunks do + // not retain an unbounded second copy of the request on the heap. + buf.drain(..size + 2); + } +} + +async fn read_chunk_line( + stream: &mut (impl AsyncRead + Unpin), + buf: &mut Vec, + limit: usize, +) -> std::io::Result> { + let mut tmp = [0u8; 4096]; + loop { + if let Some(end) = find_subslice(buf, b"\r\n") { + if end + 2 > limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk headers too large", + )); } - buf.extend_from_slice(&tmp[..n]); + let line = buf[..end].to_vec(); + buf.drain(..end + 2); + return Ok(line); } - out.extend_from_slice(&buf[pos..pos + size]); - if out.len() > MAX_BODY_BYTES { - return Ok(Vec::new()); + if buf.len() >= limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk headers too large", + )); } - pos += size + 2; + let n = stream.read(&mut tmp).await?; + if n == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete chunk headers", + )); + } + buf.extend_from_slice(&tmp[..n]); } } @@ -222,3 +323,146 @@ fn reason_phrase(status: u16) -> &'static str { fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } + +#[cfg(test)] +mod request_framing_tests { + use super::*; + + #[tokio::test] + async fn incomplete_fixed_length_body_is_rejected() { + // A complete JSON value is still an incomplete HTTP message. It must + // never reach a mutating handler after the client disconnects early. + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 9\r\n\r\n{}"[..]; + assert!(read_request(&mut wire).await.unwrap().is_none()); + } + + #[tokio::test] + async fn invalid_content_length_is_rejected() { + for value in ["bad", "-1", "+2", "", "18446744073709551616"] { + let wire = format!("POST / HTTP/1.1\r\nContent-Length: {value}\r\n\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{value:?}" + ); + } + } + + #[tokio::test] + async fn ambiguous_or_unsupported_body_framing_is_rejected() { + for headers in [ + "Transfer-Encoding: gzip\r\n", + "Transfer-Encoding: xchunked\r\n", + "Transfer-Encoding: chunked, gzip\r\n", + "Content-Length: 9\r\nContent-Length: 2\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ] { + let wire = format!("POST / HTTP/1.1\r\n{headers}\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{headers:?}" + ); + } + } + + #[tokio::test] + async fn complete_fixed_length_body_is_preserved() { + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn oversized_header_is_rejected_even_when_terminator_arrives() { + let wire = format!( + "POST / HTTP/1.1\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ); + assert!(read_request(&mut wire.as_bytes()).await.unwrap().is_none()); + } +} + +#[cfg(test)] +mod chunk_framing_tests { + use super::*; + + #[tokio::test] + async fn malformed_or_incomplete_chunks_never_return_a_partial_body() { + for wire in [ + &b"2\r\n{}\r\n"[..], + &b"2\r\n{}\r\nxyz\r\n"[..], + &b"5\r\n{}"[..], + &b"2\r\n{}XX0\r\n\r\n"[..], + &b"2\r\n{}\r\n0\r\n"[..], + &b"2\r\n{}\r\n0\r\nX-Trailer: unfinished"[..], + &b"+2\r\n{}\r\n0\r\n\r\n"[..], + &b"\r\n"[..], + ] { + assert!( + read_chunked_body(&mut &b""[..], wire.to_vec()) + .await + .is_err(), + "{wire:?}" + ); + } + } + + #[tokio::test] + async fn oversized_chunk_is_rejected_before_reading_its_payload() { + for size in [MAX_BODY_BYTES + 1, usize::MAX] { + let wire = format!("{size:x}\r\n"); + assert!(read_chunked_body(&mut &b""[..], wire.into_bytes()) + .await + .is_err()); + } + } + + #[tokio::test] + async fn chunk_lines_and_trailers_are_bounded() { + for wire in [ + format!("1;{}", "x".repeat(MAX_HEADER_BYTES)), + format!("0\r\nX-Trailer: {}", "x".repeat(MAX_HEADER_BYTES)), + ] { + assert!(read_chunked_body(&mut &b""[..], wire.into_bytes()) + .await + .is_err()); + } + } + + #[tokio::test] + async fn cumulative_chunk_framing_is_bounded() { + let chunk = format!("1;{}\r\nx\r\n", "x".repeat(MAX_HEADER_BYTES / 2)); + let wire = format!("{chunk}{chunk}{chunk}0\r\n\r\n"); + assert!(read_chunked_body(&mut wire.as_bytes(), Vec::new()) + .await + .is_err()); + } + + #[tokio::test] + async fn complete_chunked_request_is_preserved() { + let mut wire = + &b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}\r\n0\r\n\r\n"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn many_small_chunks_are_accepted() { + // The minimum legal data chunk carries five framing bytes per byte + // of content. Fragmentation alone must not exhaust the extension cap. + let wire = format!("{}0\r\n\r\n", "1\r\nx\r\n".repeat(20_000)); + let body = read_chunked_body(&mut wire.as_bytes(), Vec::new()) + .await + .unwrap(); + assert_eq!(body, vec![b'x'; 20_000]); + } + + #[tokio::test] + async fn complete_chunks_extensions_and_trailers_are_decoded() { + let wire = b"2;part=first\r\nab\r\n2\r\ncd\r\n0\r\nX-Trailer: ok\r\n\r\n"; + let body = read_chunked_body(&mut wire.as_slice(), Vec::new()) + .await + .unwrap(); + assert_eq!(body, b"abcd"); + } +} diff --git a/services/cloudrun/src/body.rs b/services/cloudrun/src/body.rs index 928de36..3bdfe36 100644 --- a/services/cloudrun/src/body.rs +++ b/services/cloudrun/src/body.rs @@ -95,11 +95,15 @@ impl Input<'_, R> { const MAX_LINE: usize = 8 * 1024; loop { if let Some(i) = self.buf[self.pos..].windows(2).position(|w| w == b"\r\n") { + if i > MAX_LINE { + return Ok(Err(BodyError::Malformed("chunk line too long"))); + } let line = String::from_utf8_lossy(&self.buf[self.pos..self.pos + i]).into_owned(); self.pos += i + 2; return Ok(Ok(line)); } - if self.buf.len() - self.pos > MAX_LINE { + // A maximum-length line may have received its CR but not its LF. + if self.buf.len() - self.pos > MAX_LINE + 1 { return Ok(Err(BodyError::Malformed("chunk line too long"))); } let pending = self.buf.len() - self.pos + 1; @@ -214,6 +218,42 @@ mod tests { assert_eq!(body.unwrap(), b"{\"value\":1} "); } + #[tokio::test] + async fn chunk_line_limits_do_not_depend_on_read_boundaries() { + let te = [("transfer-encoding", "chunked")]; + let valid = format!("1;{}\r\nx\r\n0\r\n\r\n", "a".repeat(8190)); + for split in [0, 8193, valid.len()] { + assert_eq!( + read( + &valid.as_bytes()[..split], + &valid.as_bytes()[split..], + &te, + 1 + ) + .await + .unwrap(), + b"x" + ); + } + for invalid in [ + format!("1;{}\r\nx\r\n0\r\n\r\n", "a".repeat(8191)), + format!("0\r\nX: {}\r\n\r\n", "a".repeat(8190)), + ] { + for split in [0, 8192, invalid.len()] { + assert_eq!( + read( + &invalid.as_bytes()[..split], + &invalid.as_bytes()[split..], + &te, + 1, + ) + .await, + Err(BodyError::Malformed("chunk line too long")) + ); + } + } + } + #[tokio::test] async fn content_length_and_errors() { assert_eq!( diff --git a/services/cloudrun/src/http.rs b/services/cloudrun/src/http.rs index 1e51330..035d0fe 100644 --- a/services/cloudrun/src/http.rs +++ b/services/cloudrun/src/http.rs @@ -311,33 +311,26 @@ async fn proxy( ); return client.write_all(resp.as_bytes()).await; } - let Some(spec) = server.launch_spec(&route.project, &route.location, &route.service_id) else { - return write_plain( - &mut client, - 404, - &format!("Cloud Run service {service_name} not found\n"), - ) - .await; + let authorization = head + .headers + .get("authorization") + .map(String::as_str) + .unwrap_or(""); + let current = || server.authorized_launch_spec(&service_name, authorization); + // Reject immediately when possible, and re-check the same snapshot after + // acquiring the instance slot so a queued request cannot outlive its grant. + let started = match current() { + Ok(_) => server.instances.ensure(&service_name, current).await, + Err(error) => Err(error), }; - if !server.allows_public_invoke(&service_name) - && !server.authorized( - head.headers - .get("authorization") - .map(String::as_str) - .unwrap_or(""), - ) - { - return write_plain( + let port = match started { + Ok(p) => p, + Err(StartError::Forbidden) => return write_plain( &mut client, 403, "Error: Forbidden\nYour client does not have permission to get URL from this server.\n", ) - .await; - } - drop(spec); - let current = || server.launch_spec(&route.project, &route.location, &route.service_id); - let port = match server.instances.ensure(&service_name, current).await { - Ok(p) => p, + .await, Err(StartError::Gone) => { return write_plain( &mut client, diff --git a/services/cloudrun/src/instances.rs b/services/cloudrun/src/instances.rs index 8c68ecf..2b481db 100644 --- a/services/cloudrun/src/instances.rs +++ b/services/cloudrun/src/instances.rs @@ -128,6 +128,8 @@ pub enum StartError { /// The service was deleted (or devcloud is stopping) while the request /// waited; nothing may be started for it. Gone, + /// The request no longer has permission to invoke the current service. + Forbidden, } impl std::fmt::Display for StartError { @@ -137,6 +139,7 @@ impl std::fmt::Display for StartError { f.write_str(m) } StartError::Gone => f.write_str("service no longer exists"), + StartError::Forbidden => f.write_str("permission to invoke the service was denied"), } } } @@ -182,15 +185,9 @@ impl Drop for TeardownGuard { } if let Some((docker, name)) = self.container.take() { // No runtime to await on here: remove the container from a - // thread (bounded by the CLI itself), tracked so shutdown waits + // bounded thread, tracked so shutdown waits // for it — killing the CLI alone leaves the container running. - let handle = std::thread::spawn(move || { - let _ = std::process::Command::new(docker) - .args(["rm", "-f", &name]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - }); + let handle = std::thread::spawn(move || remove_container_blocking(&docker, &name)); let mut pending = self.removals.lock().unwrap(); pending.retain(|h| !h.is_finished()); pending.push(handle); @@ -198,6 +195,44 @@ impl Drop for TeardownGuard { } } +/// Drop-time cleanup has no async runtime to enforce a timeout. Bound the +/// Docker CLI itself, including any subprocesses it launches, so a stalled +/// daemon cannot leave cleanup threads and children alive after shutdown. +fn remove_container_blocking(docker: &str, name: &str) { + let mut command = std::process::Command::new(docker); + command + .args(["rm", "-f", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let Ok(mut child) = command.spawn() else { + return; + }; + let pid = child.id(); + let deadline = std::time::Instant::now() + DOCKER_RM_TIMEOUT; + loop { + match child.try_wait() { + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(25)); + } + Ok(Some(_)) => { + signal_group(pid, Signal::Kill); + return; + } + _ => { + signal_group(pid, Signal::Kill); + let _ = child.kill(); + let _ = child.wait(); + return; + } + } + } +} + impl Instance { /// Whether the instance's process is still running. Reaping the leader /// also kills what is left of its group at once and forgets the group: @@ -258,6 +293,21 @@ pub struct Manager { removals: Removals, } +/// Releases a cold start's port if the future exits or is cancelled before an +/// instance takes ownership of the reservation. +struct StartingPort<'a> { + manager: &'a Manager, + port: Option, +} + +impl Drop for StartingPort<'_> { + fn drop(&mut self) { + if let Some(port) = self.port { + self.manager.release_port(port); + } + } +} + impl Manager { pub fn new(docker_bin: Option) -> Self { Manager { @@ -283,13 +333,13 @@ impl Manager { /// Returns the loopback port of a live instance of `service`, starting /// (or replacing) one when needed. `current` reads the service's launch - /// spec from the control-plane state; it is consulted only after the slot - /// lock is held, so a request that raced a delete or an update never - /// (re)starts code the service no longer has. + /// spec and checks invocation permission against the control-plane state; + /// it is consulted after the slot lock is held, so queued requests cannot + /// use a stale service or authorization decision. pub async fn ensure( &self, service: &str, - current: impl Fn() -> Option, + current: impl Fn() -> Result, ) -> Result { let Some(slot) = self.slot(service) else { return Err(StartError::Gone); @@ -299,11 +349,14 @@ impl Manager { // `stop` ran while we waited: the service was deleted. return Err(StartError::Gone); } - let Some(spec) = current() else { - // Deleted before we got the slot (its `stop` found no instance). - drop(guard); - self.discard_empty_slot(service, &slot); - return Err(StartError::Gone); + let spec = match current() { + Ok(spec) => spec, + Err(error) => { + // Deleted or unauthorized before we got the slot. + drop(guard); + self.discard_empty_slot(service, &slot); + return Err(error); + } }; if let Some(inst) = guard.instance.as_mut() { if inst.alive() && inst.spec == spec { @@ -443,11 +496,13 @@ impl Manager { return Err(not_runnable(spec)); } let port = self.reserve_port().map_err(StartError::Spawn)?; - let started = self.start_on(spec, port).await; - if started.is_err() { - self.release_port(port); - } - started + let mut reservation = StartingPort { + manager: self, + port: Some(port), + }; + let instance = self.start_on(spec, port).await?; + reservation.port = None; + Ok(instance) } async fn start_on(&self, spec: &LaunchSpec, port: u16) -> Result { @@ -918,7 +973,7 @@ mod tests { async fn image_only_container_is_not_runnable_without_docker() { let m = Manager::new(None); let spec = LaunchSpec::from_service(&service()).unwrap(); - match m.ensure(&spec.service, || Some(spec.clone())).await { + match m.ensure(&spec.service, || Ok(spec.clone())).await { Err(StartError::NotRunnable(msg)) => assert!(msg.contains("has no command")), other => panic!("unexpected {other:?}"), } @@ -947,7 +1002,7 @@ mod tests { let spec = LaunchSpec::from_service(&service()).unwrap(); m.stop_all().await; assert_eq!( - m.ensure(&spec.service, || Some(spec.clone())).await, + m.ensure(&spec.service, || Ok(spec.clone())).await, Err(StartError::Gone) ); assert!(m.slots.lock().unwrap().map.is_empty()); @@ -995,7 +1050,7 @@ mod tests { let m = Manager::new(Some(fake.to_string_lossy().into_owned())); let spec = LaunchSpec::from_service(&service()).unwrap(); let err = m - .ensure(&spec.service, || Some(spec.clone())) + .ensure(&spec.service, || Ok(spec.clone())) .await .unwrap_err(); assert!(matches!(err, StartError::Unhealthy(_)), "{err:?}"); @@ -1077,6 +1132,86 @@ mod tests { assert!(!m.ports.lock().unwrap().contains(&p)); } + #[tokio::test] + async fn cancelling_a_cold_start_releases_its_reserved_port() { + let m = Manager::new(None); + let mut spec = LaunchSpec::from_service(&service()).unwrap(); + spec.command = vec!["sleep".into(), "30".into()]; + spec.args.clear(); + for _ in 0..3 { + let mut start = Box::pin(m.ensure(&spec.service, || Ok(spec.clone()))); + tokio::select! { + result = &mut start => panic!("non-listening process unexpectedly started: {result:?}"), + _ = async { + while m.ports.lock().unwrap().is_empty() { + tokio::task::yield_now().await; + } + } => {} + } + drop(start); + assert!( + m.ports.lock().unwrap().is_empty(), + "a cancelled start must not retain its port reservation" + ); + } + m.stop_all().await; + } + + #[cfg(unix)] + #[tokio::test] + async fn dropped_instance_bounds_and_reaps_a_hung_docker_cleanup() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!( + "devcloud-cloudrun-drop-cleanup-{}-{}", + std::process::id(), + new_run_id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let fake = dir.join("docker"); + std::fs::write( + &fake, + "#!/bin/sh\nprintf '%s\\n' \"$$\" > \"$0.pid\"\nexec sleep 60\n", + ) + .unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + let m = Manager::new(Some(fake.to_string_lossy().into_owned())); + drop(TeardownGuard { + pgid: None, + container: Some((fake.to_string_lossy().into_owned(), "test-container".into())), + removals: Arc::clone(&m.removals), + }); + m.stop_all().await; + // The thread can finish just after stop_all's own timeout expires. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let finished = loop { + if m.removals.lock().unwrap().iter().all(|h| h.is_finished()) { + break true; + } + if tokio::time::Instant::now() >= deadline { + break false; + } + tokio::time::sleep(Duration::from_millis(25)).await; + }; + let pid: libc::pid_t = std::fs::read_to_string(dir.join("docker.pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + // SAFETY: signal 0 probes only the cleanup process this test started. + let still_running = unsafe { libc::kill(pid, 0) } == 0; + if still_running { + // Ensure a failing regression test does not leave its sleeper. + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + let _ = std::fs::remove_dir_all(dir); + assert!(finished, "drop-time Docker cleanup exceeded its deadline"); + assert!( + !still_running, + "drop-time Docker cleanup left its CLI alive" + ); + } + #[tokio::test] async fn a_reaped_instance_forgets_its_process_group() { let mut cmd = Command::new("sh"); @@ -1155,7 +1290,7 @@ mod tests { let m = Manager::new(None); let spec = LaunchSpec::from_service(&service()).unwrap(); assert_eq!( - m.ensure(&spec.service, || None).await, + m.ensure(&spec.service, || Err(StartError::Gone)).await, Err(StartError::Gone) ); assert!( @@ -1164,6 +1299,80 @@ mod tests { ); } + #[tokio::test] + async fn queued_invocation_rechecks_permission_after_waiting() { + use crate::server::{Config, Server}; + + for iam_disabled in [false, true] { + let server = Server::new(Config { + auth_mode: "strict".into(), + bearer_token: "test-token".into(), + ..Config::default() + }); + let service_name = "projects/p/locations/us-central1/services/web"; + let query = BTreeMap::from([("serviceId".into(), "web".into())]); + let input = json!({ + "template": { "containers": [{ "image": "test-image" }] }, + "invokerIamDisabled": iam_disabled, + }); + assert_eq!( + server + .create_service( + "p", + "us-central1", + &query, + &serde_json::to_vec(&input).unwrap() + ) + .status, + 200 + ); + if !iam_disabled { + let policy = json!({"policy": {"bindings": [{ + "role": "roles/run.invoker", "members": ["allUsers"] + }]}}); + assert_eq!( + server + .set_iam_policy(service_name, &serde_json::to_vec(&policy).unwrap()) + .status, + 200 + ); + } + let current = || server.authorized_launch_spec(service_name, ""); + assert!(current().is_ok(), "the original grant allows invocation"); + // Simulate a request queued behind another cold start. Poll it + // into the lock wait before changing the service's permission. + let slot = server.instances.slot(service_name).unwrap(); + let held = slot.lock().await; + let mut request = Box::pin(server.instances.ensure(service_name, current)); + tokio::select! { + biased; + result = &mut request => panic!("locked slot completed early: {result:?}"), + _ = tokio::task::yield_now() => {} + } + let revoked = if iam_disabled { + server.update_service( + "p", + "us-central1", + "web", + &BTreeMap::new(), + br#"{"invokerIamDisabled":false}"#, + ) + } else { + server.set_iam_policy(service_name, br#"{"policy":{"bindings":[]}}"#) + }; + assert_eq!(revoked.status, 200); + drop(held); + assert_eq!(request.await, Err(StartError::Forbidden)); + assert!(server.instances.slots.lock().unwrap().map.is_empty()); + assert!( + server + .authorized_launch_spec(service_name, "Bearer test-token") + .is_ok(), + "a valid bearer token still grants invocation" + ); + } + } + /// delete_service: remove from state, then `stop`. Requests already /// queued on the slot — ahead of or behind `stop` — must not start code. #[tokio::test] @@ -1172,7 +1381,11 @@ mod tests { let m = Manager::new(None); let spec = LaunchSpec::from_service(&service()).unwrap(); let deleted = AtomicBool::new(false); - let current = || (!deleted.load(Ordering::SeqCst)).then(|| spec.clone()); + let current = || { + (!deleted.load(Ordering::SeqCst)) + .then(|| spec.clone()) + .ok_or(StartError::Gone) + }; // Hold the slot like an in-flight cold start. let slot = m.slot(&spec.service).unwrap(); let held = slot.lock().await; diff --git a/services/cloudrun/src/server.rs b/services/cloudrun/src/server.rs index e77b317..5bc960f 100644 --- a/services/cloudrun/src/server.rs +++ b/services/cloudrun/src/server.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; -use crate::instances::{LaunchSpec, Manager}; +use crate::instances::{LaunchSpec, Manager, StartError}; use crate::time_fmt::now_rfc3339; const SERVICE_TYPE: &str = "type.googleapis.com/google.cloud.run.v2.Service"; @@ -946,14 +946,33 @@ impl Server { )) } + /// Reads the launch spec and its invocation policy from one state snapshot. + /// Call again after waiting for the instance slot: its service may have + /// been reconfigured or recreated, or public invocation revoked meanwhile. + pub(crate) fn authorized_launch_spec( + &self, + service_name: &str, + authorization: &str, + ) -> Result { + let st = self.state.lock().unwrap(); + let service = st.services.get(service_name).ok_or(StartError::Gone)?; + if !self.authorized(authorization) && !self.public_invoke_allowed(&st, service_name) { + return Err(StartError::Forbidden); + } + LaunchSpec::from_service(service).ok_or(StartError::Gone) + } + /// Whether unauthenticated callers may invoke the service: always in relaxed /// mode; in strict mode only with `invokerIamDisabled` or an `allUsers` /// `roles/run.invoker` binding. pub fn allows_public_invoke(&self, service_name: &str) -> bool { + self.public_invoke_allowed(&self.state.lock().unwrap(), service_name) + } + + fn public_invoke_allowed(&self, st: &State, service_name: &str) -> bool { if !is_strict(&self.config.auth_mode) { return true; } - let st = self.state.lock().unwrap(); if st .services .get(service_name) diff --git a/services/dashboard/assets/react/assets/index-C7w4pPfk.js b/services/dashboard/assets/react/assets/index-C7w4pPfk.js new file mode 100644 index 0000000..045184c --- /dev/null +++ b/services/dashboard/assets/react/assets/index-C7w4pPfk.js @@ -0,0 +1,109 @@ +var Zf=Object.defineProperty;var ep=(r,l,i)=>l in r?Zf(r,l,{enumerable:!0,configurable:!0,writable:!0,value:i}):r[l]=i;var fs=(r,l,i)=>ep(r,typeof l!="symbol"?l+"":l,i);(function(){const l=document.createElement("link").relList;if(l&&l.supports&&l.supports("modulepreload"))return;for(const d of document.querySelectorAll('link[rel="modulepreload"]'))u(d);new MutationObserver(d=>{for(const p of d)if(p.type==="childList")for(const g of p.addedNodes)g.tagName==="LINK"&&g.rel==="modulepreload"&&u(g)}).observe(document,{childList:!0,subtree:!0});function i(d){const p={};return d.integrity&&(p.integrity=d.integrity),d.referrerPolicy&&(p.referrerPolicy=d.referrerPolicy),d.crossOrigin==="use-credentials"?p.credentials="include":d.crossOrigin==="anonymous"?p.credentials="omit":p.credentials="same-origin",p}function u(d){if(d.ep)return;d.ep=!0;const p=i(d);fetch(d.href,p)}})();function zu(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var Ci={exports:{}},Rr={},Ri={exports:{}},Te={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var xu;function tp(){if(xu)return Te;xu=1;var r=Symbol.for("react.element"),l=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),d=Symbol.for("react.profiler"),p=Symbol.for("react.provider"),g=Symbol.for("react.context"),y=Symbol.for("react.forward_ref"),S=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),N=Symbol.for("react.lazy"),w=Symbol.iterator;function L(j){return j===null||typeof j!="object"?null:(j=w&&j[w]||j["@@iterator"],typeof j=="function"?j:null)}var C={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},A=Object.assign,I={};function E(j,T,ne){this.props=j,this.context=T,this.refs=I,this.updater=ne||C}E.prototype.isReactComponent={},E.prototype.setState=function(j,T){if(typeof j!="object"&&typeof j!="function"&&j!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,j,T,"setState")},E.prototype.forceUpdate=function(j){this.updater.enqueueForceUpdate(this,j,"forceUpdate")};function J(){}J.prototype=E.prototype;function ue(j,T,ne){this.props=j,this.context=T,this.refs=I,this.updater=ne||C}var q=ue.prototype=new J;q.constructor=ue,A(q,E.prototype),q.isPureReactComponent=!0;var P=Array.isArray,oe=Object.prototype.hasOwnProperty,pe={current:null},Z={key:!0,ref:!0,__self:!0,__source:!0};function Re(j,T,ne){var ce,ge={},we=null,De=null;if(T!=null)for(ce in T.ref!==void 0&&(De=T.ref),T.key!==void 0&&(we=""+T.key),T)oe.call(T,ce)&&!Z.hasOwnProperty(ce)&&(ge[ce]=T[ce]);var se=arguments.length-2;if(se===1)ge.children=ne;else if(1>>1,T=$[j];if(0>>1;jd(ge,_))wed(De,ge)?($[j]=De,$[we]=_,j=we):($[j]=ge,$[ce]=_,j=ce);else if(wed(De,_))$[j]=De,$[we]=_,j=we;else break e}}return U}function d($,U){var _=$.sortIndex-U.sortIndex;return _!==0?_:$.id-U.id}if(typeof performance=="object"&&typeof performance.now=="function"){var p=performance;r.unstable_now=function(){return p.now()}}else{var g=Date,y=g.now();r.unstable_now=function(){return g.now()-y}}var S=[],v=[],N=1,w=null,L=3,C=!1,A=!1,I=!1,E=typeof setTimeout=="function"?setTimeout:null,J=typeof clearTimeout=="function"?clearTimeout:null,ue=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function q($){for(var U=i(v);U!==null;){if(U.callback===null)u(v);else if(U.startTime<=$)u(v),U.sortIndex=U.expirationTime,l(S,U);else break;U=i(v)}}function P($){if(I=!1,q($),!A)if(i(S)!==null)A=!0,$e(oe);else{var U=i(v);U!==null&&Ne(P,U.startTime-$)}}function oe($,U){A=!1,I&&(I=!1,J(Re),Re=-1),C=!0;var _=L;try{for(q(U),w=i(S);w!==null&&(!(w.expirationTime>U)||$&&!Se());){var j=w.callback;if(typeof j=="function"){w.callback=null,L=w.priorityLevel;var T=j(w.expirationTime<=U);U=r.unstable_now(),typeof T=="function"?w.callback=T:w===i(S)&&u(S),q(U)}else u(S);w=i(S)}if(w!==null)var ne=!0;else{var ce=i(v);ce!==null&&Ne(P,ce.startTime-U),ne=!1}return ne}finally{w=null,L=_,C=!1}}var pe=!1,Z=null,Re=-1,ee=5,fe=-1;function Se(){return!(r.unstable_now()-fe$||125<$?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):ee=0<$?Math.floor(1e3/$):5},r.unstable_getCurrentPriorityLevel=function(){return L},r.unstable_getFirstCallbackNode=function(){return i(S)},r.unstable_next=function($){switch(L){case 1:case 2:case 3:var U=3;break;default:U=L}var _=L;L=U;try{return $()}finally{L=_}},r.unstable_pauseExecution=function(){},r.unstable_requestPaint=function(){},r.unstable_runWithPriority=function($,U){switch($){case 1:case 2:case 3:case 4:case 5:break;default:$=3}var _=L;L=$;try{return U()}finally{L=_}},r.unstable_scheduleCallback=function($,U,_){var j=r.unstable_now();switch(typeof _=="object"&&_!==null?(_=_.delay,_=typeof _=="number"&&0<_?j+_:j):_=j,$){case 1:var T=-1;break;case 2:T=250;break;case 5:T=1073741823;break;case 4:T=1e4;break;default:T=5e3}return T=_+T,$={id:N++,callback:U,priorityLevel:$,startTime:_,expirationTime:T,sortIndex:-1},_>j?($.sortIndex=_,l(v,$),i(S)===null&&$===i(v)&&(I?(J(Re),Re=-1):I=!0,Ne(P,_-j))):($.sortIndex=T,l(S,$),A||C||(A=!0,$e(oe))),$},r.unstable_shouldYield=Se,r.unstable_wrapCallback=function($){var U=L;return function(){var _=L;L=U;try{return $.apply(this,arguments)}finally{L=_}}}})(Ei)),Ei}var Su;function lp(){return Su||(Su=1,Ii.exports=ap()),Ii.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Nu;function ip(){if(Nu)return _t;Nu=1;var r=Bi(),l=lp();function i(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,s=1;s"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),S=Object.prototype.hasOwnProperty,v=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,N={},w={};function L(e){return S.call(w,e)?!0:S.call(N,e)?!1:v.test(e)?w[e]=!0:(N[e]=!0,!1)}function C(e,t,s,a){if(s!==null&&s.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return a?!1:s!==null?!s.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function A(e,t,s,a){if(t===null||typeof t>"u"||C(e,t,s,a))return!0;if(a)return!1;if(s!==null)switch(s.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function I(e,t,s,a,o,c,f){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=a,this.attributeNamespace=o,this.mustUseProperty=s,this.propertyName=e,this.type=t,this.sanitizeURL=c,this.removeEmptyString=f}var E={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){E[e]=new I(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];E[t]=new I(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){E[e]=new I(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){E[e]=new I(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){E[e]=new I(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){E[e]=new I(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){E[e]=new I(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){E[e]=new I(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){E[e]=new I(e,5,!1,e.toLowerCase(),null,!1,!1)});var J=/[\-:]([a-z])/g;function ue(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(J,ue);E[t]=new I(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(J,ue);E[t]=new I(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(J,ue);E[t]=new I(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){E[e]=new I(e,1,!1,e.toLowerCase(),null,!1,!1)}),E.xlinkHref=new I("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){E[e]=new I(e,1,!1,e.toLowerCase(),null,!0,!0)});function q(e,t,s,a){var o=E.hasOwnProperty(t)?E[t]:null;(o!==null?o.type!==0:a||!(2h||o[f]!==c[h]){var x=` +`+o[f].replace(" at new "," at ");return e.displayName&&x.includes("")&&(x=x.replace("",e.displayName)),x}while(1<=f&&0<=h);break}}}finally{ne=!1,Error.prepareStackTrace=s}return(e=e?e.displayName||e.name:"")?T(e):""}function ge(e){switch(e.tag){case 5:return T(e.type);case 16:return T("Lazy");case 13:return T("Suspense");case 19:return T("SuspenseList");case 0:case 2:case 15:return e=ce(e.type,!1),e;case 11:return e=ce(e.type.render,!1),e;case 1:return e=ce(e.type,!0),e;default:return""}}function we(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Z:return"Fragment";case pe:return"Portal";case ee:return"Profiler";case Re:return"StrictMode";case z:return"Suspense";case H:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Se:return(e.displayName||"Context")+".Consumer";case fe:return(e._context.displayName||"Context")+".Provider";case me:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case be:return t=e.displayName||null,t!==null?t:we(e.type)||"Memo";case $e:t=e._payload,e=e._init;try{return we(e(t))}catch{}}return null}function De(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return we(t);case 8:return t===Re?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function se(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ie(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Qe(e){var t=Ie(e)?"checked":"value",s=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),a=""+e[t];if(!e.hasOwnProperty(t)&&typeof s<"u"&&typeof s.get=="function"&&typeof s.set=="function"){var o=s.get,c=s.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(f){a=""+f,c.call(this,f)}}),Object.defineProperty(e,t,{enumerable:s.enumerable}),{getValue:function(){return a},setValue:function(f){a=""+f},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function tt(e){e._valueTracker||(e._valueTracker=Qe(e))}function Dt(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var s=t.getValue(),a="";return e&&(a=Ie(e)?e.checked?"true":"false":e.value),e=a,e!==s?(t.setValue(e),!0):!1}function ut(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function de(e,t){var s=t.checked;return _({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:s??e._wrapperState.initialChecked})}function ke(e,t){var s=t.defaultValue==null?"":t.defaultValue,a=t.checked!=null?t.checked:t.defaultChecked;s=se(t.value!=null?t.value:s),e._wrapperState={initialChecked:a,initialValue:s,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Y(e,t){t=t.checked,t!=null&&q(e,"checked",t,!1)}function je(e,t){Y(e,t);var s=se(t.value),a=t.type;if(s!=null)a==="number"?(s===0&&e.value===""||e.value!=s)&&(e.value=""+s):e.value!==""+s&&(e.value=""+s);else if(a==="submit"||a==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?at(e,t.type,s):t.hasOwnProperty("defaultValue")&&at(e,t.type,se(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function rt(e,t,s){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var a=t.type;if(!(a!=="submit"&&a!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,s||t===e.value||(e.value=t),e.defaultValue=t}s=e.name,s!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,s!==""&&(e.name=s)}function at(e,t,s){(t!=="number"||ut(e.ownerDocument)!==e)&&(s==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+s&&(e.defaultValue=""+s))}var yt=Array.isArray;function It(e,t,s,a){if(e=e.options,t){t={};for(var o=0;o"+t.valueOf().toString()+"",t=kt.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function jt(e,t){if(t){var s=e.firstChild;if(s&&s===e.lastChild&&s.nodeType===3){s.nodeValue=t;return}}e.textContent=t}var Ae={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},hn=["Webkit","ms","Moz","O"];Object.keys(Ae).forEach(function(e){hn.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ae[t]=Ae[e]})});function it(e,t,s){return t==null||typeof t=="boolean"||t===""?"":s||typeof t!="number"||t===0||Ae.hasOwnProperty(e)&&Ae[e]?(""+t).trim():t+"px"}function Xn(e,t){e=e.style;for(var s in t)if(t.hasOwnProperty(s)){var a=s.indexOf("--")===0,o=it(s,t[s],a);s==="float"&&(s="cssFloat"),a?e.setProperty(s,o):e[s]=o}}var Tt=_({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function an(e,t){if(t){if(Tt[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(i(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(i(61))}if(t.style!=null&&typeof t.style!="object")throw Error(i(62))}}function dt(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var $t=null;function ln(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var In=null,ht=null,on=null;function gn(e){if(e=fr(e)){if(typeof In!="function")throw Error(i(280));var t=e.stateNode;t&&(t=Zr(t),In(e.stateNode,e.type,t))}}function hs(e){ht?on?on.push(e):on=[e]:ht=e}function gs(){if(ht){var e=ht,t=on;if(on=ht=null,gn(e),t)for(e=0;e>>=0,e===0?32:31-(md(e)/hd|0)|0}var Mr=64,Or=4194304;function Hs(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ar(e,t){var s=e.pendingLanes;if(s===0)return 0;var a=0,o=e.suspendedLanes,c=e.pingedLanes,f=s&268435455;if(f!==0){var h=f&~o;h!==0?a=Hs(h):(c&=f,c!==0&&(a=Hs(c)))}else f=s&~o,f!==0?a=Hs(f):c!==0&&(a=Hs(c));if(a===0)return 0;if(t!==0&&t!==a&&(t&o)===0&&(o=a&-a,c=t&-t,o>=c||o===16&&(c&4194240)!==0))return t;if((a&4)!==0&&(a|=s&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=a;0s;s++)t.push(e);return t}function Ws(e,t,s){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Xt(t),e[t]=s}function jd(e,t){var s=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var a=e.eventTimes;for(e=e.expirationTimes;0=sr),co=" ",uo=!1;function fo(e,t){switch(e){case"keyup":return Jd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function po(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Ss=!1;function Wd(e,t){switch(e){case"compositionend":return po(t);case"keypress":return t.which!==32?null:(uo=!0,co);case"textInput":return e=t.data,e===co&&uo?null:e;default:return null}}function Gd(e,t){if(Ss)return e==="compositionend"||!ll&&fo(e,t)?(e=so(),Qr=el=On=null,Ss=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:s,offset:t-e};e=a}e:{for(;s;){if(s.nextSibling){s=s.nextSibling;break e}s=s.parentNode}s=void 0}s=vo(s)}}function So(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?So(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function No(){for(var e=window,t=ut();t instanceof e.HTMLIFrameElement;){try{var s=typeof t.contentWindow.location.href=="string"}catch{s=!1}if(s)e=t.contentWindow;else break;t=ut(e.document)}return t}function cl(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function af(e){var t=No(),s=e.focusedElem,a=e.selectionRange;if(t!==s&&s&&s.ownerDocument&&So(s.ownerDocument.documentElement,s)){if(a!==null&&cl(s)){if(t=a.start,e=a.end,e===void 0&&(e=t),"selectionStart"in s)s.selectionStart=t,s.selectionEnd=Math.min(e,s.value.length);else if(e=(t=s.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var o=s.textContent.length,c=Math.min(a.start,o);a=a.end===void 0?c:Math.min(a.end,o),!e.extend&&c>a&&(o=a,a=c,c=o),o=bo(s,c);var f=bo(s,a);o&&f&&(e.rangeCount!==1||e.anchorNode!==o.node||e.anchorOffset!==o.offset||e.focusNode!==f.node||e.focusOffset!==f.offset)&&(t=t.createRange(),t.setStart(o.node,o.offset),e.removeAllRanges(),c>a?(e.addRange(t),e.extend(f.node,f.offset)):(t.setEnd(f.node,f.offset),e.addRange(t)))}}for(t=[],e=s;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof s.focus=="function"&&s.focus(),s=0;s=document.documentMode,Ns=null,ul=null,ir=null,dl=!1;function wo(e,t,s){var a=s.window===s?s.document:s.nodeType===9?s:s.ownerDocument;dl||Ns==null||Ns!==ut(a)||(a=Ns,"selectionStart"in a&&cl(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),ir&&lr(ir,a)||(ir=a,a=Gr(ul,"onSelect"),0Ds||(e.current=Nl[Ds],Nl[Ds]=null,Ds--)}function Ve(e,t){Ds++,Nl[Ds]=e.current,e.current=t}var Fn={},vt=Bn(Fn),Lt=Bn(!1),ts=Fn;function Is(e,t){var s=e.type.contextTypes;if(!s)return Fn;var a=e.stateNode;if(a&&a.__reactInternalMemoizedUnmaskedChildContext===t)return a.__reactInternalMemoizedMaskedChildContext;var o={},c;for(c in s)o[c]=t[c];return a&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function Pt(e){return e=e.childContextTypes,e!=null}function ea(){Je(Lt),Je(vt)}function Bo(e,t,s){if(vt.current!==Fn)throw Error(i(168));Ve(vt,t),Ve(Lt,s)}function Fo(e,t,s){var a=e.stateNode;if(t=t.childContextTypes,typeof a.getChildContext!="function")return s;a=a.getChildContext();for(var o in a)if(!(o in t))throw Error(i(108,De(e)||"Unknown",o));return _({},s,a)}function ta(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Fn,ts=vt.current,Ve(vt,e),Ve(Lt,Lt.current),!0}function zo(e,t,s){var a=e.stateNode;if(!a)throw Error(i(169));s?(e=Fo(e,t,ts),a.__reactInternalMemoizedMergedChildContext=e,Je(Lt),Je(vt),Ve(vt,e)):Je(Lt),Ve(Lt,s)}var jn=null,na=!1,wl=!1;function Qo(e){jn===null?jn=[e]:jn.push(e)}function yf(e){na=!0,Qo(e)}function zn(){if(!wl&&jn!==null){wl=!0;var e=0,t=ze;try{var s=jn;for(ze=1;e>=f,o-=f,vn=1<<32-Xt(t)+o|s<ve?(mt=ye,ye=null):mt=ye.sibling;var Oe=M(k,ye,R[ve],Q);if(Oe===null){ye===null&&(ye=mt);break}e&&ye&&Oe.alternate===null&&t(k,ye),b=c(Oe,b,ve),xe===null?ie=Oe:xe.sibling=Oe,xe=Oe,ye=mt}if(ve===R.length)return s(k,ye),We&&ss(k,ve),ie;if(ye===null){for(;veve?(mt=ye,ye=null):mt=ye.sibling;var Gn=M(k,ye,Oe.value,Q);if(Gn===null){ye===null&&(ye=mt);break}e&&ye&&Gn.alternate===null&&t(k,ye),b=c(Gn,b,ve),xe===null?ie=Gn:xe.sibling=Gn,xe=Gn,ye=mt}if(Oe.done)return s(k,ye),We&&ss(k,ve),ie;if(ye===null){for(;!Oe.done;ve++,Oe=R.next())Oe=B(k,Oe.value,Q),Oe!==null&&(b=c(Oe,b,ve),xe===null?ie=Oe:xe.sibling=Oe,xe=Oe);return We&&ss(k,ve),ie}for(ye=a(k,ye);!Oe.done;ve++,Oe=R.next())Oe=G(ye,k,ve,Oe.value,Q),Oe!==null&&(e&&Oe.alternate!==null&&ye.delete(Oe.key===null?ve:Oe.key),b=c(Oe,b,ve),xe===null?ie=Oe:xe.sibling=Oe,xe=Oe);return e&&ye.forEach(function(Xf){return t(k,Xf)}),We&&ss(k,ve),ie}function st(k,b,R,Q){if(typeof R=="object"&&R!==null&&R.type===Z&&R.key===null&&(R=R.props.children),typeof R=="object"&&R!==null){switch(R.$$typeof){case oe:e:{for(var ie=R.key,xe=b;xe!==null;){if(xe.key===ie){if(ie=R.type,ie===Z){if(xe.tag===7){s(k,xe.sibling),b=o(xe,R.props.children),b.return=k,k=b;break e}}else if(xe.elementType===ie||typeof ie=="object"&&ie!==null&&ie.$$typeof===$e&&Ho(ie)===xe.type){s(k,xe.sibling),b=o(xe,R.props),b.ref=pr(k,xe,R),b.return=k,k=b;break e}s(k,xe);break}else t(k,xe);xe=xe.sibling}R.type===Z?(b=ds(R.props.children,k.mode,Q,R.key),b.return=k,k=b):(Q=Ea(R.type,R.key,R.props,null,k.mode,Q),Q.ref=pr(k,b,R),Q.return=k,k=Q)}return f(k);case pe:e:{for(xe=R.key;b!==null;){if(b.key===xe)if(b.tag===4&&b.stateNode.containerInfo===R.containerInfo&&b.stateNode.implementation===R.implementation){s(k,b.sibling),b=o(b,R.children||[]),b.return=k,k=b;break e}else{s(k,b);break}else t(k,b);b=b.sibling}b=bi(R,k.mode,Q),b.return=k,k=b}return f(k);case $e:return xe=R._init,st(k,b,xe(R._payload),Q)}if(yt(R))return te(k,b,R,Q);if(U(R))return re(k,b,R,Q);la(k,R)}return typeof R=="string"&&R!==""||typeof R=="number"?(R=""+R,b!==null&&b.tag===6?(s(k,b.sibling),b=o(b,R),b.return=k,k=b):(s(k,b),b=vi(R,k.mode,Q),b.return=k,k=b),f(k)):s(k,b)}return st}var Ls=Wo(!0),Go=Wo(!1),ia=Bn(null),oa=null,Ps=null,El=null;function Tl(){El=Ps=oa=null}function $l(e){var t=ia.current;Je(ia),e._currentValue=t}function Ll(e,t,s){for(;e!==null;){var a=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,a!==null&&(a.childLanes|=t)):a!==null&&(a.childLanes&t)!==t&&(a.childLanes|=t),e===s)break;e=e.return}}function Ms(e,t){oa=e,El=Ps=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Mt=!0),e.firstContext=null)}function Ht(e){var t=e._currentValue;if(El!==e)if(e={context:e,memoizedValue:t,next:null},Ps===null){if(oa===null)throw Error(i(308));Ps=e,oa.dependencies={lanes:0,firstContext:e}}else Ps=Ps.next=e;return t}var rs=null;function Pl(e){rs===null?rs=[e]:rs.push(e)}function Yo(e,t,s,a){var o=t.interleaved;return o===null?(s.next=s,Pl(t)):(s.next=o.next,o.next=s),t.interleaved=s,Sn(e,a)}function Sn(e,t){e.lanes|=t;var s=e.alternate;for(s!==null&&(s.lanes|=t),s=e,e=e.return;e!==null;)e.childLanes|=t,s=e.alternate,s!==null&&(s.childLanes|=t),s=e,e=e.return;return s.tag===3?s.stateNode:null}var Qn=!1;function Ml(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Xo(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Nn(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Un(e,t,s){var a=e.updateQueue;if(a===null)return null;if(a=a.shared,(Me&2)!==0){var o=a.pending;return o===null?t.next=t:(t.next=o.next,o.next=t),a.pending=t,Sn(e,s)}return o=a.interleaved,o===null?(t.next=t,Pl(a)):(t.next=o.next,o.next=t),a.interleaved=t,Sn(e,s)}function ca(e,t,s){if(t=t.updateQueue,t!==null&&(t=t.shared,(s&4194240)!==0)){var a=t.lanes;a&=e.pendingLanes,s|=a,t.lanes=s,Wa(e,s)}}function Zo(e,t){var s=e.updateQueue,a=e.alternate;if(a!==null&&(a=a.updateQueue,s===a)){var o=null,c=null;if(s=s.firstBaseUpdate,s!==null){do{var f={eventTime:s.eventTime,lane:s.lane,tag:s.tag,payload:s.payload,callback:s.callback,next:null};c===null?o=c=f:c=c.next=f,s=s.next}while(s!==null);c===null?o=c=t:c=c.next=t}else o=c=t;s={baseState:a.baseState,firstBaseUpdate:o,lastBaseUpdate:c,shared:a.shared,effects:a.effects},e.updateQueue=s;return}e=s.lastBaseUpdate,e===null?s.firstBaseUpdate=t:e.next=t,s.lastBaseUpdate=t}function ua(e,t,s,a){var o=e.updateQueue;Qn=!1;var c=o.firstBaseUpdate,f=o.lastBaseUpdate,h=o.shared.pending;if(h!==null){o.shared.pending=null;var x=h,D=x.next;x.next=null,f===null?c=D:f.next=D,f=x;var O=e.alternate;O!==null&&(O=O.updateQueue,h=O.lastBaseUpdate,h!==f&&(h===null?O.firstBaseUpdate=D:h.next=D,O.lastBaseUpdate=x))}if(c!==null){var B=o.baseState;f=0,O=D=x=null,h=c;do{var M=h.lane,G=h.eventTime;if((a&M)===M){O!==null&&(O=O.next={eventTime:G,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});e:{var te=e,re=h;switch(M=t,G=s,re.tag){case 1:if(te=re.payload,typeof te=="function"){B=te.call(G,B,M);break e}B=te;break e;case 3:te.flags=te.flags&-65537|128;case 0:if(te=re.payload,M=typeof te=="function"?te.call(G,B,M):te,M==null)break e;B=_({},B,M);break e;case 2:Qn=!0}}h.callback!==null&&h.lane!==0&&(e.flags|=64,M=o.effects,M===null?o.effects=[h]:M.push(h))}else G={eventTime:G,lane:M,tag:h.tag,payload:h.payload,callback:h.callback,next:null},O===null?(D=O=G,x=B):O=O.next=G,f|=M;if(h=h.next,h===null){if(h=o.shared.pending,h===null)break;M=h,h=M.next,M.next=null,o.lastBaseUpdate=M,o.shared.pending=null}}while(!0);if(O===null&&(x=B),o.baseState=x,o.firstBaseUpdate=D,o.lastBaseUpdate=O,t=o.shared.interleaved,t!==null){o=t;do f|=o.lane,o=o.next;while(o!==t)}else c===null&&(o.shared.lanes=0);is|=f,e.lanes=f,e.memoizedState=B}}function ec(e,t,s){if(e=t.effects,t.effects=null,e!==null)for(t=0;ts?s:4,e(!0);var a=Fl.transition;Fl.transition={};try{e(!1),t()}finally{ze=s,Fl.transition=a}}function jc(){return Wt().memoizedState}function Sf(e,t,s){var a=Jn(e);if(s={lane:a,action:s,hasEagerState:!1,eagerState:null,next:null},vc(e))bc(t,s);else if(s=Yo(e,t,s,a),s!==null){var o=Rt();rn(s,e,a,o),Sc(s,t,a)}}function Nf(e,t,s){var a=Jn(e),o={lane:a,action:s,hasEagerState:!1,eagerState:null,next:null};if(vc(e))bc(t,o);else{var c=e.alternate;if(e.lanes===0&&(c===null||c.lanes===0)&&(c=t.lastRenderedReducer,c!==null))try{var f=t.lastRenderedState,h=c(f,s);if(o.hasEagerState=!0,o.eagerState=h,Zt(h,f)){var x=t.interleaved;x===null?(o.next=o,Pl(t)):(o.next=x.next,x.next=o),t.interleaved=o;return}}catch{}finally{}s=Yo(e,t,o,a),s!==null&&(o=Rt(),rn(s,e,a,o),Sc(s,t,a))}}function vc(e){var t=e.alternate;return e===Xe||t!==null&&t===Xe}function bc(e,t){xr=pa=!0;var s=e.pending;s===null?t.next=t:(t.next=s.next,s.next=t),e.pending=t}function Sc(e,t,s){if((s&4194240)!==0){var a=t.lanes;a&=e.pendingLanes,s|=a,t.lanes=s,Wa(e,s)}}var ga={readContext:Ht,useCallback:bt,useContext:bt,useEffect:bt,useImperativeHandle:bt,useInsertionEffect:bt,useLayoutEffect:bt,useMemo:bt,useReducer:bt,useRef:bt,useState:bt,useDebugValue:bt,useDeferredValue:bt,useTransition:bt,useMutableSource:bt,useSyncExternalStore:bt,useId:bt,unstable_isNewReconciler:!1},wf={readContext:Ht,useCallback:function(e,t){return fn().memoizedState=[e,t===void 0?null:t],e},useContext:Ht,useEffect:dc,useImperativeHandle:function(e,t,s){return s=s!=null?s.concat([e]):null,ma(4194308,4,mc.bind(null,t,e),s)},useLayoutEffect:function(e,t){return ma(4194308,4,e,t)},useInsertionEffect:function(e,t){return ma(4,2,e,t)},useMemo:function(e,t){var s=fn();return t=t===void 0?null:t,e=e(),s.memoizedState=[e,t],e},useReducer:function(e,t,s){var a=fn();return t=s!==void 0?s(t):t,a.memoizedState=a.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},a.queue=e,e=e.dispatch=Sf.bind(null,Xe,e),[a.memoizedState,e]},useRef:function(e){var t=fn();return e={current:e},t.memoizedState=e},useState:cc,useDebugValue:Jl,useDeferredValue:function(e){return fn().memoizedState=e},useTransition:function(){var e=cc(!1),t=e[0];return e=bf.bind(null,e[1]),fn().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,s){var a=Xe,o=fn();if(We){if(s===void 0)throw Error(i(407));s=s()}else{if(s=t(),pt===null)throw Error(i(349));(ls&30)!==0||rc(a,t,s)}o.memoizedState=s;var c={value:s,getSnapshot:t};return o.queue=c,dc(lc.bind(null,a,c,e),[e]),a.flags|=2048,vr(9,ac.bind(null,a,c,s,t),void 0,null),s},useId:function(){var e=fn(),t=pt.identifierPrefix;if(We){var s=bn,a=vn;s=(a&~(1<<32-Xt(a)-1)).toString(32)+s,t=":"+t+"R"+s,s=yr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof a.is=="string"?e=f.createElement(s,{is:a.is}):(e=f.createElement(s),s==="select"&&(f=e,a.multiple?f.multiple=!0:a.size&&(f.size=a.size))):e=f.createElementNS(e,s),e[un]=t,e[dr]=a,Qc(e,t,!1,!1),t.stateNode=e;e:{switch(f=dt(s,a),s){case"dialog":Ke("cancel",e),Ke("close",e),o=a;break;case"iframe":case"object":case"embed":Ke("load",e),o=a;break;case"video":case"audio":for(o=0;oFs&&(t.flags|=128,a=!0,br(c,!1),t.lanes=4194304)}else{if(!a)if(e=da(f),e!==null){if(t.flags|=128,a=!0,s=e.updateQueue,s!==null&&(t.updateQueue=s,t.flags|=4),br(c,!0),c.tail===null&&c.tailMode==="hidden"&&!f.alternate&&!We)return St(t),null}else 2*nt()-c.renderingStartTime>Fs&&s!==1073741824&&(t.flags|=128,a=!0,br(c,!1),t.lanes=4194304);c.isBackwards?(f.sibling=t.child,t.child=f):(s=c.last,s!==null?s.sibling=f:t.child=f,c.last=f)}return c.tail!==null?(t=c.tail,c.rendering=t,c.tail=t.sibling,c.renderingStartTime=nt(),t.sibling=null,s=Ye.current,Ve(Ye,a?s&1|2:s&1),t):(St(t),null);case 22:case 23:return xi(),a=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==a&&(t.flags|=8192),a&&(t.mode&1)!==0?(qt&1073741824)!==0&&(St(t),t.subtreeFlags&6&&(t.flags|=8192)):St(t),null;case 24:return null;case 25:return null}throw Error(i(156,t.tag))}function $f(e,t){switch(Cl(t),t.tag){case 1:return Pt(t.type)&&ea(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Os(),Je(Lt),Je(vt),Bl(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Al(t),null;case 13:if(Je(Ye),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));$s()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Je(Ye),null;case 4:return Os(),null;case 10:return $l(t.type._context),null;case 22:case 23:return xi(),null;case 24:return null;default:return null}}var va=!1,Nt=!1,Lf=typeof WeakSet=="function"?WeakSet:Set,X=null;function _s(e,t){var s=e.ref;if(s!==null)if(typeof s=="function")try{s(null)}catch(a){et(e,t,a)}else s.current=null}function ai(e,t,s){try{s()}catch(a){et(e,t,a)}}var Vc=!1;function Pf(e,t){if(xl=Fr,e=No(),cl(e)){if("selectionStart"in e)var s={start:e.selectionStart,end:e.selectionEnd};else e:{s=(s=e.ownerDocument)&&s.defaultView||window;var a=s.getSelection&&s.getSelection();if(a&&a.rangeCount!==0){s=a.anchorNode;var o=a.anchorOffset,c=a.focusNode;a=a.focusOffset;try{s.nodeType,c.nodeType}catch{s=null;break e}var f=0,h=-1,x=-1,D=0,O=0,B=e,M=null;t:for(;;){for(var G;B!==s||o!==0&&B.nodeType!==3||(h=f+o),B!==c||a!==0&&B.nodeType!==3||(x=f+a),B.nodeType===3&&(f+=B.nodeValue.length),(G=B.firstChild)!==null;)M=B,B=G;for(;;){if(B===e)break t;if(M===s&&++D===o&&(h=f),M===c&&++O===a&&(x=f),(G=B.nextSibling)!==null)break;B=M,M=B.parentNode}B=G}s=h===-1||x===-1?null:{start:h,end:x}}else s=null}s=s||{start:0,end:0}}else s=null;for(yl={focusedElem:e,selectionRange:s},Fr=!1,X=t;X!==null;)if(t=X,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,X=e;else for(;X!==null;){t=X;try{var te=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(te!==null){var re=te.memoizedProps,st=te.memoizedState,k=t.stateNode,b=k.getSnapshotBeforeUpdate(t.elementType===t.type?re:tn(t.type,re),st);k.__reactInternalSnapshotBeforeUpdate=b}break;case 3:var R=t.stateNode.containerInfo;R.nodeType===1?R.textContent="":R.nodeType===9&&R.documentElement&&R.removeChild(R.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(Q){et(t,t.return,Q)}if(e=t.sibling,e!==null){e.return=t.return,X=e;break}X=t.return}return te=Vc,Vc=!1,te}function Sr(e,t,s){var a=t.updateQueue;if(a=a!==null?a.lastEffect:null,a!==null){var o=a=a.next;do{if((o.tag&e)===e){var c=o.destroy;o.destroy=void 0,c!==void 0&&ai(t,s,c)}o=o.next}while(o!==a)}}function ba(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var s=t=t.next;do{if((s.tag&e)===e){var a=s.create;s.destroy=a()}s=s.next}while(s!==t)}}function li(e){var t=e.ref;if(t!==null){var s=e.stateNode;switch(e.tag){case 5:e=s;break;default:e=s}typeof t=="function"?t(e):t.current=e}}function Kc(e){var t=e.alternate;t!==null&&(e.alternate=null,Kc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[un],delete t[dr],delete t[Sl],delete t[gf],delete t[xf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Jc(e){return e.tag===5||e.tag===3||e.tag===4}function Hc(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Jc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function ii(e,t,s){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?s.nodeType===8?s.parentNode.insertBefore(e,t):s.insertBefore(e,t):(s.nodeType===8?(t=s.parentNode,t.insertBefore(e,s)):(t=s,t.appendChild(e)),s=s._reactRootContainer,s!=null||t.onclick!==null||(t.onclick=Xr));else if(a!==4&&(e=e.child,e!==null))for(ii(e,t,s),e=e.sibling;e!==null;)ii(e,t,s),e=e.sibling}function oi(e,t,s){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?s.insertBefore(e,t):s.appendChild(e);else if(a!==4&&(e=e.child,e!==null))for(oi(e,t,s),e=e.sibling;e!==null;)oi(e,t,s),e=e.sibling}var gt=null,nn=!1;function qn(e,t,s){for(s=s.child;s!==null;)Wc(e,t,s),s=s.sibling}function Wc(e,t,s){if(cn&&typeof cn.onCommitFiberUnmount=="function")try{cn.onCommitFiberUnmount(Pr,s)}catch{}switch(s.tag){case 5:Nt||_s(s,t);case 6:var a=gt,o=nn;gt=null,qn(e,t,s),gt=a,nn=o,gt!==null&&(nn?(e=gt,s=s.stateNode,e.nodeType===8?e.parentNode.removeChild(s):e.removeChild(s)):gt.removeChild(s.stateNode));break;case 18:gt!==null&&(nn?(e=gt,s=s.stateNode,e.nodeType===8?bl(e.parentNode,s):e.nodeType===1&&bl(e,s),er(e)):bl(gt,s.stateNode));break;case 4:a=gt,o=nn,gt=s.stateNode.containerInfo,nn=!0,qn(e,t,s),gt=a,nn=o;break;case 0:case 11:case 14:case 15:if(!Nt&&(a=s.updateQueue,a!==null&&(a=a.lastEffect,a!==null))){o=a=a.next;do{var c=o,f=c.destroy;c=c.tag,f!==void 0&&((c&2)!==0||(c&4)!==0)&&ai(s,t,f),o=o.next}while(o!==a)}qn(e,t,s);break;case 1:if(!Nt&&(_s(s,t),a=s.stateNode,typeof a.componentWillUnmount=="function"))try{a.props=s.memoizedProps,a.state=s.memoizedState,a.componentWillUnmount()}catch(h){et(s,t,h)}qn(e,t,s);break;case 21:qn(e,t,s);break;case 22:s.mode&1?(Nt=(a=Nt)||s.memoizedState!==null,qn(e,t,s),Nt=a):qn(e,t,s);break;default:qn(e,t,s)}}function Gc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var s=e.stateNode;s===null&&(s=e.stateNode=new Lf),t.forEach(function(a){var o=Uf.bind(null,e,a);s.has(a)||(s.add(a),a.then(o,o))})}}function sn(e,t){var s=t.deletions;if(s!==null)for(var a=0;ao&&(o=f),a&=~c}if(a=o,a=nt()-a,a=(120>a?120:480>a?480:1080>a?1080:1920>a?1920:3e3>a?3e3:4320>a?4320:1960*Of(a/1960))-a,10e?16:e,Kn===null)var a=!1;else{if(e=Kn,Kn=null,Ca=0,(Me&6)!==0)throw Error(i(331));var o=Me;for(Me|=4,X=e.current;X!==null;){var c=X,f=c.child;if((X.flags&16)!==0){var h=c.deletions;if(h!==null){for(var x=0;xnt()-di?cs(e,0):ui|=s),At(e,t)}function cu(e,t){t===0&&((e.mode&1)===0?t=1:(t=Or,Or<<=1,(Or&130023424)===0&&(Or=4194304)));var s=Rt();e=Sn(e,t),e!==null&&(Ws(e,t,s),At(e,s))}function Qf(e){var t=e.memoizedState,s=0;t!==null&&(s=t.retryLane),cu(e,s)}function Uf(e,t){var s=0;switch(e.tag){case 13:var a=e.stateNode,o=e.memoizedState;o!==null&&(s=o.retryLane);break;case 19:a=e.stateNode;break;default:throw Error(i(314))}a!==null&&a.delete(t),cu(e,s)}var uu;uu=function(e,t,s){if(e!==null)if(e.memoizedProps!==t.pendingProps||Lt.current)Mt=!0;else{if((e.lanes&s)===0&&(t.flags&128)===0)return Mt=!1,Ef(e,t,s);Mt=(e.flags&131072)!==0}else Mt=!1,We&&(t.flags&1048576)!==0&&Uo(t,ra,t.index);switch(t.lanes=0,t.tag){case 2:var a=t.type;ja(e,t),e=t.pendingProps;var o=Is(t,vt.current);Ms(t,s),o=Ql(null,t,a,e,o,s);var c=Ul();return t.flags|=1,typeof o=="object"&&o!==null&&typeof o.render=="function"&&o.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Pt(a)?(c=!0,ta(t)):c=!1,t.memoizedState=o.state!==null&&o.state!==void 0?o.state:null,Ml(t),o.updater=xa,t.stateNode=o,o._reactInternals=t,Wl(t,a,e,s),t=Zl(null,t,a,!0,c,s)):(t.tag=0,We&&c&&kl(t),Ct(null,t,o,s),t=t.child),t;case 16:a=t.elementType;e:{switch(ja(e,t),e=t.pendingProps,o=a._init,a=o(a._payload),t.type=a,o=t.tag=Vf(a),e=tn(a,e),o){case 0:t=Xl(null,t,a,e,s);break e;case 1:t=Oc(null,t,a,e,s);break e;case 11:t=Tc(null,t,a,e,s);break e;case 14:t=$c(null,t,a,tn(a.type,e),s);break e}throw Error(i(306,a,""))}return t;case 0:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:tn(a,o),Xl(e,t,a,o,s);case 1:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:tn(a,o),Oc(e,t,a,o,s);case 3:e:{if(Ac(t),e===null)throw Error(i(387));a=t.pendingProps,c=t.memoizedState,o=c.element,Xo(e,t),ua(t,a,null,s);var f=t.memoizedState;if(a=f.element,c.isDehydrated)if(c={element:a,isDehydrated:!1,cache:f.cache,pendingSuspenseBoundaries:f.pendingSuspenseBoundaries,transitions:f.transitions},t.updateQueue.baseState=c,t.memoizedState=c,t.flags&256){o=As(Error(i(423)),t),t=_c(e,t,a,s,o);break e}else if(a!==o){o=As(Error(i(424)),t),t=_c(e,t,a,s,o);break e}else for(Ut=_n(t.stateNode.containerInfo.firstChild),Qt=t,We=!0,en=null,s=Go(t,null,a,s),t.child=s;s;)s.flags=s.flags&-3|4096,s=s.sibling;else{if($s(),a===o){t=wn(e,t,s);break e}Ct(e,t,a,s)}t=t.child}return t;case 5:return tc(t),e===null&&Dl(t),a=t.type,o=t.pendingProps,c=e!==null?e.memoizedProps:null,f=o.children,jl(a,o)?f=null:c!==null&&jl(a,c)&&(t.flags|=32),Mc(e,t),Ct(e,t,f,s),t.child;case 6:return e===null&&Dl(t),null;case 13:return Bc(e,t,s);case 4:return Ol(t,t.stateNode.containerInfo),a=t.pendingProps,e===null?t.child=Ls(t,null,a,s):Ct(e,t,a,s),t.child;case 11:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:tn(a,o),Tc(e,t,a,o,s);case 7:return Ct(e,t,t.pendingProps,s),t.child;case 8:return Ct(e,t,t.pendingProps.children,s),t.child;case 12:return Ct(e,t,t.pendingProps.children,s),t.child;case 10:e:{if(a=t.type._context,o=t.pendingProps,c=t.memoizedProps,f=o.value,Ve(ia,a._currentValue),a._currentValue=f,c!==null)if(Zt(c.value,f)){if(c.children===o.children&&!Lt.current){t=wn(e,t,s);break e}}else for(c=t.child,c!==null&&(c.return=t);c!==null;){var h=c.dependencies;if(h!==null){f=c.child;for(var x=h.firstContext;x!==null;){if(x.context===a){if(c.tag===1){x=Nn(-1,s&-s),x.tag=2;var D=c.updateQueue;if(D!==null){D=D.shared;var O=D.pending;O===null?x.next=x:(x.next=O.next,O.next=x),D.pending=x}}c.lanes|=s,x=c.alternate,x!==null&&(x.lanes|=s),Ll(c.return,s,t),h.lanes|=s;break}x=x.next}}else if(c.tag===10)f=c.type===t.type?null:c.child;else if(c.tag===18){if(f=c.return,f===null)throw Error(i(341));f.lanes|=s,h=f.alternate,h!==null&&(h.lanes|=s),Ll(f,s,t),f=c.sibling}else f=c.child;if(f!==null)f.return=c;else for(f=c;f!==null;){if(f===t){f=null;break}if(c=f.sibling,c!==null){c.return=f.return,f=c;break}f=f.return}c=f}Ct(e,t,o.children,s),t=t.child}return t;case 9:return o=t.type,a=t.pendingProps.children,Ms(t,s),o=Ht(o),a=a(o),t.flags|=1,Ct(e,t,a,s),t.child;case 14:return a=t.type,o=tn(a,t.pendingProps),o=tn(a.type,o),$c(e,t,a,o,s);case 15:return Lc(e,t,t.type,t.pendingProps,s);case 17:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:tn(a,o),ja(e,t),t.tag=1,Pt(a)?(e=!0,ta(t)):e=!1,Ms(t,s),wc(t,a,o),Wl(t,a,o,s),Zl(null,t,a,!0,e,s);case 19:return zc(e,t,s);case 22:return Pc(e,t,s)}throw Error(i(156,t.tag))};function du(e,t){return Js(e,t)}function qf(e,t,s,a){this.tag=e,this.key=s,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=a,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Yt(e,t,s,a){return new qf(e,t,s,a)}function ji(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Vf(e){if(typeof e=="function")return ji(e)?1:0;if(e!=null){if(e=e.$$typeof,e===me)return 11;if(e===be)return 14}return 2}function Wn(e,t){var s=e.alternate;return s===null?(s=Yt(e.tag,t,e.key,e.mode),s.elementType=e.elementType,s.type=e.type,s.stateNode=e.stateNode,s.alternate=e,e.alternate=s):(s.pendingProps=t,s.type=e.type,s.flags=0,s.subtreeFlags=0,s.deletions=null),s.flags=e.flags&14680064,s.childLanes=e.childLanes,s.lanes=e.lanes,s.child=e.child,s.memoizedProps=e.memoizedProps,s.memoizedState=e.memoizedState,s.updateQueue=e.updateQueue,t=e.dependencies,s.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},s.sibling=e.sibling,s.index=e.index,s.ref=e.ref,s}function Ea(e,t,s,a,o,c){var f=2;if(a=e,typeof e=="function")ji(e)&&(f=1);else if(typeof e=="string")f=5;else e:switch(e){case Z:return ds(s.children,o,c,t);case Re:f=8,o|=8;break;case ee:return e=Yt(12,s,t,o|2),e.elementType=ee,e.lanes=c,e;case z:return e=Yt(13,s,t,o),e.elementType=z,e.lanes=c,e;case H:return e=Yt(19,s,t,o),e.elementType=H,e.lanes=c,e;case Ne:return Ta(s,o,c,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case fe:f=10;break e;case Se:f=9;break e;case me:f=11;break e;case be:f=14;break e;case $e:f=16,a=null;break e}throw Error(i(130,e==null?e:typeof e,""))}return t=Yt(f,s,t,o),t.elementType=e,t.type=a,t.lanes=c,t}function ds(e,t,s,a){return e=Yt(7,e,a,t),e.lanes=s,e}function Ta(e,t,s,a){return e=Yt(22,e,a,t),e.elementType=Ne,e.lanes=s,e.stateNode={isHidden:!1},e}function vi(e,t,s){return e=Yt(6,e,null,t),e.lanes=s,e}function bi(e,t,s){return t=Yt(4,e.children!==null?e.children:[],e.key,t),t.lanes=s,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Kf(e,t,s,a,o){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ha(0),this.expirationTimes=Ha(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ha(0),this.identifierPrefix=a,this.onRecoverableError=o,this.mutableSourceEagerHydrationData=null}function Si(e,t,s,a,o,c,f,h,x){return e=new Kf(e,t,s,h,x),t===1?(t=1,c===!0&&(t|=8)):t=0,c=Yt(3,null,null,t),e.current=c,c.stateNode=e,c.memoizedState={element:a,isDehydrated:s,cache:null,transitions:null,pendingSuspenseBoundaries:null},Ml(c),e}function Jf(e,t,s){var a=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(l){console.error(l)}}return r(),Di.exports=ip(),Di.exports}var ku;function cp(){if(ku)return _a;ku=1;var r=op();return _a.createRoot=r.createRoot,_a.hydrateRoot=r.hydrateRoot,_a}var up=cp();const dp=zu(up);class Ba extends Error{constructor(l,i,u){super(l),this.code=i,this.status=u,this.name="DashboardRequestError"}}const Mi=new Set;let Tr;function fp(){return Tr}function pp(r){return Mi.add(r),Tr&&r(Tr),()=>{Mi.delete(r)}}async function V(r,l={}){return Fi(r,i=>i.json(),l)}async function mp(r,l={}){return Fi(r,i=>i.text(),l)}async function mn(r,l={}){return Fi(r,async()=>{},l)}async function Fi(r,l,i={}){const u=i.timeoutMs??5e3,d=i.method??"GET",p=new AbortController,g=window.setTimeout(()=>p.abort(),u),y=new Headers(i.headers);y.has("Accept")||y.set("Accept","application/json"),i.body!==void 0&&!y.has("Content-Type")&&y.set("Content-Type","application/json");const S=i.rawBody??(i.body===void 0?void 0:JSON.stringify(i.body));try{Dr({path:r,status:"pending"});const v=await fetch(r,{headers:y,body:S,method:d,signal:p.signal});if(!v.ok){const w=await hp(v);throw Dr({path:r,status:"error",statusCode:v.status,message:w}),new Ba(w,v.status===503?"disabled":"http",v.status)}const N=await l(v);return Dr({path:r,status:"success",statusCode:v.status}),N}catch(v){if(v instanceof Ba)throw v;if(v instanceof DOMException&&v.name==="AbortError")throw Dr({path:r,status:"error",message:"Dashboard request timed out"}),new Ba("Dashboard request timed out","timeout");const N=v instanceof Error?v.message:"Dashboard request failed";throw Dr({path:r,status:"error",message:N}),new Ba(N,"network")}finally{window.clearTimeout(g)}}function Dr(r){Tr={...r,at:new Date().toISOString()},Mi.forEach(l=>l(Tr))}async function hp(r){if((r.headers.get("Content-Type")??"").includes("application/json")){const u=await r.json().catch(()=>{}),d=(u==null?void 0:u.error)??(u==null?void 0:u.message);if(typeof d=="string"&&d.trim()!=="")return d}const i=await r.text().catch(()=>"");return i.trim()!==""?i.trim():`Dashboard request failed with status ${r.status}`}async function gp(r=5e3){return V("/api/dashboard/services",{timeoutMs:r})}let Yn=Qu();function Qu(){return{status:"pending",promise:gp().then(l=>{Yn={status:"success",value:l}}).catch(l=>{Yn={status:"error",error:l}})}}function xp(){if(Yn.status==="pending")throw Yn.promise;if(Yn.status==="error")throw Yn.error;return Yn.value}function yp(){Yn=Qu()}function ae({children:r,className:l,type:i="button",...u}){return n.jsx("button",{className:l?`button ${l}`:"button",type:i,...u,children:r})}function F({title:r,description:l,actionLabel:i,onAction:u}){return n.jsxs("div",{className:"empty-state",children:[n.jsx("strong",{children:r}),n.jsx("p",{children:l}),i&&u?n.jsx(ae,{onClick:u,children:i}):null]})}function he({title:r,children:l}){return n.jsxs("section",{className:"panel",children:[r?n.jsx("header",{className:"panel-head",children:n.jsx("h2",{children:r})}):null,n.jsx("div",{className:"panel-body",children:l})]})}const Cn="/dashboard";function Er(r){return r===Cn||r===`${Cn}/`?"/":r.startsWith(`${Cn}/`)?r.slice(Cn.length)||"/":r}function za(r,l=window.location.pathname){return r===Cn||r.startsWith(`${Cn}/`)||!l.startsWith(Cn)?r:r==="/"?`${Cn}/`:`${Cn}${r}`}function jp({services:r}){const l=r.filter(u=>u.status==="running").length,i=r.filter(u=>u.status==="disabled").length;return r.length===0?n.jsx("section",{className:"service-index-page","aria-label":"Services",children:n.jsx(F,{title:"No dashboard services",description:"Configured services will appear here when they are available."})}):n.jsxs("section",{className:"service-index-page","aria-label":"Services",children:[n.jsxs("header",{className:"service-index-summary",children:[n.jsxs("div",{children:[n.jsx("h1",{children:"Services"}),n.jsx("p",{children:"Local dashboard registry from /api/dashboard/services."})]}),n.jsxs("dl",{className:"health-summary","aria-label":"Service health summary",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Running"}),n.jsx("dd",{children:l})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Disabled"}),n.jsx("dd",{children:i})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Total"}),n.jsx("dd",{children:r.length})]})]})]}),n.jsx("div",{className:"service-index",children:r.map(u=>n.jsx("a",{className:"service-card",href:za(u.path),children:n.jsxs(he,{children:[n.jsxs("div",{className:"service-card-head",children:[n.jsx("h2",{children:u.name}),n.jsx("span",{className:`service-status ${u.status}`,children:u.status})]}),n.jsx("p",{children:u.description}),u.endpoint?n.jsxs("dl",{className:"endpoint-summary",children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:u.endpoint})})]}):null]})},u.id))})]})}const Uu=m.createContext(null);function vp({children:r}){const[l,i]=m.useState(null),u=m.useCallback(p=>new Promise(g=>{i({options:p,resolve:g})}),[]),d=m.useCallback(p=>{i(g=>(g==null||g.resolve(p),null))},[]);return n.jsxs(Uu.Provider,{value:u,children:[r,l?n.jsx(bp,{options:l.options,onCancel:()=>d(!1),onConfirm:()=>d(!0)}):null]})}function $r(){const r=m.useContext(Uu);if(!r)throw new Error("useConfirm must be called inside ");return r}function bp({options:r,onCancel:l,onConfirm:i}){const u=m.useId(),d=m.useId(),p=m.useId(),g=r.confirmLabel??"Confirm",y=r.cancelLabel??"Cancel",S=r.tone??"default",v=r.requirePhrase??"",[N,w]=m.useState(""),L=m.useRef(null),C=v===""||N===v;m.useEffect(()=>{w("")},[r]),m.useEffect(()=>{v!==""&&L.current&&L.current.focus()},[v]),m.useEffect(()=>{function I(E){if(E.key==="Escape")E.preventDefault(),l();else if(E.key==="Enter"&&C){const J=E.target;if((J==null?void 0:J.tagName)==="TEXTAREA")return;E.preventDefault(),i()}}return document.addEventListener("keydown",I),()=>document.removeEventListener("keydown",I)},[l,i,C]);function A(I){I.target===I.currentTarget&&l()}return n.jsx("div",{className:"confirm-backdrop",onMouseDown:A,role:"presentation",children:n.jsxs("section",{"aria-describedby":r.description||r.detail?d:void 0,"aria-labelledby":u,"aria-modal":"true",className:`confirm-dialog tone-${S}`,role:"alertdialog",children:[n.jsx("h2",{className:"confirm-title",id:u,children:r.title}),r.description||r.detail?n.jsxs("div",{className:"confirm-body",id:d,children:[r.description?n.jsx("p",{className:"confirm-description",children:r.description}):null,r.detail?n.jsx("p",{className:"confirm-detail",children:r.detail}):null]}):null,v!==""?n.jsxs("div",{className:"confirm-phrase",children:[n.jsx("label",{htmlFor:p,children:r.requirePhraseLabel??n.jsxs(n.Fragment,{children:["Type ",n.jsx("code",{children:v})," to confirm"]})}),n.jsx("input",{autoComplete:"off",autoCorrect:"off",className:"confirm-phrase-input",id:p,onChange:I=>w(I.target.value),ref:L,spellCheck:!1,type:"text",value:N})]}):null,n.jsxs("div",{className:"confirm-actions",children:[n.jsx(ae,{onClick:l,type:"button",children:y}),n.jsx(ae,{autoFocus:v==="",className:S==="danger"?"danger primary":"primary",disabled:!C,onClick:i,type:"button",children:g})]})]})})}function Rn(r){return{title:r.title,description:r.description,detail:n.jsxs(n.Fragment,{children:["Target: ",n.jsx("code",{children:r.target})]}),confirmLabel:r.confirmLabel??"Delete",tone:"danger",requirePhrase:r.target}}const Cu=[1e3,2e3,4e3,8e3,16e3,3e4];function Sp(){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/api/events`}class Np{constructor(){fs(this,"ws",null);fs(this,"listeners",new Map);fs(this,"nextID",0);fs(this,"retryIndex",0);fs(this,"retryTimeout",null)}subscribe(l,i,u){const d=this.nextID;this.nextID+=1,this.listeners.set(d,{topics:new Set(l),cb:i,onResync:u}),this.ensureOpen();let p=!1;return()=>{p||(p=!0,this.listeners.delete(d),this.listeners.size===0&&this.close())}}ensureOpen(){this.ws||this.retryTimeout!==null||this.connect()}connect(){if(typeof WebSocket>"u")return;let l;try{l=new WebSocket(Sp())}catch{this.scheduleReconnect();return}this.ws=l,l.onopen=()=>{this.ws===l&&(this.retryIndex=0)},l.onmessage=u=>{if(!(this.ws!==l||typeof u.data!="string"))try{const d=JSON.parse(u.data);if(!Oi(d))return;d.type==="event"&&wp(d.event)?this.dispatch(d.event):d.type==="hello"?this.resync():d.type==="resync"&&this.resync()}catch{}};const i=()=>{if(this.ws===l){this.ws=null,this.detach(l);try{l.close()}catch{}this.listeners.size!==0&&this.scheduleReconnect()}};l.onerror=i,l.onclose=i}scheduleReconnect(){if(this.retryTimeout!==null)return;const l=Cu[Math.min(this.retryIndex,Cu.length-1)];this.retryIndex+=1,this.retryTimeout=window.setTimeout(()=>{this.retryTimeout=null,this.listeners.size>0&&!this.ws&&this.connect()},l)}dispatch(l){for(const i of this.listeners.values())if(!(i.topics.size>0&&!i.topics.has(l.service)))try{i.cb(l)}catch{}}resync(){var l;for(const i of this.listeners.values())try{(l=i.onResync)==null||l.call(i)}catch{}}detach(l){l.onopen=null,l.onmessage=null,l.onerror=null,l.onclose=null}close(){if(this.retryTimeout!==null&&(window.clearTimeout(this.retryTimeout),this.retryTimeout=null),this.ws){this.detach(this.ws);try{this.ws.close(1e3,"no listeners")}catch{}}this.ws=null,this.retryIndex=0}}function Oi(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}function wp(r){return Oi(r)&&typeof r.type=="string"&&typeof r.service=="string"&&typeof r.timestamp=="string"&&(r.payload===void 0||Oi(r.payload))}const kp=new Np;function Cp(r,l,i){return kp.subscribe(r,l,i)}function Vt({topics:r,onEvent:l,onResync:i,enabled:u=!0}){const d=m.useRef(l),p=m.useRef(i);m.useEffect(()=>{d.current=l,p.current=i},[l,i]);const g=r.slice().sort().join(",");m.useEffect(()=>{if(!u)return;const y=g?g.split(","):[];return Cp(y,v=>d.current(v),()=>{var v;return(v=p.current)==null?void 0:v.call(p)})},[g,u])}function wt(){const r=m.useRef(0),l=m.useRef(!0);return m.useEffect(()=>(l.current=!0,()=>{l.current=!1,r.current+=1}),[]),m.useCallback(()=>{const i=++r.current;return()=>l.current&&r.current===i},[])}async function Rp(){return V("/api/messages")}async function Dp(r){return V(`/api/messages/${encodeURIComponent(r)}`)}async function Ip(r){return mp(`/api/messages/${encodeURIComponent(r)}/raw`)}async function Ep(){return mn("/api/messages",{method:"DELETE"})}function ms(r){return!r||r.indexOf("=?")===-1?r:r.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=(\s+(?==\?))?/g,(l,i,u,d)=>{try{const p=u.toUpperCase()==="B"?Tp(d):$p(d);return new TextDecoder(Lp(i)).decode(p)}catch{return l}})}function Dn(r){return r?ms(r).trim():""}function Tp(r){const l=atob(r.replace(/\s+/g,"")),i=new Uint8Array(l.length);for(let u=0;u{const I=v();if(S){u({status:"success",messages:[]});return}u({status:"loading"}),Rp().then(({messages:E})=>{I()&&(u({status:"success",messages:E}),p(J=>{var ue;return J??((ue=E[0])==null?void 0:ue.id)}))}).catch(E=>{I()&&u({status:"error",message:E.message})})},[v,S]);Vt({topics:["mail"],onEvent:N,onResync:N,enabled:!S});async function w(){S||!await l(Rn({title:"Clear inbox",description:"Delete all stored Mail messages from this local devcloud inbox.",target:"CLEAR INBOX",confirmLabel:"Clear inbox"}))||(u({status:"loading"}),Ep().then(()=>{p(void 0),u({status:"success",messages:[]})}).catch(E=>{u({status:"error",message:E.message})}))}m.useEffect(()=>{N()},[N]);const L=i.status==="success"?i.messages:[],C=m.useMemo(()=>zp(L,g),[L,g]),A=C.find(I=>I.id===d)??C[0]??(g.trim()===""?L[0]:void 0);return S?n.jsx(he,{title:"Mail",children:n.jsx(F,{title:"Mail is disabled",description:"Enable services.mail.enabled in .devcloud/config.yaml to inspect received messages."})}):n.jsxs("div",{className:"mail-shell",children:[n.jsxs(he,{title:"Inbox",children:[n.jsxs("div",{className:"mail-inbox-toolbar",children:[n.jsxs("label",{className:"mail-inbox-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter messages",onChange:I=>y(I.target.value),placeholder:"sender, recipient, subject",type:"search",value:g})]}),n.jsxs("div",{className:"mail-inbox-actions",children:[n.jsx(ae,{onClick:N,children:"Refresh"}),n.jsx(ae,{className:"danger",disabled:L.length===0,onClick:w,children:"Clear all"})]})]}),i.status==="loading"?n.jsx(F,{title:"Loading messages",description:"Reading the local Mail inbox."}):null,i.status==="error"?n.jsx(F,{title:"Mail messages unavailable",description:i.message,actionLabel:"Retry",onAction:N}):null,i.status==="success"?n.jsx(Mp,{messages:C,selectedID:A==null?void 0:A.id,totalMessages:L.length,onSelectMessage:p}):null]}),n.jsx(he,{title:"Message",children:n.jsx(Op,{message:A})})]})}function Mp({messages:r,selectedID:l,totalMessages:i,onSelectMessage:u}){return i===0?n.jsx(F,{title:"Inbox is empty",description:"Send mail to localhost:11025 (relaxed SMTP AUTH) and refresh."}):r.length===0?n.jsx(F,{title:"No matches",description:"Try a different filter or clear the search."}):n.jsx("ul",{className:"mail-inbox-list","aria-label":"Messages",children:r.map(d=>{const p=d.id===l,g=ms(d.subject)||"(No subject)",y=Dn(d.from)||"(unknown sender)",S=qu(d);return n.jsx("li",{children:n.jsxs("button",{"aria-current":p?"true":void 0,className:p?"mail-inbox-row active":"mail-inbox-row",onClick:()=>u(d.id),type:"button",children:[n.jsxs("span",{className:"mail-inbox-row-top",children:[n.jsx("span",{className:"mail-inbox-sender",children:y}),n.jsx("time",{className:"mail-inbox-time",dateTime:d.receivedAt,title:Ai(d.receivedAt),children:Vu(d.receivedAt)})]}),n.jsx("span",{className:"mail-inbox-subject",children:g}),S?n.jsx("span",{className:"mail-inbox-snippet",children:S}):null]})},d.id)})})}function Op({message:r}){const[l,i]=m.useState("preview"),[u,d]=m.useState(!1);if(m.useEffect(()=>{i("preview"),d(!1)},[r==null?void 0:r.id]),!r)return n.jsx(F,{title:"Select a message",description:"Messages accepted via SMTP appear here. Pick a row on the left to inspect."});const p=ms(r.subject)||"(No subject)",g=Dn(r.from),y=r.to.map(Dn).filter(Boolean),S=Ti(r.headers,"Cc"),v=Ti(r.headers,"Bcc"),N=Ti(r.headers,"Reply-To"),w=r.attachments??[],L=r.textBody||r.htmlBody||r.parseError||"",C=L.trim().length>0,A=[{id:"preview",label:"Preview"},{id:"attachments",label:"Attachments",count:w.length},{id:"raw",label:"Raw"}];return n.jsxs("article",{className:"mail-inspector",children:[n.jsxs("header",{className:"mail-inspector-header",children:[n.jsx("h1",{className:"mail-subject",children:p}),n.jsxs("dl",{className:"mail-recipients",children:[n.jsx(Ir,{label:"From",values:g?[g]:["(unknown sender)"]}),n.jsx(Ir,{label:"To",values:y.length>0?y:["(no recipients)"]}),S.length>0?n.jsx(Ir,{label:"Cc",values:S}):null,v.length>0?n.jsx(Ir,{label:"Bcc",values:v}):null,N.length>0?n.jsx(Ir,{label:"Reply-To",values:N}):null,n.jsxs("div",{className:"mail-recipients-row",children:[n.jsx("dt",{children:"Date"}),n.jsxs("dd",{children:[n.jsx("time",{dateTime:r.receivedAt,title:Ai(r.receivedAt),children:Ai(r.receivedAt)}),n.jsxs("span",{className:"mail-recipients-aux",children:["· ",Vu(r.receivedAt)]})]})]})]})]}),n.jsx("nav",{className:"mail-tabs",role:"tablist","aria-label":"Message view",children:A.map(I=>n.jsxs("button",{role:"tab","aria-selected":l===I.id,className:l===I.id?"mail-tab active":"mail-tab",onClick:()=>i(I.id),type:"button",children:[I.label,I.count!==void 0&&I.count>0?n.jsx("span",{className:"mail-tab-count",children:I.count}):null]},I.id))}),l==="preview"?n.jsx(Ap,{body:L,hasBody:C,parseError:r.parseError}):null,l==="attachments"?n.jsx(_p,{attachments:w}):null,l==="raw"?n.jsx(Bp,{messageID:r.id}):null,n.jsxs("details",{className:"mail-headers",open:u,onToggle:I=>d(I.target.open),children:[n.jsxs("summary",{children:[n.jsx("span",{children:"Headers"}),n.jsx("span",{className:"mail-headers-count",children:Object.keys(r.headers??{}).length})]}),n.jsx(Fp,{headers:r.headers??{}})]})]})}function Ir({label:r,values:l}){return n.jsxs("div",{className:"mail-recipients-row",children:[n.jsx("dt",{children:r}),n.jsx("dd",{children:l.join(", ")})]})}function Ap({body:r,hasBody:l,parseError:i}){return!l&&!i?n.jsx(F,{title:"No body",description:"The message contains no decoded text content."}):i&&!l?n.jsxs("div",{className:"mail-preview-error",children:[n.jsx("span",{className:"mail-eyebrow",children:"Parse error"}),n.jsx("p",{children:i})]}):n.jsx("div",{className:"mail-preview",lang:"auto",children:Up(r)})}function _p({attachments:r}){return r.length===0?n.jsx(F,{title:"No attachments",description:"This message has no parsed attachments."}):n.jsx("ul",{className:"mail-attachment-list","aria-label":"Attachments",children:r.map(l=>n.jsxs("li",{className:"mail-attachment-item",children:[n.jsx("span",{className:"mail-attachment-icon","aria-hidden":"true",children:qp(l.contentType)}),n.jsxs("div",{className:"mail-attachment-body",children:[n.jsx("span",{className:"mail-attachment-name",children:l.fileName||l.id}),n.jsxs("span",{className:"mail-attachment-meta",children:[l.contentType||"application/octet-stream"," · ",Vp(l.size)]})]})]},l.id))})}function Bp({messageID:r}){const[l,i]=m.useState({status:"loading"});if(m.useEffect(()=>{let p=!1;return i({status:"loading"}),Ip(r).then(g=>{p||i({status:"success",raw:g})}).catch(g=>{p||i({status:"error",message:g.message})}),()=>{p=!0}},[r]),l.status==="loading")return n.jsx(F,{title:"Loading raw source",description:"Reading the stored RFC 822 message."});if(l.status==="error")return n.jsx(F,{title:"Raw source unavailable",description:l.message});const u=l.raw;function d(){navigator.clipboard&&navigator.clipboard.writeText(u).catch(()=>{})}return n.jsxs("div",{className:"mail-raw",children:[n.jsxs("div",{className:"mail-raw-toolbar",children:[n.jsxs("span",{className:"mail-raw-meta",children:[u.length.toLocaleString()," bytes"]}),n.jsx("button",{className:"mail-raw-copy",onClick:d,type:"button",children:"Copy"})]}),n.jsx("pre",{className:"mail-raw-pre",children:u||"(empty)"})]})}function Fp({headers:r}){const l=Object.entries(r).sort(([i],[u])=>i.localeCompare(u));return l.length===0?n.jsx("p",{className:"mail-headers-empty",children:"No parsed headers."}):n.jsx("table",{className:"mail-headers-table",children:n.jsx("tbody",{children:l.map(([i,u])=>n.jsxs("tr",{children:[n.jsx("th",{scope:"row",children:i}),n.jsx("td",{children:u.map(ms).join(", ")})]},i))})})}function zp(r,l){const i=l.trim().toLowerCase();return i===""?r:r.filter(u=>[ms(u.subject),Dn(u.from),...u.to.map(Dn),qu(u)].join(" ").toLowerCase().includes(i))}function qu(r){return(r.textBody||r.htmlBody||r.parseError||"").replace(/\s+/g," ").trim()}function Ti(r,l){if(!r)return[];const i=l.toLowerCase();for(const[u,d]of Object.entries(r))if(u.toLowerCase()===i)return d.flatMap(p=>Qp(p)).map(Dn).filter(Boolean);return[]}function Qp(r){return r.split(/,(?![^<]*>)/).map(l=>l.trim()).filter(l=>l.length>0)}function Up(r){const l=[],i=/(https?:\/\/[^\s<>"']+)|((?:[a-zA-Z0-9._%+-]+)@(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g;let u=0,d=0,p=i.exec(r);for(;p!==null;){const y=r.slice(u,p.index);y&&(d+=1,l.push(n.jsx("span",{children:y},`t-${d}`))),d+=1,p[1]?l.push(n.jsx("a",{href:p[1],rel:"noopener noreferrer",target:"_blank",children:p[1]},`l-${d}`)):l.push(n.jsx("a",{href:`mailto:${p[2]}`,children:p[2]},`m-${d}`)),u=i.lastIndex,p=i.exec(r)}const g=r.slice(u);return g&&(d+=1,l.push(n.jsx("span",{children:g},`t-${d}`))),l}function qp(r){if(!r)return"📎";const l=r.toLowerCase();return l.startsWith("image/")?"🖼":l==="application/pdf"?"📄":l.startsWith("audio/")?"🎵":l.startsWith("video/")?"🎬":l.includes("zip")||l.includes("tar")||l.includes("gzip")?"🗜":l.startsWith("text/")?"📝":"📎"}function Ai(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function Vu(r){const l=new Date(r);if(Number.isNaN(l.getTime()))return r||"unknown";const i=Date.now()-l.getTime();if(i<0)return"in the future";const u=Math.floor(i/1e3);if(u<60)return"just now";const d=Math.floor(u/60);if(d<60)return`${d}m ago`;const p=Math.floor(d/60);if(p<24)return`${p}h ago`;const g=Math.floor(p/24);return g<7?`${g}d ago`:l.toLocaleDateString()}function Vp(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function Kp({buckets:r,activeBucket:l,disabled:i,onDeleteBucket:u,onSelectBucket:d}){return r.length===0?n.jsx(F,{title:"No buckets",description:"Buckets created through the S3 API will appear here."}):n.jsx("ul",{className:"s3-bucket-list","aria-label":"S3 buckets",children:r.map(p=>{const g=p.name===l,y=Yp(p.creationDate),S=Wp(p.objectCount),v=`${p.name} +${S} · created ${Gp(p.creationDate)}`;return n.jsxs("li",{className:g?"s3-bucket-row active":"s3-bucket-row",title:v,children:[n.jsxs("button",{className:"s3-bucket-select",onClick:()=>d(p.name),title:v,type:"button",children:[n.jsx("span",{className:"s3-bucket-icon","aria-hidden":!0,children:n.jsx(Jp,{})}),n.jsxs("span",{className:"s3-bucket-info",children:[n.jsx("span",{className:"s3-bucket-name",title:p.name,children:p.name}),n.jsxs("span",{className:"s3-bucket-meta",children:[S," · ",y]})]})]}),n.jsx("button",{"aria-label":`Delete bucket ${p.name}`,className:"s3-icon-button danger s3-bucket-delete",disabled:i,onClick:()=>u(p.name),type:"button",children:n.jsx(Hp,{})})]},p.name)})})}function Jp(){return n.jsxs("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M2.5 4.5h11l-1 8.5a1 1 0 0 1-1 .9H4.5a1 1 0 0 1-1-.9z"}),n.jsx("path",{d:"M2.5 4.5L4 2.5h8l1.5 2"}),n.jsx("path",{d:"M6 7v4"}),n.jsx("path",{d:"M10 7v4"})]})}function Hp(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M2.5 4h11"}),n.jsx("path",{d:"M6 4V2.5h4V4"}),n.jsx("path",{d:"M4 4l.7 9.2a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L12 4"}),n.jsx("path",{d:"M6.5 7v5"}),n.jsx("path",{d:"M9.5 7v5"})]})}function Wp(r){return!Number.isFinite(r)||r<0?"— objects":r===1?"1 object":`${r} objects`}function Gp(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function Yp(r){const l=new Date(r);if(Number.isNaN(l.getTime()))return"created —";const i=Date.now()-l.getTime(),u=60*1e3,d=60*u,p=24*d,g=7*p,y=30*p,S=365*p;return i{if(d(),!r){w({status:"idle"});return}let C=!1;return w({status:"loading"}),tm(r,S.trim()).then(({objects:A})=>{C||w({status:"success",objects:A})}).catch(A=>{C||w({status:"error",message:A.message})}),()=>{C=!0}},[r,d,S,u]),!r)return n.jsx(F,{title:"Select a bucket",description:"Choose a bucket to browse its objects."});const L=N.status==="success"?N.objects:[];return n.jsxs("div",{className:"s3-browser",children:[n.jsxs("div",{className:"s3-browser-toolbar",children:[n.jsx(cm,{prefix:S,onChange:v}),n.jsx(um,{disabled:i,onUploadObject:g})]}),n.jsx(om,{bucketName:r,disabled:i}),N.status==="loading"?n.jsx(F,{title:"Loading objects",description:`Reading objects in ${r}.`}):null,N.status==="error"?n.jsx(F,{title:"Objects unavailable",description:N.message}):null,N.status==="success"&&L.length===0?n.jsx(F,{title:"No objects",description:S.trim()===""?`Objects uploaded to ${r} will appear here.`:`No objects in ${r} match this prefix.`}):null,N.status==="success"&&L.length>0?n.jsx("div",{className:"s3-object-table-wrap",children:n.jsxs("table",{className:"s3-object-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Key"}),n.jsx("th",{scope:"col",children:"Size"}),n.jsx("th",{scope:"col",children:"Modified"}),n.jsx("th",{scope:"col",children:"Type"}),n.jsx("th",{"aria-label":"Row actions",className:"s3-object-actions-head",scope:"col"})]})}),n.jsx("tbody",{children:L.map(C=>{const A=C.key===l;return n.jsxs("tr",{className:A?"s3-object-row active":"s3-object-row",children:[n.jsx("td",{children:n.jsx("button",{className:"s3-object-key",onClick:()=>y(C),title:C.key,type:"button",children:C.key})}),n.jsx("td",{className:"s3-object-numeric",children:fm(C.size)}),n.jsx("td",{className:"s3-object-numeric",children:Ju(C.lastModified)}),n.jsx("td",{className:"s3-object-type",children:C.contentType||"application/octet-stream"}),n.jsxs("td",{className:"s3-object-actions",children:[n.jsx("a",{"aria-label":`Download ${C.key}`,className:"s3-icon-button neutral",href:pm(C.downloadUrl),title:"Download",children:n.jsx(gm,{})}),n.jsx("button",{"aria-label":`Delete ${C.key}`,className:"s3-icon-button danger",disabled:i,onClick:()=>p(C),title:"Delete",type:"button",children:n.jsx(xm,{})})]})]},C.key)})})]})}):null]})}function om({bucketName:r,disabled:l}){const[i,u]=m.useState({status:"loading"}),[d,p]=m.useState(0),[g,y]=m.useState(""),S=$r();m.useEffect(()=>{let N=!1;return u({status:"loading"}),sm(r).then(({uploads:w})=>{N||u({status:"success",uploads:w})}).catch(w=>{N||u({status:"error",message:w.message})}),()=>{N=!0}},[r,d]);async function v(N){if(!(l||g!==""||!await S(Rn({title:"Abort multipart upload",description:"Pending parts will be discarded and the upload will be unrecoverable.",target:N.uploadId,confirmLabel:"Abort upload"})))){y(N.uploadId);try{await rm(r,N.uploadId),p(L=>L+1)}finally{y("")}}}return i.status!=="success"||i.uploads.length===0?null:n.jsxs("details",{className:"s3-multipart-details",children:[n.jsxs("summary",{children:[n.jsx("span",{children:"Incomplete multipart uploads"}),n.jsx("span",{className:"s3-multipart-count",children:i.uploads.length})]}),n.jsx("ul",{className:"s3-multipart-list",children:i.uploads.map(N=>n.jsxs("li",{className:"s3-multipart-item",children:[n.jsxs("div",{className:"s3-multipart-meta",children:[n.jsx("strong",{children:N.key}),n.jsx("code",{children:N.uploadId}),n.jsx("span",{children:Ju(N.initiated)})]}),n.jsx(ae,{"aria-label":`Abort multipart upload ${N.uploadId} with confirmation`,className:"danger",disabled:l||g!=="",onClick:()=>void v(N),children:g===N.uploadId?"Aborting":"Abort"})]},N.uploadId))})]})}function cm({prefix:r,onChange:l}){return n.jsxs("label",{className:"s3-search",children:[n.jsx(mm,{}),n.jsx("input",{"aria-label":"Filter objects by prefix",onChange:i=>l(i.target.value),placeholder:"Filter by prefix (e.g. docs/)",type:"search",value:r})]})}function um({disabled:r,onUploadObject:l}){const i=m.useRef(null),[u,d]=m.useState(""),[p,g]=m.useState(""),[y,S]=m.useState(""),[v,N]=m.useState(!1),w=m.useRef(null);async function L(C){var I,E;C.preventDefault();const A=(E=(I=i.current)==null?void 0:I.files)==null?void 0:E[0];if(!(r||v||u.trim()===""||!A)){N(!0);try{await l({key:u.trim(),file:A,contentType:p.trim()||A.type||"application/octet-stream",metadata:dm(y)}),d(""),g(""),S(""),i.current&&(i.current.value=""),w.current&&(w.current.open=!1)}finally{N(!1)}}}return n.jsxs("details",{className:"s3-upload-details",ref:w,children:[n.jsxs("summary",{children:[n.jsx(hm,{}),n.jsx("span",{children:"Upload object"})]}),n.jsxs("form",{className:"s3-upload-form",onSubmit:L,children:[n.jsxs("label",{className:"s3-field",children:[n.jsx("span",{children:"Key"}),n.jsx("input",{"aria-label":"S3 upload object key",className:"s3-text-input",disabled:r||v,onChange:C=>d(C.target.value),placeholder:"docs/readme.txt",value:u})]}),n.jsxs("label",{className:"s3-field",children:[n.jsx("span",{children:"Content-Type"}),n.jsx("input",{"aria-label":"S3 upload content type",className:"s3-text-input",disabled:r||v,onChange:C=>g(C.target.value),placeholder:"text/plain",value:p})]}),n.jsxs("label",{className:"s3-field s3-field-full",children:[n.jsx("span",{children:"File"}),n.jsx("input",{"aria-label":"S3 upload file",className:"s3-file-input",disabled:r||v,ref:i,type:"file"})]}),n.jsxs("label",{className:"s3-field s3-field-full",children:[n.jsx("span",{children:"Metadata (x-amz-meta)"}),n.jsx("textarea",{"aria-label":"S3 upload metadata",className:"s3-text-input",disabled:r||v,onChange:C=>S(C.target.value),placeholder:`source=dashboard +purpose=local-test`,rows:2,value:y})]}),n.jsx("div",{className:"s3-upload-actions",children:n.jsx(ae,{disabled:r||v||u.trim()==="",type:"submit",children:v?"Uploading…":"Upload"})})]})]})}function dm(r){return r.split(/\r?\n/).reduce((l,i)=>{const u=i.trim();if(u==="")return l;const[d,...p]=u.split("="),g=d.trim(),y=p.join("=").trim();return g!==""&&(l[g]=y),l},{})}function fm(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function Ju(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function pm(r){return r.startsWith("/api/s3/")?r:"#"}function mm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("circle",{cx:"7",cy:"7",r:"4.5"}),n.jsx("path",{d:"M13.5 13.5L10.5 10.5"})]})}function hm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",children:[n.jsx("path",{d:"M8 3v10"}),n.jsx("path",{d:"M3 8h10"})]})}function gm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M8 2.5v8"}),n.jsx("path",{d:"M4.5 7L8 10.5 11.5 7"}),n.jsx("path",{d:"M3 13h10"})]})}function xm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M2.5 4h11"}),n.jsx("path",{d:"M6 4V2.5h4V4"}),n.jsx("path",{d:"M4 4l.7 9.2a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L12 4"}),n.jsx("path",{d:"M6.5 7v5"}),n.jsx("path",{d:"M9.5 7v5"})]})}function ym({bucketName:r,disabled:l,object:i,onCopyObject:u}){if(!i)return n.jsxs("div",{className:"s3-inspector-empty",children:[n.jsx(Nm,{}),n.jsx("p",{className:"s3-inspector-empty-title",children:"No object selected"}),n.jsx("p",{className:"s3-inspector-empty-body",children:r?`Pick an object in ${r} to see metadata, copy, and download actions.`:"Select a bucket first, then click an object to inspect it here."})]});const d=Object.entries(i.metadata??{});return n.jsxs("div",{className:"s3-inspector",children:[n.jsxs("div",{className:"s3-inspector-section",children:[n.jsx("span",{className:"s3-field-label",children:"Object"}),n.jsx("code",{className:"s3-inspector-key",title:i.key,children:i.key}),n.jsx("code",{className:"s3-inspector-uri",title:i.s3Uri,children:i.s3Uri})]}),n.jsxs("dl",{className:"s3-inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Size"}),n.jsx("dd",{children:vm(i.size)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Content type"}),n.jsx("dd",{children:i.contentType||"application/octet-stream"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Last modified"}),n.jsx("dd",{children:bm(i.lastModified)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"ETag"}),n.jsx("dd",{children:n.jsx("code",{children:i.etag||"unknown"})})]})]}),n.jsxs("div",{className:"s3-inspector-section",children:[n.jsx("span",{className:"s3-field-label",children:"Metadata"}),d.length===0?n.jsx("p",{className:"s3-inspector-muted",children:"No user metadata."}):n.jsx("dl",{className:"s3-inspector-metadata",children:d.map(([p,g])=>n.jsxs("div",{children:[n.jsx("dt",{children:p}),n.jsx("dd",{children:g})]},p))})]}),n.jsxs("a",{className:"s3-inspector-download",href:Sm(i.downloadUrl),children:[n.jsx(wm,{}),n.jsx("span",{children:"Download object"})]}),n.jsx(jm,{disabled:l,object:i,onCopyObject:u})]})}function jm({disabled:r,object:l,onCopyObject:i}){const[u,d]=m.useState(`${l.key}.copy`),[p,g]=m.useState(!1);m.useEffect(()=>{d(`${l.key}.copy`)},[l.key]);async function y(S){S.preventDefault();const v=u.trim();if(!(r||p||v==="")){g(!0);try{await i(v)}finally{g(!1)}}}return n.jsxs("form",{className:"s3-inspector-copy",onSubmit:y,children:[n.jsxs("label",{className:"s3-field",children:[n.jsx("span",{children:"Copy to key"}),n.jsx("input",{"aria-label":"S3 copy destination key",className:"s3-text-input",disabled:r||p,onChange:S=>d(S.target.value),value:u})]}),n.jsx(ae,{disabled:r||p||u.trim()==="",type:"submit",children:p?"Copying…":"Copy"})]})}function vm(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function bm(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function Sm(r){return r.startsWith("/api/s3/")?r:"#"}function Nm(){return n.jsxs("svg",{"aria-hidden":!0,width:"36",height:"36",viewBox:"0 0 36 36",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M7 11l11-6 11 6v14L18 31 7 25z"}),n.jsx("path",{d:"M7 11l11 6 11-6"}),n.jsx("path",{d:"M18 17v14"})]})}function wm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M8 2.5v8"}),n.jsx("path",{d:"M4.5 7L8 10.5 11.5 7"}),n.jsx("path",{d:"M3 13h10"})]})}function km({service:r}){var me;const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(),[y,S]=m.useState(""),[v,N]=m.useState(""),[w,L]=m.useState(0),C=(r==null?void 0:r.status)==="disabled",A=$r(),I=wt(),E=m.useCallback(()=>{const z=I();if(C){i({status:"success",buckets:[]}),d(void 0),g(void 0);return}i({status:"loading"}),Xp().then(({buckets:H})=>{z()&&(i({status:"success",buckets:H}),d(be=>{var $e;return be&&H.some(Ne=>Ne.name===be)?be:($e=H[0])==null?void 0:$e.name}))}).catch(H=>{z()&&i({status:"error",message:H.message})})},[I,C]);m.useEffect(()=>{E()},[E]);const J=m.useCallback(z=>{E(),z.type.startsWith("s3.object.")&&L(H=>H+1)},[E]);Vt({topics:["s3"],onEvent:J,onResync:()=>{E(),L(z=>z+1)},enabled:!C});const ue=l.status==="success"?l.buckets:[],q=(me=ue.find(z=>z.name===u))==null?void 0:me.name,P=p&&q?p:void 0,oe=m.useCallback(()=>{g(void 0)},[]);if(C)return n.jsx(he,{title:"S3",children:n.jsx(F,{title:"S3 is disabled",description:"Enable the S3 service in devcloud config to browse buckets and objects."})});function pe(z){d(z),g(void 0)}function Z(){const z=y.trim();C||z===""||Zp(z).then(H=>{N(`Created bucket ${H.name}`),S(""),d(H.name),E()}).catch(H=>N(H.message))}async function Re(z){C||!await A(Rn({title:"Delete bucket",description:"All objects and metadata in this bucket will be removed. This cannot be undone.",target:z}))||em(z).then(()=>{N(`Deleted bucket ${z}`),d(be=>be===z?void 0:be),g(void 0),E()}).catch(be=>N(be.message))}async function ee(z){!q||C||!await A(Rn({title:"Delete object",description:"This object will be permanently removed from the bucket.",target:z.key}))||nm(q,z.key).then(()=>{N(`Deleted object ${z.s3Uri}`),g(void 0),L(be=>be+1),E()}).catch(be=>N(be.message))}async function fe(z){if(!(!q||C))try{const H=await am({bucketName:q,objectKey:z.key,body:z.file,contentType:z.contentType,metadata:z.metadata});N(`Uploaded object ${H.s3Uri}`),g(H),L(be=>be+1),E()}catch(H){throw N(H instanceof Error?H.message:"S3 upload failed"),H}}async function Se(z){if(!(!q||!P||C))try{const H=await lm({sourceBucketName:q,sourceObjectKey:P.key,destinationBucketName:q,destinationObjectKey:z});N(`Copied object ${P.s3Uri} to ${H.s3Uri}`),g(H),L(be=>be+1),E()}catch(H){throw N(H instanceof Error?H.message:"S3 copy failed"),H}}return n.jsxs("div",{className:"s3-workspace",children:[n.jsxs(he,{title:"Buckets",children:[n.jsxs("div",{className:"s3-bucket-toolbar",children:[n.jsx("span",{className:"s3-bucket-count",children:ue.length===1?"1 bucket":`${ue.length} buckets`}),n.jsx("button",{"aria-label":"Refresh buckets",className:"s3-icon-button neutral",onClick:E,type:"button",children:n.jsx(Rm,{})})]}),n.jsx(Cm,{bucketName:y,disabled:C,onChange:S,onCreate:Z}),l.status==="loading"?n.jsx(F,{title:"Loading buckets",description:"Reading the local S3 bucket registry."}):null,l.status==="error"?n.jsx(F,{title:"S3 buckets unavailable",description:l.message,actionLabel:"Retry",onAction:E}):null,l.status==="success"?n.jsx(Kp,{buckets:ue,activeBucket:q,disabled:C,onDeleteBucket:Re,onSelectBucket:pe}):null]}),n.jsx(he,{title:q?`Objects · ${q}`:"Objects",children:n.jsx(im,{bucketName:q,activeObjectKey:P==null?void 0:P.key,disabled:C,refreshNonce:w,onClearObject:oe,onDeleteObject:ee,onUploadObject:fe,onSelectObject:g})}),n.jsx(he,{title:"Inspector",children:n.jsx(ym,{bucketName:q,disabled:C,object:P,onCopyObject:Se})}),v?n.jsx("p",{className:"s3-status-banner",children:v}):null]})}function Cm({bucketName:r,disabled:l,onChange:i,onCreate:u}){return n.jsxs("form",{className:"s3-bucket-create",onSubmit:d=>{d.preventDefault(),u()},children:[n.jsx("input",{"aria-label":"New bucket name",className:"s3-text-input",disabled:l,onChange:d=>i(d.target.value),placeholder:"New bucket name",value:r}),n.jsx(ae,{disabled:l||r.trim()==="",type:"submit",children:"Create"})]})}function Rm(){return n.jsxs("svg",{"aria-hidden":!0,width:"16",height:"16",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M13.5 8a5.5 5.5 0 1 1-1.61-3.89"}),n.jsx("path",{d:"M13.5 3v3h-3"})]})}async function Dm(){return V("/api/dynamodb/status")}async function Im(){return V("/api/dynamodb/tables")}async function Em(r){return V("/api/dynamodb/tables",{method:"POST",body:{input:r}})}async function Tm(r,l=100){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/items?limit=${encodeURIComponent(String(l))}`)}async function $m(r,l){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/items`,{method:"POST",body:{input:l}})}async function Lm(r,l){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/items/update`,{method:"POST",body:{input:l}})}async function Pm(r,l,i){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/items/delete`,{method:"POST",body:{input:l,confirmation:i}})}async function Mm(r,l){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/query`,{method:"POST",body:{input:l}})}async function Om(r,l){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/scan`,{method:"POST",body:{input:l}})}async function Am(r){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}`)}async function _m(r){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/indexes`)}async function Bm(r){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/ttl`)}async function Fm(r,l){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/ttl`,{method:"POST",body:{input:l}})}async function zm(r,l,i){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/delete`,{method:"POST",body:{input:l,confirmation:i}})}async function Qm(r){return V(`/api/dynamodb/tables/${encodeURIComponent(r)}/streams`)}const _i="devcloud.dynamodb.recentOperations.v1",zi=10,Um=`{ + "TableName": "Demo", + "AttributeDefinitions": [ + { "AttributeName": "pk", "AttributeType": "S" } + ], + "KeySchema": [ + { "AttributeName": "pk", "KeyType": "HASH" } + ], + "BillingMode": "PAY_PER_REQUEST" +}`,qm=`{ + "Item": { + "pk": { "S": "user#1" }, + "name": { "S": "Ada" } + } +}`,Vm=`{ + "Key": { + "pk": { "S": "user#1" } + }, + "UpdateExpression": "SET #name = :name", + "ExpressionAttributeNames": { + "#name": "name" + }, + "ExpressionAttributeValues": { + ":name": { "S": "Grace" } + } +}`,Km=`{ + "Key": { + "pk": { "S": "user#1" } + } +}`,Jm=`{ + "TimeToLiveSpecification": { + "Enabled": true, + "AttributeName": "expiresAt" + } +}`,Hm=`{ + ":pk": { "S": "user#1" } +}`;function Wm(r){return{status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:18000",region:"us-east-1",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/dynamodb",tableCount:0}}function Qi(r){const l=r.keySchema??[];return l.length===0?"No key schema":l.map(i=>`${i.AttributeName} ${i.KeyType}`).join(" / ")}function Gm(r){return(r.globalSecondaryIndexes??[]).length+(r.localSecondaryIndexes??[]).length}function Ym(r){const l=r.attributeDefinitions??[];return l.length===0?"none":l.map(i=>`${i.AttributeName} ${i.AttributeType}`).join(", ")}function Xm(r){const l=r.timeToLiveDescription;return!l||l.TimeToLiveStatus===""?"not configured":l.AttributeName?`${l.TimeToLiveStatus} on ${l.AttributeName}`:l.TimeToLiveStatus}function Zm(r){var i;if(!r.streamEnabled)return"disabled";const l=((i=r.streamSpecification)==null?void 0:i.StreamViewType)??"enabled";return r.latestStreamLabel?`${l} (${r.latestStreamLabel})`:l}function $i(r){const l=Number.parseInt(r,10);return!Number.isFinite(l)||l<=0?100:Math.min(l,1e3)}function ps(r){try{const l=JSON.parse(r);return!l||Array.isArray(l)||typeof l!="object"?{ok:!1,message:"Input must be a JSON object."}:{ok:!0,value:l}}catch(l){return{ok:!1,message:l instanceof Error?l.message:"Input must be valid JSON."}}}function eh(r){if(r.trim()==="")return{ok:!0};const l=ps(r);return l.ok?{ok:!0,value:l.value}:l}function th(r){return r==null?"null":typeof r=="object"?JSON.stringify(r):String(r)}function nh(r){return r==null?"":typeof r=="string"?r:typeof r=="number"||typeof r=="boolean"?String(r):JSON.stringify(r)}function sh(r){return!Number.isFinite(r)||r<0?"unknown":r<1024?`${r} B`:`${(r/1024).toFixed(1)} KB`}function rh(){if(typeof window>"u")return[];try{const r=window.localStorage.getItem(_i);if(!r)return[];const l=JSON.parse(r);return Array.isArray(l)?l.filter(lh).slice(0,zi):[]}catch{return[]}}function ah(r){if(!(typeof window>"u"))try{if(r.length===0){window.localStorage.removeItem(_i);return}window.localStorage.setItem(_i,JSON.stringify(r.slice(0,zi)))}catch{}}function lh(r){if(!r||Array.isArray(r)||typeof r!="object")return!1;const l=r;return(l.operation==="Query"||l.operation==="Scan")&&typeof l.id=="string"&&typeof l.tableName=="string"&&typeof l.limit=="number"&&typeof l.expressionSummary=="string"&&typeof l.count=="number"&&typeof l.scannedCount=="number"&&typeof l.page=="number"&&typeof l.hasMore=="boolean"&&typeof l.createdAt=="string"}function ih(r){return{id:`${Date.now()}-${r.mode}-${r.tableName}-${r.page}`,operation:r.mode,tableName:r.tableName,indexName:r.indexName.trim()||void 0,limit:r.limit,expressionSummary:r.usesExpressionAttributeValues?"expression values present":"no expression values",count:r.count,scannedCount:r.scannedCount,page:r.page,hasMore:r.hasMore,createdAt:new Date().toISOString()}}function oh(r){const l=new Date(r);return Number.isNaN(l.getTime())?"unknown":l.toLocaleString()}function ch({activeIndex:r,items:l,itemsState:i,onSelectIndex:u,tableName:d}){return d?i.status==="loading"?n.jsx(F,{title:"Loading items",description:`Reading items from ${d}.`}):i.status==="error"?n.jsx(F,{title:"DynamoDB items unavailable",description:i.message}):l.length===0?n.jsx(F,{title:"No items",description:`No loaded items in ${d} match the current filter.`}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Key"}),n.jsx("th",{scope:"col",children:"Attributes"}),n.jsx("th",{scope:"col",children:"Size"})]})}),n.jsx("tbody",{children:l.map((p,g)=>n.jsxs("tr",{className:g===r?"item-row active":"item-row",onClick:()=>u(g),children:[n.jsx("td",{children:n.jsx("code",{children:JSON.stringify(p.key)})}),n.jsx("td",{children:n.jsx(Hu,{item:p.item})}),n.jsx("td",{children:sh(JSON.stringify(p.item).length)})]},`${JSON.stringify(p.key)}-${g}`))})]})}):n.jsx(F,{title:"No table selected",description:"Choose a table to inspect its stored items."})}function Hu({item:r}){const l=Object.entries(r).filter(([i])=>i!=="pk"&&i!=="sk").slice(0,6);return l.length===0?n.jsx("span",{className:"service-status",children:"key only"}):n.jsx("span",{className:"attribute-preview",children:l.map(([i,u])=>n.jsxs("span",{className:"attribute-chip",children:[i,": ",th(u)]},i))})}function uh({message:r,onFind:l,onUpdateValue:i,table:u,values:d}){const p=(u==null?void 0:u.keySchema)??[];return!u||p.length===0?null:n.jsxs("div",{className:"dynamodb-key-lookup",children:[n.jsx("span",{className:"inspector-label",children:"Key lookup"}),n.jsxs("div",{className:"pubsub-action-row",children:[p.map(g=>n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:[g.AttributeName," ",g.KeyType]}),n.jsx("input",{"aria-label":`DynamoDB key lookup ${g.AttributeName}`,onChange:y=>i(g.AttributeName,y.target.value),placeholder:g.AttributeName,value:d[g.AttributeName]??""})]},g.AttributeName)),n.jsx(ae,{onClick:l,children:"Find loaded item"})]}),r?n.jsx("p",{className:"inspector-muted",children:r}):null]})}function dh({activeTableName:r,busyOperation:l,createTableJSON:i,putItemJSON:u,updateItemJSON:d,deleteItemJSON:p,ttlJSON:g,deleteTableJSON:y,deleteItemConfirmation:S,deleteTableConfirmation:v,deleteItemAcknowledged:N,deleteTableAcknowledged:w,onCreateTable:L,onPutItem:C,onUpdateItem:A,onDeleteItem:I,onUpdateTTL:E,onDeleteTable:J,setCreateTableJSON:ue,setPutItemJSON:q,setUpdateItemJSON:P,setDeleteItemJSON:oe,setTTLJSON:pe,setDeleteTableJSON:Z,setDeleteItemConfirmation:Re,setDeleteTableConfirmation:ee,setDeleteItemAcknowledged:fe,setDeleteTableAcknowledged:Se}){const me=!r;return n.jsxs("div",{className:"dynamodb-operation-stack",children:[n.jsx(Qs,{buttonLabel:"Create table",disabled:l==="create-table",json:i,label:"CreateTable input",onChange:ue,onSubmit:L}),n.jsx(Qs,{buttonLabel:"Put item",disabled:me||l==="put-item",json:u,label:"PutItem input",onChange:q,onSubmit:C}),n.jsx(Qs,{buttonLabel:"Update item",disabled:me||l==="update-item",json:d,label:"UpdateItem input",onChange:P,onSubmit:A}),n.jsx(Qs,{buttonLabel:"Update TTL",disabled:me||l==="ttl",json:g,label:"UpdateTimeToLive input",onChange:pe,onSubmit:E}),n.jsx(Qs,{buttonClassName:"danger",buttonLabel:"Delete item",confirmation:S,destructiveAcknowledgement:N,destructiveAcknowledgementLabel:"Step 1: I understand this deletes the item identified by the JSON key.",confirmationLabel:`Type ${r??"table name"} to delete item`,disabled:me||!N||S!==r||l==="delete-item",json:p,label:"DeleteItem input",onChange:oe,onDestructiveAcknowledgementChange:fe,onConfirmationChange:Re,onSubmit:I}),n.jsx(Qs,{buttonClassName:"danger",buttonLabel:"Delete table",confirmation:v,destructiveAcknowledgement:w,destructiveAcknowledgementLabel:"Step 1: I understand this deletes the selected table and its local items.",confirmationLabel:`Type ${r??"table name"} to delete table`,disabled:me||!w||v!==r||l==="delete-table",json:y,label:"DeleteTable input",onChange:Z,onDestructiveAcknowledgementChange:Se,onConfirmationChange:ee,onSubmit:J})]})}function Qs({buttonClassName:r,buttonLabel:l,confirmation:i,confirmationLabel:u,destructiveAcknowledgement:d,destructiveAcknowledgementLabel:p,disabled:g,json:y,label:S,onChange:v,onConfirmationChange:N,onDestructiveAcknowledgementChange:w,onSubmit:L}){return n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:L,children:[n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:S}),n.jsx("textarea",{"aria-label":S,onChange:C=>v(C.target.value),spellCheck:!1,value:y})]}),w?n.jsxs("label",{className:"destructive-confirmation",children:[n.jsx("input",{"aria-label":p,checked:!!d,onChange:C=>w(C.target.checked),type:"checkbox"}),n.jsx("span",{children:p})]}):null,N?n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:["Step 2: ",u]}),n.jsx("input",{"aria-label":u,onChange:C=>N(C.target.value),value:i??""})]}):null,n.jsx(ae,{className:r,disabled:g,type:"submit",children:l})]})}function fh({activeTableName:r,busyOperation:l,error:i,expressionAttributeValues:u,filterExpression:d,indexName:p,keyConditionExpression:g,limit:y,message:S,mode:v,onNextPage:N,onPreviousPage:w,onSubmit:L,pageIndex:C,result:A,selectedItemIndex:I,setExpressionAttributeValues:E,setFilterExpression:J,setIndexName:ue,setKeyConditionExpression:q,setLimit:P,setMode:oe,setSelectedItemIndex:pe}){const Z=!r||l==="query"||l==="scan",Re=(A==null?void 0:A.Items)??[],ee=C>0,fe=!!(A!=null&&A.LastEvaluatedKey),Se=Re[Math.min(I,Math.max(Re.length-1,0))];return n.jsxs("div",{className:"dynamodb-query-scan",children:[i?n.jsx("p",{className:"operation-message error",children:i}):null,S?n.jsx("p",{className:"operation-message success",children:S}):null,n.jsxs("form",{className:"dynamodb-query-scan-form",onSubmit:L,children:[n.jsxs("div",{className:"segmented-control","aria-label":"DynamoDB read operation",children:[n.jsx("button",{className:v==="Query"?"active":"",onClick:()=>oe("Query"),type:"button",children:"Query"}),n.jsx("button",{className:v==="Scan"?"active":"",onClick:()=>oe("Scan"),type:"button",children:"Scan"})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"TableName"}),n.jsx("input",{"aria-label":"DynamoDB Query Scan table name",disabled:!0,value:r??""})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"IndexName"}),n.jsx("input",{"aria-label":"DynamoDB Query Scan index name",onChange:me=>ue(me.target.value),placeholder:"optional",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Limit"}),n.jsx("input",{"aria-label":"DynamoDB Query Scan limit",inputMode:"numeric",onChange:me=>P(me.target.value),value:y})]}),v==="Query"?n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"KeyConditionExpression"}),n.jsx("input",{"aria-label":"DynamoDB Query key condition expression",onChange:me=>q(me.target.value),placeholder:"pk = :pk",value:g})]}):n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"FilterExpression"}),n.jsx("input",{"aria-label":"DynamoDB Scan filter expression",onChange:me=>J(me.target.value),placeholder:"optional",value:d})]}),n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"ExpressionAttributeValues JSON"}),n.jsx("textarea",{"aria-label":"DynamoDB Query Scan expression attribute values JSON",onChange:me=>E(me.target.value),spellCheck:!1,value:u})]}),n.jsxs(ae,{disabled:Z,type:"submit",children:["Run ",v]})]}),A?n.jsxs("div",{className:"redshift-query-result",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("span",{className:"toolbar-count",children:["Page ",C+1," / Count ",A.Count??Re.length," / Scanned ",A.ScannedCount??0]}),n.jsx("span",{className:"toolbar-count",children:fe?"More results available":"End of results"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsx(ae,{disabled:Z||!ee,onClick:w,type:"button",children:"Previous page"}),n.jsx(ae,{disabled:Z||!fe,onClick:N,type:"button",children:"Next page"})]})]}),Re.length>0?n.jsxs("div",{className:"dynamodb-query-result-grid",children:[n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"#"}),n.jsx("th",{scope:"col",children:"Item preview"})]})}),n.jsx("tbody",{children:Re.map((me,z)=>n.jsxs("tr",{className:z===I?"item-row active":"item-row",onClick:()=>pe(z),children:[n.jsx("td",{children:z+1}),n.jsx("td",{children:n.jsx(Hu,{item:me})})]},`${C}-${z}-${JSON.stringify(me).length}`))})]})}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected result item JSON"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(Se,null,2)})]})]}):n.jsx("p",{className:"inspector-muted",children:"No items returned."})]}):null]})}function ph({operations:r,onClear:l}){return n.jsxs("section",{className:"dynamodb-recent-operations",children:[n.jsxs("div",{className:"inspector-heading",children:[n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"Recent operation history"}),n.jsx("p",{className:"inspector-muted",children:"Stored locally without item payloads, credentials, or pagination keys."})]}),n.jsx(ae,{disabled:r.length===0,onClick:l,type:"button",children:"Clear"})]}),r.length===0?n.jsx("p",{className:"inspector-muted",children:"Run Query or Scan to record local operation metadata."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"When"}),n.jsx("th",{scope:"col",children:"Operation"}),n.jsx("th",{scope:"col",children:"Table"}),n.jsx("th",{scope:"col",children:"Result"})]})}),n.jsx("tbody",{children:r.map(i=>n.jsxs("tr",{children:[n.jsx("td",{children:oh(i.createdAt)}),n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:i.operation}),n.jsxs("span",{className:"attribute-chip",children:["page ",i.page]}),i.indexName?n.jsxs("span",{className:"attribute-chip",children:["index: ",i.indexName]}):null]})}),n.jsx("td",{children:n.jsx("code",{children:i.tableName})}),n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsxs("span",{className:"attribute-chip",children:["count: ",i.count]}),n.jsxs("span",{className:"attribute-chip",children:["scanned: ",i.scannedCount]}),n.jsxs("span",{className:"attribute-chip",children:["limit: ",i.limit]}),n.jsx("span",{className:"attribute-chip",children:i.hasMore?"has next page":"last page"}),n.jsx("span",{className:"attribute-chip",children:i.expressionSummary})]})})]},i.id))})]})})]})}function mh({detailState:r,table:l,item:i,onRefreshDetail:u,status:d}){var N;if(!l)return n.jsx(F,{title:"Inspector",description:"Table schema and selected item JSON will appear here."});const p=r.status==="success"?r.table:l,g=r.status==="success"?r.globalSecondaryIndexes:l.globalSecondaryIndexes??[],y=r.status==="success"?r.localSecondaryIndexes:l.localSecondaryIndexes??[],S=r.status==="success"?r.ttl:l.timeToLiveDescription,v=r.status==="success"?r.streams:{tableName:l.tableName,streamEnabled:((N=l.streamSpecification)==null?void 0:N.StreamEnabled)??!1,latestStreamArn:l.latestStreamArn,latestStreamLabel:l.latestStreamLabel,streamSpecification:l.streamSpecification};return n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsxs("div",{className:"inspector-heading",children:[n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"Table"}),n.jsx("h3",{children:p.tableName})]}),n.jsx(ae,{onClick:u,children:"Refresh detail"})]}),r.status==="loading"?n.jsx("p",{className:"inspector-muted",children:"Loading detail metadata."}):null,r.status==="error"?n.jsx("p",{className:"operation-message error",children:r.message}):null,n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Status"}),n.jsx("dd",{children:p.tableStatus})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(d==null?void 0:d.endpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:(d==null?void 0:d.region)??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Key schema"}),n.jsx("dd",{children:Qi(p)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Attributes"}),n.jsx("dd",{children:Ym(p)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"TTL"}),n.jsx("dd",{children:Xm({...p,timeToLiveDescription:S})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Streams"}),n.jsx("dd",{children:Zm(v)})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Indexes"}),n.jsx(hh,{globalSecondaryIndexes:g,localSecondaryIndexes:y})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Streams"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(v,null,2)})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected item"}),i?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(i.item,null,2)}):n.jsx("p",{className:"inspector-muted",children:"Select an item row to inspect JSON."})]})]})}function hh({globalSecondaryIndexes:r,localSecondaryIndexes:l}){const i=[...r.map(u=>({...u,type:"GSI"})),...l.map(u=>({...u,type:"LSI"}))];return i.length===0?n.jsx("p",{className:"inspector-muted",children:"No secondary indexes."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Type"}),n.jsx("th",{scope:"col",children:"Name"}),n.jsx("th",{scope:"col",children:"Key schema"}),n.jsx("th",{scope:"col",children:"Items"})]})}),n.jsx("tbody",{children:i.map(u=>n.jsxs("tr",{children:[n.jsx("td",{children:u.type}),n.jsx("td",{children:u.IndexName}),n.jsx("td",{children:Qi({tableName:u.IndexName,keySchema:u.KeySchema})}),n.jsx("td",{children:u.ItemCount??0})]},`${u.type}-${u.IndexName}`))})]})})}function gh({tables:r,activeTableName:l,onSelectTable:i}){return r.length===0?n.jsx(F,{title:"No tables",description:"Tables created through the DynamoDB API will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"DynamoDB tables",children:r.map(u=>{var d;return n.jsxs("button",{className:u.tableName===l?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>i(u.tableName),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:u.tableName}),n.jsx("span",{className:"count-pill",children:u.itemCount})]}),n.jsx("span",{className:"table-row-meta",children:Qi(u)}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:u.tableStatus}),n.jsxs("span",{children:[Gm(u)," indexes"]}),n.jsx("span",{children:(d=u.streamSpecification)!=null&&d.StreamEnabled?"streams on":"streams off"})]})]},u.tableName)})})}function xh({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState({status:"idle"}),[y,S]=m.useState(),[v,N]=m.useState(0),[w,L]=m.useState(""),[C,A]=m.useState(""),[I,E]=m.useState("100"),[J,ue]=m.useState({}),[q,P]=m.useState(""),[oe,pe]=m.useState(Um),[Z,Re]=m.useState(qm),[ee,fe]=m.useState(Vm),[Se,me]=m.useState(Km),[z,H]=m.useState(Jm),[be,$e]=m.useState("{}"),[Ne,$]=m.useState(""),[U,_]=m.useState(""),[j,T]=m.useState(!1),[ne,ce]=m.useState(!1),[ge,we]=m.useState(""),[De,se]=m.useState(""),[Ie,Qe]=m.useState(),[tt,Dt]=m.useState("Query"),[ut,de]=m.useState(""),[ke,Y]=m.useState("25"),[je,rt]=m.useState("pk = :pk"),[at,yt]=m.useState(""),[It,Ze]=m.useState(Hm),[Et,K]=m.useState(""),[Be,Ue]=m.useState(""),[Le,kt]=m.useState(),[_e,jt]=m.useState({pageHistory:[void 0],pageIndex:0,selectedItemIndex:0}),[Ae,hn]=m.useState(rh),it=(r==null?void 0:r.status)==="disabled";m.useEffect(()=>{ah(Ae)},[Ae]);const Xn=wt(),Tt=m.useCallback(()=>{const le=Xn();if(it){i({status:"success",statusPayload:Wm(r),tables:[]}),d({status:"idle"}),g({status:"idle"}),S(void 0);return}i({status:"loading"}),Promise.all([Dm(),Im()]).then(([W,{tables:Pe}])=>{le()&&(i({status:"success",statusPayload:W,tables:Pe}),S(He=>{var Ge;return He&&Pe.some(qe=>qe.tableName===He)?He:(Ge=Pe[0])==null?void 0:Ge.tableName}))}).catch(W=>{le()&&i({status:"error",message:W.message})})},[Xn,it,r]);m.useEffect(()=>{Tt()},[Tt]),Vt({topics:["dynamodb"],onEvent:Tt,onResync:Tt,enabled:!it});const an=l.status==="success"?l.tables:[],dt=an.find(le=>le.tableName===y),$t=wt(),ln=m.useCallback(()=>{const le=$t();if(!y||it){d({status:"idle"});return}d({status:"loading"}),Tm(y,$i(I)).then(({items:W})=>{le()&&(N(0),d({status:"success",items:W}))}).catch(W=>{le()&&d({status:"error",message:W.message})})},[$t,y,it,I]);m.useEffect(()=>{ln()},[ln]);const In=wt(),ht=m.useCallback(()=>{const le=In();if(!y||it){g({status:"idle"});return}g({status:"loading"}),Promise.all([Am(y),_m(y),Bm(y),Qm(y)]).then(([W,Pe,He,Ge])=>{le()&&g({status:"success",table:W.table,globalSecondaryIndexes:Pe.globalSecondaryIndexes??[],localSecondaryIndexes:Pe.localSecondaryIndexes??[],ttl:He.timeToLiveDescription,streams:Ge})}).catch(W=>{le()&&g({status:"error",message:W.message})})},[In,y,it]);m.useEffect(()=>{ht()},[ht]);const on=m.useMemo(()=>{const le=w.trim().toLowerCase();return le===""?an:an.filter(W=>W.tableName.toLowerCase().includes(le))},[an,w]),gn=m.useMemo(()=>{const le=u.status==="success"?u.items:[],W=C.trim().toLowerCase();return W===""?le:le.filter(Pe=>JSON.stringify(Pe).toLowerCase().includes(W))},[u,C]),hs=gn[Math.min(v,Math.max(gn.length-1,0))];if(it)return n.jsx(he,{title:"DynamoDB",children:n.jsx(F,{title:"DynamoDB is disabled",description:"Enable the DynamoDB service in devcloud config to inspect tables and items."})});function gs(le){S(le),N(0),A(""),ue({}),P(""),T(!1),ce(!1),$(""),_("")}function xs(le,W){ue(Pe=>({...Pe,[le]:W})),P("")}function Ee(){var He;if(!dt||u.status!=="success"){P("Load table items before using key lookup.");return}const W=(((He=dt.keySchema)==null?void 0:He.map(Ge=>Ge.AttributeName))??[]).map(Ge=>{var qe;return[Ge,((qe=J[Ge])==null?void 0:qe.trim())??""]}).filter(([,Ge])=>Ge!=="");if(W.length===0){P("Enter at least one key value.");return}const Pe=u.items.findIndex(Ge=>W.every(([qe,js])=>nh(Ge.item[qe])===js));if(Pe<0){P(`No loaded item matched ${W.map(([Ge])=>Ge).join(" / ")}.`);return}A(""),N(Pe),P(`Selected loaded item ${Pe+1}.`)}async function Ce(le,W){Qe(le),se(""),we("");try{const Pe=await W();we(Pe),Tt(),ln(),ht()}catch(Pe){se(Pe instanceof Error?Pe.message:"DynamoDB operation failed")}finally{Qe(void 0)}}function Fe(le){le.preventDefault();const W=ps(oe);if(!W.ok){se(W.message);return}const Pe=typeof W.value.TableName=="string"?W.value.TableName:"";if(Pe.trim()===""){se("CreateTable input requires TableName.");return}Ce("create-table",async()=>(await Em(W.value),S(Pe),`Created table ${Pe}.`))}function Ft(le){if(le.preventDefault(),!y){se("Select a table before putting an item.");return}const W=ps(Z);if(!W.ok){se(W.message);return}Ce("put-item",async()=>(await $m(y,W.value),`Put item in ${y}.`))}function En(le){if(le.preventDefault(),!y){se("Select a table before updating an item.");return}const W=ps(ee);if(!W.ok){se(W.message);return}Ce("update-item",async()=>(await Lm(y,W.value),`Updated item in ${y}.`))}function xn(le){if(le.preventDefault(),!y){se("Select a table before deleting an item.");return}if(!j){se("Acknowledge the DeleteItem destructive action before confirming the table name.");return}if(Ne!==y){se("DeleteItem confirmation must match the selected table name.");return}const W=ps(Se);if(!W.ok){se(W.message);return}Ce("delete-item",async()=>(await Pm(y,W.value,Ne),$(""),T(!1),`Deleted item from ${y}.`))}function Ua(le){if(le.preventDefault(),!y){se("Select a table before updating TTL.");return}const W=ps(z);if(!W.ok){se(W.message);return}Ce("ttl",async()=>(await Fm(y,W.value),`Updated TTL for ${y}.`))}function Zn(le){if(le.preventDefault(),!y){se("Select a table before deleting it.");return}if(!ne){se("Acknowledge the DeleteTable destructive action before confirming the table name.");return}if(U!==y){se("DeleteTable confirmation must match the selected table name.");return}const W=ps(be);if(!W.ok){se(W.message);return}Ce("delete-table",async()=>(await zm(y,W.value,U),_(""),ce(!1),S(void 0),`Deleted table ${y}.`))}function ys(le){if(!y)return{ok:!1,message:"Select a table before running Query or Scan."};const W=$i(ke),Pe=eh(It);if(!Pe.ok)return{ok:!1,message:Pe.message};const He={Limit:W},Ge=ut.trim();if(Ge!==""&&(He.IndexName=Ge),Pe.value&&(He.ExpressionAttributeValues=Pe.value),tt==="Query"){const qe=je.trim();if(qe==="")return{ok:!1,message:"Query requires KeyConditionExpression."};He.KeyConditionExpression=qe}else{const qe=at.trim();qe!==""&&(He.FilterExpression=qe)}return le&&(He.ExclusiveStartKey=le),{ok:!0,value:He}}async function Tn(le,W,Pe){var Ge;if(!y){Ue("Select a table before running Query or Scan.");return}const He=ys(le);if(!He.ok){Ue(He.message);return}Qe(tt.toLowerCase()),Ue(""),K(""),kt(void 0);try{const qe=tt==="Query"?await Mm(y,He.value):await Om(y,He.value);kt(qe),jt({pageHistory:Pe,pageIndex:W,selectedItemIndex:0}),hn(js=>{var Js;return[ih({count:qe.Count??((Js=qe.Items)==null?void 0:Js.length)??0,hasMore:!!qe.LastEvaluatedKey,indexName:ut,limit:$i(ke),mode:tt,page:W+1,scannedCount:qe.ScannedCount??0,tableName:y,usesExpressionAttributeValues:!!He.value.ExpressionAttributeValues}),...js].slice(0,zi)}),K(`${tt} page ${W+1} returned ${qe.Count??((Ge=qe.Items)==null?void 0:Ge.length)??0} item(s).`)}catch(qe){Ue(qe instanceof Error?qe.message:`${tt} failed`)}finally{Qe(void 0)}}async function Ks(le){le.preventDefault(),await Tn(void 0,0,[void 0])}function qa(){if(!(Le!=null&&Le.LastEvaluatedKey))return;const le=_e.pageIndex+1,W=_e.pageHistory.slice(0,le);W[le]=Le.LastEvaluatedKey,Tn(Le.LastEvaluatedKey,le,W)}function Va(){if(_e.pageIndex<=0)return;const le=_e.pageIndex-1,W=_e.pageHistory[le];Tn(W,le,_e.pageHistory)}return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(he,{title:"Tables",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter DynamoDB tables",onChange:le=>L(le.target.value),placeholder:"table name",type:"search",value:w})]}),n.jsx(ae,{onClick:Tt,children:"Refresh"})]}),l.status==="loading"?n.jsx(F,{title:"Loading tables",description:"Reading local DynamoDB table metadata."}):null,l.status==="error"?n.jsx(F,{title:"DynamoDB tables unavailable",description:l.message,actionLabel:"Retry",onAction:Tt}):null,l.status==="success"?n.jsx(gh,{tables:on,activeTableName:y,onSelectTable:gs}):null]}),n.jsxs(he,{title:"Items",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:dt?`${gn.length} shown / ${dt.itemCount} reported`:"Select a table"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter DynamoDB items",disabled:!dt,onChange:le=>{N(0),A(le.target.value)},placeholder:"attribute value",type:"search",value:C})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Limit"}),n.jsx("input",{"aria-label":"Limit DynamoDB items",disabled:!dt,inputMode:"numeric",onChange:le=>E(le.target.value),value:I})]}),n.jsx(ae,{disabled:!dt,onClick:ln,children:"Refresh"})]}),n.jsx(ch,{activeIndex:v,items:gn,itemsState:u,onSelectIndex:N,tableName:y}),n.jsx(uh,{message:q,onFind:Ee,onUpdateValue:xs,table:dt,values:J})]}),n.jsxs(he,{title:"Operations",children:[De?n.jsx("p",{className:"operation-message error",children:De}):null,ge?n.jsx("p",{className:"operation-message success",children:ge}):null,n.jsx(dh,{activeTableName:y,busyOperation:Ie,createTableJSON:oe,deleteItemConfirmation:Ne,deleteItemAcknowledged:j,deleteItemJSON:Se,deleteTableConfirmation:U,deleteTableAcknowledged:ne,deleteTableJSON:be,onCreateTable:Fe,onDeleteItem:xn,onDeleteTable:Zn,onPutItem:Ft,onUpdateItem:En,onUpdateTTL:Ua,putItemJSON:Z,setCreateTableJSON:pe,setDeleteItemConfirmation:$,setDeleteItemAcknowledged:T,setDeleteItemJSON:me,setDeleteTableConfirmation:_,setDeleteTableAcknowledged:ce,setDeleteTableJSON:$e,setPutItemJSON:Re,setTTLJSON:H,setUpdateItemJSON:fe,ttlJSON:z,updateItemJSON:ee})]}),n.jsxs(he,{title:"Query / Scan",children:[n.jsx(fh,{activeTableName:y,busyOperation:Ie,expressionAttributeValues:It,filterExpression:at,indexName:ut,keyConditionExpression:je,limit:ke,message:Et,mode:tt,onNextPage:qa,onPreviousPage:Va,onSubmit:le=>{Ks(le)},pageIndex:_e.pageIndex,result:Le,selectedItemIndex:_e.selectedItemIndex,setExpressionAttributeValues:Ze,setFilterExpression:yt,setIndexName:de,setKeyConditionExpression:rt,setLimit:Y,setMode:Dt,setSelectedItemIndex:le=>jt(W=>({...W,selectedItemIndex:le})),error:Be}),n.jsx(ph,{operations:Ae,onClear:()=>hn([])})]}),n.jsx(he,{title:"Inspector",children:n.jsx(mh,{detailState:p,item:hs,onRefreshDetail:ht,status:l.status==="success"?l.statusPayload:void 0,table:dt})})]})}async function yh(){return V("/api/bigquery/status")}async function jh(){return V("/api/bigquery/projects")}async function vh(r){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets`)}async function bh(r,l,i,u=100){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables/${encodeURIComponent(i)}/rows?limit=${encodeURIComponent(String(u))}`)}async function Sh(r){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/jobs`)}async function Nh(r,l){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/jobs/${encodeURIComponent(l)}`)}async function wh(r,l){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/queries`,{method:"POST",body:l,timeoutMs:15e3})}async function kh(r,l){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets`,{method:"POST",body:l})}async function Ch(r,l,i){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables`,{method:"POST",body:i})}async function Rh(r,l,i,u){const d=u?"?deleteContents=true":"";return mn(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}${d}`,{method:"DELETE",body:{confirmation:i}})}async function Dh(r,l,i,u){return mn(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables/${encodeURIComponent(i)}`,{method:"DELETE",body:{confirmation:u}})}async function Ih(r,l,i,u){return V(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables/${encodeURIComponent(i)}/insertAll`,{method:"POST",body:u})}function Eh({activeDatasetId:r,activeTableId:l,datasets:i,onSelectDataset:u,onSelectTable:d}){return i.length===0?n.jsx(F,{title:"No datasets",description:"Datasets created through the BigQuery API will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"BigQuery datasets",children:i.map(p=>n.jsxs("section",{className:p.datasetId===r?"dynamodb-table-row active":"dynamodb-table-row",children:[n.jsxs("button",{className:"object-select",onClick:()=>u(p),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:p.datasetId}),n.jsx("span",{className:"count-pill",children:p.tables.length})]}),n.jsxs("span",{className:"table-row-meta",children:[p.location||"default location"," dataset"]})]}),n.jsxs("span",{className:"table-row-tags",children:[p.tables.length===0?n.jsx("span",{children:"no tables"}):null,p.tables.map(g=>n.jsxs("button",{className:"attribute-chip",onClick:()=>d(g),type:"button",children:[g.tableId===l?">":""," ",g.tableId," (",g.numRows,")"]},g.tableId))]})]},p.datasetId))})}function Th(r){return{status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:19050",project:"devcloud",location:"US",authMode:"relaxed",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/bigquery",datasetCount:0,jobCount:0}}function $h(r){const l=r.fields??[];return l.length===0?"No schema fields":l.map(i=>`${i.name} ${i.type??"STRING"} ${i.mode??"NULLABLE"}`).join(" / ")}function Lh(r){return[...r].sort((l,i)=>Ru(i)-Ru(l)).slice(0,8)}function Ru(r){var u,d;const l=(d=(u=r.job)==null?void 0:u.statistics)==null?void 0:d.creationTime;if(!l)return 0;const i=Number.parseInt(l,10);return Number.isFinite(i)?i:0}function Ph(r){var p,g;const l=(g=(p=r.job)==null?void 0:p.statistics)==null?void 0:g.query,i=(l==null?void 0:l.totalRows)??"0",u=l!=null&&l.dryRun?"dry run":"executed",d=l!=null&&l.cacheHit?"cache hit":"cache miss";return`${u} / ${i} rows / ${d}`}function Mh(r){const l=r.job??r,i=JSON.parse(JSON.stringify(l)),u=i.configuration;return Vs(u)&&Vs(u.query)&&delete u.query.queryParameters,i}function Oh(r,l){return l?l.f.reduce((i,u,d)=>{var p;return i[((p=r[d])==null?void 0:p.name)??`column_${d+1}`]=u.v,i},{}):{}}function Du(r,l){try{const i=JSON.parse(r);return Vs(i)?i:new Error(`${l} must be a JSON object.`)}catch{return new Error(`${l} has a JSON validation error.`)}}function Ah(r){const l=r.split(` +`).map(u=>u.trim()).filter(Boolean).map(u=>{const[d,p="STRING",g]=u.split(":").map(y=>y.trim());return d?{name:d,type:p||"STRING",mode:g||void 0}:new Error("Schema field names are required.")}),i=l.find(u=>u instanceof Error);return i||{fields:l}}function _h(r,l){let i;try{i=JSON.parse(r)}catch{return new Error("Row JSON has a JSON validation error.")}if(Array.isArray(i)){const u=[];for(const[d,p]of i.entries()){if(!Vs(p))return new Error(`Row ${d+1} must be a JSON object.`);u.push({json:p})}return u}return Vs(i)?[{insertId:l||void 0,json:i}]:new Error("Row JSON must be a JSON object or an array of JSON objects.")}function Iu(r,l){let i=r;for(const u of l){if(!Vs(i))return;i=i[u]}return typeof i=="string"&&i.trim()!==""?i.trim():void 0}function Vs(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}function Wu(r){return r==null?"null":typeof r=="object"?JSON.stringify(r):String(r)}function Bh({dataset:r,jobs:l,project:i,row:u,status:d,table:p}){const[g,y]=m.useState(),[S,v]=m.useState({status:"idle"}),N=m.useMemo(()=>Lh(l),[l]);return m.useEffect(()=>{if(l.length===0){y(void 0),v({status:"idle"});return}y(w=>w&&l.some(L=>L.jobId===w)?w:l[0].jobId)},[l]),m.useEffect(()=>{if(!g){v({status:"idle"});return}v({status:"loading",jobId:g}),Nh(i,g).then(w=>{v({status:"success",job:w.job})}).catch(w=>{v({status:"error",message:w.message})})},[i,g]),r?n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Catalog"}),n.jsx("h3",{children:p?`${r.datasetId}.${p.tableId}`:r.datasetId}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Project"}),n.jsx("dd",{children:i})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(d==null?void 0:d.endpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Location"}),n.jsx("dd",{children:(d==null?void 0:d.location)??r.location??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Rows"}),n.jsx("dd",{children:(p==null?void 0:p.numRows)??"select a table"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Schema"}),n.jsx("dd",{children:p?$h(p.schema):"select a table"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Jobs"}),n.jsx("dd",{children:l.length===0?"none":l.map(w=>`${w.jobId} ${w.state}`).join(", ")})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Recent query metadata"}),N.length===0?n.jsx("p",{className:"inspector-muted",children:"Query jobs will appear after running SQL."}):n.jsx("div",{className:"dynamodb-table-list compact-list","aria-label":"BigQuery recent query jobs",children:N.map(w=>n.jsxs("button",{className:w.jobId===g?"object-select active":"object-select",onClick:()=>y(w.jobId),type:"button",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:w.jobId}),n.jsx("span",{className:"count-pill",children:w.state})]}),n.jsx("span",{className:"table-row-meta",children:Ph(w)})]},w.jobId))})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected job JSON"}),S.status==="loading"?n.jsx("p",{className:"inspector-muted",children:"Loading job detail."}):null,S.status==="error"?n.jsx("p",{className:"operation-message error",children:S.message}):null,S.status==="success"?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(Mh(S.job),null,2)}):null]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected row"}),u?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(u.json,null,2)}):n.jsx("p",{className:"inspector-muted",children:"Select a row to inspect JSON."})]})]}):n.jsx(F,{title:"Inspector",description:"Dataset, table schema, selected row, and jobs will appear here."})}function Fh({activeDatasetId:r,activeTableId:l,disabled:i,onMutationSuccess:u,projectId:d}){var Et;const[p,g]=m.useState(""),[y,S]=m.useState("US"),[v,N]=m.useState(""),[w,L]=m.useState(!1),[C,A]=m.useState(`{ + "datasetReference": { + "datasetId": "dashboard_ops" + }, + "location": "US" +}`),[I,E]=m.useState(""),[J,ue]=m.useState(""),[q,P]=m.useState(""),[oe,pe]=m.useState(`event_id:STRING:REQUIRED +created_at:TIMESTAMP +payload:STRING`),[Z,Re]=m.useState(!1),[ee,fe]=m.useState(`{ + "tableReference": { + "tableId": "events" + }, + "schema": { + "fields": [ + { "name": "event_id", "type": "STRING", "mode": "REQUIRED" } + ] + } +}`),[Se,me]=m.useState(""),[z,H]=m.useState(""),[be,$e]=m.useState(""),[Ne,$]=m.useState(`{ + "event_id": "evt-1", + "payload": "local test" +}`),[U,_]=m.useState(""),[j,T]=m.useState(!1),[ne,ce]=m.useState(!1),[ge,we]=m.useState(""),[De,se]=m.useState(""),[Ie,Qe]=m.useState(""),[tt,Dt]=m.useState(!1),[ut,de]=m.useState(""),[ke,Y]=m.useState({status:"idle"});m.useEffect(()=>{r&&(E(K=>K||r),me(K=>K||r),_(K=>K||r),se(K=>K||r))},[r]),m.useEffect(()=>{l&&(H(K=>K||l),Qe(K=>K||l))},[l]);const je=i||ke.status==="running";function rt(K){K.preventDefault();const Be=w?Du(C,"Dataset raw JSON"):{datasetReference:{datasetId:p.trim()},location:y.trim()||void 0,description:v.trim()||void 0};if(Be instanceof Error){Y({status:"error",message:Be.message});return}if(!Iu(Be,["datasetReference","datasetId"])){Y({status:"error",message:"Dataset ID is required."});return}Y({status:"running",label:"Creating dataset"}),kh(d,Be).then(Le=>{Y({status:"success",message:`Created dataset ${Le.datasetReference.datasetId}.`}),E(Le.datasetReference.datasetId),me(Le.datasetReference.datasetId),u()}).catch(Le=>{Y({status:"error",message:Le.message})})}function at(K){K.preventDefault();const Be=I.trim()||r;if(!Be){Y({status:"error",message:"Choose a dataset before creating a table."});return}const Ue=Z?void 0:Ah(oe);if(Ue instanceof Error){Y({status:"error",message:Ue.message});return}const Le=Z?Du(ee,"Table raw JSON"):{tableReference:{tableId:J.trim()},schema:Ue,description:q.trim()||void 0};if(Le instanceof Error){Y({status:"error",message:Le.message});return}if(!Iu(Le,["tableReference","tableId"])){Y({status:"error",message:"Table ID is required."});return}Y({status:"running",label:"Creating table"}),Ch(d,Be,Le).then(_e=>{Y({status:"success",message:`Created table ${Be}.${_e.tableReference.tableId}.`}),me(Be),H(_e.tableReference.tableId),u()}).catch(_e=>{Y({status:"error",message:_e.message})})}function yt(K){K.preventDefault();const Be=Se.trim()||r,Ue=z.trim()||l;if(!Be||!Ue){Y({status:"error",message:"Choose a dataset and table before inserting rows."});return}const Le=_h(Ne,be.trim());if(Le instanceof Error){Y({status:"error",message:Le.message});return}Y({status:"running",label:"Inserting rows"}),Ih(d,Be,Ue,{rows:Le}).then(kt=>{var jt;const _e=(jt=kt.insertErrors)==null?void 0:jt.map(Ae=>{const hn=Ae.errors.map(it=>it.message).join("; ");return`row ${Ae.index}: ${hn}`});Y({status:"success",message:_e!=null&&_e.length?"Insert completed with partial insert errors.":"Inserted row data.",insertErrors:_e}),u()}).catch(kt=>{Y({status:"error",message:kt.message})})}function It(K){K.preventDefault();const Be=U.trim();if(!Be){Y({status:"error",message:"Choose a dataset to delete."});return}if(!ne){Y({status:"error",message:"Acknowledge the delete-dataset destructive action before confirming the dataset ID."});return}if(ge!==Be){Y({status:"error",message:"Delete-dataset confirmation must match the dataset ID."});return}Y({status:"running",label:"Deleting dataset"}),Rh(d,Be,ge,j).then(()=>{Y({status:"success",message:`Deleted dataset ${Be}.`}),we(""),ce(!1),u()}).catch(Ue=>{Y({status:"error",message:Ue.message})})}function Ze(K){K.preventDefault();const Be=De.trim(),Ue=Ie.trim();if(!Be||!Ue){Y({status:"error",message:"Choose a dataset and table to delete."});return}if(!tt){Y({status:"error",message:"Acknowledge the delete-table destructive action before confirming the table ID."});return}if(ut!==Ue){Y({status:"error",message:"Delete-table confirmation must match the table ID."});return}Y({status:"running",label:"Deleting table"}),Dh(d,Be,Ue,ut).then(()=>{Y({status:"success",message:`Deleted table ${Be}.${Ue}.`}),de(""),Dt(!1),u()}).catch(Le=>{Y({status:"error",message:Le.message})})}return n.jsxs("section",{className:"redshift-query-runner","aria-label":"BigQuery management controls",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Local management"}),n.jsx("span",{className:"toolbar-count",children:"datasets.insert / tables.insert / tabledata.insertAll"})]}),i?n.jsx("p",{className:"inspector-muted",children:"BigQuery management controls are disabled until the service is active."}):null,ke.status==="running"?n.jsxs("p",{className:"operation-message",children:[ke.label,"."]}):null,ke.status==="success"?n.jsxs("div",{className:"operation-message success",children:[ke.message,(Et=ke.insertErrors)!=null&&Et.length?n.jsx("ul",{children:ke.insertErrors.map(K=>n.jsx("li",{children:K},K))}):null]}):null,ke.status==="error"?n.jsx("p",{className:"operation-message error",children:ke.message}):null,n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:rt,children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Create dataset"}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"raw JSON"}),n.jsx("input",{"aria-label":"Use raw JSON for BigQuery dataset",checked:w,disabled:je,onChange:K=>L(K.target.checked),type:"checkbox"})]})]}),w?n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Dataset raw JSON"}),n.jsx("textarea",{"aria-label":"BigQuery dataset raw JSON",disabled:je,onChange:K=>A(K.target.value),rows:7,spellCheck:!1,value:C})]}):n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery dataset ID",disabled:je,onChange:K=>g(K.target.value),placeholder:"dashboard_ops",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Location"}),n.jsx("input",{"aria-label":"BigQuery dataset location",disabled:je,onChange:K=>S(K.target.value),placeholder:"US",value:y})]}),n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Description"}),n.jsx("input",{"aria-label":"BigQuery dataset description",disabled:je,onChange:K=>N(K.target.value),placeholder:"local dashboard dataset",value:v})]})]}),n.jsx(ae,{disabled:je,type:"submit",children:"Create dataset"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:at,children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Create table"}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"raw JSON"}),n.jsx("input",{"aria-label":"Use raw JSON for BigQuery table",checked:Z,disabled:je,onChange:K=>Re(K.target.checked),type:"checkbox"})]})]}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery table dataset ID",disabled:je,onChange:K=>E(K.target.value),placeholder:r??"dataset",value:I})]}),Z?null:n.jsxs(n.Fragment,{children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Table ID"}),n.jsx("input",{"aria-label":"BigQuery table ID",disabled:je,onChange:K=>ue(K.target.value),placeholder:"events",value:J})]}),n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Description"}),n.jsx("input",{"aria-label":"BigQuery table description",disabled:je,onChange:K=>P(K.target.value),placeholder:"local events table",value:q})]})]})]}),Z?n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Table raw JSON"}),n.jsx("textarea",{"aria-label":"BigQuery table raw JSON",disabled:je,onChange:K=>fe(K.target.value),rows:9,spellCheck:!1,value:ee})]}):n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Schema fields"}),n.jsx("textarea",{"aria-label":"BigQuery table schema fields",disabled:je,onChange:K=>pe(K.target.value),rows:4,spellCheck:!1,value:oe})]}),n.jsx(ae,{disabled:je,type:"submit",children:"Create table"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:yt,children:[n.jsx("span",{className:"inspector-label",children:"Insert row"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery insert dataset ID",disabled:je,onChange:K=>me(K.target.value),placeholder:r??"dataset",value:Se})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Table ID"}),n.jsx("input",{"aria-label":"BigQuery insert table ID",disabled:je,onChange:K=>H(K.target.value),placeholder:l??"table",value:z})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Insert ID"}),n.jsx("input",{"aria-label":"BigQuery insert ID",disabled:je,onChange:K=>$e(K.target.value),placeholder:"optional",value:be})]})]}),n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Row JSON"}),n.jsx("textarea",{"aria-label":"BigQuery insert row JSON",disabled:je,onChange:K=>$(K.target.value),rows:6,spellCheck:!1,value:Ne})]}),n.jsx("p",{className:"inspector-muted",children:"JSON validation runs locally before tabledata.insertAll. Row payloads are not written to logs."}),n.jsx(ae,{disabled:je,type:"submit",children:"Insert row"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:It,children:[n.jsx("span",{className:"inspector-label",children:"Delete dataset"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery delete dataset ID",disabled:je,onChange:K=>_(K.target.value),placeholder:r??"dataset",value:U})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Delete contents"}),n.jsx("input",{"aria-label":"Delete BigQuery dataset contents",checked:j,disabled:je,onChange:K=>T(K.target.checked),type:"checkbox"})]})]}),n.jsxs("label",{className:"destructive-confirmation",children:[n.jsx("input",{"aria-label":"I understand this deletes the dataset and, if selected, its tables.",checked:ne,disabled:je,onChange:K=>ce(K.target.checked),type:"checkbox"}),n.jsxs("span",{children:["Step 1: I understand this deletes the dataset",j?" and all of its tables":"","."]})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:["Step 2: Type ",U||"dataset id"," to delete dataset"]}),n.jsx("input",{"aria-label":"BigQuery delete dataset confirmation",disabled:je,onChange:K=>we(K.target.value),value:ge})]}),n.jsx(ae,{className:"danger",disabled:je||!ne||ge!==U.trim()||U.trim()==="",type:"submit",children:"Delete dataset"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:Ze,children:[n.jsx("span",{className:"inspector-label",children:"Delete table"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery delete table dataset ID",disabled:je,onChange:K=>se(K.target.value),placeholder:r??"dataset",value:De})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Table ID"}),n.jsx("input",{"aria-label":"BigQuery delete table ID",disabled:je,onChange:K=>Qe(K.target.value),placeholder:l??"table",value:Ie})]})]}),n.jsxs("label",{className:"destructive-confirmation",children:[n.jsx("input",{"aria-label":"I understand this deletes the table and its local rows.",checked:tt,disabled:je,onChange:K=>Dt(K.target.checked),type:"checkbox"}),n.jsx("span",{children:"Step 1: I understand this deletes the table and its local rows."})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:["Step 2: Type ",Ie||"table id"," to delete table"]}),n.jsx("input",{"aria-label":"BigQuery delete table confirmation",disabled:je,onChange:K=>de(K.target.value),value:ut})]}),n.jsx(ae,{className:"danger",disabled:je||!tt||ut!==Ie.trim()||Ie.trim()==="",type:"submit",children:"Delete table"})]})]})}function zh({disabled:r,projectId:l,onQuerySuccess:i}){const[u,d]=m.useState("SELECT * FROM `analytics.people` LIMIT 10"),[p,g]=m.useState("25"),[y,S]=m.useState(!1),[v,N]=m.useState({status:"idle"}),[w,L]=m.useState(0),C=v.status==="success"?v.response:void 0,A=(C==null?void 0:C.rows)??[],I=A[Math.min(w,Math.max(A.length-1,0))];function E(J){J.preventDefault();const ue=u.trim();if(ue===""){N({status:"error",message:"Query text is required."});return}const q=Number.parseInt(p,10);if(!Number.isInteger(q)||q<1||q>1e3){N({status:"error",message:"Max results must be between 1 and 1000."});return}N({status:"running"}),wh(l,{query:ue,maxResults:q,dryRun:y,useLegacySql:!1}).then(P=>{L(0),N({status:"success",response:P}),i()}).catch(P=>{N({status:"error",message:P.message})})}return n.jsxs("section",{className:"redshift-query-runner","aria-label":"BigQuery SQL query runner",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"SQL query runner"}),n.jsx("span",{className:"toolbar-count",children:C?`${C.totalRows} rows / ${C.jobReference.jobId}`:"useLegacySql=false"})]}),n.jsxs("form",{className:"redshift-query-form",onSubmit:E,children:[n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Query text"}),n.jsx("textarea",{"aria-label":"BigQuery SQL query text",disabled:r||v.status==="running",onChange:J=>d(J.target.value),rows:5,spellCheck:!1,value:u})]}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Max results"}),n.jsx("input",{"aria-label":"BigQuery query max results",disabled:r||v.status==="running",max:1e3,min:1,onChange:J=>g(J.target.value),type:"number",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Dry run"}),n.jsx("input",{"aria-label":"BigQuery dry run",checked:y,disabled:r||v.status==="running",onChange:J=>S(J.target.checked),type:"checkbox"})]}),n.jsx(ae,{disabled:r||v.status==="running",type:"submit",children:v.status==="running"?"Running":y?"Dry run":"Run query"})]})]}),r?n.jsx("p",{className:"inspector-muted",children:"BigQuery query controls are disabled until the service is active."}):null,v.status==="error"?n.jsx("p",{className:"operation-message error",children:v.message}):null,C?n.jsx(Qh,{response:C,selectedRow:I,selectedRowIndex:w,onSelectRow:L}):null]})}function Qh({onSelectRow:r,response:l,selectedRow:i,selectedRowIndex:u}){var S,v;const d=((S=l.schema)==null?void 0:S.fields)??[],p=l.rows??[],g=Math.max(d.length,((v=p[0])==null?void 0:v.f.length)??0),y=Array.from({length:g},(N,w)=>{var L,C;return{name:((L=d[w])==null?void 0:L.name)??`column_${w+1}`,type:((C=d[w])==null?void 0:C.type)??"UNKNOWN"}});return n.jsxs("div",{className:"redshift-query-result",children:[n.jsxs("div",{className:"attribute-preview","aria-label":"BigQuery query summary",children:[n.jsx("span",{className:"attribute-chip",children:l.jobComplete?"complete":"running"}),n.jsx("span",{className:"attribute-chip",children:l.cacheHit?"cache hit":"cache miss"}),n.jsxs("span",{className:"attribute-chip",children:[l.totalRows," rows"]}),n.jsx("span",{className:"attribute-chip",children:l.jobReference.jobId})]}),p.length===0?n.jsx("p",{className:"inspector-muted",children:"Query completed without loaded result rows."}):n.jsxs("div",{className:"dynamodb-query-result-grid",children:[n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsx("tr",{children:y.map(N=>n.jsxs("th",{scope:"col",children:[N.name,n.jsx("span",{className:"query-column-type",children:N.type})]},N.name))})}),n.jsx("tbody",{children:p.map((N,w)=>n.jsx("tr",{className:w===u?"item-row active":"item-row",onClick:()=>r(w),children:y.map((L,C)=>{var A;return n.jsx("td",{children:Wu((A=N.f[C])==null?void 0:A.v)},L.name)})},`${l.jobReference.jobId}-${w}`))})]})}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected result JSON"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(Oh(d,i),null,2)})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Job reference"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(l.jobReference,null,2)})]})]})}function Uh({activeIndex:r,rows:l,rowsState:i,tableName:u,onSelectIndex:d}){return u?i.status==="loading"?n.jsx(F,{title:"Loading rows",description:`Reading rows from ${u}.`}):i.status==="error"?n.jsx(F,{title:"BigQuery rows unavailable",description:i.message}):l.length===0?n.jsx(F,{title:"No rows",description:`No loaded rows in ${u} match the current filter.`}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Insert ID"}),n.jsx("th",{scope:"col",children:"Values"}),n.jsx("th",{scope:"col",children:"Inserted"})]})}),n.jsx("tbody",{children:l.map((p,g)=>n.jsxs("tr",{className:g===r?"item-row active":"item-row",onClick:()=>d(g),children:[n.jsx("td",{children:n.jsx("code",{children:p.insertId??g+1})}),n.jsx("td",{children:n.jsx(qh,{item:p.json})}),n.jsx("td",{children:p.insertedAt??"unknown"})]},`${p.insertId??"row"}-${g}`))})]})}):n.jsx(F,{title:"No table selected",description:"Choose a BigQuery table to inspect stored rows."})}function qh({item:r}){const l=Object.entries(r).slice(0,6);return l.length===0?n.jsx("span",{className:"service-status",children:"empty row"}):n.jsx("span",{className:"attribute-preview",children:l.map(([i,u])=>n.jsxs("span",{className:"attribute-chip",children:[i,": ",Wu(u)]},i))})}function Vh({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState(),[y,S]=m.useState(),[v,N]=m.useState(0),[w,L]=m.useState(""),[C,A]=m.useState(""),I=(r==null?void 0:r.status)==="disabled",E=wt(),J=m.useCallback(()=>{const z=E();if(I){i({status:"success",statusPayload:Th(r),datasets:[],jobs:[]}),d({status:"idle"}),g(void 0),S(void 0);return}i({status:"loading"}),Promise.all([yh(),jh()]).then(async([H,be])=>{const $e=be.projects[0],Ne=($e==null?void 0:$e.projectId)??H.project,[{datasets:$},{jobs:U}]=await Promise.all([vh(Ne),Sh(Ne)]);z()&&(i({status:"success",statusPayload:H,datasets:$,jobs:U}),g(_=>{var j;return _&&$.some(T=>T.datasetId===_)?_:(j=$[0])==null?void 0:j.datasetId}),S(_=>{var T;const j=$.find(ne=>ne.datasetId===p)??$[0];return _&&(j!=null&&j.tables.some(ne=>ne.tableId===_))?_:(T=j==null?void 0:j.tables[0])==null?void 0:T.tableId}))}).catch(H=>{z()&&i({status:"error",message:H.message})})},[E,p,I,r]);m.useEffect(()=>{J()},[J]),Vt({topics:["bigquery"],onEvent:J,onResync:J,enabled:!I});const ue=l.status==="success"?l.datasets:[],q=ue.find(z=>z.datasetId===p),P=q==null?void 0:q.tables.find(z=>z.tableId===y),oe=l.status==="success"?l.statusPayload.project:r==null?void 0:r.endpoint,pe=wt(),Z=m.useCallback(()=>{const z=pe();if(!q||!P||l.status!=="success"||I){d({status:"idle"});return}d({status:"loading"}),bh(l.statusPayload.project,q.datasetId,P.tableId).then(({rows:H})=>{z()&&(N(0),d({status:"success",rows:H}))}).catch(H=>{z()&&d({status:"error",message:H.message})})},[pe,q,P,l,I]);m.useEffect(()=>{Z()},[Z]);const Re=m.useMemo(()=>{const z=w.trim().toLowerCase();return z===""?ue:ue.filter(H=>H.datasetId.toLowerCase().includes(z))},[ue,w]),ee=m.useMemo(()=>{const z=u.status==="success"?u.rows:[],H=C.trim().toLowerCase();return H===""?z:z.filter(be=>JSON.stringify(be).toLowerCase().includes(H))},[u,C]),fe=ee[Math.min(v,Math.max(ee.length-1,0))];if(I)return n.jsx(he,{title:"BigQuery",children:n.jsx(F,{title:"BigQuery is disabled",description:"Enable the BigQuery service in devcloud config to inspect projects, datasets, tables, rows, and jobs."})});function Se(z){var H;g(z.datasetId),S((H=z.tables[0])==null?void 0:H.tableId),N(0),A("")}function me(z){S(z.tableId),N(0),A("")}return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(he,{title:"Datasets",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter BigQuery datasets",onChange:z=>L(z.target.value),placeholder:"dataset id",type:"search",value:w})]}),n.jsx(ae,{onClick:J,children:"Refresh"})]}),l.status==="loading"?n.jsx(F,{title:"Loading datasets",description:"Reading local BigQuery catalog metadata."}):null,l.status==="error"?n.jsx(F,{title:"BigQuery catalog unavailable",description:l.message,actionLabel:"Retry",onAction:J}):null,l.status==="success"?n.jsx(Eh,{activeDatasetId:p,activeTableId:y,datasets:Re,onSelectDataset:Se,onSelectTable:me}):null]}),n.jsxs(he,{title:"Rows",children:[n.jsx(zh,{disabled:I||l.status!=="success",projectId:oe??"devcloud",onQuerySuccess:J}),n.jsx(Fh,{activeDatasetId:q==null?void 0:q.datasetId,activeTableId:P==null?void 0:P.tableId,disabled:I||l.status!=="success",projectId:oe??"devcloud",onMutationSuccess:()=>{J(),Z()}}),n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:P?`${ee.length} shown / ${P.numRows} reported`:"Select a table"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter BigQuery rows",disabled:!P,onChange:z=>{N(0),A(z.target.value)},placeholder:"row value",type:"search",value:C})]}),n.jsx(ae,{disabled:!P,onClick:Z,children:"Refresh"})]}),n.jsx(Uh,{activeIndex:v,rows:ee,rowsState:u,tableName:P==null?void 0:P.tableId,onSelectIndex:N})]}),n.jsx(he,{title:"Inspector",children:n.jsx(Bh,{dataset:q,jobs:l.status==="success"?l.jobs:[],project:oe??"unknown",row:fe,status:l.status==="success"?l.statusPayload:void 0,table:P})})]})}async function Kh(){return V("/api/redshift/status")}async function Jh(){return V("/api/redshift/clusters")}async function Hh(){return V("/api/redshift/catalog")}async function Wh(){return V("/api/redshift/statements")}async function Gh(r){return V("/api/redshift/query",{method:"POST",body:r,timeoutMs:15e3})}function Yh({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(""),y=(r==null?void 0:r.status)==="disabled",S=wt(),v=m.useCallback(()=>{const I=S();if(y){i({status:"success",statusPayload:sg(r),clusters:[],catalog:{database:"dev",schemas:[],tables:[],columns:[]},statements:[]}),d(void 0);return}i({status:"loading"}),Promise.all([Kh(),Jh(),Hh(),Wh()]).then(([E,J,ue,q])=>{if(!I())return;const P=ue.catalog.tables;i({status:"success",statusPayload:E,clusters:J.clusters,catalog:ue.catalog,statements:q.statements}),d(oe=>oe&&P.some(pe=>Us(pe)===oe)?oe:Us(P[0]))}).catch(E=>{I()&&i({status:"error",message:E.message})})},[S,y,r]);m.useEffect(()=>{v()},[v]),Vt({topics:["redshift"],onEvent:v,onResync:v,enabled:!y});const N=l.status==="success"?l.catalog:void 0,w=(N==null?void 0:N.tables)??[],L=m.useMemo(()=>{const I=p.trim().toLowerCase();return I===""?w:w.filter(E=>`${E.schema}.${E.name}`.toLowerCase().includes(I))},[p,w]),C=w.find(I=>Us(I)===u),A=(N==null?void 0:N.columns.filter(I=>C&&I.schema===C.schema&&I.table===C.name))??[];return y?n.jsx(he,{title:"Redshift",children:n.jsx(F,{title:"Redshift is disabled",description:"Enable the Redshift service in devcloud config to inspect clusters, catalog metadata, and statements."})}):n.jsxs("div",{className:"redshift-workspace",children:[n.jsxs(he,{title:"Clusters",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.clusters.length} clusters`:"Loading"}),n.jsx(ae,{onClick:v,children:"Refresh"})]}),l.status==="loading"?n.jsx(F,{title:"Loading Redshift",description:"Reading local Redshift metadata."}):null,l.status==="error"?n.jsx(F,{title:"Redshift unavailable",description:l.message,actionLabel:"Retry",onAction:v}):null,l.status==="success"?n.jsx(Xh,{clusters:l.clusters,status:l.statusPayload}):null]}),n.jsxs(he,{title:"Catalog",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:`${L.length} shown / ${w.length} tables`}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter Redshift tables",onChange:I=>g(I.target.value),placeholder:"schema or table",type:"search",value:p})]})]}),n.jsx(Zh,{activeTableKey:u,tables:L,onSelectTable:I=>d(Us(I))})]}),n.jsx(he,{title:"Inspector",children:n.jsx(eg,{columns:A,onQuerySuccess:v,statements:l.status==="success"?l.statements:[],status:l.status==="success"?l.statusPayload:void 0,table:C})})]})}function Xh({clusters:r,status:l}){return r.length===0?n.jsx(F,{title:"No clusters",description:"Local Redshift cluster metadata will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Redshift clusters",children:r.map(i=>n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:i.clusterIdentifier}),n.jsx("span",{className:"count-pill",children:i.clusterStatus})]}),n.jsxs("span",{className:"table-row-meta",children:[i.databaseName," on ",i.endpoint.address,":",i.endpoint.port]}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:i.nodeType}),n.jsxs("span",{children:[i.numberOfNodes," node"]}),n.jsx("span",{children:l.region})]})]},i.clusterIdentifier))})}function Zh({activeTableKey:r,tables:l,onSelectTable:i}){return l.length===0?n.jsx(F,{title:"No tables",description:"Tables created through Redshift SQL or Data API clients will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Redshift tables",children:l.map(u=>n.jsxs("button",{className:Us(u)===r?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>i(u),type:"button",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:u.name}),n.jsx("span",{className:"count-pill",children:u.rowCount})]}),n.jsxs("span",{className:"table-row-meta",children:[u.schema," schema"]}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:[u.columnCount," columns"]}),u.distKey?n.jsxs("span",{children:["distkey ",u.distKey]}):null,u.sortKeys.length>0?n.jsxs("span",{children:["sortkey ",u.sortKeys.join(", ")]}):null]})]},Us(u)))})}function eg({columns:r,onQuerySuccess:l,statements:i,status:u,table:d}){return n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsx(tg,{onQuerySuccess:l}),d?null:n.jsx(F,{title:"Inspector",description:"Table columns and recent statements will appear here."}),d?n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Table"}),n.jsx("h3",{children:`${d.schema}.${d.name}`}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"SQL endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(u==null?void 0:u.sqlEndpoint)??"127.0.0.1:15439"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"API endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(u==null?void 0:u.apiEndpoint)??"http://127.0.0.1:19099"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Rows"}),n.jsx("dd",{children:d.rowCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Distribution"}),n.jsxs("dd",{children:[d.distStyle||"auto"," ",d.distKey?`on ${d.distKey}`:""]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Sort keys"}),n.jsx("dd",{children:d.sortKeys.length>0?d.sortKeys.join(", "):"none"})]})]})]}):null,d?n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Columns"}),r.length===0?n.jsx("p",{className:"inspector-muted",children:"No column metadata recorded."}):n.jsx("div",{className:"attribute-preview",children:r.map(p=>n.jsxs("span",{className:"attribute-chip",children:[p.name," ",p.dataType,p.encoding?` encode ${p.encoding}`:"",p.identity?" identity":""]},`${p.table}-${p.name}`))})]}):null,n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Recent statements"}),i.length===0?n.jsx("p",{className:"inspector-muted",children:"No Redshift statements recorded."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"ID"}),n.jsx("th",{scope:"col",children:"Status"}),n.jsx("th",{scope:"col",children:"Rows"}),n.jsx("th",{scope:"col",children:"SQL"})]})}),n.jsx("tbody",{children:i.slice(0,8).map(p=>n.jsxs("tr",{className:"item-row",children:[n.jsx("td",{children:n.jsx("code",{children:p.redshiftQueryId})}),n.jsx("td",{children:p.status}),n.jsx("td",{children:p.resultRows}),n.jsxs("td",{children:[p.queryPreview,p.queryRedacted||p.queryTruncated?n.jsx("span",{className:"attribute-chip",children:p.queryRedacted?"redacted":"truncated"}):null]})]},p.id))})]})})]})]})}function tg({onQuerySuccess:r}){const[l,i]=m.useState("select 1"),[u,d]=m.useState("100"),[p,g]=m.useState(),[y,S]=m.useState(),[v,N]=m.useState(),[w,L]=m.useState(!1),C=A=>{if(A.preventDefault(),l.trim()===""){g(void 0),N(void 0),S("SQL is required.");return}const I=Number.parseInt(u,10),E=Number.isFinite(I)?Math.max(1,I):100;d(String(E)),L(!0),S(void 0),N(void 0),Gh({sql:l,maxRows:E}).then(J=>{g(J.result),N(`${J.result.statement.status}: ${J.result.commandTag||"statement completed"}`),r()}).catch(()=>{g(void 0),N(void 0),S("Query failed. Check SQL syntax and local Redshift state.")}).finally(()=>L(!1))};return n.jsxs("section",{className:"redshift-query-runner","aria-label":"Redshift query runner",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Query runner"}),n.jsx("span",{className:"toolbar-count",children:p?`${p.rowCount} rows`:"Ready"})]}),n.jsxs("form",{className:"redshift-query-form",onSubmit:C,children:[n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"SQL"}),n.jsx("textarea",{"aria-label":"Redshift SQL",onChange:A=>i(A.target.value),rows:5,spellCheck:!1,value:l})]}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Max rows"}),n.jsx("input",{min:1,onChange:A=>d(A.target.value),type:"number",value:u})]}),n.jsx(ae,{disabled:w,type:"submit",children:w?"Running":"Run query"})]})]}),y?n.jsx("p",{className:"operation-message error",children:y}):null,v?n.jsx("p",{className:"operation-message success",children:v}):null,p?n.jsx(ng,{queryResult:p}):null]})}function ng({queryResult:r}){return n.jsxs("div",{className:"redshift-query-result",children:[n.jsxs("div",{className:"attribute-preview","aria-label":"Redshift query summary",children:[n.jsx("span",{className:"attribute-chip",children:r.statement.status}),n.jsx("span",{className:"attribute-chip",children:r.commandTag}),n.jsxs("span",{className:"attribute-chip",children:[r.rowCount," rows"]})]}),r.columns.length===0?n.jsx("p",{className:"inspector-muted",children:"Statement completed without a result set."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsx("tr",{children:r.columns.map(l=>n.jsxs("th",{scope:"col",children:[l.name,n.jsx("span",{className:"query-column-type",children:l.typeName})]},l.name))})}),n.jsx("tbody",{children:r.rows.length===0?n.jsx("tr",{children:n.jsx("td",{colSpan:r.columns.length,children:"No rows returned."})}):r.rows.map((l,i)=>n.jsx("tr",{className:"item-row",children:r.columns.map((u,d)=>n.jsx("td",{children:String(l[d]??"")},`${i}-${u.name}`))},i))})]})})]})}function sg(r){return{service:"redshift",status:"disabled",running:!1,sqlEndpoint:"127.0.0.1:15439",apiEndpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:19099",region:"us-east-1",clusterCount:0,storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/redshift",backendKind:"postgres",backendMode:"managed"}}function Us(r){if(r)return`${r.schema}.${r.name}`}async function rg(){return V("/api/redis/status")}async function Eu(r=0,l="*",i=100){const u=new URLSearchParams({cursor:String(r),match:l,count:String(i)});return V(`/api/redis/keys?${u.toString()}`)}async function Tu(r){return V(`/api/redis/keys/${encodeURIComponent(r)}`)}async function ag(r){return V("/api/redis/command",{method:"POST",body:r})}async function lg(r){return V(`/api/redis/keys/${encodeURIComponent(r)}`,{method:"DELETE"})}async function ig(r,l){return V(`/api/redis/keys/${encodeURIComponent(r)}/expire`,{method:"POST",body:{ttlSeconds:l}})}async function og(){return V("/api/redis/keys?confirm=FLUSHDB",{method:"DELETE"})}async function cg(r){return V("/api/redis/select-db",{method:"POST",body:{db:r}})}const $u=/("(?:\\.|[^"\\])*")(\s*:)?|(\b(?:true|false|null)\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;function Gu(r){const l=[];let i=0,u=0;$u.lastIndex=0;let d;for(;(d=$u.exec(r))!==null;){const[p,g,y,S,v]=d,N=d.index;if(N>i&&l.push(r.slice(i,N)),g!==void 0){const w=y!==void 0;l.push(n.jsx("span",{className:w?"json-token-key":"json-token-string",children:g},`t${u++}`)),y&&l.push(y)}else if(S!==void 0){const w=S==="null"?"json-token-null":"json-token-bool";l.push(n.jsx("span",{className:w,children:S},`t${u++}`))}else v!==void 0?l.push(n.jsx("span",{className:"json-token-number",children:v},`t${u++}`)):l.push(p);i=N+p.length}return i{const ee=I();if(A){u({status:"success",statusPayload:Ig(r),keys:[],nextCursor:0}),p(void 0),y(void 0);return}u({status:"loading"}),Promise.all([rg(),Eu(0,N)]).then(([fe,Se])=>{ee()&&(u({status:"success",statusPayload:fe,keys:Se.keys,nextCursor:Se.nextCursor}),p(me=>{var z;return me&&Se.keys.some(H=>H.key===me)?me:(z=Se.keys[0])==null?void 0:z.key}))}).catch(fe=>{ee()&&u({status:"error",message:fe.message})})},[I,A,N,r]),J=m.useCallback(()=>{i.status!=="success"||i.nextCursor===0||A||Eu(i.nextCursor,N).then(ee=>{u(fe=>fe.status!=="success"?fe:{...fe,keys:[...fe.keys,...ee.keys],nextCursor:ee.nextCursor})}).catch(ee=>C({tone:"error",text:ee.message}))},[A,N,i]);m.useEffect(()=>{E()},[E]),Vt({topics:["redis"],onEvent:E,onResync:E,enabled:!A}),m.useEffect(()=>{if(!d||A){y(void 0),v(void 0);return}let ee=!1;return v(void 0),Tu(d).then(fe=>{ee||y(fe)}).catch(fe=>{ee||(y(void 0),v(fe.message))}),()=>{ee=!0}},[d,A]);const ue=i.status==="success"?i.keys:[],q=i.status==="success"?i.nextCursor:0,P=i.status==="success"?i.statusPayload:void 0;async function oe(ee){await l(Rn({title:"Delete Redis key",description:"This key will be permanently removed from the selected Redis database.",target:ee}))&&lg(ee).then(Se=>{C({tone:"success",text:`Deleted ${ee} (${Se.deleted} key)`}),p(void 0),y(void 0),E()}).catch(Se=>C({tone:"error",text:Se.message}))}function pe(ee,fe){ig(ee,fe).then(Se=>{C({tone:Se.updated?"success":"warning",text:Se.updated?`Set TTL on ${ee} to ${fe}s`:`TTL not updated on ${ee} (key may be missing)`}),Tu(ee).then(y).catch(()=>{}),E()}).catch(Se=>C({tone:"error",text:Se.message}))}function Z(ee){cg(ee).then(()=>{p(void 0),y(void 0),v(void 0),C({tone:"success",text:`Switched to db${ee}`}),E()}).catch(fe=>C({tone:"error",text:fe.message}))}async function Re(){await l(Rn({title:"Flush Redis DB",description:"This removes every key from the selected Redis database. FLUSHALL is never used.",target:"FLUSHDB",confirmLabel:"Flush DB"}))&&og().then(fe=>{C({tone:"success",text:`FLUSHDB ${fe.result}`}),p(void 0),y(void 0),E()}).catch(fe=>C({tone:"error",text:fe.message}))}return A?n.jsx(he,{title:"Redis",children:n.jsx(F,{title:"Redis is disabled",description:"Enable services.redis.enabled in .devcloud/config.yaml and restart devcloud."})}):n.jsxs("div",{className:"redis-shell",children:[n.jsx(mg,{status:P,loading:i.status==="loading",error:i.status==="error"?i.message:void 0,onSelectDB:Z}),n.jsxs("div",{className:"redis-workspace",children:[n.jsxs(he,{title:"Keys",children:[n.jsxs("div",{className:"redis-keys-toolbar",children:[n.jsx(pg,{loading:i.status==="loading",keys:ue.length,total:P==null?void 0:P.currentDBKeys}),n.jsxs("div",{className:"redis-toolbar-actions",children:[n.jsx(ae,{onClick:E,children:"Refresh"}),n.jsx(ae,{disabled:i.status!=="success"||q===0,onClick:J,children:"Load more"}),n.jsx(ae,{className:"danger",disabled:ue.length===0,onClick:Re,children:"Flush DB"})]})]}),n.jsxs("label",{className:"redis-match-field",children:[n.jsx("span",{children:"Match pattern"}),n.jsx("input",{"aria-label":"Match Redis keys",onChange:ee=>w(ee.target.value),placeholder:"* | prefix:* | exact-key",spellCheck:!1,type:"search",value:N})]}),L?n.jsx(fg,{message:L,onDismiss:()=>C(void 0)}):null,i.status==="loading"?n.jsx(F,{title:"Loading Redis",description:"Reading key metadata via SCAN."}):null,i.status==="error"?n.jsx(F,{title:"Redis unavailable",description:i.message,actionLabel:"Retry",onAction:E}):null,i.status==="success"?n.jsx(hg,{activeKey:d,keys:ue,onSelectKey:p}):null]}),n.jsx(he,{title:"Inspector",children:n.jsx(gg,{detail:g,error:S,onDeleteKey:oe,onApplyTTL:pe})}),n.jsx(he,{title:"Command Runner",children:n.jsx(Dg,{})})]})]})}function fg({message:r,onDismiss:l}){return n.jsxs("div",{className:`redis-message redis-message-${r.tone}`,role:"status",children:[n.jsx("span",{children:r.text}),n.jsx("button",{"aria-label":"Dismiss",className:"redis-message-close",onClick:l,type:"button",children:"×"})]})}function pg({keys:r,total:l,loading:i}){if(i)return n.jsx("span",{className:"redis-keys-count",children:"Loading…"});const u=l!==void 0&&l>=0?` / ${l}`:"";return n.jsxs("span",{className:"redis-keys-count",children:[n.jsx("strong",{children:r}),n.jsxs("span",{className:"redis-keys-count-total",children:[u," key",r===1?"":"s"]})]})}function mg({status:r,loading:l,error:i,onSelectDB:u}){if(l&&!r)return n.jsx("header",{className:"redis-status-bar redis-status-bar-loading",children:n.jsx("span",{children:"Connecting to Redis…"})});if(i&&!r)return n.jsxs("header",{className:"redis-status-bar redis-status-bar-error",role:"alert",children:[n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"State"}),n.jsx("span",{className:"cell-value",children:"unreachable"})]}),n.jsxs("span",{className:"redis-status-cell redis-status-cell-wide",children:[n.jsx("span",{className:"cell-label",children:"Reason"}),n.jsx("span",{className:"cell-value",children:i})]})]});if(!r)return n.jsx("header",{className:"redis-status-bar redis-status-bar-loading",children:n.jsx("span",{children:"—"})});const d=r.databaseCount>0?r.databaseCount:1;return n.jsxs("header",{className:"redis-status-bar",children:[n.jsxs("span",{className:`redis-status-cell redis-mode-pill redis-mode-${r.mode||"unknown"}`,children:[n.jsx("span",{className:"cell-label",children:"Mode"}),n.jsx("span",{className:"cell-value",children:r.mode||"unknown"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Address"}),n.jsx("code",{children:r.address||"—"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Server"}),n.jsx("span",{className:"cell-value",children:r.serverVersion||"—"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Clients"}),n.jsx("span",{className:"cell-value",children:r.connectedClients})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Memory"}),n.jsx("span",{className:"cell-value",children:r.usedMemoryHuman||"—"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Database"}),n.jsx("select",{"aria-label":"Select Redis database",className:"redis-db-select",disabled:d<=1,onChange:p=>u(Number(p.target.value)),value:r.currentDB,children:Array.from({length:d},(p,g)=>n.jsx("option",{value:g,children:`db${g}`},g))})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:`Keys (db${r.currentDB})`}),n.jsx("span",{className:"cell-value",children:r.currentDBKeys})]})]})}function hg({activeKey:r,keys:l,onSelectKey:i}){return l.length===0?n.jsx(F,{title:"No keys",description:"Send SET / HSET / LPUSH from a Redis client and refresh."}):n.jsx("ul",{className:"redis-key-list","aria-label":"Redis keys",children:l.map(u=>{const d=u.key===r;return n.jsx("li",{children:n.jsxs("button",{"aria-current":d?"true":void 0,className:d?"redis-key-row active":"redis-key-row",onClick:()=>i(u.key),title:u.key,type:"button",children:[n.jsxs("span",{className:"redis-key-row-top",children:[n.jsx(Xu,{type:u.type}),n.jsx(Zu,{ttlSeconds:u.ttlSeconds,compact:!0})]}),n.jsx("span",{className:"redis-key-row-name",children:u.key})]})},u.key)})})}function gg({detail:r,error:l,onApplyTTL:i,onDeleteKey:u}){return l?n.jsx(F,{title:"Key unavailable",description:l}):r?n.jsxs("div",{className:"redis-inspector",children:[n.jsx(xg,{detail:r}),n.jsx(yg,{detail:r}),n.jsx(Cg,{detail:r,onApplyTTL:i,onDeleteKey:u})]}):n.jsx(F,{title:"Select a key",description:"Choose a key on the left to inspect its type, TTL, and value."})}function xg({detail:r}){const[l,i]=m.useState(!1);function u(){navigator.clipboard&&navigator.clipboard.writeText(r.key).then(()=>{i(!0),window.setTimeout(()=>i(!1),1500)}).catch(()=>{})}return n.jsxs("header",{className:"redis-inspector-header",children:[n.jsx("span",{className:"redis-inspector-eyebrow",children:"Key"}),n.jsxs("div",{className:"redis-key-title-row",children:[n.jsx("code",{className:"redis-key-title",children:r.key}),n.jsx("button",{className:"redis-copy-button",onClick:u,title:"Copy key",type:"button",children:l?"copied":"copy"})]}),n.jsxs("div",{className:"redis-key-badges",children:[n.jsx(Xu,{type:r.type}),n.jsx(Zu,{ttlSeconds:r.ttlSeconds}),n.jsx(Rg,{detail:r})]})]})}function yg({detail:r}){return!r.preview||r.preview.length===0?n.jsxs("section",{className:"redis-inspector-section",children:[n.jsx("span",{className:"redis-inspector-eyebrow",children:"Value"}),n.jsx(F,{title:"Empty value",description:"The key exists but has no previewable content in this slice."})]}):n.jsxs("section",{className:"redis-inspector-section",children:[n.jsx("header",{className:"redis-section-head",children:n.jsx("span",{className:"redis-inspector-eyebrow",children:"Value"})}),jg(r)]})}function jg(r){switch(r.type){case"string":return n.jsx(vg,{raw:r.preview[0]??""});case"list":return n.jsx(bg,{items:r.preview});case"hash":return n.jsx(Sg,{entries:r.preview});case"set":return n.jsx(wg,{members:r.preview});case"zset":return n.jsx(kg,{items:r.preview});default:return n.jsx("pre",{className:"redis-value-pre",children:r.preview.join(` +`)})}}function vg({raw:r}){const l=m.useMemo(()=>Yu(r),[r]),[i,u]=m.useState(l?"pretty":"raw"),d=i==="pretty"&&l!==void 0;return n.jsxs("div",{className:"redis-value-string",children:[n.jsxs("div",{className:"redis-value-toolbar",children:[n.jsxs("span",{className:"redis-value-meta",children:[r.length.toLocaleString()," char",r.length===1?"":"s"]}),l?n.jsxs("div",{role:"tablist","aria-label":"Value format",className:"redis-segmented",children:[n.jsx("button",{type:"button",role:"tab","aria-selected":i==="pretty",className:i==="pretty"?"active":void 0,onClick:()=>u("pretty"),children:"Pretty"}),n.jsx("button",{type:"button",role:"tab","aria-selected":i==="raw",className:i==="raw"?"active":void 0,onClick:()=>u("raw"),children:"Raw"})]}):null]}),n.jsx("pre",{className:"redis-value-pre json-code",children:d?Gu(l):r})]})}function bg({items:r}){return n.jsx("ol",{className:"redis-list-view",children:r.map((l,i)=>n.jsxs("li",{children:[n.jsx("span",{className:"redis-list-index",children:i}),n.jsx("code",{className:"redis-list-value",children:l})]},i))})}function Sg({entries:r}){return n.jsxs("table",{className:"redis-hash-table","aria-label":"Hash fields",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Field"}),n.jsx("th",{scope:"col",children:"Value"})]})}),n.jsx("tbody",{children:r.map((l,i)=>{const[u,d]=ed(l,": ");return n.jsxs("tr",{children:[n.jsx("th",{scope:"row",children:n.jsx("code",{children:u})}),n.jsx("td",{children:n.jsx(Ng,{raw:d})})]},i)})})]})}function Ng({raw:r}){const l=m.useMemo(()=>Yu(r),[r]);return l===void 0?n.jsx("code",{children:r}):n.jsx("pre",{className:"redis-value-pre json-code redis-value-pre-inline",children:Gu(l)})}function wg({members:r}){return n.jsx("ul",{className:"redis-set-view","aria-label":"Set members",children:r.map((l,i)=>n.jsx("li",{children:n.jsx("code",{children:l})},i))})}function kg({items:r}){return n.jsxs("table",{className:"redis-zset-table","aria-label":"Sorted set members",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Member"}),n.jsx("th",{scope:"col",children:"Score"})]})}),n.jsx("tbody",{children:r.map((l,i)=>{const[u,d]=ed(l,": ");return n.jsxs("tr",{children:[n.jsx("th",{scope:"row",children:n.jsx("code",{children:u})}),n.jsx("td",{className:"redis-zset-score",children:d})]},i)})})]})}function Cg({detail:r,onApplyTTL:l,onDeleteKey:i}){const[u,d]=m.useState("60"),p=Number(u),g=Number.isInteger(p)&&p>0;function y(v){v.preventDefault(),g&&l(r.key,p)}function S(){l(r.key,-1)}return n.jsxs("section",{className:"redis-inspector-section",children:[n.jsx("header",{className:"redis-section-head",children:n.jsx("span",{className:"redis-inspector-eyebrow",children:"Actions"})}),n.jsxs("form",{className:"redis-ttl-form",onSubmit:y,children:[n.jsxs("label",{children:[n.jsx("span",{children:"TTL seconds"}),n.jsx("input",{"aria-label":"TTL seconds",min:1,onChange:v=>d(v.target.value),type:"number",value:u})]}),n.jsx(ae,{disabled:!g,type:"submit",children:"Set TTL"}),n.jsx(ae,{className:"ghost",onClick:S,type:"button",children:"Persist (no TTL)"})]}),n.jsx("div",{className:"redis-danger-row",children:n.jsx(ae,{className:"danger",onClick:()=>i(r.key),type:"button",children:"Delete key"})})]})}function Xu({type:r}){const l=(r||"unknown").toLowerCase();return n.jsx("span",{className:`redis-badge redis-type-${l}`,children:l})}function Zu({ttlSeconds:r,compact:l}){return r===-2?n.jsx("span",{className:"redis-badge redis-ttl-missing",children:l?"✕":"missing"}):r===-1?n.jsx("span",{className:"redis-badge redis-ttl-persistent",children:l?"∞":"persistent"}):n.jsx("span",{className:"redis-badge redis-ttl-active",title:`${r}s`,children:l?`${Pu(r)}`:`TTL ${Pu(r)}`})}function Rg({detail:r}){if(!r.preview||r.preview.length===0)return null;const l=r.preview.length;let i;switch(r.type){case"string":i=`${(r.preview[0]??"").length} char${(r.preview[0]??"").length===1?"":"s"}`;break;case"list":i=`${l} item${l===1?"":"s"}`;break;case"hash":i=`${l} field${l===1?"":"s"}`;break;case"set":i=`${l} member${l===1?"":"s"}`;break;case"zset":i=`${l} entr${l===1?"y":"ies"}`;break;default:i=`${l}`}return n.jsx("span",{className:"redis-badge redis-size",children:i})}function Dg(){const[r,l]=m.useState("GET your-key"),[i,u]=m.useState([]),d=m.useRef(0),p=m.useMemo(()=>Lu(r),[r]);function g(w){d.current+=1;const L=d.current;u(C=>[{id:L,...w},...C].slice(0,ug))}function y(w,L){if(!w.command){g({input:L,outcome:"error",detail:"No command supplied"});return}ag(w).then(C=>{g({input:L,outcome:"success",detail:`${C.command} (${C.class})`,rows:C.rows??[]})}).catch(C=>{g({input:L,outcome:"error",detail:C.message})})}function S(w){w.preventDefault(),y(p,r)}function v(w){(w.metaKey||w.ctrlKey)&&w.key==="Enter"&&(w.preventDefault(),y(p,r))}function N(w){l(w),y(Lu(w),w)}return n.jsxs("div",{className:"redis-runner",children:[n.jsxs("form",{className:"redis-runner-form",onSubmit:S,children:[n.jsxs("label",{className:"redis-runner-label",children:[n.jsx("span",{children:"Command"}),n.jsx("textarea",{"aria-label":"Redis command",onChange:w=>l(w.target.value),onKeyDown:v,rows:3,spellCheck:!1,value:r})]}),n.jsxs("div",{className:"redis-runner-toolbar",children:[n.jsxs("span",{className:"redis-runner-hint",children:["Allowlisted commands only · ",n.jsx("kbd",{children:"⌘"}),"/",n.jsx("kbd",{children:"Ctrl"}),"+",n.jsx("kbd",{children:"Enter"})," to run"]}),n.jsx(ae,{type:"submit",children:"Run"})]})]}),i.length===0?n.jsx(F,{title:"No commands yet",description:"Try GET, HGETALL, LRANGE, SCAN 0 MATCH * COUNT 50, or TYPE."}):null,i.length>0?n.jsx("ol",{className:"redis-runner-history","aria-label":"Command history",children:i.map(w=>n.jsxs("li",{className:`redis-runner-entry redis-runner-${w.outcome}`,children:[n.jsxs("header",{className:"redis-runner-entry-head",children:[n.jsx("code",{className:"redis-runner-input",children:w.input}),n.jsx("button",{className:"redis-runner-rerun",onClick:()=>N(w.input),title:"Run again",type:"button",children:"rerun"})]}),n.jsxs("p",{className:"redis-runner-detail",children:[n.jsx("span",{className:"redis-runner-tag",children:w.outcome==="success"?"OK":"ERR"}),n.jsx("span",{children:w.detail})]}),w.rows&&w.rows.length>0?n.jsx("pre",{className:"redis-runner-result",children:w.rows.join(` +`)}):null]},w.id))}):null]})}function Ig(r){var l;return{service:"redis",status:"disabled",running:!1,mode:"managed",address:((l=r==null?void 0:r.endpoint)==null?void 0:l.replace(/^redis:\/\//,""))??"127.0.0.1:16379",serverVersion:"",connectedClients:0,usedMemoryHuman:"",currentDB:0,databaseCount:0,currentDBKeys:0,storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/redis"}}function Lu(r){const l=r.trim().split(/\s+/).filter(d=>d.length>0),[i="",...u]=l;return{command:i,args:u}}function ed(r,l){const i=r.indexOf(l);return i===-1?[r,""]:[r.slice(0,i),r.slice(i+l.length)]}function Pu(r){return r<60?`${r}s`:r<3600?`${Math.floor(r/60)}m`:r<86400?`${Math.floor(r/3600)}h`:`${Math.floor(r/86400)}d`}async function Eg(){return V("/api/sqs/status")}async function Tg(){return V("/api/sqs/queues")}async function $g(r){return V("/api/sqs/queues",{method:"POST",body:{input:r}})}async function Lg(r){return V(`/api/sqs/queues/${encodeURIComponent(r)}/messages`)}async function Pg(r,l){return V(`/api/sqs/queues/${encodeURIComponent(r)}/messages`,{method:"POST",body:{input:l}})}async function Mg(r,l){return V(`/api/sqs/queues/${encodeURIComponent(r)}/receive`,{method:"POST",body:{input:l}})}async function Og(r,l){await V(`/api/sqs/queues/${encodeURIComponent(r)}/delete`,{method:"POST",body:{input:l}})}async function Ag(r,l){await V(`/api/sqs/queues/${encodeURIComponent(r)}/visibility`,{method:"POST",body:{input:l}})}async function _g(r){return V(`/api/sqs/queues/${encodeURIComponent(r)}/leases`)}async function Bg(r){return V(`/api/sqs/queues/${encodeURIComponent(r)}/dlq`)}async function Fg(r){return mn(`/api/sqs/queues/${encodeURIComponent(r)}/purge`,{method:"POST"})}function zg({busyAction:r,newQueueName:l,newQueueKind:i,newQueueVisibility:u,newQueueDelay:d,onSubmit:p,setNewQueueName:g,setNewQueueKind:y,setNewQueueVisibility:S,setNewQueueDelay:v}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:p,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Queue name"}),n.jsx("input",{"aria-label":"New SQS queue name",onChange:N=>g(N.target.value),placeholder:i==="fifo"?"jobs.fifo":"jobs",value:l})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Type"}),n.jsxs("select",{"aria-label":"New SQS queue type",onChange:N=>y(N.target.value==="fifo"?"fifo":"standard"),value:i,children:[n.jsx("option",{value:"standard",children:"Standard"}),n.jsx("option",{value:"fifo",children:"FIFO"})]})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Visibility"}),n.jsx("input",{"aria-label":"New SQS queue visibility timeout seconds",inputMode:"numeric",onChange:N=>S(N.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Delay"}),n.jsx("input",{"aria-label":"New SQS queue delay seconds",inputMode:"numeric",onChange:N=>v(N.target.value),value:d})]}),n.jsx(ae,{disabled:r==="create-queue",type:"submit",children:"Create"})]})}function Qg(r){return{service:"sqs",status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:19324",region:"us-east-1",authMode:"relaxed",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/sqs",queueCount:0}}function Mu(r){const l=Number.parseInt(r,10);if(!(!Number.isFinite(l)||l<0))return String(l)}function Ou(r){const l=Number.parseInt(r,10);if(!(!Number.isFinite(l)||l<=0))return l}function Li(r){const l=Number.parseInt(r,10);if(!(!Number.isFinite(l)||l<0))return l}function Ug(r){return Object.fromEntries(Object.entries(r).filter(l=>typeof l[1]=="string"))}function qg(r){const l=r.trim();if(l==="")return;let i;try{i=JSON.parse(l)}catch{return new Error("Message attributes JSON is invalid")}if(!Qa(i))return new Error("Message attributes must be a JSON object");for(const[u,d]of Object.entries(i))if(!Qa(d)||typeof d.DataType!="string"||d.DataType.trim()==="")return new Error(`Message attribute ${u} must include DataType`);return i}function Au(r){const l=r.split(",").map(i=>i.trim()).filter(Boolean);return l.length>0?l:void 0}function Vg(r){if(r)try{const l=JSON.parse(r);if(!Qa(l))return;const i=l.deadLetterTargetArn,u=l.maxReceiveCount;return typeof i!="string"?void 0:{deadLetterTargetArn:i,maxReceiveCount:typeof u=="string"?u:String(u??"")}}catch{return}}function Kg(r){if(r)try{const l=JSON.parse(r);return!Qa(l)||typeof l.redrivePermission!="string"?void 0:{redrivePermission:l.redrivePermission}}catch{return}}function td(r){return r.length<=12?r===""?"":"...":`${r.slice(0,6)}...${r.slice(-6)}`}function Qa(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}function Jg({activeQueue:r,busyAction:l,purgeConfirmation:i,onSubmit:u,setPurgeConfirmation:d}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:u,children:[n.jsx("span",{className:"toolbar-count",children:r?r.name:"No queue selected"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Purge confirmation"}),n.jsx("input",{"aria-label":"Confirm SQS purge queue",disabled:!r,onChange:p=>d(p.target.value),placeholder:(r==null?void 0:r.name)??"queue name",value:i})]}),n.jsx(ae,{className:"danger",disabled:!r||i!==r.name||l==="purge-queue",type:"submit",children:"Purge"})]})}function Hg({activeQueue:r,busyAction:l,selectedReceiptHandle:i,visibilityTimeout:u,visibilityConfirmation:d,onSubmit:p,setVisibilityTimeout:g,setVisibilityConfirmation:y}){const S=!r||i==="";return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:p,children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Visibility timeout"}),n.jsx("input",{"aria-label":"SQS change visibility timeout seconds",disabled:S,inputMode:"numeric",onChange:v=>g(v.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Confirm"}),n.jsx("input",{"aria-label":"Confirm SQS change message visibility",disabled:S,onChange:v=>y(v.target.value),placeholder:"visibility",value:d})]}),n.jsx(ae,{disabled:S||d!=="visibility"||l==="change-visibility",type:"submit",children:"Change visibility"})]})}function Wg({activeQueue:r,busyAction:l,selectedReceivedMessage:i,selectedReceiptHandle:u,pastedReceiptHandle:d,deleteConfirmation:p,onSubmit:g,setPastedReceiptHandle:y,setDeleteConfirmation:S}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:g,children:[n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Receipt handle"}),n.jsx("input",{"aria-label":"SQS delete receipt handle",disabled:!r,onChange:v=>y(v.target.value),placeholder:i?td(i.ReceiptHandle):"paste receipt handle or select received message",value:d})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Confirm"}),n.jsx("input",{"aria-label":"Confirm SQS delete message",disabled:!r||u==="",onChange:v=>S(v.target.value),placeholder:"delete",value:p})]}),n.jsx(ae,{className:"danger",disabled:!r||u===""||p!=="delete"||l==="delete-message",type:"submit",children:"Delete"})]})}function Gg({activeIndex:r,detailState:l,messages:i,onSelectIndex:u,queueName:d}){return d?l.status==="loading"?n.jsx(F,{title:"Loading messages",description:`Reading messages from ${d}.`}):l.status==="error"?n.jsx(F,{title:"SQS messages unavailable",description:l.message}):i.length===0?n.jsx(F,{title:"No messages",description:`No retained messages in ${d} match the current filter.`}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"State"}),n.jsx("th",{scope:"col",children:"Body"}),n.jsx("th",{scope:"col",children:"Receive"})]})}),n.jsx("tbody",{children:i.map((p,g)=>n.jsxs("tr",{className:g===r?"item-row active":"item-row",onClick:()=>u(g),children:[n.jsx("td",{children:p.state}),n.jsx("td",{children:n.jsx(Yg,{message:p})}),n.jsx("td",{children:p.receiveCount})]},`${p.messageId}-${g}`))})]})}):n.jsx(F,{title:"No queue selected",description:"Choose a queue to inspect retained messages."})}function Yg({message:r}){return n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:r.body||"(empty body)"}),r.messageGroupId?n.jsxs("span",{className:"attribute-chip",children:["group: ",r.messageGroupId]}):null,r.sequenceNumber?n.jsxs("span",{className:"attribute-chip",children:["seq: ",r.sequenceNumber]}):null]})}function Xg({activeQueue:r,busyAction:l,sendBody:i,sendDelay:u,sendAttributesJSON:d,sendGroupId:p,sendDeduplicationId:g,onSubmit:y,setSendBody:S,setSendDelay:v,setSendAttributesJSON:N,setSendGroupId:w,setSendDeduplicationId:L}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:y,children:[n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Message body"}),n.jsx("textarea",{"aria-label":"SQS message body",disabled:!r,onChange:C=>S(C.target.value),placeholder:"message body",rows:3,value:i})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Delay"}),n.jsx("input",{"aria-label":"SQS message delay seconds",disabled:!r,inputMode:"numeric",onChange:C=>v(C.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Attributes JSON"}),n.jsx("input",{"aria-label":"SQS message attributes JSON",disabled:!r,onChange:C=>N(C.target.value),placeholder:'{"kind":{"DataType":"String","StringValue":"test"}}',value:d})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Group"}),n.jsx("input",{"aria-label":"SQS FIFO message group ID",disabled:!r,onChange:C=>w(C.target.value),placeholder:"FIFO only",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Dedup"}),n.jsx("input",{"aria-label":"SQS FIFO message deduplication ID",disabled:!r,onChange:C=>L(C.target.value),placeholder:"optional",value:g})]}),n.jsx(ae,{disabled:!r||l==="send-message",type:"submit",children:"Send"})]})}function Zg({activeQueue:r,busyAction:l,receiveMaxMessages:i,receiveVisibilityTimeout:u,receiveWaitTime:d,receiveAttributeNames:p,receiveMessageAttributeNames:g,onSubmit:y,setReceiveMaxMessages:S,setReceiveVisibilityTimeout:v,setReceiveWaitTime:N,setReceiveAttributeNames:w,setReceiveMessageAttributeNames:L}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:y,children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Max"}),n.jsx("input",{"aria-label":"SQS receive max messages",disabled:!r,inputMode:"numeric",onChange:C=>S(C.target.value),value:i})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Visibility"}),n.jsx("input",{"aria-label":"SQS receive visibility timeout seconds",disabled:!r,inputMode:"numeric",onChange:C=>v(C.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Wait"}),n.jsx("input",{"aria-label":"SQS receive wait time seconds",disabled:!r,inputMode:"numeric",onChange:C=>N(C.target.value),value:d})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Attrs"}),n.jsx("input",{"aria-label":"SQS receive attribute names",disabled:!r,onChange:C=>w(C.target.value),placeholder:"All or comma-separated names",value:p})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Msg attrs"}),n.jsx("input",{"aria-label":"SQS receive message attribute names",disabled:!r,onChange:C=>L(C.target.value),placeholder:"All or comma-separated names",value:g})]}),n.jsx(ae,{disabled:!r||l==="receive-message",type:"submit",children:"Receive"})]})}function ex({activeQueueName:r,onSelectQueue:l,queues:i}){return i.length===0?n.jsx(F,{title:"No queues",description:"Queues created through the SQS API will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"SQS queues",children:i.map(u=>n.jsxs("button",{className:u.name===r?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>l(u.name),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:u.name}),n.jsx("span",{className:"count-pill",children:u.totalRetainedMessages})]}),n.jsx("span",{className:"table-row-meta",children:u.url}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:[u.visibleMessages," visible"]}),n.jsxs("span",{children:[u.notVisibleMessages," in flight"]}),n.jsxs("span",{children:[u.delayedMessages," delayed"]})]})]},u.name))})}function tx({messages:r,onSelectIndex:l,selectedIndex:i}){return r.length===0?null:n.jsx("div",{className:"dynamodb-item-table-wrap","aria-label":"Received SQS messages",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Use"}),n.jsx("th",{scope:"col",children:"Message"}),n.jsx("th",{scope:"col",children:"Receipt handle"})]})}),n.jsx("tbody",{children:r.map((u,d)=>n.jsxs("tr",{className:d===i?"item-row active":"item-row",onClick:()=>l(d),children:[n.jsx("td",{children:d===i?"selected":"select"}),n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:u.Body||"(empty body)"}),n.jsx("span",{className:"attribute-chip",children:u.MessageId})]})}),n.jsx("td",{children:n.jsx("code",{children:td(u.ReceiptHandle)})})]},`${u.MessageId}-${d}`))})]})})}function nx({detailState:r,message:l,queue:i,status:u}){var S;if(!i)return n.jsx(F,{title:"Inspector",description:"Queue attributes and selected message JSON will appear here."});const d=r.status==="success"?r.leases:[],p=r.status==="success"?r.dlq:void 0,g=Vg(i.attributes.RedrivePolicy),y=Kg(i.attributes.RedriveAllowPolicy);return n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Queue"}),n.jsx("h3",{children:i.name}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(u==null?void 0:u.endpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:(u==null?void 0:u.region)??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"ARN"}),n.jsx("dd",{children:n.jsx("code",{children:i.arn})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Visibility"}),n.jsxs("dd",{children:[i.attributes.VisibilityTimeout??"unknown","s"]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Leases"}),n.jsx("dd",{children:d.length})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"DLQ sources"}),n.jsx("dd",{children:(p==null?void 0:p.deadLetterSourceQueues.map(v=>v.name).join(", "))||"none"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"DLQ target"}),n.jsx("dd",{children:((S=p==null?void 0:p.deadLetterQueue)==null?void 0:S.name)??(g==null?void 0:g.deadLetterTargetArn)??"none"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Redrive"}),n.jsxs("dd",{children:[g?`maxReceiveCount ${g.maxReceiveCount}`:"none",y?`, allow ${y.redrivePermission}`:""]})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected message"}),l?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(l,null,2)}):n.jsx("p",{className:"inspector-muted",children:"Select a message row to inspect JSON."})]})]})}function sx({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState(),[y,S]=m.useState(0),[v,N]=m.useState(""),[w,L]=m.useState(""),[C,A]=m.useState(""),[I,E]=m.useState("standard"),[J,ue]=m.useState("30"),[q,P]=m.useState("0"),[oe,pe]=m.useState(""),[Z,Re]=m.useState("0"),[ee,fe]=m.useState(""),[Se,me]=m.useState(""),[z,H]=m.useState(""),[be,$e]=m.useState("1"),[Ne,$]=m.useState("30"),[U,_]=m.useState("0"),[j,T]=m.useState("All"),[ne,ce]=m.useState("All"),[ge,we]=m.useState([]),[De,se]=m.useState(0),[Ie,Qe]=m.useState(""),[tt,Dt]=m.useState(""),[ut,de]=m.useState("0"),[ke,Y]=m.useState(""),[je,rt]=m.useState(""),[at,yt]=m.useState(""),[It,Ze]=m.useState(""),[Et,K]=m.useState(),[Be,Ue]=m.useState(),Le=(r==null?void 0:r.status)==="disabled",kt=wt(),_e=m.useCallback(()=>{const Ee=kt();if(Le){i({status:"success",statusPayload:Qg(r),queues:[]}),d({status:"idle"}),g(void 0);return}i({status:"loading"}),Promise.all([Eg(),Tg()]).then(([Ce,{queues:Fe}])=>{Ee()&&(i({status:"success",statusPayload:Ce,queues:Fe}),g(Ft=>{var En;return Ft&&Fe.some(xn=>xn.name===Ft)?Ft:(En=Fe[0])==null?void 0:En.name}))}).catch(Ce=>{Ee()&&i({status:"error",message:Ce.message})})},[kt,Le,r]);m.useEffect(()=>{_e()},[_e]),Vt({topics:["sqs"],onEvent:_e,onResync:_e,enabled:!Le});const jt=l.status==="success"?l.queues:[],Ae=jt.find(Ee=>Ee.name===p),hn=wt(),it=m.useCallback(()=>{const Ee=hn();if(!p||Le){d({status:"idle"});return}d({status:"loading"}),Promise.all([Lg(p),_g(p),Bg(p)]).then(([Ce,Fe,Ft])=>{Ee()&&(S(0),d({status:"success",messages:Ce.messages,leases:Fe.leases,dlq:Ft}))}).catch(Ce=>{Ee()&&d({status:"error",message:Ce.message})})},[hn,p,Le]);m.useEffect(()=>{it()},[it]);const Xn=m.useMemo(()=>{const Ee=v.trim().toLowerCase();return Ee===""?jt:jt.filter(Ce=>Ce.name.toLowerCase().includes(Ee))},[jt,v]),Tt=m.useMemo(()=>{const Ee=u.status==="success"?u.messages:[],Ce=w.trim().toLowerCase();return Ce===""?Ee:Ee.filter(Fe=>JSON.stringify(Fe).toLowerCase().includes(Ce))},[u,w]),an=Tt[Math.min(y,Math.max(Tt.length-1,0))],dt=ge[Math.min(De,Math.max(ge.length-1,0))],$t=Ie.trim()||(dt==null?void 0:dt.ReceiptHandle)||"";if(Le)return n.jsx(he,{title:"SQS",children:n.jsx(F,{title:"SQS is disabled",description:"Enable the SQS service in devcloud config to inspect queues and messages."})});function ln(Ee){g(Ee),S(0),we([]),se(0),Qe(""),Dt(""),Y(""),rt(""),L(""),Ue(void 0)}function In(Ee){if(Ee.preventDefault(),!!p){if(je!==p){Ue("Type the queue name to confirm purge confirmation");return}Ue(void 0),Fg(p).then(()=>{rt(""),_e(),it()}).catch(Ce=>{Ue(Ce.message)})}}async function ht(Ee,Ce){K(Ee),Ze(""),yt("");try{const Fe=await Ce();yt(Fe),_e(),it()}catch(Fe){Ze(Fe instanceof Error?Fe.message:"SQS operation failed")}finally{K(void 0)}}function on(Ee){Ee.preventDefault();const Ce=C.trim();if(Ce===""){Ze("Queue name is required");return}const Fe=I==="fifo"&&!Ce.endsWith(".fifo")?`${Ce}.fifo`:Ce;ht("create-queue",async()=>(await $g({QueueName:Fe,Attributes:Ug({VisibilityTimeout:Mu(J),DelaySeconds:Mu(q),FifoQueue:I==="fifo"?"true":void 0,ContentBasedDeduplication:I==="fifo"?"true":void 0}),Tags:{source:"dashboard"}}),A(""),g(Fe),`Created queue ${Fe}`))}function gn(Ee){if(Ee.preventDefault(),!Ae){Ze("Queue is required");return}if(oe===""){Ze("Message body is required");return}const Ce=qg(ee);if(Ce instanceof Error){Ze(Ce.message);return}ht("send-message",async()=>{const Fe=await Pg(Ae.name,{MessageBody:oe,DelaySeconds:Ou(Z),MessageAttributes:Ce,MessageGroupId:Se.trim()||void 0,MessageDeduplicationId:z.trim()||void 0});return pe(""),fe(""),`Sent message ${Fe.MessageId}`})}function hs(Ee){if(Ee.preventDefault(),!Ae){Ze("Queue is required");return}ht("receive-message",async()=>{const Fe=(await Mg(Ae.name,{MaxNumberOfMessages:Ou(be),VisibilityTimeout:Li(Ne),WaitTimeSeconds:Li(U),AttributeNames:Au(j),MessageAttributeNames:Au(ne)})).Messages??[];return we(Fe),se(0),Qe(""),Dt(""),Fe.length===1?"Received 1 message":`Received ${Fe.length} messages`})}function gs(Ee){if(Ee.preventDefault(),!Ae){Ze("Queue is required");return}if($t===""){Ze("Receipt handle is required");return}if(tt!=="delete"){Ze("Type delete to confirm DeleteMessage");return}ht("delete-message",async()=>(await Og(Ae.name,{ReceiptHandle:$t}),we(Ce=>Ce.filter(Fe=>Fe.ReceiptHandle!==$t)),se(0),Qe(""),Dt(""),Y(""),"Deleted message for the selected receipt handle"))}function xs(Ee){if(Ee.preventDefault(),!Ae){Ze("Queue is required");return}if($t===""){Ze("Receipt handle is required");return}if(ke!=="visibility"){Ze("Type visibility to confirm ChangeMessageVisibility");return}const Ce=Li(ut);if(typeof Ce!="number"){Ze("Visibility timeout must be a non-negative number");return}ht("change-visibility",async()=>(await Ag(Ae.name,{ReceiptHandle:$t,VisibilityTimeout:Ce}),Y(""),`Changed visibility timeout to ${Ce}s`))}return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(he,{title:"Queues",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter SQS queues",onChange:Ee=>N(Ee.target.value),placeholder:"queue name",type:"search",value:v})]}),n.jsx(ae,{onClick:_e,children:"Refresh"})]}),n.jsx(zg,{busyAction:Et,newQueueDelay:q,newQueueKind:I,newQueueName:C,newQueueVisibility:J,onSubmit:on,setNewQueueDelay:P,setNewQueueKind:E,setNewQueueName:A,setNewQueueVisibility:ue}),l.status==="loading"?n.jsx(F,{title:"Loading queues",description:"Reading local SQS queue metadata."}):null,l.status==="error"?n.jsx(F,{title:"SQS queues unavailable",description:l.message,actionLabel:"Retry",onAction:_e}):null,l.status==="success"?n.jsx(ex,{activeQueueName:p,onSelectQueue:ln,queues:Xn}):null]}),n.jsxs(he,{title:"Messages",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:Ae?`${Tt.length} shown / ${Ae.totalRetainedMessages} retained`:"Select a queue"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter SQS messages",disabled:!Ae,onChange:Ee=>{S(0),L(Ee.target.value)},placeholder:"body or attribute",type:"search",value:w})]}),n.jsx(ae,{disabled:!Ae,onClick:it,children:"Refresh"})]}),n.jsx(Xg,{activeQueue:!!Ae,busyAction:Et,onSubmit:gn,sendAttributesJSON:ee,sendBody:oe,sendDeduplicationId:z,sendDelay:Z,sendGroupId:Se,setSendAttributesJSON:fe,setSendBody:pe,setSendDeduplicationId:H,setSendDelay:Re,setSendGroupId:me}),n.jsx(Gg,{activeIndex:y,detailState:u,messages:Tt,onSelectIndex:S,queueName:p}),n.jsx(Zg,{activeQueue:!!Ae,busyAction:Et,onSubmit:hs,receiveAttributeNames:j,receiveMaxMessages:be,receiveMessageAttributeNames:ne,receiveVisibilityTimeout:Ne,receiveWaitTime:U,setReceiveAttributeNames:T,setReceiveMaxMessages:$e,setReceiveMessageAttributeNames:ce,setReceiveVisibilityTimeout:$,setReceiveWaitTime:_}),n.jsx(tx,{messages:ge,onSelectIndex:se,selectedIndex:De})]}),n.jsxs(he,{title:"Inspector",children:[It?n.jsx("p",{className:"operation-message error",children:It}):null,at?n.jsx("p",{className:"operation-message success",children:at}):null,n.jsx(Jg,{activeQueue:Ae,busyAction:Et,onSubmit:In,purgeConfirmation:je,setPurgeConfirmation:rt}),Be?n.jsx("p",{className:"inspector-muted",children:Be}):null,n.jsx(Hg,{activeQueue:!!Ae,busyAction:Et,onSubmit:xs,selectedReceiptHandle:$t,setVisibilityConfirmation:Y,setVisibilityTimeout:de,visibilityConfirmation:ke,visibilityTimeout:ut}),n.jsx(Wg,{activeQueue:!!Ae,busyAction:Et,deleteConfirmation:tt,onSubmit:gs,pastedReceiptHandle:Ie,selectedReceiptHandle:$t,selectedReceivedMessage:dt,setDeleteConfirmation:Dt,setPastedReceiptHandle:Qe}),n.jsx(nx,{detailState:u,message:an,queue:Ae,status:l.status==="success"?l.statusPayload:void 0})]})]})}async function rx(){return V("/api/pubsub/status")}async function ax(){return V("/api/pubsub/topics")}async function lx(r){return V("/api/pubsub/topics",{method:"POST",body:{topicId:r}})}async function ix(){return V("/api/pubsub/subscriptions")}async function ox(r){return V("/api/pubsub/subscriptions",{method:"POST",body:r})}async function cx(r){return V(`/api/pubsub/topics/${encodeURIComponent(r.topicId)}/publish`,{method:"POST",body:{messages:[{data:r.data,attributes:r.attributes,orderingKey:r.orderingKey}]}})}async function ux(r,l){return V(`/api/pubsub/subscriptions/${encodeURIComponent(r)}/pull`,{method:"POST",body:{maxMessages:l}})}async function dx(r,l){await V(`/api/pubsub/subscriptions/${encodeURIComponent(r)}/ack`,{method:"POST",body:{ackIds:l}})}function fx({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(),[y,S]=m.useState(""),[v,N]=m.useState(""),[w,L]=m.useState(""),[C,A]=m.useState(""),[I,E]=m.useState(""),[J,ue]=m.useState("10"),[q,P]=m.useState(""),[oe,pe]=m.useState(""),[Z,Re]=m.useState("1"),[ee,fe]=m.useState([]),[Se,me]=m.useState(""),[z,H]=m.useState(""),[be,$e]=m.useState(""),[Ne,$]=m.useState(),U=(r==null?void 0:r.status)==="disabled",_=wt(),j=m.useCallback(()=>{const de=_();if(U){i({status:"success",statusPayload:vx(r),topics:[],subscriptions:[]}),g(void 0);return}i({status:"loading"}),Promise.all([rx(),ax(),ix()]).then(([ke,Y,je])=>{de()&&(i({status:"success",statusPayload:ke,topics:Y.topics,subscriptions:je.subscriptions}),d(rt=>{var at;return rt&&Y.topics.some(yt=>yt.name===rt)?rt:(at=Y.topics[0])==null?void 0:at.name}),E(rt=>{var at;return rt||Bt(((at=Y.topics[0])==null?void 0:at.name)??"")}),g(rt=>{var at;return rt&&je.subscriptions.some(yt=>yt.name===rt)?rt:(at=je.subscriptions[0])==null?void 0:at.name}))}).catch(ke=>{de()&&i({status:"error",message:ke.message})})},[_,U,r]);m.useEffect(()=>{j()},[j]),Vt({topics:["pubsub"],onEvent:j,onResync:j,enabled:!U});const T=l.status==="success"?l.topics:[],ne=l.status==="success"?l.subscriptions:[],ce=T.find(de=>de.name===u),ge=ne.find(de=>de.name===p),we=m.useMemo(()=>{const de=y.trim().toLowerCase();return de===""?T:T.filter(ke=>ke.name.toLowerCase().includes(de))},[T,y]),De=m.useMemo(()=>{const de=v.trim().toLowerCase();return de===""?ne:ne.filter(ke=>JSON.stringify(ke).toLowerCase().includes(de))},[ne,v]);async function se(de,ke){$(de),$e(""),H("");try{const Y=await ke();H(Y),j()}catch(Y){$e(Y instanceof Error?Y.message:"Pub/Sub operation failed")}finally{$(void 0)}}function Ie(de){de.preventDefault();const ke=w.trim();if(ke===""){$e("Topic ID is required");return}se("create-topic",async()=>{const Y=await lx(ke);return L(""),d(Y.name),E(Bt(Y.name)),`Created topic ${Bt(Y.name)}`})}function Qe(de){de.preventDefault();const ke=C.trim(),Y=I.trim()||Bt((ce==null?void 0:ce.name)??"");if(ke===""||Y===""){$e("Subscription ID and topic are required");return}se("create-subscription",async()=>{const je=await ox({subscriptionId:ke,topicId:Y,ackDeadlineSeconds:Number.parseInt(J,10)||void 0});return A(""),g(je.name),`Created subscription ${Bt(je.name)}`})}function tt(de){de.preventDefault();const ke=Bt((ce==null?void 0:ce.name)??"");if(ke===""||q===""){$e("Topic and message are required");return}se("publish",async()=>{const Y=await cx({topicId:ke,data:gx(q),attributes:{source:"dashboard"},orderingKey:oe.trim()||void 0});return P(""),pe(""),`Published ${Y.messageIds.length} message`})}function Dt(){const de=Bt((ge==null?void 0:ge.name)??"");if(de===""){$e("Subscription is required");return}se("pull",async()=>{var je;const Y=(await ux(de,Number.parseInt(Z,10)||1)).receivedMessages??[];return fe(Y),me(((je=Y[0])==null?void 0:je.ackId)??""),`Pulled ${Y.length} message`})}function ut(){const de=Bt((ge==null?void 0:ge.name)??""),ke=Se?[Se]:ee.map(Y=>Y.ackId).filter(Boolean);if(de===""||ke.length===0){$e("Ack ID is required");return}se("ack",async()=>(await dx(de,ke),fe(Y=>Y.filter(je=>!ke.includes(je.ackId))),me(""),`Acknowledged ${ke.length} message`))}return U?n.jsx(he,{title:"Pub/Sub",children:n.jsx(F,{title:"Pub/Sub is disabled",description:"Enable the Pub/Sub service in devcloud config to inspect topics and subscriptions."})}):n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(he,{title:"Topics",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter Pub/Sub topics",onChange:de=>S(de.target.value),placeholder:"topic name",type:"search",value:y})]}),n.jsx(ae,{onClick:j,children:"Refresh"})]}),n.jsxs("form",{className:"pubsub-action-form",onSubmit:Ie,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Topic ID"}),n.jsx("input",{"aria-label":"New Pub/Sub topic ID",onChange:de=>L(de.target.value),placeholder:"orders",value:w})]}),n.jsx(ae,{disabled:Ne==="create-topic",type:"submit",children:"Create"})]}),l.status==="loading"?n.jsx(F,{title:"Loading topics",description:"Reading local Pub/Sub resource metadata."}):null,l.status==="error"?n.jsx(F,{title:"Pub/Sub unavailable",description:l.message,actionLabel:"Retry",onAction:j}):null,l.status==="success"?n.jsx(px,{activeTopicName:u,onSelectTopic:d,topics:we}):null,n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:tt,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Message"}),n.jsx("input",{"aria-label":"Pub/Sub publish message",disabled:!ce,onChange:de=>P(de.target.value),placeholder:"message body",value:q})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Ordering key"}),n.jsx("input",{"aria-label":"Pub/Sub publish ordering key",disabled:!ce,onChange:de=>pe(de.target.value),placeholder:"optional",value:oe})]}),n.jsx(ae,{disabled:!ce||Ne==="publish",type:"submit",children:"Publish"})]})]}),n.jsxs(he,{title:"Subscriptions",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${De.length} shown / ${ne.length} subscriptions`:"Loading"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter Pub/Sub subscriptions",onChange:de=>N(de.target.value),placeholder:"subscription or topic",type:"search",value:v})]})]}),n.jsxs("form",{className:"pubsub-action-form",onSubmit:Qe,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Subscription ID"}),n.jsx("input",{"aria-label":"New Pub/Sub subscription ID",onChange:de=>A(de.target.value),placeholder:"orders-sub",value:C})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Topic"}),n.jsxs("select",{"aria-label":"Topic for new Pub/Sub subscription",onChange:de=>E(de.target.value),value:I,children:[n.jsx("option",{value:"",children:"Select topic"}),T.map(de=>n.jsx("option",{value:Bt(de.name),children:Bt(de.name)},de.name))]})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Ack deadline"}),n.jsx("input",{"aria-label":"New Pub/Sub subscription ack deadline seconds",inputMode:"numeric",onChange:de=>ue(de.target.value),value:J})]}),n.jsx(ae,{disabled:Ne==="create-subscription",type:"submit",children:"Create"})]}),l.status==="success"?n.jsx(mx,{activeSubscriptionName:p,onSelectSubscription:g,subscriptions:De}):null]}),n.jsxs(he,{title:"Inspector",children:[be?n.jsx("p",{className:"operation-message error",children:be}):null,z?n.jsx("p",{className:"operation-message success",children:z}):null,n.jsx(hx,{busyAction:Ne,onAck:ut,onPull:Dt,pulledMessages:ee,pullMaxMessages:Z,selectedAckId:Se,setPullMaxMessages:Re,setSelectedAckId:me,status:l.status==="success"?l.statusPayload:void 0,subscription:ge,topicCount:T.length})]})]})}function px({activeTopicName:r,onSelectTopic:l,topics:i}){return i.length===0?n.jsx(F,{title:"No topics",description:"Topics created through Pub/Sub REST or SDK clients will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Pub/Sub topics",children:i.map(u=>n.jsxs("button",{className:u.name===r?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>l(u.name),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:Bt(u.name)}),n.jsx("span",{className:"count-pill",children:u.subscriptionCount})]}),n.jsx("span",{className:"table-row-meta",children:u.name}),n.jsx("span",{className:"table-row-tags",children:n.jsxs("span",{children:[u.subscriptionCount," subscriptions"]})})]},u.name))})}function mx({activeSubscriptionName:r,onSelectSubscription:l,subscriptions:i}){return i.length===0?n.jsx(F,{title:"No subscriptions",description:"Subscriptions created through Pub/Sub REST or SDK clients will appear here."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Subscription"}),n.jsx("th",{scope:"col",children:"Backlog"}),n.jsx("th",{scope:"col",children:"In flight"}),n.jsx("th",{scope:"col",children:"Attempts"})]})}),n.jsx("tbody",{children:i.map(u=>n.jsxs("tr",{className:u.name===r?"item-row active":"item-row",onClick:()=>l(u.name),children:[n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:Bt(u.name)}),n.jsx("span",{className:"attribute-chip",children:Bt(u.topic)})]})}),n.jsx("td",{children:u.backlogMessages}),n.jsx("td",{children:u.inFlightMessages}),n.jsx("td",{children:u.maxDeliveryAttemptSeen})]},u.name))})]})})}function hx({busyAction:r,onAck:l,onPull:i,pulledMessages:u,pullMaxMessages:d,selectedAckId:p,setPullMaxMessages:g,setSelectedAckId:y,status:S,subscription:v,topicCount:N}){return v?n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Service"}),n.jsx("h3",{children:Bt(v.name)}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"REST"}),n.jsx("dd",{children:n.jsx("code",{children:(S==null?void 0:S.restEndpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"gRPC"}),n.jsx("dd",{children:n.jsx("code",{children:(S==null?void 0:S.grpcEndpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Project"}),n.jsx("dd",{children:(S==null?void 0:S.project)??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Topics"}),n.jsx("dd",{children:N})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Ack deadline"}),n.jsxs("dd",{children:[v.ackDeadlineSeconds,"s"]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Retained"}),n.jsx("dd",{children:v.totalRetainedMessages})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Actions"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Pull max"}),n.jsx("input",{"aria-label":"Pub/Sub pull max messages",inputMode:"numeric",onChange:w=>g(w.target.value),value:d})]}),n.jsx(ae,{disabled:r==="pull",onClick:i,children:"Pull"}),n.jsx(ae,{disabled:u.length===0||r==="ack",onClick:l,children:"Ack"})]}),u.length>0?n.jsx("div",{className:"pulled-message-list",children:u.map(w=>n.jsxs("label",{className:"pulled-message",children:[n.jsx("input",{checked:p===w.ackId,name:"pubsub-ack-id",onChange:()=>y(w.ackId),type:"radio"}),n.jsxs("span",{children:[n.jsx("strong",{children:w.message.messageId??"pulled message"}),n.jsx("code",{children:xx(w.message.data??"")})]})]},w.ackId))}):null]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Recent deliveries"}),v.recentDeliveries&&v.recentDeliveries.length>0?n.jsx(yx,{deliveries:v.recentDeliveries}):n.jsx("p",{className:"inspector-muted",children:"No retained delivery records for this subscription."})]})]}):n.jsx(F,{title:"Inspector",description:"Subscription backlog, leases, and recent deliveries will appear here."})}function gx(r){return window.btoa(unescape(encodeURIComponent(r)))}function xx(r){if(r==="")return"";try{return decodeURIComponent(escape(window.atob(r)))}catch{return r}}function yx({deliveries:r}){return n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Message"}),n.jsx("th",{scope:"col",children:"State"}),n.jsx("th",{scope:"col",children:"Attempt"}),n.jsx("th",{scope:"col",children:"Ready"})]})}),n.jsx("tbody",{children:r.map(l=>n.jsxs("tr",{className:"item-row",children:[n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:l.messageId}),l.orderingKey?n.jsx("span",{className:"attribute-chip",children:l.orderingKey}):null]})}),n.jsx("td",{children:l.state}),n.jsx("td",{children:l.deliveryAttempt}),n.jsx("td",{children:jx(l)})]},`${l.messageId}-${l.deliveryAttempt}-${l.state}`))})]})})}function jx(r){return r.leaseDeadline?`lease until ${Pi(r.leaseDeadline)}`:r.nextDeliveryTime?`retry at ${Pi(r.nextDeliveryTime)}`:r.publishTime?`published ${Pi(r.publishTime)}`:"available"}function vx(r){return{service:"pubsub",status:"disabled",running:!1,grpcEndpoint:"127.0.0.1:18085",restEndpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:18086",project:"devcloud",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/pubsub",topicCount:0,subscriptionCount:0}}function Bt(r){return r.split("/").pop()??r}function Pi(r){const l=new Date(r);return Number.isNaN(l.getTime())?r:l.toLocaleString()}async function bx(){return V("/api/gcs/status")}async function Sx(){return V("/api/gcs/buckets")}async function Nx(r){return V("/api/gcs/buckets",{method:"POST",body:{name:r}})}async function wx(r){return mn(`/api/gcs/buckets/${encodeURIComponent(r)}`,{method:"DELETE"})}async function kx(r,l=""){const i=new URLSearchParams;l!==""&&i.set("prefix",l);const u=i.toString();return V(`/api/gcs/buckets/${encodeURIComponent(r)}/objects${u?`?${u}`:""}`)}async function Cx(r,l){return V(`/api/gcs/buckets/${encodeURIComponent(r)}/objects/${encodeURIComponent(l)}`)}async function Rx(r,l){return mn(`/api/gcs/buckets/${encodeURIComponent(r)}/objects/${encodeURIComponent(l)}`,{method:"DELETE"})}async function Dx(){return V("/api/gcs/upload-sessions")}async function Ix(r){return mn(`/api/gcs/uploads/${encodeURIComponent(r)}`,{method:"DELETE"})}function Ex({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState(),[y,S]=m.useState(),[v,N]=m.useState(),[w,L]=m.useState(""),[C,A]=m.useState(""),[I,E]=m.useState(),[J,ue]=m.useState(0),q=(r==null?void 0:r.status)==="disabled",P=$r(),oe=(v==null?void 0:v.bucket)===p?v==null?void 0:v.name:void 0,pe=wt(),Z=m.useCallback(()=>{const $=pe();if(q){i({status:"success",statusPayload:Bu(r),buckets:[],sessions:[]}),d({status:"idle"}),g(void 0),S(void 0),N(void 0);return}i({status:"loading"}),Promise.all([bx(),Sx(),Dx()]).then(([U,_,j])=>{$()&&(i({status:"success",statusPayload:U,buckets:_.buckets,sessions:j.sessions}),g(T=>{var ne;return T&&_.buckets.some(ce=>ce.name===T)?T:(ne=_.buckets[0])==null?void 0:ne.name}))}).catch(U=>{$()&&i({status:"error",message:U.message})})},[pe,q,r]);m.useEffect(()=>{Z()},[Z]);const Re=m.useCallback($=>{Z(),$.type.startsWith("gcs.object.")&&ue(U=>U+1)},[Z]);Vt({topics:["gcs"],onEvent:Re,onResync:()=>{Z(),ue($=>$+1)},enabled:!q}),m.useEffect(()=>{if(S(void 0),N(void 0),!p||q){d({status:"idle"});return}let $=!1;return d({status:"loading"}),kx(p,w.trim()).then(({objects:U})=>{$||d({status:"success",objects:U})}).catch(U=>{$||d({status:"error",message:U.message})}),()=>{$=!0}},[p,q,w,J]),m.useEffect(()=>{if(S(void 0),!v||v.bucket!==p||q)return;let $=!1;return Cx(v.bucket,v.name).then(U=>{$||S(U)}).catch(U=>{$||E(U.message)}),()=>{$=!0}},[p,v,q]);const ee=l.status==="success"?l.buckets:[],fe=l.status==="success"?l.sessions:[],Se=l.status==="success"?l.statusPayload:void 0;function me($){g($),S(void 0),N(void 0)}function z($){p&&N({bucket:p,name:$.name})}function H(){const $=C.trim();!$||q||Nx($).then(()=>{A(""),E(`Created bucket ${$}`),Z()}).catch(U=>E(U.message))}async function be($){q||!await P(Rn({title:"Delete bucket",description:"All objects and metadata in this bucket will be removed. This cannot be undone.",target:$}))||wx($).then(()=>{E(`Deleted bucket ${$}`),Z()}).catch(_=>E(_.message))}async function $e($){!p||q||!await P(Rn({title:"Delete object",description:"This object will be permanently removed from the bucket.",target:$.name}))||Rx(p,$.name).then(()=>{E(`Deleted object ${$.gcsUri}`),ue(_=>_+1)}).catch(_=>E(_.message))}async function Ne($){q||!await P(Rn({title:"Delete upload session",description:"In-progress upload state will be discarded.",target:$.id}))||Ix($.id).then(()=>{E(`Deleted upload session ${$.id}`),Z()}).catch(_=>E(_.message))}return q?n.jsxs(he,{title:"devcloud GCS",children:[n.jsx(F,{title:"GCS is disabled",description:"Enable the GCS service in devcloud config to inspect local buckets and objects."}),n.jsx(_u,{status:Se??Bu(r)})]}):n.jsxs("div",{className:"gcs-workspace",children:[n.jsxs(he,{title:"devcloud GCS",children:[n.jsxs("div",{className:"s3-toolbar",children:[n.jsxs("span",{children:[ee.length," buckets"]}),n.jsx(ae,{onClick:Z,children:"Refresh"})]}),l.status==="loading"?n.jsx(F,{title:"Loading GCS",description:"Reading local GCS status."}):null,l.status==="error"?n.jsx(F,{title:"GCS unavailable",description:l.message,actionLabel:"Retry",onAction:Z}):null,l.status==="success"?n.jsxs("div",{className:"gcs-sidebar",children:[n.jsx(_u,{status:l.statusPayload}),n.jsx(Tx,{bucketName:C,disabled:q,onChange:A,onCreate:H}),n.jsx($x,{activeBucket:p,buckets:ee,disabled:q,onDeleteBucket:be,onSelectBucket:me})]}):null]}),n.jsx(he,{title:"Objects",children:n.jsx(Lx,{activeObjectName:oe,bucketName:p,disabled:q,objectsState:u,onDeleteObject:$e,onInspectObject:z,onPrefixChange:L,prefix:w})}),n.jsx(he,{title:"Inspector",children:n.jsx(Px,{bucketName:p,object:y})}),n.jsx(he,{title:"Upload sessions",children:n.jsx(Mx,{disabled:q,onDeleteSession:Ne,sessions:fe})}),I?n.jsx("p",{className:"inspector-muted gcs-message",children:I}):null]})}function _u({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Status"}),n.jsx("dd",{children:r.running?"running":"disabled"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Project"}),n.jsx("dd",{children:r.project})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Upload sessions"}),n.jsx("dd",{children:n.jsx("code",{children:r.uploadSessionPath})})]})]})}function Tx({bucketName:r,disabled:l,onChange:i,onCreate:u}){return n.jsxs("div",{className:"gcs-create-bucket",children:[n.jsxs("label",{className:"prefix-filter",children:[n.jsx("span",{children:"Create bucket"}),n.jsx("input",{"aria-label":"GCS bucket name",disabled:l,onChange:d=>i(d.target.value),placeholder:"local-bucket",value:r})]}),n.jsx(ae,{disabled:l||r.trim()==="",onClick:u,children:"Create"})]})}function $x({activeBucket:r,buckets:l,disabled:i,onDeleteBucket:u,onSelectBucket:d}){return l.length===0?n.jsx(F,{title:"No buckets",description:"Buckets created through the GCS API will appear here."}):n.jsx("div",{className:"bucket-list",children:l.map(p=>n.jsxs("div",{className:p.name===r?"bucket-item active gcs-bucket-row":"bucket-item gcs-bucket-row",children:[n.jsxs("button",{className:"object-select",onClick:()=>d(p.name),children:[n.jsx("span",{className:"bucket-name",children:p.name}),n.jsx("span",{className:"bucket-meta",children:p.gcsUri}),n.jsxs("span",{className:"bucket-meta",children:["Created ",Ui(p.timeCreated)]})]}),n.jsx("span",{className:"count-pill",children:p.objectCount}),n.jsx(ae,{className:"danger",disabled:i,onClick:()=>u(p.name),children:"Delete bucket"})]},p.name))})}function Lx({activeObjectName:r,bucketName:l,disabled:i,objectsState:u,onDeleteObject:d,onInspectObject:p,onPrefixChange:g,prefix:y}){return l?n.jsxs("div",{className:"object-browser",children:[n.jsxs("label",{className:"prefix-filter",children:[n.jsx("span",{children:"Prefix"}),n.jsx("input",{"aria-label":"Filter GCS objects by prefix",onChange:S=>g(S.target.value),placeholder:"docs/",type:"search",value:y})]}),u.status==="loading"?n.jsx(F,{title:"Loading objects",description:`Reading objects in ${l}.`}):null,u.status==="error"?n.jsx(F,{title:"Objects unavailable",description:u.message}):null,u.status==="success"&&u.objects.length===0?n.jsx(F,{title:"No objects",description:`No objects in ${l} match the current prefix.`}):null,u.status==="success"&&u.objects.length>0?n.jsx("div",{className:"object-table-wrap",children:n.jsxs("table",{className:"object-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Name"}),n.jsx("th",{scope:"col",children:"Size"}),n.jsx("th",{scope:"col",children:"Updated"}),n.jsx("th",{scope:"col",children:"Class"}),n.jsx("th",{scope:"col",children:"Actions"})]})}),n.jsx("tbody",{children:u.objects.map(S=>n.jsxs("tr",{className:S.name===r?"object-row active":"object-row",children:[n.jsxs("td",{children:[n.jsx("button",{className:"object-select",onClick:()=>p(S),children:n.jsx("span",{className:"object-key",children:S.name})}),n.jsx("code",{children:S.gcsUri})]}),n.jsx("td",{children:nd(S.size)}),n.jsx("td",{children:Ui(S.updated)}),n.jsx("td",{children:S.storageClass||"STANDARD"}),n.jsxs("td",{children:[n.jsx("a",{className:"object-action",href:sd(S.downloadUrl),children:"Download"}),n.jsx(ae,{className:"danger",disabled:i,onClick:()=>d(S),children:"Delete object"})]})]},S.name))})]})}):null]}):n.jsx(F,{title:"Select a bucket",description:"Choose a bucket to browse objects and metadata."})}function Px({bucketName:r,object:l}){if(!l)return n.jsx(F,{title:"Inspector",description:r?`Select an object in ${r} to inspect GCS metadata.`:"Object metadata, generation, and download links will appear here."});const i=Object.entries(l.metadata??{});return n.jsxs("div",{className:"object-inspector",children:[n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"gs:// URI"}),n.jsx("code",{children:l.gcsUri})]}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"generation"}),n.jsx("dd",{children:l.generation})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"metageneration"}),n.jsx("dd",{children:l.metageneration})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"storageClass"}),n.jsx("dd",{children:l.storageClass||"STANDARD"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"crc32c"}),n.jsx("dd",{children:l.crc32c||"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"contentType"}),n.jsx("dd",{children:l.contentType||"application/octet-stream"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"ETag"}),n.jsx("dd",{children:n.jsx("code",{children:l.etag||"unknown"})})]})]}),n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"metadata"}),i.length===0?n.jsx("p",{className:"inspector-muted",children:"No user metadata."}):n.jsx("dl",{className:"metadata-list",children:i.map(([u,d])=>n.jsxs("div",{children:[n.jsx("dt",{children:u}),n.jsx("dd",{children:d})]},u))})]}),n.jsx("a",{className:"compat-link inspector-download",href:sd(l.downloadUrl),children:"Download object"})]})}function Mx({disabled:r,onDeleteSession:l,sessions:i}){return i.length===0?n.jsx(F,{title:"No upload sessions",description:"Active resumable upload sessions will appear here."}):n.jsx("div",{className:"object-table-wrap",children:n.jsxs("table",{className:"object-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Session"}),n.jsx("th",{scope:"col",children:"Object"}),n.jsx("th",{scope:"col",children:"Received"}),n.jsx("th",{scope:"col",children:"Created"}),n.jsx("th",{scope:"col",children:"Action"})]})}),n.jsx("tbody",{children:i.map(u=>n.jsxs("tr",{children:[n.jsx("td",{children:n.jsx("code",{children:u.id})}),n.jsxs("td",{children:[n.jsx("span",{className:"object-key",children:u.name}),n.jsx("code",{children:`gs://${u.bucket}/${u.name}`})]}),n.jsx("td",{children:nd(u.receivedBytes)}),n.jsx("td",{children:Ui(u.createdAt)}),n.jsx("td",{children:n.jsx(ae,{className:"danger",disabled:r,onClick:()=>l(u),children:"Delete session"})})]},u.id))})]})})}function Bu(r){return{status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:14443",project:"devcloud",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/s3",uploadSessionPath:".devcloud/data/gcs/upload_sessions"}}function nd(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function Ui(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function sd(r){return r.startsWith("/api/gcs/")?r:"#"}async function Ox(){return V("/api/applicationautoscaling/status")}async function Ax(){return V("/api/applicationautoscaling/scalable-targets")}async function _x(){return V("/api/applicationautoscaling/scaling-policies")}async function Bx(){return V("/api/applicationautoscaling/scheduled-actions")}function Fx({service:r}){const[l,i]=m.useState({status:"loading"}),u=(r==null?void 0:r.status)==="disabled",d=wt(),p=m.useCallback(()=>{const g=d();if(u){i({status:"success",statusPayload:Vx(r),scalableTargets:[],scalingPolicies:[],scheduledActions:[]});return}i({status:"loading"}),Promise.all([Ox(),Ax(),_x(),Bx()]).then(([y,S,v,N])=>{g()&&i({status:"success",statusPayload:y,scalableTargets:S.scalableTargets,scalingPolicies:v.scalingPolicies,scheduledActions:N.scheduledActions})}).catch(y=>{g()&&i({status:"error",message:y.message})})},[d,u,r]);return m.useEffect(()=>{p()},[p]),Vt({topics:["applicationautoscaling"],onEvent:p,onResync:p,enabled:!u}),u?n.jsx(he,{title:"Application Auto Scaling",children:n.jsx(F,{title:"Application Auto Scaling is disabled",description:"Enable the Application Auto Scaling service in devcloud config to inspect scalable targets, policies, and scheduled actions."})}):n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(he,{title:"Status",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.statusPayload.status} / ${l.statusPayload.region}`:"Loading"}),n.jsx(ae,{onClick:p,children:"Refresh"})]}),l.status==="loading"?n.jsx(F,{title:"Loading Application Auto Scaling",description:"Reading local scalable targets, policies, and scheduled actions."}):null,l.status==="error"?n.jsx(F,{title:"Application Auto Scaling unavailable",description:l.message,actionLabel:"Retry",onAction:p}):null,l.status==="success"?n.jsx(zx,{status:l.statusPayload}):null]}),n.jsx(he,{title:"Scalable targets",children:n.jsx(Qx,{targets:l.status==="success"?l.scalableTargets:[]})}),n.jsx(he,{title:"Scaling policies",children:n.jsx(Ux,{policies:l.status==="success"?l.scalingPolicies:[]})}),n.jsx(he,{title:"Scheduled actions",children:n.jsx(qx,{actions:l.status==="success"?l.scheduledActions:[]})})]})}function zx({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:r.region})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Storage path"}),n.jsx("dd",{children:n.jsx("code",{children:r.storagePath})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Scalable targets"}),n.jsx("dd",{children:r.scalableTargetCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Scaling policies"}),n.jsx("dd",{children:r.scalingPolicyCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Scheduled actions"}),n.jsx("dd",{children:r.scheduledActionCount})]})]})}function Qx({targets:r}){return r.length===0?n.jsx(F,{title:"No scalable targets",description:"Targets registered via RegisterScalableTarget will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Application Auto Scaling scalable targets",children:r.map(l=>{var i,u,d;return n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:l.ResourceId}),n.jsxs("span",{className:"count-pill",children:[l.MinCapacity,"-",l.MaxCapacity]})]}),n.jsx("span",{className:"table-row-meta",children:l.ScalableDimension}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:l.ServiceNamespace}),(i=l.SuspendedState)!=null&&i.DynamicScalingInSuspended||(u=l.SuspendedState)!=null&&u.DynamicScalingOutSuspended||(d=l.SuspendedState)!=null&&d.ScheduledScalingSuspended?n.jsx("span",{children:"suspended"}):null]})]},`${l.ResourceId}-${l.ScalableDimension}`)})})}function Ux({policies:r}){return r.length===0?n.jsx(F,{title:"No scaling policies",description:"Policies created via PutScalingPolicy will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Application Auto Scaling scaling policies",children:r.map(l=>n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:l.PolicyName}),n.jsx("span",{className:"count-pill",children:l.PolicyType})]}),n.jsx("span",{className:"table-row-meta",children:l.ResourceId}),n.jsx("span",{className:"table-row-tags",children:n.jsx("span",{children:l.ScalableDimension})})]},l.PolicyARN))})}function qx({actions:r}){return r.length===0?n.jsx(F,{title:"No scheduled actions",description:"Actions created via PutScheduledAction will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Application Auto Scaling scheduled actions",children:r.map(l=>n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsx("span",{className:"table-row-top",children:n.jsx("span",{className:"table-row-name",children:l.ScheduledActionName})}),n.jsx("span",{className:"table-row-meta",children:l.ResourceId}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:l.Schedule}),l.Timezone?n.jsx("span",{children:l.Timezone}):null]})]},l.ScheduledActionName))})}function Vx(r){return{service:"applicationautoscaling",status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:18030",region:"us-east-1",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/applicationautoscaling",scalableTargetCount:0,scalingPolicyCount:0,scheduledActionCount:0}}async function Kx(){return V("/api/lambda/status")}async function Jx(){return V("/api/lambda/functions")}async function Hx(){return V("/api/lambda/invocations")}async function Wx(r,l){return V(`/api/lambda/functions/${encodeURIComponent(r)}/invoke`,{method:"POST",rawBody:l,headers:{"Content-Type":"application/json"},timeoutMs:91e4})}function Gx({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState("{}"),[y,S]=m.useState(()=>new Map),v=m.useRef(new Map),N=m.useRef(0),w=(r==null?void 0:r.status)==="disabled",L=wt(),C=m.useCallback(()=>{const P=L();w||Promise.all([Kx(),Jx(),Hx()]).then(([oe,pe,Z])=>{P()&&(i({status:"success",statusPayload:oe,functions:pe.functions,invocations:Z.invocations}),d(Re=>{var ee;return Re&&pe.functions.some(fe=>fe.FunctionName===Re)?Re:(ee=pe.functions[0])==null?void 0:ee.FunctionName}))}).catch(oe=>{P()&&i({status:"error",message:oe.message})})},[L,w]);if(m.useEffect(()=>{C()},[C]),Vt({topics:["lambda"],onEvent:C,onResync:C,enabled:!w}),w)return n.jsx(he,{title:"Lambda",children:n.jsx(F,{title:"Lambda is disabled",description:"Enable the Lambda service in devcloud config to deploy and invoke local functions."})});const A=l.status==="success"?l.functions:[],I=l.status==="success"?l.invocations:[],E=A.find(P=>P.FunctionName===u),J=E&&y.get(E.FunctionName)||{status:"idle"},ue=(P,oe,pe)=>{v.current.get(P)===oe&&S(Z=>new Map(Z).set(P,pe))},q=P=>{if(P.preventDefault(),!E)return;const oe=E.FunctionName;N.current+=1;const pe=N.current;v.current.set(oe,pe);try{JSON.parse(p||"{}")}catch(Z){ue(oe,pe,{status:"error",message:`Event is not valid JSON: ${Z.message}`});return}ue(oe,pe,{status:"running"}),Wx(oe,p||"{}").then(Z=>ue(oe,pe,{status:"done",result:Z})).catch(Z=>ue(oe,pe,{status:"error",message:Z.message}))};return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(he,{title:"Status",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.statusPayload.status} / ${l.statusPayload.region}`:"Loading"}),n.jsx(ae,{onClick:C,children:"Refresh"})]}),l.status==="loading"?n.jsx(F,{title:"Loading Lambda",description:"Reading local functions and recent invocations."}):null,l.status==="error"?n.jsx(F,{title:"Lambda unavailable",description:l.message,actionLabel:"Retry",onAction:C}):null,l.status==="success"?n.jsx(Yx,{status:l.statusPayload}):null]}),n.jsx(he,{title:"Functions",children:A.length===0?n.jsx(F,{title:"No functions",description:"Functions created via CreateFunction will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Lambda functions",children:A.map(P=>n.jsxs("button",{type:"button",className:`dynamodb-table-row${P.FunctionName===u?" active":""}`,"aria-pressed":P.FunctionName===u,onClick:()=>d(P.FunctionName),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:P.FunctionName}),n.jsx("span",{className:"count-pill",children:P.Runtime})]}),n.jsx("span",{className:"table-row-meta",children:P.Handler}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:[P.MemorySize," MB"]}),n.jsxs("span",{children:[P.Timeout,"s"]}),n.jsx("span",{children:P.State})]})]},P.FunctionArn))})}),E?n.jsxs(he,{title:`Function: ${E.FunctionName}`,children:[n.jsx(Xx,{fn:E}),n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:q,children:[n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Test event (JSON)"}),n.jsx("textarea",{"aria-label":"Lambda test event JSON",onChange:P=>g(P.target.value),rows:4,value:p})]}),n.jsx(ae,{type:"submit",disabled:J.status==="running",children:J.status==="running"?"Invoking…":"Invoke"})]}),n.jsx(Zx,{state:J})]}):null,n.jsx(he,{title:"Recent invocations",children:I.length===0?n.jsx(F,{title:"No invocations yet",description:"Invoke a function to see its result and log tail here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Lambda invocations",children:I.map(P=>n.jsxs("details",{className:"dynamodb-table-row",children:[n.jsxs("summary",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:P.functionName}),n.jsx("span",{className:"count-pill",children:P.status})]}),n.jsxs("span",{className:"table-row-meta",children:[P.startedAt," · ",P.invocationType," · ",P.durationMs.toFixed(1)," ms"]}),n.jsx("pre",{className:"redis-value-pre",children:P.logTail||"(no log output)"})]},P.requestId))})})]})}function Yx({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:r.region})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Storage path"}),n.jsx("dd",{children:n.jsx("code",{children:r.storagePath})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Functions"}),n.jsx("dd",{children:r.functionCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Recorded invocations"}),n.jsx("dd",{children:r.invocationCount})]})]})}function Xx({fn:r}){var i;const l=Object.keys(((i=r.Environment)==null?void 0:i.Variables)??{});return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"ARN"}),n.jsx("dd",{children:n.jsx("code",{children:r.FunctionArn})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Role"}),n.jsx("dd",{children:n.jsx("code",{children:r.Role})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Code"}),n.jsxs("dd",{children:[r.CodeSize," bytes · ",n.jsx("code",{children:r.CodeSha256})]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Last modified"}),n.jsx("dd",{children:r.LastModified})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Environment keys"}),n.jsx("dd",{children:l.length>0?l.join(", "):"none"})]})]})}function Zx({state:r}){if(r.status==="idle"||r.status==="running")return null;if(r.status==="error")return n.jsx(F,{title:"Invoke failed",description:r.message});const{result:l}=r;return n.jsxs("div",{"aria-label":"Lambda invoke result",children:[n.jsx("p",{className:"inspector-muted",children:l.functionError?`Function error: ${l.functionError}`:`Succeeded (HTTP ${l.statusCode})`}),n.jsx("pre",{className:"redis-value-pre",children:l.payload}),n.jsx("pre",{className:"redis-value-pre",children:l.log})]})}async function ey(){return V("/api/cloudrun/status")}async function ty(){return V("/api/cloudrun/services")}async function ny(){return V("/api/cloudrun/instances")}function rd(r){const[,l,,i,,u]=r.split("/");return[l,i,u].map(encodeURIComponent).join("/")}async function sy(r){return V(`/api/cloudrun/services/${rd(r)}/revisions`)}async function ry(r){return V(`/api/cloudrun/services/${rd(r)}/logs`)}function Fa(r){return r.split("/").pop()??r}function ay({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(),y=(r==null?void 0:r.status)==="disabled",S=wt(),v=m.useCallback(()=>{const I=S();y||Promise.all([ey(),ty(),ny()]).then(([E,J,ue])=>{I()&&(i({status:"success",statusPayload:E,services:J.services,instances:ue.instances}),d(q=>{var P;return q&&J.services.some(oe=>oe.name===q)?q:(P=J.services[0])==null?void 0:P.name}))}).catch(E=>{I()&&i({status:"error",message:E.message})})},[S,y]);if(m.useEffect(()=>{v()},[v]),m.useEffect(()=>{if(!u||y){g(void 0);return}let I=!1;return Promise.all([sy(u),ry(u)]).then(([E,J])=>{I||g({service:u,revisions:E.revisions,logs:J.lines})}).catch(()=>{I||g({service:u,revisions:[],logs:[]})}),()=>{I=!0}},[u,y,l]),Vt({topics:["cloudrun"],onEvent:v,onResync:v,enabled:!y}),y)return n.jsx(he,{title:"Cloud Run",children:n.jsx(F,{title:"Cloud Run is disabled",description:"Enable the Cloud Run service in devcloud config to deploy and run local services."})});const N=l.status==="success"?l.services:[],w=l.status==="success"?l.instances:[],L=N.find(I=>I.name===u),C=w.find(I=>I.service===u),A=p&&p.service===u?p:void 0;return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(he,{title:"Status",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.statusPayload.status} / ${l.statusPayload.project} / ${l.statusPayload.region}`:"Loading"}),n.jsx(ae,{onClick:v,children:"Refresh"})]}),l.status==="loading"?n.jsx(F,{title:"Loading Cloud Run",description:"Reading local services and running instances."}):null,l.status==="error"?n.jsx(F,{title:"Cloud Run unavailable",description:l.message,actionLabel:"Retry",onAction:v}):null,l.status==="success"?n.jsx(ly,{status:l.statusPayload}):null]}),n.jsx(he,{title:"Services",children:N.length===0?n.jsx(F,{title:"No services",description:"Services created via the Cloud Run Admin API v2 will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Cloud Run services",children:N.map(I=>{const E=w.some(J=>J.service===I.name);return n.jsxs("button",{type:"button",className:`dynamodb-table-row${I.name===u?" active":""}`,"aria-pressed":I.name===u,onClick:()=>d(I.name),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:Fa(I.name)}),n.jsx("span",{className:"count-pill",children:E?"running":"idle"})]}),n.jsx("span",{className:"table-row-meta",children:I.name.split("/services/")[0]}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:["gen ",I.generation]}),n.jsx("span",{children:Fa(I.latestReadyRevision)})]})]},I.name)})})}),L?n.jsxs(he,{title:`Service: ${Fa(L.name)}`,children:[n.jsx(iy,{svc:L,instance:C}),n.jsx("h3",{className:"inspector-heading",children:"Revisions"}),A&&A.revisions.length>0?n.jsx("div",{className:"dynamodb-table-list","aria-label":"Cloud Run revisions",children:A.revisions.map(I=>{var E;return n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:Fa(I.name)}),I.name===L.latestReadyRevision?n.jsx("span",{className:"count-pill",children:"latest"}):null]}),n.jsx("span",{className:"table-row-meta",children:I.createTime}),n.jsx("span",{className:"table-row-tags",children:n.jsx("span",{children:(E=I.containers[0])==null?void 0:E.image})})]},I.name)})}):n.jsx("p",{className:"inspector-muted",children:A?"No revisions.":"Loading revisions…"}),n.jsx("h3",{className:"inspector-heading",children:"Instance logs"}),n.jsx("pre",{className:"redis-value-pre","aria-label":"Cloud Run instance logs",children:A&&A.logs.length>0?A.logs.join(` +`):"(no output yet — send a request to start the instance)"})]}):null]})}function ly({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Default project / region"}),n.jsxs("dd",{children:[r.project," / ",r.region]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Storage path"}),n.jsx("dd",{children:n.jsx("code",{children:r.storagePath})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Services"}),n.jsx("dd",{children:r.serviceCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Running instances"}),n.jsx("dd",{children:r.instanceCount})]})]})}function iy({svc:r,instance:l}){var u;const i=r.template.containers[0];return n.jsxs("dl",{className:"inspector-list",children:[(r.urls??[r.uri]).map(d=>n.jsxs("div",{children:[n.jsx("dt",{children:"URL"}),n.jsx("dd",{children:n.jsx("a",{href:d,target:"_blank",rel:"noreferrer",children:n.jsx("code",{children:d})})})]},d)),n.jsxs("div",{children:[n.jsx("dt",{children:"Image"}),n.jsx("dd",{children:n.jsx("code",{children:i==null?void 0:i.image})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Command"}),n.jsx("dd",{children:n.jsx("code",{children:(u=i==null?void 0:i.command)!=null&&u.length?[...i.command,...i.args??[]].join(" "):"(image entrypoint)"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Ingress"}),n.jsx("dd",{children:r.ingress})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Instance"}),n.jsx("dd",{children:l?`${l.mode} · port ${l.port}${l.pid?` · pid ${l.pid}`:""} · ${l.requestCount} requests`:"not running (starts on first request)"})]})]})}function oy({services:r,path:l}){return l==="/mail"?n.jsx(Pp,{service:r.find(i=>i.id==="mail")}):l==="/s3"?n.jsx(km,{service:r.find(i=>i.id==="s3")}):l==="/gcs"?n.jsx(Ex,{service:r.find(i=>i.id==="gcs")}):l==="/dynamodb"?n.jsx(xh,{service:r.find(i=>i.id==="dynamodb")}):l==="/bigquery"?n.jsx(Vh,{service:r.find(i=>i.id==="bigquery")}):l==="/redshift"?n.jsx(Yh,{service:r.find(i=>i.id==="redshift")}):l==="/redis"?n.jsx(dg,{service:r.find(i=>i.id==="redis")}):l==="/sqs"?n.jsx(sx,{service:r.find(i=>i.id==="sqs")}):l==="/pubsub"?n.jsx(fx,{service:r.find(i=>i.id==="pubsub")}):l==="/applicationautoscaling"?n.jsx(Fx,{service:r.find(i=>i.id==="applicationautoscaling")}):l==="/lambda"?n.jsx(Gx,{service:r.find(i=>i.id==="lambda")}):l==="/cloudrun"?n.jsx(ay,{service:r.find(i=>i.id==="cloudrun")}):n.jsx(jp,{services:r})}function cy({services:r}){const l=Er(window.location.pathname);return n.jsxs("nav",{className:"service-switcher","aria-label":"Services",children:[n.jsx("a",{"aria-current":l==="/"?"page":void 0,className:l==="/"?"active":void 0,href:za("/"),children:"Services"}),r.map(i=>n.jsxs("a",{"aria-current":l===Er(i.path)?"page":void 0,"aria-label":`${i.name}: ${i.status}`,className:l===Er(i.path)?"active":void 0,href:za(i.path),children:[n.jsx("span",{children:i.name}),n.jsxs("span",{className:`switcher-status ${uy(i.status)}`,children:[n.jsx("span",{className:"status-dot"}),i.status]})]},i.id))]})}function uy(r){return r==="running"?"running":"disabled"}function dy({services:r}){const l=r.filter(d=>d.status==="running").length,i=`${l}/${r.length} running`,u=r.length>0&&l===r.length?"running":"attention";return n.jsxs("div",{className:`status-pill ${u}`,"aria-label":`Service status: ${i}`,children:[n.jsx("span",{className:"status-dot"}),i]})}function fy({services:r}){const l=Er(window.location.pathname),i=r.find(p=>p.path===l),[u,d]=m.useState(()=>fp());return m.useEffect(()=>pp(d),[]),n.jsxs("footer",{className:"activity-footer",children:[n.jsxs("span",{children:["Last request: ",(u==null?void 0:u.path)??"/api/dashboard/services"]}),n.jsxs("span",{children:["Active: ",(i==null?void 0:i.name)??"Service index"]}),n.jsxs("span",{children:["API status: ",n.jsx(py,{activity:u,fallback:(i==null?void 0:i.status)??"running"})]}),n.jsxs("span",{children:["Storage: ",(i==null?void 0:i.storagePath)??"registry"]})]})}function py({activity:r,fallback:l}){if(!r)return n.jsx("span",{children:l});const i=r.status==="success"&&r.statusCode?`${r.status} ${r.statusCode}`:r.status;return n.jsx("span",{className:`activity-status ${r.status}`,title:r.message,children:i})}function my({notifications:r}){const{permission:l,enabled:i,setEnabled:u,requestPermission:d}=r;return l==="unsupported"?null:l==="default"?n.jsx(ae,{className:"notifications-toggle",onClick:()=>{d()},title:"Show desktop notifications when local services emit events",children:"Enable notifications"}):l==="denied"?n.jsx(ae,{className:"notifications-toggle is-disabled",disabled:!0,title:"Notifications are blocked. Allow them in your browser site settings to enable.",children:"Notifications blocked"}):n.jsx(ae,{"aria-pressed":i,className:i?"notifications-toggle is-on":"notifications-toggle",onClick:()=>u(!i),title:i?"Click to mute desktop notifications":"Click to enable desktop notifications",children:i?"Notifications: on":"Notifications: off"})}const ad="devcloud.notifications.enabled";function Fu(){return typeof window>"u"||typeof Notification>"u"?"unsupported":Notification.permission}function hy(){if(typeof window>"u")return!1;try{return window.localStorage.getItem(ad)==="1"}catch{return!1}}function gy(r){if(!(typeof window>"u"))try{window.localStorage.setItem(ad,r?"1":"0")}catch{}}function xy(){const[r,l]=m.useState(()=>Fu()),[i,u]=m.useState(()=>hy()),d=m.useCallback(g=>{u(g),gy(g)},[]),p=m.useCallback(async()=>{if(typeof Notification>"u")return"unsupported";try{const y=await Notification.requestPermission();return l(y),y==="granted"&&d(!0),y}catch{return Fu()}},[d]);return{permission:r,enabled:i,setEnabled:d,requestPermission:p}}const yy={mail:"Mail",s3:"S3",gcs:"GCS",redis:"Redis",dynamodb:"DynamoDB",bigquery:"BigQuery",sqs:"SQS",pubsub:"Pub/Sub",redshift:"Redshift"};function jy(r){return yy[r]??r}function qs(r,l){const i=r==null?void 0:r[l];if(typeof i=="string")return i;if(typeof i=="number"&&Number.isFinite(i))return String(i)}function ld(r,l){const i=r==null?void 0:r[l];return Array.isArray(i)?i.filter(u=>typeof u=="string"):[]}function id(r){return r.map(l=>Dn(l)).filter(l=>l.length>0).join(", ")}function vy(r){if(r===void 0)return;const l=Number(r);if(!(!Number.isFinite(l)||l<0))return l<1024?`${l} B`:l<1024*1024?`${(l/1024).toFixed(1)} KB`:l<1024*1024*1024?`${(l/1024/1024).toFixed(1)} MB`:`${(l/1024/1024/1024).toFixed(2)} GB`}const by=180;function Sy(r){return r.replace(/<(script|style)[\s\S]*?<\/\1>/gi," ").replace(/<[^>]+>/g," ").replace(/ /gi," ").replace(/&/gi,"&").replace(/</gi,"<").replace(/>/gi,">").replace(/"/gi,'"').replace(/'/gi,"'").replace(/\s+/g," ").trim()}function Ny(r,l){const i=r.replace(/\r/g,"").replace(/\n{3,}/g,` + +`).trim();return i.length<=l?i:`${i.slice(0,l).trimEnd()}…`}function od(r){const l=qs(r.payload,"from")??"(unknown sender)",i=qs(r.payload,"subject")??"",u=Dn(l)||l,d=id(ld(r.payload,"to")),p=ms(i).trim()||"(no subject)",g=qs(r.payload,"messageID"),y=[`From: ${u}`];return d&&y.push(`To: ${d}`),{title:p,body:y.join(` +`),tag:`mail.received:${g??p}`}}async function wy(r){var u,d;if(typeof Notification>"u")return;const l=qs(r.payload,"messageID");let i=od(r);if(l)try{const p=await Dp(l),g=ms(p.subject??"").trim()||"(no subject)",y=((u=p.from)==null?void 0:u.trim())||qs(r.payload,"from")||"(unknown sender)",S=Dn(y)||y,v=id(p.to??ld(r.payload,"to")),N=((d=p.textBody)==null?void 0:d.trim())||Sy(p.htmlBody??""),w=N?Ny(N,by):"",L=v?`From: ${S} +To: ${v}`:`From: ${S}`;i={title:g,body:w?`${L} + +${w}`:L,tag:`mail.received:${l}`}}catch{}try{new Notification(i.title,{body:i.body,tag:i.tag})}catch{}}function ky(r){const l=r.payload,i=d=>qs(l,d),u=jy(r.service);switch(r.type){case"mail.received":return od(r);case"mail.deleted":return{title:"Mail message deleted",body:"A message was removed from the inbox.",tag:`mail.deleted:${i("messageID")??r.timestamp}`};case"mail.cleared":return{title:"Mail inbox cleared",body:"All messages were removed.",tag:"mail.cleared"};case"s3.object.put":case"gcs.object.put":{const d=i("bucket")??"?",p=i("key")??"?",g=vy(i("contentLength")),y=g?`${d}/${p} +${g}`:`${d}/${p}`;return{title:`${u} object uploaded`,body:y,tag:`${r.type}:${d}/${p}`}}case"s3.object.deleted":case"gcs.object.deleted":{const d=i("bucket")??"?",p=i("key")??"?";return{title:`${u} object deleted`,body:`${d}/${p}`,tag:`${r.type}:${d}/${p}`}}case"s3.bucket.created":case"gcs.bucket.created":{const d=i("bucket")??"?";return{title:`${u} bucket created`,body:d,tag:`${r.type}:${d}`}}case"s3.bucket.deleted":case"gcs.bucket.deleted":{const d=i("bucket")??"?";return{title:`${u} bucket deleted`,body:d,tag:`${r.type}:${d}`}}case"dynamodb.table.created":case"dynamodb.table.deleted":{const d=i("table")??"?";return{title:`DynamoDB table ${r.type.endsWith(".created")?"created":"deleted"}`,body:d,tag:`${r.type}:${d}`}}case"dynamodb.item.put":case"dynamodb.item.updated":case"dynamodb.item.deleted":{const d=i("table")??"?";return{title:`DynamoDB item ${r.type.split(".").pop()??"changed"}`,body:`Table: ${d}`,tag:`${r.type}:${d}`}}case"bigquery.dataset.created":case"bigquery.dataset.deleted":{const d=i("project")??"?",p=i("dataset")??"?";return{title:`BigQuery dataset ${r.type.endsWith(".created")?"created":"deleted"}`,body:`${d}.${p}`,tag:`${r.type}:${d}.${p}`}}case"bigquery.table.created":case"bigquery.table.deleted":{const d=i("project")??"?",p=i("dataset")??"?",g=i("table")??"?";return{title:`BigQuery table ${r.type.endsWith(".created")?"created":"deleted"}`,body:`${d}.${p}.${g}`,tag:`${r.type}:${d}.${p}.${g}`}}case"bigquery.job.inserted":{const d=i("project")??"?",p=i("jobType")??"job";return{title:`BigQuery ${p} job started`,body:`Project: ${d}`,tag:`bigquery.job.inserted:${d}:${p}:${r.timestamp}`}}case"sqs.message.sent":case"sqs.message.deleted":{const d=i("queue")??"?",p=i("count"),g=r.type.endsWith(".sent")?"sent":"deleted",y=p?`Queue: ${d} (${p} message${p==="1"?"":"s"})`:`Queue: ${d}`;return{title:`SQS message ${g}`,body:y,tag:`${r.type}:${d}:${r.timestamp}`}}case"pubsub.topic.created":case"pubsub.topic.deleted":{const d=i("topic")??"?";return{title:`Pub/Sub topic ${r.type.endsWith(".created")?"created":"deleted"}`,body:d,tag:`${r.type}:${d}`}}case"pubsub.subscription.created":{const d=i("subscription")??"?",p=i("topic")??"?";return{title:"Pub/Sub subscription created",body:`${d} +on topic ${p}`,tag:`${r.type}:${d}`}}case"pubsub.message.published":{const d=i("topic")??"?",p=i("count")??"1";return{title:"Pub/Sub message published",body:`${p} message${p==="1"?"":"s"} → ${d}`,tag:`${r.type}:${d}:${r.timestamp}`}}case"pubsub.message.pulled":{const d=i("subscription")??"?";return{title:"Pub/Sub messages pulled",body:`${i("count")??"0"} from ${d}`,tag:`${r.type}:${d}:${r.timestamp}`}}case"redshift.statement.executed":case"redshift.statement.batch_executed":{const d=i("statementID")??"?";return{title:`Redshift ${(r.type.endsWith(".batch_executed")?"Batch statement":"Statement").toLowerCase()} executed`,body:`ID: ${d}`,tag:`${r.type}:${d}`}}case"redis.command.mutation":{const d=i("command")??"?",p=i("key");return{title:`Redis ${d}`,body:p?`Key: ${p}`:"Mutation executed",tag:`redis.command.mutation:${d}:${p??""}:${r.timestamp}`}}default:return{title:`${u} event`,body:r.type,tag:`${r.type}:${r.timestamp}`}}}function Cy({enabled:r,permission:l,suppressWhileVisible:i=!1}){const u=r&&l==="granted",d=m.useCallback(p=>{if(typeof Notification>"u"||i&&typeof document<"u"&&document.visibilityState==="visible")return;if(p.type==="mail.received"){wy(p);return}const g=ky(p);if(g)try{new Notification(g.title,{body:g.body,tag:g.tag})}catch{}},[i]);Vt({topics:[],onEvent:d,enabled:u})}function Ry({services:r,children:l}){const i=xy();return Cy({enabled:i.enabled,permission:i.permission}),n.jsxs("div",{className:"app-shell",children:[n.jsxs("header",{className:"top-bar",children:[n.jsxs("div",{className:"brand-block",children:[n.jsx("a",{className:"brand-title",href:za("/"),children:"devcloud"}),n.jsx(dy,{services:r})]}),n.jsxs("div",{className:"top-actions",children:[n.jsx(my,{notifications:i}),n.jsx(cy,{services:r}),n.jsx(ae,{onClick:()=>window.location.reload(),children:"Refresh"})]})]}),n.jsx("main",{className:"service-surface",children:l}),n.jsx(fy,{services:r})]})}class Dy extends m.Component{constructor(){super(...arguments);fs(this,"state",{})}static getDerivedStateFromError(i){return{error:i}}render(){return this.state.error?n.jsx(F,{title:"Dashboard request failed",description:this.state.error.message,actionLabel:"Retry",onAction:()=>{yp(),this.setState({error:void 0})}}):this.props.children}}function Iy(){const{services:r}=xp(),l=Er(window.location.pathname);return n.jsx(Ry,{services:r,children:oy({services:r,path:l})})}function Ey(){return n.jsx(Dy,{children:n.jsx(m.Suspense,{fallback:n.jsx(F,{title:"Loading dashboard",description:"Checking local service status."}),children:n.jsx(vp,{children:n.jsx(Iy,{})})})})}dp.createRoot(document.getElementById("root")).render(n.jsx(rp.StrictMode,{children:n.jsx(Ey,{})})); diff --git a/services/dashboard/assets/react/assets/index-DYqY_0vS.js b/services/dashboard/assets/react/assets/index-DYqY_0vS.js deleted file mode 100644 index 674b422..0000000 --- a/services/dashboard/assets/react/assets/index-DYqY_0vS.js +++ /dev/null @@ -1,109 +0,0 @@ -var Xf=Object.defineProperty;var Zf=(r,l,i)=>l in r?Xf(r,l,{enumerable:!0,configurable:!0,writable:!0,value:i}):r[l]=i;var cs=(r,l,i)=>Zf(r,typeof l!="symbol"?l+"":l,i);(function(){const l=document.createElement("link").relList;if(l&&l.supports&&l.supports("modulepreload"))return;for(const d of document.querySelectorAll('link[rel="modulepreload"]'))u(d);new MutationObserver(d=>{for(const p of d)if(p.type==="childList")for(const g of p.addedNodes)g.tagName==="LINK"&&g.rel==="modulepreload"&&u(g)}).observe(document,{childList:!0,subtree:!0});function i(d){const p={};return d.integrity&&(p.integrity=d.integrity),d.referrerPolicy&&(p.referrerPolicy=d.referrerPolicy),d.crossOrigin==="use-credentials"?p.credentials="include":d.crossOrigin==="anonymous"?p.credentials="omit":p.credentials="same-origin",p}function u(d){if(d.ep)return;d.ep=!0;const p=i(d);fetch(d.href,p)}})();function Bu(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var Si={exports:{}},kr={},Ni={exports:{}},Te={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var hu;function ep(){if(hu)return Te;hu=1;var r=Symbol.for("react.element"),l=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),d=Symbol.for("react.profiler"),p=Symbol.for("react.provider"),g=Symbol.for("react.context"),y=Symbol.for("react.forward_ref"),v=Symbol.for("react.suspense"),S=Symbol.for("react.memo"),w=Symbol.for("react.lazy"),N=Symbol.iterator;function $(b){return b===null||typeof b!="object"?null:(b=N&&b[N]||b["@@iterator"],typeof b=="function"?b:null)}var C={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},I=Object.assign,E={};function F(b,T,oe){this.props=b,this.context=T,this.refs=E,this.updater=oe||C}F.prototype.isReactComponent={},F.prototype.setState=function(b,T){if(typeof b!="object"&&typeof b!="function"&&b!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,b,T,"setState")},F.prototype.forceUpdate=function(b){this.updater.enqueueForceUpdate(this,b,"forceUpdate")};function q(){}q.prototype=F.prototype;function Y(b,T,oe){this.props=b,this.context=T,this.refs=E,this.updater=oe||C}var L=Y.prototype=new q;L.constructor=Y,I(L,F.prototype),L.isPureReactComponent=!0;var ne=Array.isArray,ge=Object.prototype.hasOwnProperty,xe={current:null},ye={key:!0,ref:!0,__self:!0,__source:!0};function Z(b,T,oe){var fe,Ne={},we=null,Re=null;if(T!=null)for(fe in T.ref!==void 0&&(Re=T.ref),T.key!==void 0&&(we=""+T.key),T)ge.call(T,fe)&&!ye.hasOwnProperty(fe)&&(Ne[fe]=T[fe]);var ce=arguments.length-2;if(ce===1)Ne.children=oe;else if(1>>1,T=O[b];if(0>>1;bd(Ne,Q))wed(Re,Ne)?(O[b]=Re,O[we]=Q,b=we):(O[b]=Ne,O[fe]=Q,b=fe);else if(wed(Re,Q))O[b]=Re,O[we]=Q,b=we;else break e}}return K}function d(O,K){var Q=O.sortIndex-K.sortIndex;return Q!==0?Q:O.id-K.id}if(typeof performance=="object"&&typeof performance.now=="function"){var p=performance;r.unstable_now=function(){return p.now()}}else{var g=Date,y=g.now();r.unstable_now=function(){return g.now()-y}}var v=[],S=[],w=1,N=null,$=3,C=!1,I=!1,E=!1,F=typeof setTimeout=="function"?setTimeout:null,q=typeof clearTimeout=="function"?clearTimeout:null,Y=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function L(O){for(var K=i(S);K!==null;){if(K.callback===null)u(S);else if(K.startTime<=O)u(S),K.sortIndex=K.expirationTime,l(v,K);else break;K=i(S)}}function ne(O){if(E=!1,L(O),!I)if(i(v)!==null)I=!0,Ie(ge);else{var K=i(S);K!==null&&je(ne,K.startTime-O)}}function ge(O,K){I=!1,E&&(E=!1,q(Z),Z=-1),C=!0;var Q=$;try{for(L(K),N=i(v);N!==null&&(!(N.expirationTime>K)||O&&!ae());){var b=N.callback;if(typeof b=="function"){N.callback=null,$=N.priorityLevel;var T=b(N.expirationTime<=K);K=r.unstable_now(),typeof T=="function"?N.callback=T:N===i(v)&&u(v),L(K)}else u(v);N=i(v)}if(N!==null)var oe=!0;else{var fe=i(S);fe!==null&&je(ne,fe.startTime-K),oe=!1}return oe}finally{N=null,$=Q,C=!1}}var xe=!1,ye=null,Z=-1,de=5,De=-1;function ae(){return!(r.unstable_now()-DeO||125b?(O.sortIndex=Q,l(S,O),i(v)===null&&O===i(S)&&(E?(q(Z),Z=-1):E=!0,je(ne,Q-b))):(O.sortIndex=T,l(v,O),I||C||(I=!0,Ie(ge))),O},r.unstable_shouldYield=ae,r.unstable_wrapCallback=function(O){var K=$;return function(){var Q=$;$=K;try{return O.apply(this,arguments)}finally{$=Q}}}})(Ci)),Ci}var vu;function ap(){return vu||(vu=1,ki.exports=rp()),ki.exports}/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var bu;function lp(){if(bu)return $t;bu=1;var r=Pi(),l=ap();function i(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,s=1;s"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),v=Object.prototype.hasOwnProperty,S=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,w={},N={};function $(e){return v.call(N,e)?!0:v.call(w,e)?!1:S.test(e)?N[e]=!0:(w[e]=!0,!1)}function C(e,t,s,a){if(s!==null&&s.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return a?!1:s!==null?!s.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function I(e,t,s,a){if(t===null||typeof t>"u"||C(e,t,s,a))return!0;if(a)return!1;if(s!==null)switch(s.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function E(e,t,s,a,o,c,f){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=a,this.attributeNamespace=o,this.mustUseProperty=s,this.propertyName=e,this.type=t,this.sanitizeURL=c,this.removeEmptyString=f}var F={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){F[e]=new E(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];F[t]=new E(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){F[e]=new E(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){F[e]=new E(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){F[e]=new E(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){F[e]=new E(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){F[e]=new E(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){F[e]=new E(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){F[e]=new E(e,5,!1,e.toLowerCase(),null,!1,!1)});var q=/[\-:]([a-z])/g;function Y(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(q,Y);F[t]=new E(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(q,Y);F[t]=new E(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(q,Y);F[t]=new E(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){F[e]=new E(e,1,!1,e.toLowerCase(),null,!1,!1)}),F.xlinkHref=new E("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){F[e]=new E(e,1,!1,e.toLowerCase(),null,!0,!0)});function L(e,t,s,a){var o=F.hasOwnProperty(t)?F[t]:null;(o!==null?o.type!==0:a||!(2h||o[f]!==c[h]){var x=` -`+o[f].replace(" at new "," at ");return e.displayName&&x.includes("")&&(x=x.replace("",e.displayName)),x}while(1<=f&&0<=h);break}}}finally{oe=!1,Error.prepareStackTrace=s}return(e=e?e.displayName||e.name:"")?T(e):""}function Ne(e){switch(e.tag){case 5:return T(e.type);case 16:return T("Lazy");case 13:return T("Suspense");case 19:return T("SuspenseList");case 0:case 2:case 15:return e=fe(e.type,!1),e;case 11:return e=fe(e.type.render,!1),e;case 1:return e=fe(e.type,!0),e;default:return""}}function we(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case ye:return"Fragment";case xe:return"Portal";case de:return"Profiler";case Z:return"StrictMode";case X:return"Suspense";case J:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ae:return(e.displayName||"Context")+".Consumer";case De:return(e._context.displayName||"Context")+".Provider";case z:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case ve:return t=e.displayName||null,t!==null?t:we(e.type)||"Memo";case Ie:t=e._payload,e=e._init;try{return we(e(t))}catch{}}return null}function Re(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return we(t);case 8:return t===Z?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ce(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ee(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Qe(e){var t=Ee(e)?"checked":"value",s=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),a=""+e[t];if(!e.hasOwnProperty(t)&&typeof s<"u"&&typeof s.get=="function"&&typeof s.set=="function"){var o=s.get,c=s.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(f){a=""+f,c.call(this,f)}}),Object.defineProperty(e,t,{enumerable:s.enumerable}),{getValue:function(){return a},setValue:function(f){a=""+f},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function nt(e){e._valueTracker||(e._valueTracker=Qe(e))}function Nt(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var s=t.getValue(),a="";return e&&(a=Ee(e)?e.checked?"true":"false":e.value),e=a,e!==s?(t.setValue(e),!0):!1}function ee(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function $e(e,t){var s=t.checked;return Q({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:s??e._wrapperState.initialChecked})}function ke(e,t){var s=t.defaultValue==null?"":t.defaultValue,a=t.checked!=null?t.checked:t.defaultChecked;s=ce(t.value!=null?t.value:s),e._wrapperState={initialChecked:a,initialValue:s,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ue(e,t){t=t.checked,t!=null&&L(e,"checked",t,!1)}function be(e,t){ue(e,t);var s=ce(t.value),a=t.type;if(s!=null)a==="number"?(s===0&&e.value===""||e.value!=s)&&(e.value=""+s):e.value!==""+s&&(e.value=""+s);else if(a==="submit"||a==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Kt(e,t.type,s):t.hasOwnProperty("defaultValue")&&Kt(e,t.type,ce(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function wt(e,t,s){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var a=t.type;if(!(a!=="submit"&&a!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,s||t===e.value||(e.value=t),e.defaultValue=t}s=e.name,s!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,s!==""&&(e.name=s)}function Kt(e,t,s){(t!=="number"||ee(e.ownerDocument)!==e)&&(s==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+s&&(e.defaultValue=""+s))}var Bt=Array.isArray;function kt(e,t,s,a){if(e=e.options,t){t={};for(var o=0;o"+t.valueOf().toString()+"",t=lt.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function _e(e,t){if(t){var s=e.firstChild;if(s&&s===e.lastChild&&s.nodeType===3){s.nodeValue=t;return}}e.textContent=t}var ut={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Cn=["Webkit","ms","Moz","O"];Object.keys(ut).forEach(function(e){Cn.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),ut[t]=ut[e]})});function dt(e,t,s){return t==null||typeof t=="boolean"||t===""?"":s||typeof t!="number"||t===0||ut.hasOwnProperty(e)&&ut[e]?(""+t).trim():t+"px"}function Jt(e,t){e=e.style;for(var s in t)if(t.hasOwnProperty(s)){var a=s.indexOf("--")===0,o=dt(s,t[s],a);s==="float"&&(s="cssFloat"),a?e.setProperty(s,o):e[s]=o}}var Ht=Q({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function st(e,t){if(t){if(Ht[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(i(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(i(61))}if(t.style!=null&&typeof t.style!="object")throw Error(i(62))}}function sn(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var rn=null;function Wt(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var an=null,ln=null,on=null;function fs(e){if(e=ur(e)){if(typeof an!="function")throw Error(i(280));var t=e.stateNode;t&&(t=Xr(t),an(e.stateNode,e.type,t))}}function ps(e){ln?on?on.push(e):on=[e]:ln=e}function Ce(){if(ln){var e=ln,t=on;if(on=ln=null,fs(e),t)for(e=0;e>>=0,e===0?32:31-(pd(e)/md|0)|0}var Pr=64,Mr=4194304;function Ks(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Or(e,t){var s=e.pendingLanes;if(s===0)return 0;var a=0,o=e.suspendedLanes,c=e.pingedLanes,f=s&268435455;if(f!==0){var h=f&~o;h!==0?a=Ks(h):(c&=f,c!==0&&(a=Ks(c)))}else f=s&~o,f!==0?a=Ks(f):c!==0&&(a=Ks(c));if(a===0)return 0;if(t!==0&&t!==a&&(t&o)===0&&(o=a&-a,c=t&-t,o>=c||o===16&&(c&4194240)!==0))return t;if((a&4)!==0&&(a|=s&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=a;0s;s++)t.push(e);return t}function Js(e,t,s){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Gt(t),e[t]=s}function yd(e,t){var s=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var a=e.eventTimes;for(e=e.expirationTimes;0=tr),io=" ",oo=!1;function co(e,t){switch(e){case"keyup":return Kd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function uo(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var vs=!1;function Hd(e,t){switch(e){case"compositionend":return uo(t);case"keypress":return t.which!==32?null:(oo=!0,io);case"textInput":return e=t.data,e===io&&oo?null:e;default:return null}}function Wd(e,t){if(vs)return e==="compositionend"||!nl&&co(e,t)?(e=to(),zr=Ga=$n=null,vs=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:s,offset:t-e};e=a}e:{for(;s;){if(s.nextSibling){s=s.nextSibling;break e}s=s.parentNode}s=void 0}s=yo(s)}}function vo(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?vo(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function bo(){for(var e=window,t=ee();t instanceof e.HTMLIFrameElement;){try{var s=typeof t.contentWindow.location.href=="string"}catch{s=!1}if(s)e=t.contentWindow;else break;t=ee(e.document)}return t}function al(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function rf(e){var t=bo(),s=e.focusedElem,a=e.selectionRange;if(t!==s&&s&&s.ownerDocument&&vo(s.ownerDocument.documentElement,s)){if(a!==null&&al(s)){if(t=a.start,e=a.end,e===void 0&&(e=t),"selectionStart"in s)s.selectionStart=t,s.selectionEnd=Math.min(e,s.value.length);else if(e=(t=s.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var o=s.textContent.length,c=Math.min(a.start,o);a=a.end===void 0?c:Math.min(a.end,o),!e.extend&&c>a&&(o=a,a=c,c=o),o=jo(s,c);var f=jo(s,a);o&&f&&(e.rangeCount!==1||e.anchorNode!==o.node||e.anchorOffset!==o.offset||e.focusNode!==f.node||e.focusOffset!==f.offset)&&(t=t.createRange(),t.setStart(o.node,o.offset),e.removeAllRanges(),c>a?(e.addRange(t),e.extend(f.node,f.offset)):(t.setEnd(f.node,f.offset),e.addRange(t)))}}for(t=[],e=s;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof s.focus=="function"&&s.focus(),s=0;s=document.documentMode,bs=null,ll=null,ar=null,il=!1;function So(e,t,s){var a=s.window===s?s.document:s.nodeType===9?s:s.ownerDocument;il||bs==null||bs!==ee(a)||(a=bs,"selectionStart"in a&&al(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),ar&&rr(ar,a)||(ar=a,a=Wr(ll,"onSelect"),0Cs||(e.current=jl[Cs],jl[Cs]=null,Cs--)}function Ke(e,t){Cs++,jl[Cs]=e.current,e.current=t}var On={},xt=Mn(On),Dt=Mn(!1),Xn=On;function Ds(e,t){var s=e.type.contextTypes;if(!s)return On;var a=e.stateNode;if(a&&a.__reactInternalMemoizedUnmaskedChildContext===t)return a.__reactInternalMemoizedMaskedChildContext;var o={},c;for(c in s)o[c]=t[c];return a&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function Rt(e){return e=e.childContextTypes,e!=null}function Zr(){He(Dt),He(xt)}function Ao(e,t,s){if(xt.current!==On)throw Error(i(168));Ke(xt,t),Ke(Dt,s)}function _o(e,t,s){var a=e.stateNode;if(t=t.childContextTypes,typeof a.getChildContext!="function")return s;a=a.getChildContext();for(var o in a)if(!(o in t))throw Error(i(108,Re(e)||"Unknown",o));return Q({},s,a)}function ea(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||On,Xn=xt.current,Ke(xt,e),Ke(Dt,Dt.current),!0}function Bo(e,t,s){var a=e.stateNode;if(!a)throw Error(i(169));s?(e=_o(e,t,Xn),a.__reactInternalMemoizedMergedChildContext=e,He(Dt),He(xt),Ke(xt,e)):He(Dt),Ke(Dt,s)}var gn=null,ta=!1,vl=!1;function Fo(e){gn===null?gn=[e]:gn.push(e)}function xf(e){ta=!0,Fo(e)}function An(){if(!vl&&gn!==null){vl=!0;var e=0,t=ze;try{var s=gn;for(ze=1;e>=f,o-=f,xn=1<<32-Gt(t)+o|s<Se?(mt=he,he=null):mt=he.sibling;var Oe=P(k,he,D[Se],B);if(Oe===null){he===null&&(he=mt);break}e&&he&&Oe.alternate===null&&t(k,he),j=c(Oe,j,Se),me===null?ie=Oe:me.sibling=Oe,me=Oe,he=mt}if(Se===D.length)return s(k,he),Ge&&es(k,Se),ie;if(he===null){for(;SeSe?(mt=he,he=null):mt=he.sibling;var Kn=P(k,he,Oe.value,B);if(Kn===null){he===null&&(he=mt);break}e&&he&&Kn.alternate===null&&t(k,he),j=c(Kn,j,Se),me===null?ie=Kn:me.sibling=Kn,me=Kn,he=mt}if(Oe.done)return s(k,he),Ge&&es(k,Se),ie;if(he===null){for(;!Oe.done;Se++,Oe=D.next())Oe=A(k,Oe.value,B),Oe!==null&&(j=c(Oe,j,Se),me===null?ie=Oe:me.sibling=Oe,me=Oe);return Ge&&es(k,Se),ie}for(he=a(k,he);!Oe.done;Se++,Oe=D.next())Oe=H(he,k,Se,Oe.value,B),Oe!==null&&(e&&Oe.alternate!==null&&he.delete(Oe.key===null?Se:Oe.key),j=c(Oe,j,Se),me===null?ie=Oe:me.sibling=Oe,me=Oe);return e&&he.forEach(function(Yf){return t(k,Yf)}),Ge&&es(k,Se),ie}function at(k,j,D,B){if(typeof D=="object"&&D!==null&&D.type===ye&&D.key===null&&(D=D.props.children),typeof D=="object"&&D!==null){switch(D.$$typeof){case ge:e:{for(var ie=D.key,me=j;me!==null;){if(me.key===ie){if(ie=D.type,ie===ye){if(me.tag===7){s(k,me.sibling),j=o(me,D.props.children),j.return=k,k=j;break e}}else if(me.elementType===ie||typeof ie=="object"&&ie!==null&&ie.$$typeof===Ie&&Ko(ie)===me.type){s(k,me.sibling),j=o(me,D.props),j.ref=dr(k,me,D),j.return=k,k=j;break e}s(k,me);break}else t(k,me);me=me.sibling}D.type===ye?(j=os(D.props.children,k.mode,B,D.key),j.return=k,k=j):(B=Ia(D.type,D.key,D.props,null,k.mode,B),B.ref=dr(k,j,D),B.return=k,k=B)}return f(k);case xe:e:{for(me=D.key;j!==null;){if(j.key===me)if(j.tag===4&&j.stateNode.containerInfo===D.containerInfo&&j.stateNode.implementation===D.implementation){s(k,j.sibling),j=o(j,D.children||[]),j.return=k,k=j;break e}else{s(k,j);break}else t(k,j);j=j.sibling}j=xi(D,k.mode,B),j.return=k,k=j}return f(k);case Ie:return me=D._init,at(k,j,me(D._payload),B)}if(Bt(D))return te(k,j,D,B);if(K(D))return se(k,j,D,B);aa(k,D)}return typeof D=="string"&&D!==""||typeof D=="number"?(D=""+D,j!==null&&j.tag===6?(s(k,j.sibling),j=o(j,D),j.return=k,k=j):(s(k,j),j=gi(D,k.mode,B),j.return=k,k=j),f(k)):s(k,j)}return at}var Ts=Jo(!0),Ho=Jo(!1),la=Mn(null),ia=null,$s=null,Cl=null;function Dl(){Cl=$s=ia=null}function Rl(e){var t=la.current;He(la),e._currentValue=t}function Il(e,t,s){for(;e!==null;){var a=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,a!==null&&(a.childLanes|=t)):a!==null&&(a.childLanes&t)!==t&&(a.childLanes|=t),e===s)break;e=e.return}}function Ls(e,t){ia=e,Cl=$s=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(It=!0),e.firstContext=null)}function Qt(e){var t=e._currentValue;if(Cl!==e)if(e={context:e,memoizedValue:t,next:null},$s===null){if(ia===null)throw Error(i(308));$s=e,ia.dependencies={lanes:0,firstContext:e}}else $s=$s.next=e;return t}var ts=null;function El(e){ts===null?ts=[e]:ts.push(e)}function Wo(e,t,s,a){var o=t.interleaved;return o===null?(s.next=s,El(t)):(s.next=o.next,o.next=s),t.interleaved=s,jn(e,a)}function jn(e,t){e.lanes|=t;var s=e.alternate;for(s!==null&&(s.lanes|=t),s=e,e=e.return;e!==null;)e.childLanes|=t,s=e.alternate,s!==null&&(s.childLanes|=t),s=e,e=e.return;return s.tag===3?s.stateNode:null}var _n=!1;function Tl(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Go(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function vn(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Bn(e,t,s){var a=e.updateQueue;if(a===null)return null;if(a=a.shared,(Me&2)!==0){var o=a.pending;return o===null?t.next=t:(t.next=o.next,o.next=t),a.pending=t,jn(e,s)}return o=a.interleaved,o===null?(t.next=t,El(a)):(t.next=o.next,o.next=t),a.interleaved=t,jn(e,s)}function oa(e,t,s){if(t=t.updateQueue,t!==null&&(t=t.shared,(s&4194240)!==0)){var a=t.lanes;a&=e.pendingLanes,s|=a,t.lanes=s,Va(e,s)}}function Yo(e,t){var s=e.updateQueue,a=e.alternate;if(a!==null&&(a=a.updateQueue,s===a)){var o=null,c=null;if(s=s.firstBaseUpdate,s!==null){do{var f={eventTime:s.eventTime,lane:s.lane,tag:s.tag,payload:s.payload,callback:s.callback,next:null};c===null?o=c=f:c=c.next=f,s=s.next}while(s!==null);c===null?o=c=t:c=c.next=t}else o=c=t;s={baseState:a.baseState,firstBaseUpdate:o,lastBaseUpdate:c,shared:a.shared,effects:a.effects},e.updateQueue=s;return}e=s.lastBaseUpdate,e===null?s.firstBaseUpdate=t:e.next=t,s.lastBaseUpdate=t}function ca(e,t,s,a){var o=e.updateQueue;_n=!1;var c=o.firstBaseUpdate,f=o.lastBaseUpdate,h=o.shared.pending;if(h!==null){o.shared.pending=null;var x=h,R=x.next;x.next=null,f===null?c=R:f.next=R,f=x;var M=e.alternate;M!==null&&(M=M.updateQueue,h=M.lastBaseUpdate,h!==f&&(h===null?M.firstBaseUpdate=R:h.next=R,M.lastBaseUpdate=x))}if(c!==null){var A=o.baseState;f=0,M=R=x=null,h=c;do{var P=h.lane,H=h.eventTime;if((a&P)===P){M!==null&&(M=M.next={eventTime:H,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});e:{var te=e,se=h;switch(P=t,H=s,se.tag){case 1:if(te=se.payload,typeof te=="function"){A=te.call(H,A,P);break e}A=te;break e;case 3:te.flags=te.flags&-65537|128;case 0:if(te=se.payload,P=typeof te=="function"?te.call(H,A,P):te,P==null)break e;A=Q({},A,P);break e;case 2:_n=!0}}h.callback!==null&&h.lane!==0&&(e.flags|=64,P=o.effects,P===null?o.effects=[h]:P.push(h))}else H={eventTime:H,lane:P,tag:h.tag,payload:h.payload,callback:h.callback,next:null},M===null?(R=M=H,x=A):M=M.next=H,f|=P;if(h=h.next,h===null){if(h=o.shared.pending,h===null)break;P=h,h=P.next,P.next=null,o.lastBaseUpdate=P,o.shared.pending=null}}while(!0);if(M===null&&(x=A),o.baseState=x,o.firstBaseUpdate=R,o.lastBaseUpdate=M,t=o.shared.interleaved,t!==null){o=t;do f|=o.lane,o=o.next;while(o!==t)}else c===null&&(o.shared.lanes=0);rs|=f,e.lanes=f,e.memoizedState=A}}function Xo(e,t,s){if(e=t.effects,t.effects=null,e!==null)for(t=0;ts?s:4,e(!0);var a=Ol.transition;Ol.transition={};try{e(!1),t()}finally{ze=s,Ol.transition=a}}function xc(){return Ut().memoizedState}function bf(e,t,s){var a=Un(e);if(s={lane:a,action:s,hasEagerState:!1,eagerState:null,next:null},yc(e))jc(t,s);else if(s=Wo(e,t,s,a),s!==null){var o=St();nn(s,e,a,o),vc(s,t,a)}}function Sf(e,t,s){var a=Un(e),o={lane:a,action:s,hasEagerState:!1,eagerState:null,next:null};if(yc(e))jc(t,o);else{var c=e.alternate;if(e.lanes===0&&(c===null||c.lanes===0)&&(c=t.lastRenderedReducer,c!==null))try{var f=t.lastRenderedState,h=c(f,s);if(o.hasEagerState=!0,o.eagerState=h,Yt(h,f)){var x=t.interleaved;x===null?(o.next=o,El(t)):(o.next=x.next,x.next=o),t.interleaved=o;return}}catch{}finally{}s=Wo(e,t,o,a),s!==null&&(o=St(),nn(s,e,a,o),vc(s,t,a))}}function yc(e){var t=e.alternate;return e===Xe||t!==null&&t===Xe}function jc(e,t){hr=fa=!0;var s=e.pending;s===null?t.next=t:(t.next=s.next,s.next=t),e.pending=t}function vc(e,t,s){if((s&4194240)!==0){var a=t.lanes;a&=e.pendingLanes,s|=a,t.lanes=s,Va(e,s)}}var ha={readContext:Qt,useCallback:yt,useContext:yt,useEffect:yt,useImperativeHandle:yt,useInsertionEffect:yt,useLayoutEffect:yt,useMemo:yt,useReducer:yt,useRef:yt,useState:yt,useDebugValue:yt,useDeferredValue:yt,useTransition:yt,useMutableSource:yt,useSyncExternalStore:yt,useId:yt,unstable_isNewReconciler:!1},Nf={readContext:Qt,useCallback:function(e,t){return fn().memoizedState=[e,t===void 0?null:t],e},useContext:Qt,useEffect:cc,useImperativeHandle:function(e,t,s){return s=s!=null?s.concat([e]):null,pa(4194308,4,fc.bind(null,t,e),s)},useLayoutEffect:function(e,t){return pa(4194308,4,e,t)},useInsertionEffect:function(e,t){return pa(4,2,e,t)},useMemo:function(e,t){var s=fn();return t=t===void 0?null:t,e=e(),s.memoizedState=[e,t],e},useReducer:function(e,t,s){var a=fn();return t=s!==void 0?s(t):t,a.memoizedState=a.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},a.queue=e,e=e.dispatch=bf.bind(null,Xe,e),[a.memoizedState,e]},useRef:function(e){var t=fn();return e={current:e},t.memoizedState=e},useState:ic,useDebugValue:Ul,useDeferredValue:function(e){return fn().memoizedState=e},useTransition:function(){var e=ic(!1),t=e[0];return e=vf.bind(null,e[1]),fn().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,s){var a=Xe,o=fn();if(Ge){if(s===void 0)throw Error(i(407));s=s()}else{if(s=t(),pt===null)throw Error(i(349));(ss&30)!==0||nc(a,t,s)}o.memoizedState=s;var c={value:s,getSnapshot:t};return o.queue=c,cc(rc.bind(null,a,c,e),[e]),a.flags|=2048,yr(9,sc.bind(null,a,c,s,t),void 0,null),s},useId:function(){var e=fn(),t=pt.identifierPrefix;if(Ge){var s=yn,a=xn;s=(a&~(1<<32-Gt(a)-1)).toString(32)+s,t=":"+t+"R"+s,s=gr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof a.is=="string"?e=f.createElement(s,{is:a.is}):(e=f.createElement(s),s==="select"&&(f=e,a.multiple?f.multiple=!0:a.size&&(f.size=a.size))):e=f.createElementNS(e,s),e[un]=t,e[cr]=a,Fc(e,t,!1,!1),t.stateNode=e;e:{switch(f=sn(s,a),s){case"dialog":Je("cancel",e),Je("close",e),o=a;break;case"iframe":case"object":case"embed":Je("load",e),o=a;break;case"video":case"audio":for(o=0;o_s&&(t.flags|=128,a=!0,jr(c,!1),t.lanes=4194304)}else{if(!a)if(e=ua(f),e!==null){if(t.flags|=128,a=!0,s=e.updateQueue,s!==null&&(t.updateQueue=s,t.flags|=4),jr(c,!0),c.tail===null&&c.tailMode==="hidden"&&!f.alternate&&!Ge)return jt(t),null}else 2*rt()-c.renderingStartTime>_s&&s!==1073741824&&(t.flags|=128,a=!0,jr(c,!1),t.lanes=4194304);c.isBackwards?(f.sibling=t.child,t.child=f):(s=c.last,s!==null?s.sibling=f:t.child=f,c.last=f)}return c.tail!==null?(t=c.tail,c.rendering=t,c.tail=t.sibling,c.renderingStartTime=rt(),t.sibling=null,s=Ye.current,Ke(Ye,a?s&1|2:s&1),t):(jt(t),null);case 22:case 23:return pi(),a=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==a&&(t.flags|=8192),a&&(t.mode&1)!==0?(At&1073741824)!==0&&(jt(t),t.subtreeFlags&6&&(t.flags|=8192)):jt(t),null;case 24:return null;case 25:return null}throw Error(i(156,t.tag))}function Tf(e,t){switch(Sl(t),t.tag){case 1:return Rt(t.type)&&Zr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ps(),He(Dt),He(xt),Ml(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Ll(t),null;case 13:if(He(Ye),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Es()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return He(Ye),null;case 4:return Ps(),null;case 10:return Rl(t.type._context),null;case 22:case 23:return pi(),null;case 24:return null;default:return null}}var ja=!1,vt=!1,$f=typeof WeakSet=="function"?WeakSet:Set,G=null;function Os(e,t){var s=e.ref;if(s!==null)if(typeof s=="function")try{s(null)}catch(a){tt(e,t,a)}else s.current=null}function ti(e,t,s){try{s()}catch(a){tt(e,t,a)}}var Uc=!1;function Lf(e,t){if(pl=Br,e=bo(),al(e)){if("selectionStart"in e)var s={start:e.selectionStart,end:e.selectionEnd};else e:{s=(s=e.ownerDocument)&&s.defaultView||window;var a=s.getSelection&&s.getSelection();if(a&&a.rangeCount!==0){s=a.anchorNode;var o=a.anchorOffset,c=a.focusNode;a=a.focusOffset;try{s.nodeType,c.nodeType}catch{s=null;break e}var f=0,h=-1,x=-1,R=0,M=0,A=e,P=null;t:for(;;){for(var H;A!==s||o!==0&&A.nodeType!==3||(h=f+o),A!==c||a!==0&&A.nodeType!==3||(x=f+a),A.nodeType===3&&(f+=A.nodeValue.length),(H=A.firstChild)!==null;)P=A,A=H;for(;;){if(A===e)break t;if(P===s&&++R===o&&(h=f),P===c&&++M===a&&(x=f),(H=A.nextSibling)!==null)break;A=P,P=A.parentNode}A=H}s=h===-1||x===-1?null:{start:h,end:x}}else s=null}s=s||{start:0,end:0}}else s=null;for(ml={focusedElem:e,selectionRange:s},Br=!1,G=t;G!==null;)if(t=G,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,G=e;else for(;G!==null;){t=G;try{var te=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(te!==null){var se=te.memoizedProps,at=te.memoizedState,k=t.stateNode,j=k.getSnapshotBeforeUpdate(t.elementType===t.type?se:Zt(t.type,se),at);k.__reactInternalSnapshotBeforeUpdate=j}break;case 3:var D=t.stateNode.containerInfo;D.nodeType===1?D.textContent="":D.nodeType===9&&D.documentElement&&D.removeChild(D.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(B){tt(t,t.return,B)}if(e=t.sibling,e!==null){e.return=t.return,G=e;break}G=t.return}return te=Uc,Uc=!1,te}function vr(e,t,s){var a=t.updateQueue;if(a=a!==null?a.lastEffect:null,a!==null){var o=a=a.next;do{if((o.tag&e)===e){var c=o.destroy;o.destroy=void 0,c!==void 0&&ti(t,s,c)}o=o.next}while(o!==a)}}function va(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var s=t=t.next;do{if((s.tag&e)===e){var a=s.create;s.destroy=a()}s=s.next}while(s!==t)}}function ni(e){var t=e.ref;if(t!==null){var s=e.stateNode;switch(e.tag){case 5:e=s;break;default:e=s}typeof t=="function"?t(e):t.current=e}}function qc(e){var t=e.alternate;t!==null&&(e.alternate=null,qc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[un],delete t[cr],delete t[yl],delete t[hf],delete t[gf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Vc(e){return e.tag===5||e.tag===3||e.tag===4}function Kc(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Vc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function si(e,t,s){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?s.nodeType===8?s.parentNode.insertBefore(e,t):s.insertBefore(e,t):(s.nodeType===8?(t=s.parentNode,t.insertBefore(e,s)):(t=s,t.appendChild(e)),s=s._reactRootContainer,s!=null||t.onclick!==null||(t.onclick=Yr));else if(a!==4&&(e=e.child,e!==null))for(si(e,t,s),e=e.sibling;e!==null;)si(e,t,s),e=e.sibling}function ri(e,t,s){var a=e.tag;if(a===5||a===6)e=e.stateNode,t?s.insertBefore(e,t):s.appendChild(e);else if(a!==4&&(e=e.child,e!==null))for(ri(e,t,s),e=e.sibling;e!==null;)ri(e,t,s),e=e.sibling}var ht=null,en=!1;function Fn(e,t,s){for(s=s.child;s!==null;)Jc(e,t,s),s=s.sibling}function Jc(e,t,s){if(cn&&typeof cn.onCommitFiberUnmount=="function")try{cn.onCommitFiberUnmount(Lr,s)}catch{}switch(s.tag){case 5:vt||Os(s,t);case 6:var a=ht,o=en;ht=null,Fn(e,t,s),ht=a,en=o,ht!==null&&(en?(e=ht,s=s.stateNode,e.nodeType===8?e.parentNode.removeChild(s):e.removeChild(s)):ht.removeChild(s.stateNode));break;case 18:ht!==null&&(en?(e=ht,s=s.stateNode,e.nodeType===8?xl(e.parentNode,s):e.nodeType===1&&xl(e,s),Xs(e)):xl(ht,s.stateNode));break;case 4:a=ht,o=en,ht=s.stateNode.containerInfo,en=!0,Fn(e,t,s),ht=a,en=o;break;case 0:case 11:case 14:case 15:if(!vt&&(a=s.updateQueue,a!==null&&(a=a.lastEffect,a!==null))){o=a=a.next;do{var c=o,f=c.destroy;c=c.tag,f!==void 0&&((c&2)!==0||(c&4)!==0)&&ti(s,t,f),o=o.next}while(o!==a)}Fn(e,t,s);break;case 1:if(!vt&&(Os(s,t),a=s.stateNode,typeof a.componentWillUnmount=="function"))try{a.props=s.memoizedProps,a.state=s.memoizedState,a.componentWillUnmount()}catch(h){tt(s,t,h)}Fn(e,t,s);break;case 21:Fn(e,t,s);break;case 22:s.mode&1?(vt=(a=vt)||s.memoizedState!==null,Fn(e,t,s),vt=a):Fn(e,t,s);break;default:Fn(e,t,s)}}function Hc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var s=e.stateNode;s===null&&(s=e.stateNode=new $f),t.forEach(function(a){var o=Qf.bind(null,e,a);s.has(a)||(s.add(a),a.then(o,o))})}}function tn(e,t){var s=t.deletions;if(s!==null)for(var a=0;ao&&(o=f),a&=~c}if(a=o,a=rt()-a,a=(120>a?120:480>a?480:1080>a?1080:1920>a?1920:3e3>a?3e3:4320>a?4320:1960*Mf(a/1960))-a,10e?16:e,Qn===null)var a=!1;else{if(e=Qn,Qn=null,ka=0,(Me&6)!==0)throw Error(i(331));var o=Me;for(Me|=4,G=e.current;G!==null;){var c=G,f=c.child;if((G.flags&16)!==0){var h=c.deletions;if(h!==null){for(var x=0;xrt()-ii?ls(e,0):li|=s),Tt(e,t)}function iu(e,t){t===0&&((e.mode&1)===0?t=1:(t=Mr,Mr<<=1,(Mr&130023424)===0&&(Mr=4194304)));var s=St();e=jn(e,t),e!==null&&(Js(e,t,s),Tt(e,s))}function zf(e){var t=e.memoizedState,s=0;t!==null&&(s=t.retryLane),iu(e,s)}function Qf(e,t){var s=0;switch(e.tag){case 13:var a=e.stateNode,o=e.memoizedState;o!==null&&(s=o.retryLane);break;case 19:a=e.stateNode;break;default:throw Error(i(314))}a!==null&&a.delete(t),iu(e,s)}var ou;ou=function(e,t,s){if(e!==null)if(e.memoizedProps!==t.pendingProps||Dt.current)It=!0;else{if((e.lanes&s)===0&&(t.flags&128)===0)return It=!1,If(e,t,s);It=(e.flags&131072)!==0}else It=!1,Ge&&(t.flags&1048576)!==0&&zo(t,sa,t.index);switch(t.lanes=0,t.tag){case 2:var a=t.type;ya(e,t),e=t.pendingProps;var o=Ds(t,xt.current);Ls(t,s),o=_l(null,t,a,e,o,s);var c=Bl();return t.flags|=1,typeof o=="object"&&o!==null&&typeof o.render=="function"&&o.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Rt(a)?(c=!0,ea(t)):c=!1,t.memoizedState=o.state!==null&&o.state!==void 0?o.state:null,Tl(t),o.updater=ga,t.stateNode=o,o._reactInternals=t,Vl(t,a,e,s),t=Wl(null,t,a,!0,c,s)):(t.tag=0,Ge&&c&&bl(t),bt(null,t,o,s),t=t.child),t;case 16:a=t.elementType;e:{switch(ya(e,t),e=t.pendingProps,o=a._init,a=o(a._payload),t.type=a,o=t.tag=qf(a),e=Zt(a,e),o){case 0:t=Hl(null,t,a,e,s);break e;case 1:t=Pc(null,t,a,e,s);break e;case 11:t=Ic(null,t,a,e,s);break e;case 14:t=Ec(null,t,a,Zt(a.type,e),s);break e}throw Error(i(306,a,""))}return t;case 0:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:Zt(a,o),Hl(e,t,a,o,s);case 1:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:Zt(a,o),Pc(e,t,a,o,s);case 3:e:{if(Mc(t),e===null)throw Error(i(387));a=t.pendingProps,c=t.memoizedState,o=c.element,Go(e,t),ca(t,a,null,s);var f=t.memoizedState;if(a=f.element,c.isDehydrated)if(c={element:a,isDehydrated:!1,cache:f.cache,pendingSuspenseBoundaries:f.pendingSuspenseBoundaries,transitions:f.transitions},t.updateQueue.baseState=c,t.memoizedState=c,t.flags&256){o=Ms(Error(i(423)),t),t=Oc(e,t,a,s,o);break e}else if(a!==o){o=Ms(Error(i(424)),t),t=Oc(e,t,a,s,o);break e}else for(Ot=Pn(t.stateNode.containerInfo.firstChild),Mt=t,Ge=!0,Xt=null,s=Ho(t,null,a,s),t.child=s;s;)s.flags=s.flags&-3|4096,s=s.sibling;else{if(Es(),a===o){t=bn(e,t,s);break e}bt(e,t,a,s)}t=t.child}return t;case 5:return Zo(t),e===null&&wl(t),a=t.type,o=t.pendingProps,c=e!==null?e.memoizedProps:null,f=o.children,hl(a,o)?f=null:c!==null&&hl(a,c)&&(t.flags|=32),Lc(e,t),bt(e,t,f,s),t.child;case 6:return e===null&&wl(t),null;case 13:return Ac(e,t,s);case 4:return $l(t,t.stateNode.containerInfo),a=t.pendingProps,e===null?t.child=Ts(t,null,a,s):bt(e,t,a,s),t.child;case 11:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:Zt(a,o),Ic(e,t,a,o,s);case 7:return bt(e,t,t.pendingProps,s),t.child;case 8:return bt(e,t,t.pendingProps.children,s),t.child;case 12:return bt(e,t,t.pendingProps.children,s),t.child;case 10:e:{if(a=t.type._context,o=t.pendingProps,c=t.memoizedProps,f=o.value,Ke(la,a._currentValue),a._currentValue=f,c!==null)if(Yt(c.value,f)){if(c.children===o.children&&!Dt.current){t=bn(e,t,s);break e}}else for(c=t.child,c!==null&&(c.return=t);c!==null;){var h=c.dependencies;if(h!==null){f=c.child;for(var x=h.firstContext;x!==null;){if(x.context===a){if(c.tag===1){x=vn(-1,s&-s),x.tag=2;var R=c.updateQueue;if(R!==null){R=R.shared;var M=R.pending;M===null?x.next=x:(x.next=M.next,M.next=x),R.pending=x}}c.lanes|=s,x=c.alternate,x!==null&&(x.lanes|=s),Il(c.return,s,t),h.lanes|=s;break}x=x.next}}else if(c.tag===10)f=c.type===t.type?null:c.child;else if(c.tag===18){if(f=c.return,f===null)throw Error(i(341));f.lanes|=s,h=f.alternate,h!==null&&(h.lanes|=s),Il(f,s,t),f=c.sibling}else f=c.child;if(f!==null)f.return=c;else for(f=c;f!==null;){if(f===t){f=null;break}if(c=f.sibling,c!==null){c.return=f.return,f=c;break}f=f.return}c=f}bt(e,t,o.children,s),t=t.child}return t;case 9:return o=t.type,a=t.pendingProps.children,Ls(t,s),o=Qt(o),a=a(o),t.flags|=1,bt(e,t,a,s),t.child;case 14:return a=t.type,o=Zt(a,t.pendingProps),o=Zt(a.type,o),Ec(e,t,a,o,s);case 15:return Tc(e,t,t.type,t.pendingProps,s);case 17:return a=t.type,o=t.pendingProps,o=t.elementType===a?o:Zt(a,o),ya(e,t),t.tag=1,Rt(a)?(e=!0,ea(t)):e=!1,Ls(t,s),Sc(t,a,o),Vl(t,a,o,s),Wl(null,t,a,!0,e,s);case 19:return Bc(e,t,s);case 22:return $c(e,t,s)}throw Error(i(156,t.tag))};function cu(e,t){return zi(e,t)}function Uf(e,t,s,a){this.tag=e,this.key=s,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=a,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Vt(e,t,s,a){return new Uf(e,t,s,a)}function hi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function qf(e){if(typeof e=="function")return hi(e)?1:0;if(e!=null){if(e=e.$$typeof,e===z)return 11;if(e===ve)return 14}return 2}function Vn(e,t){var s=e.alternate;return s===null?(s=Vt(e.tag,t,e.key,e.mode),s.elementType=e.elementType,s.type=e.type,s.stateNode=e.stateNode,s.alternate=e,e.alternate=s):(s.pendingProps=t,s.type=e.type,s.flags=0,s.subtreeFlags=0,s.deletions=null),s.flags=e.flags&14680064,s.childLanes=e.childLanes,s.lanes=e.lanes,s.child=e.child,s.memoizedProps=e.memoizedProps,s.memoizedState=e.memoizedState,s.updateQueue=e.updateQueue,t=e.dependencies,s.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},s.sibling=e.sibling,s.index=e.index,s.ref=e.ref,s}function Ia(e,t,s,a,o,c){var f=2;if(a=e,typeof e=="function")hi(e)&&(f=1);else if(typeof e=="string")f=5;else e:switch(e){case ye:return os(s.children,o,c,t);case Z:f=8,o|=8;break;case de:return e=Vt(12,s,t,o|2),e.elementType=de,e.lanes=c,e;case X:return e=Vt(13,s,t,o),e.elementType=X,e.lanes=c,e;case J:return e=Vt(19,s,t,o),e.elementType=J,e.lanes=c,e;case je:return Ea(s,o,c,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case De:f=10;break e;case ae:f=9;break e;case z:f=11;break e;case ve:f=14;break e;case Ie:f=16,a=null;break e}throw Error(i(130,e==null?e:typeof e,""))}return t=Vt(f,s,t,o),t.elementType=e,t.type=a,t.lanes=c,t}function os(e,t,s,a){return e=Vt(7,e,a,t),e.lanes=s,e}function Ea(e,t,s,a){return e=Vt(22,e,a,t),e.elementType=je,e.lanes=s,e.stateNode={isHidden:!1},e}function gi(e,t,s){return e=Vt(6,e,null,t),e.lanes=s,e}function xi(e,t,s){return t=Vt(4,e.children!==null?e.children:[],e.key,t),t.lanes=s,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Vf(e,t,s,a,o){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=qa(0),this.expirationTimes=qa(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=qa(0),this.identifierPrefix=a,this.onRecoverableError=o,this.mutableSourceEagerHydrationData=null}function yi(e,t,s,a,o,c,f,h,x){return e=new Vf(e,t,s,h,x),t===1?(t=1,c===!0&&(t|=8)):t=0,c=Vt(3,null,null,t),e.current=c,c.stateNode=e,c.memoizedState={element:a,isDehydrated:s,cache:null,transitions:null,pendingSuspenseBoundaries:null},Tl(c),e}function Kf(e,t,s){var a=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(l){console.error(l)}}return r(),wi.exports=lp(),wi.exports}var Nu;function op(){if(Nu)return Aa;Nu=1;var r=ip();return Aa.createRoot=r.createRoot,Aa.hydrateRoot=r.hydrateRoot,Aa}var cp=op();const up=Bu(cp);class _a extends Error{constructor(l,i,u){super(l),this.code=i,this.status=u,this.name="DashboardRequestError"}}const Ti=new Set;let Ir;function dp(){return Ir}function fp(r){return Ti.add(r),Ir&&r(Ir),()=>{Ti.delete(r)}}async function U(r,l={}){return Mi(r,i=>i.json(),l)}async function pp(r,l={}){return Mi(r,i=>i.text(),l)}async function mn(r,l={}){return Mi(r,async()=>{},l)}async function Mi(r,l,i={}){const u=i.timeoutMs??5e3,d=i.method??"GET",p=new AbortController,g=window.setTimeout(()=>p.abort(),u),y=new Headers(i.headers);y.has("Accept")||y.set("Accept","application/json"),i.body!==void 0&&!y.has("Content-Type")&&y.set("Content-Type","application/json");const v=i.rawBody??(i.body===void 0?void 0:JSON.stringify(i.body));try{Cr({path:r,status:"pending"});const S=await fetch(r,{headers:y,body:v,method:d,signal:p.signal});if(!S.ok){const N=await mp(S);throw Cr({path:r,status:"error",statusCode:S.status,message:N}),new _a(N,S.status===503?"disabled":"http",S.status)}const w=await l(S);return Cr({path:r,status:"success",statusCode:S.status}),w}catch(S){if(S instanceof _a)throw S;if(S instanceof DOMException&&S.name==="AbortError")throw Cr({path:r,status:"error",message:"Dashboard request timed out"}),new _a("Dashboard request timed out","timeout");const w=S instanceof Error?S.message:"Dashboard request failed";throw Cr({path:r,status:"error",message:w}),new _a(w,"network")}finally{window.clearTimeout(g)}}function Cr(r){Ir={...r,at:new Date().toISOString()},Ti.forEach(l=>l(Ir))}async function mp(r){if((r.headers.get("Content-Type")??"").includes("application/json")){const u=await r.json().catch(()=>{}),d=(u==null?void 0:u.error)??(u==null?void 0:u.message);if(typeof d=="string"&&d.trim()!=="")return d}const i=await r.text().catch(()=>"");return i.trim()!==""?i.trim():`Dashboard request failed with status ${r.status}`}async function hp(r=5e3){return U("/api/dashboard/services",{timeoutMs:r})}let Jn=Fu();function Fu(){return{status:"pending",promise:hp().then(l=>{Jn={status:"success",value:l}}).catch(l=>{Jn={status:"error",error:l}})}}function gp(){if(Jn.status==="pending")throw Jn.promise;if(Jn.status==="error")throw Jn.error;return Jn.value}function xp(){Jn=Fu()}function re({children:r,className:l,type:i="button",...u}){return n.jsx("button",{className:l?`button ${l}`:"button",type:i,...u,children:r})}function _({title:r,description:l,actionLabel:i,onAction:u}){return n.jsxs("div",{className:"empty-state",children:[n.jsx("strong",{children:r}),n.jsx("p",{children:l}),i&&u?n.jsx(re,{onClick:u,children:i}):null]})}function pe({title:r,children:l}){return n.jsxs("section",{className:"panel",children:[r?n.jsx("header",{className:"panel-head",children:n.jsx("h2",{children:r})}):null,n.jsx("div",{className:"panel-body",children:l})]})}const Nn="/dashboard";function Rr(r){return r===Nn||r===`${Nn}/`?"/":r.startsWith(`${Nn}/`)?r.slice(Nn.length)||"/":r}function Fa(r,l=window.location.pathname){return r===Nn||r.startsWith(`${Nn}/`)||!l.startsWith(Nn)?r:r==="/"?`${Nn}/`:`${Nn}${r}`}function yp({services:r}){const l=r.filter(u=>u.status==="running").length,i=r.filter(u=>u.status==="disabled").length;return r.length===0?n.jsx("section",{className:"service-index-page","aria-label":"Services",children:n.jsx(_,{title:"No dashboard services",description:"Configured services will appear here when they are available."})}):n.jsxs("section",{className:"service-index-page","aria-label":"Services",children:[n.jsxs("header",{className:"service-index-summary",children:[n.jsxs("div",{children:[n.jsx("h1",{children:"Services"}),n.jsx("p",{children:"Local dashboard registry from /api/dashboard/services."})]}),n.jsxs("dl",{className:"health-summary","aria-label":"Service health summary",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Running"}),n.jsx("dd",{children:l})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Disabled"}),n.jsx("dd",{children:i})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Total"}),n.jsx("dd",{children:r.length})]})]})]}),n.jsx("div",{className:"service-index",children:r.map(u=>n.jsx("a",{className:"service-card",href:Fa(u.path),children:n.jsxs(pe,{children:[n.jsxs("div",{className:"service-card-head",children:[n.jsx("h2",{children:u.name}),n.jsx("span",{className:`service-status ${u.status}`,children:u.status})]}),n.jsx("p",{children:u.description}),u.endpoint?n.jsxs("dl",{className:"endpoint-summary",children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:u.endpoint})})]}):null]})},u.id))})]})}const zu=m.createContext(null);function jp({children:r}){const[l,i]=m.useState(null),u=m.useCallback(p=>new Promise(g=>{i({options:p,resolve:g})}),[]),d=m.useCallback(p=>{i(g=>(g==null||g.resolve(p),null))},[]);return n.jsxs(zu.Provider,{value:u,children:[r,l?n.jsx(vp,{options:l.options,onCancel:()=>d(!1),onConfirm:()=>d(!0)}):null]})}function Er(){const r=m.useContext(zu);if(!r)throw new Error("useConfirm must be called inside ");return r}function vp({options:r,onCancel:l,onConfirm:i}){const u=m.useId(),d=m.useId(),p=m.useId(),g=r.confirmLabel??"Confirm",y=r.cancelLabel??"Cancel",v=r.tone??"default",S=r.requirePhrase??"",[w,N]=m.useState(""),$=m.useRef(null),C=S===""||w===S;m.useEffect(()=>{N("")},[r]),m.useEffect(()=>{S!==""&&$.current&&$.current.focus()},[S]),m.useEffect(()=>{function E(F){if(F.key==="Escape")F.preventDefault(),l();else if(F.key==="Enter"&&C){const q=F.target;if((q==null?void 0:q.tagName)==="TEXTAREA")return;F.preventDefault(),i()}}return document.addEventListener("keydown",E),()=>document.removeEventListener("keydown",E)},[l,i,C]);function I(E){E.target===E.currentTarget&&l()}return n.jsx("div",{className:"confirm-backdrop",onMouseDown:I,role:"presentation",children:n.jsxs("section",{"aria-describedby":r.description||r.detail?d:void 0,"aria-labelledby":u,"aria-modal":"true",className:`confirm-dialog tone-${v}`,role:"alertdialog",children:[n.jsx("h2",{className:"confirm-title",id:u,children:r.title}),r.description||r.detail?n.jsxs("div",{className:"confirm-body",id:d,children:[r.description?n.jsx("p",{className:"confirm-description",children:r.description}):null,r.detail?n.jsx("p",{className:"confirm-detail",children:r.detail}):null]}):null,S!==""?n.jsxs("div",{className:"confirm-phrase",children:[n.jsx("label",{htmlFor:p,children:r.requirePhraseLabel??n.jsxs(n.Fragment,{children:["Type ",n.jsx("code",{children:S})," to confirm"]})}),n.jsx("input",{autoComplete:"off",autoCorrect:"off",className:"confirm-phrase-input",id:p,onChange:E=>N(E.target.value),ref:$,spellCheck:!1,type:"text",value:w})]}):null,n.jsxs("div",{className:"confirm-actions",children:[n.jsx(re,{onClick:l,type:"button",children:y}),n.jsx(re,{autoFocus:S==="",className:v==="danger"?"danger primary":"primary",disabled:!C,onClick:i,type:"button",children:g})]})]})})}function wn(r){return{title:r.title,description:r.description,detail:n.jsxs(n.Fragment,{children:["Target: ",n.jsx("code",{children:r.target})]}),confirmLabel:r.confirmLabel??"Delete",tone:"danger",requirePhrase:r.target}}const wu=[1e3,2e3,4e3,8e3,16e3,3e4];function bp(){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/api/events`}class Sp{constructor(){cs(this,"ws",null);cs(this,"listeners",new Map);cs(this,"nextID",0);cs(this,"retryIndex",0);cs(this,"retryTimeout",null)}subscribe(l,i){const u=this.nextID;this.nextID+=1,this.listeners.set(u,{topics:new Set(l),cb:i}),this.ensureOpen();let d=!1;return()=>{d||(d=!0,this.listeners.delete(u),this.listeners.size===0&&this.close())}}ensureOpen(){this.ws||this.connect()}connect(){if(typeof WebSocket>"u")return;let l;try{l=new WebSocket(bp())}catch{this.scheduleReconnect();return}this.ws=l,l.onopen=()=>{this.retryIndex=0},l.onmessage=u=>{try{const d=JSON.parse(u.data);if(d.type==="ready")return;this.dispatch(d)}catch{}};const i=()=>{this.ws===l&&(this.ws=null,this.listeners.size!==0&&this.scheduleReconnect())};l.onerror=i,l.onclose=i}scheduleReconnect(){if(this.retryTimeout!==null)return;const l=wu[Math.min(this.retryIndex,wu.length-1)];this.retryIndex+=1,this.retryTimeout=window.setTimeout(()=>{this.retryTimeout=null,this.listeners.size>0&&!this.ws&&this.connect()},l)}dispatch(l){for(const i of this.listeners.values())if(!(i.topics.size>0&&!i.topics.has(l.service)))try{i.cb(l)}catch{}}close(){if(this.retryTimeout!==null&&(window.clearTimeout(this.retryTimeout),this.retryTimeout=null),this.ws){this.ws.onerror=null,this.ws.onclose=null;try{this.ws.close(1e3,"no listeners")}catch{}}this.ws=null,this.retryIndex=0}}const Np=new Sp;function wp(r,l){return Np.subscribe(r,l)}function _t({topics:r,onEvent:l,enabled:i=!0}){const u=m.useRef(l);m.useEffect(()=>{u.current=l},[l]);const d=r.slice().sort().join(",");m.useEffect(()=>{if(!i)return;const p=d?d.split(","):[];return wp(p,y=>{u.current(y)})},[d,i])}async function kp(){return U("/api/messages")}async function Cp(r){return U(`/api/messages/${encodeURIComponent(r)}`)}async function Dp(r){return pp(`/api/messages/${encodeURIComponent(r)}/raw`)}async function Rp(){return mn("/api/messages",{method:"DELETE"})}function ds(r){return!r||r.indexOf("=?")===-1?r:r.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=(\s+(?==\?))?/g,(l,i,u,d)=>{try{const p=u.toUpperCase()==="B"?Ip(d):Ep(d);return new TextDecoder(Tp(i)).decode(p)}catch{return l}})}function kn(r){return r?ds(r).trim():""}function Ip(r){const l=atob(r.replace(/\s+/g,"")),i=new Uint8Array(l.length);for(let u=0;u{if(v){u({status:"success",messages:[]});return}u({status:"loading"}),kp().then(({messages:I})=>{u({status:"success",messages:I}),p(E=>{var F;return E??((F=I[0])==null?void 0:F.id)})}).catch(I=>{u({status:"error",message:I.message})})},[v]);_t({topics:["mail"],onEvent:S,enabled:!v});async function w(){v||!await l(wn({title:"Clear inbox",description:"Delete all stored Mail messages from this local devcloud inbox.",target:"CLEAR INBOX",confirmLabel:"Clear inbox"}))||(u({status:"loading"}),Rp().then(()=>{p(void 0),u({status:"success",messages:[]})}).catch(E=>{u({status:"error",message:E.message})}))}m.useEffect(()=>{S()},[S]);const N=i.status==="success"?i.messages:[],$=m.useMemo(()=>Bp(N,g),[N,g]),C=$.find(I=>I.id===d)??$[0]??(g.trim()===""?N[0]:void 0);return v?n.jsx(pe,{title:"Mail",children:n.jsx(_,{title:"Mail is disabled",description:"Enable services.mail.enabled in .devcloud/config.yaml to inspect received messages."})}):n.jsxs("div",{className:"mail-shell",children:[n.jsxs(pe,{title:"Inbox",children:[n.jsxs("div",{className:"mail-inbox-toolbar",children:[n.jsxs("label",{className:"mail-inbox-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter messages",onChange:I=>y(I.target.value),placeholder:"sender, recipient, subject",type:"search",value:g})]}),n.jsxs("div",{className:"mail-inbox-actions",children:[n.jsx(re,{onClick:S,children:"Refresh"}),n.jsx(re,{className:"danger",disabled:N.length===0,onClick:w,children:"Clear all"})]})]}),i.status==="loading"?n.jsx(_,{title:"Loading messages",description:"Reading the local Mail inbox."}):null,i.status==="error"?n.jsx(_,{title:"Mail messages unavailable",description:i.message,actionLabel:"Retry",onAction:S}):null,i.status==="success"?n.jsx(Lp,{messages:$,selectedID:C==null?void 0:C.id,totalMessages:N.length,onSelectMessage:p}):null]}),n.jsx(pe,{title:"Message",children:n.jsx(Pp,{message:C})})]})}function Lp({messages:r,selectedID:l,totalMessages:i,onSelectMessage:u}){return i===0?n.jsx(_,{title:"Inbox is empty",description:"Send mail to localhost:11025 (relaxed SMTP AUTH) and refresh."}):r.length===0?n.jsx(_,{title:"No matches",description:"Try a different filter or clear the search."}):n.jsx("ul",{className:"mail-inbox-list","aria-label":"Messages",children:r.map(d=>{const p=d.id===l,g=ds(d.subject)||"(No subject)",y=kn(d.from)||"(unknown sender)",v=Qu(d);return n.jsx("li",{children:n.jsxs("button",{"aria-current":p?"true":void 0,className:p?"mail-inbox-row active":"mail-inbox-row",onClick:()=>u(d.id),type:"button",children:[n.jsxs("span",{className:"mail-inbox-row-top",children:[n.jsx("span",{className:"mail-inbox-sender",children:y}),n.jsx("time",{className:"mail-inbox-time",dateTime:d.receivedAt,title:$i(d.receivedAt),children:Uu(d.receivedAt)})]}),n.jsx("span",{className:"mail-inbox-subject",children:g}),v?n.jsx("span",{className:"mail-inbox-snippet",children:v}):null]})},d.id)})})}function Pp({message:r}){const[l,i]=m.useState("preview"),[u,d]=m.useState(!1);if(m.useEffect(()=>{i("preview"),d(!1)},[r==null?void 0:r.id]),!r)return n.jsx(_,{title:"Select a message",description:"Messages accepted via SMTP appear here. Pick a row on the left to inspect."});const p=ds(r.subject)||"(No subject)",g=kn(r.from),y=r.to.map(kn).filter(Boolean),v=Di(r.headers,"Cc"),S=Di(r.headers,"Bcc"),w=Di(r.headers,"Reply-To"),N=r.attachments??[],$=r.textBody||r.htmlBody||r.parseError||"",C=$.trim().length>0,I=[{id:"preview",label:"Preview"},{id:"attachments",label:"Attachments",count:N.length},{id:"raw",label:"Raw"}];return n.jsxs("article",{className:"mail-inspector",children:[n.jsxs("header",{className:"mail-inspector-header",children:[n.jsx("h1",{className:"mail-subject",children:p}),n.jsxs("dl",{className:"mail-recipients",children:[n.jsx(Dr,{label:"From",values:g?[g]:["(unknown sender)"]}),n.jsx(Dr,{label:"To",values:y.length>0?y:["(no recipients)"]}),v.length>0?n.jsx(Dr,{label:"Cc",values:v}):null,S.length>0?n.jsx(Dr,{label:"Bcc",values:S}):null,w.length>0?n.jsx(Dr,{label:"Reply-To",values:w}):null,n.jsxs("div",{className:"mail-recipients-row",children:[n.jsx("dt",{children:"Date"}),n.jsxs("dd",{children:[n.jsx("time",{dateTime:r.receivedAt,title:$i(r.receivedAt),children:$i(r.receivedAt)}),n.jsxs("span",{className:"mail-recipients-aux",children:["· ",Uu(r.receivedAt)]})]})]})]})]}),n.jsx("nav",{className:"mail-tabs",role:"tablist","aria-label":"Message view",children:I.map(E=>n.jsxs("button",{role:"tab","aria-selected":l===E.id,className:l===E.id?"mail-tab active":"mail-tab",onClick:()=>i(E.id),type:"button",children:[E.label,E.count!==void 0&&E.count>0?n.jsx("span",{className:"mail-tab-count",children:E.count}):null]},E.id))}),l==="preview"?n.jsx(Mp,{body:$,hasBody:C,parseError:r.parseError}):null,l==="attachments"?n.jsx(Op,{attachments:N}):null,l==="raw"?n.jsx(Ap,{messageID:r.id}):null,n.jsxs("details",{className:"mail-headers",open:u,onToggle:E=>d(E.target.open),children:[n.jsxs("summary",{children:[n.jsx("span",{children:"Headers"}),n.jsx("span",{className:"mail-headers-count",children:Object.keys(r.headers??{}).length})]}),n.jsx(_p,{headers:r.headers??{}})]})]})}function Dr({label:r,values:l}){return n.jsxs("div",{className:"mail-recipients-row",children:[n.jsx("dt",{children:r}),n.jsx("dd",{children:l.join(", ")})]})}function Mp({body:r,hasBody:l,parseError:i}){return!l&&!i?n.jsx(_,{title:"No body",description:"The message contains no decoded text content."}):i&&!l?n.jsxs("div",{className:"mail-preview-error",children:[n.jsx("span",{className:"mail-eyebrow",children:"Parse error"}),n.jsx("p",{children:i})]}):n.jsx("div",{className:"mail-preview",lang:"auto",children:zp(r)})}function Op({attachments:r}){return r.length===0?n.jsx(_,{title:"No attachments",description:"This message has no parsed attachments."}):n.jsx("ul",{className:"mail-attachment-list","aria-label":"Attachments",children:r.map(l=>n.jsxs("li",{className:"mail-attachment-item",children:[n.jsx("span",{className:"mail-attachment-icon","aria-hidden":"true",children:Qp(l.contentType)}),n.jsxs("div",{className:"mail-attachment-body",children:[n.jsx("span",{className:"mail-attachment-name",children:l.fileName||l.id}),n.jsxs("span",{className:"mail-attachment-meta",children:[l.contentType||"application/octet-stream"," · ",Up(l.size)]})]})]},l.id))})}function Ap({messageID:r}){const[l,i]=m.useState({status:"loading"});if(m.useEffect(()=>{let p=!1;return i({status:"loading"}),Dp(r).then(g=>{p||i({status:"success",raw:g})}).catch(g=>{p||i({status:"error",message:g.message})}),()=>{p=!0}},[r]),l.status==="loading")return n.jsx(_,{title:"Loading raw source",description:"Reading the stored RFC 822 message."});if(l.status==="error")return n.jsx(_,{title:"Raw source unavailable",description:l.message});const u=l.raw;function d(){navigator.clipboard&&navigator.clipboard.writeText(u).catch(()=>{})}return n.jsxs("div",{className:"mail-raw",children:[n.jsxs("div",{className:"mail-raw-toolbar",children:[n.jsxs("span",{className:"mail-raw-meta",children:[u.length.toLocaleString()," bytes"]}),n.jsx("button",{className:"mail-raw-copy",onClick:d,type:"button",children:"Copy"})]}),n.jsx("pre",{className:"mail-raw-pre",children:u||"(empty)"})]})}function _p({headers:r}){const l=Object.entries(r).sort(([i],[u])=>i.localeCompare(u));return l.length===0?n.jsx("p",{className:"mail-headers-empty",children:"No parsed headers."}):n.jsx("table",{className:"mail-headers-table",children:n.jsx("tbody",{children:l.map(([i,u])=>n.jsxs("tr",{children:[n.jsx("th",{scope:"row",children:i}),n.jsx("td",{children:u.map(ds).join(", ")})]},i))})})}function Bp(r,l){const i=l.trim().toLowerCase();return i===""?r:r.filter(u=>[ds(u.subject),kn(u.from),...u.to.map(kn),Qu(u)].join(" ").toLowerCase().includes(i))}function Qu(r){return(r.textBody||r.htmlBody||r.parseError||"").replace(/\s+/g," ").trim()}function Di(r,l){if(!r)return[];const i=l.toLowerCase();for(const[u,d]of Object.entries(r))if(u.toLowerCase()===i)return d.flatMap(p=>Fp(p)).map(kn).filter(Boolean);return[]}function Fp(r){return r.split(/,(?![^<]*>)/).map(l=>l.trim()).filter(l=>l.length>0)}function zp(r){const l=[],i=/(https?:\/\/[^\s<>"']+)|((?:[a-zA-Z0-9._%+-]+)@(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g;let u=0,d=0,p=i.exec(r);for(;p!==null;){const y=r.slice(u,p.index);y&&(d+=1,l.push(n.jsx("span",{children:y},`t-${d}`))),d+=1,p[1]?l.push(n.jsx("a",{href:p[1],rel:"noopener noreferrer",target:"_blank",children:p[1]},`l-${d}`)):l.push(n.jsx("a",{href:`mailto:${p[2]}`,children:p[2]},`m-${d}`)),u=i.lastIndex,p=i.exec(r)}const g=r.slice(u);return g&&(d+=1,l.push(n.jsx("span",{children:g},`t-${d}`))),l}function Qp(r){if(!r)return"📎";const l=r.toLowerCase();return l.startsWith("image/")?"🖼":l==="application/pdf"?"📄":l.startsWith("audio/")?"🎵":l.startsWith("video/")?"🎬":l.includes("zip")||l.includes("tar")||l.includes("gzip")?"🗜":l.startsWith("text/")?"📝":"📎"}function $i(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function Uu(r){const l=new Date(r);if(Number.isNaN(l.getTime()))return r||"unknown";const i=Date.now()-l.getTime();if(i<0)return"in the future";const u=Math.floor(i/1e3);if(u<60)return"just now";const d=Math.floor(u/60);if(d<60)return`${d}m ago`;const p=Math.floor(d/60);if(p<24)return`${p}h ago`;const g=Math.floor(p/24);return g<7?`${g}d ago`:l.toLocaleDateString()}function Up(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function qp({buckets:r,activeBucket:l,disabled:i,onDeleteBucket:u,onSelectBucket:d}){return r.length===0?n.jsx(_,{title:"No buckets",description:"Buckets created through the S3 API will appear here."}):n.jsx("ul",{className:"s3-bucket-list","aria-label":"S3 buckets",children:r.map(p=>{const g=p.name===l,y=Wp(p.creationDate),v=Jp(p.objectCount),S=`${p.name} -${v} · created ${Hp(p.creationDate)}`;return n.jsxs("li",{className:g?"s3-bucket-row active":"s3-bucket-row",title:S,children:[n.jsxs("button",{className:"s3-bucket-select",onClick:()=>d(p.name),title:S,type:"button",children:[n.jsx("span",{className:"s3-bucket-icon","aria-hidden":!0,children:n.jsx(Vp,{})}),n.jsxs("span",{className:"s3-bucket-info",children:[n.jsx("span",{className:"s3-bucket-name",title:p.name,children:p.name}),n.jsxs("span",{className:"s3-bucket-meta",children:[v," · ",y]})]})]}),n.jsx("button",{"aria-label":`Delete bucket ${p.name}`,className:"s3-icon-button danger s3-bucket-delete",disabled:i,onClick:()=>u(p.name),type:"button",children:n.jsx(Kp,{})})]},p.name)})})}function Vp(){return n.jsxs("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M2.5 4.5h11l-1 8.5a1 1 0 0 1-1 .9H4.5a1 1 0 0 1-1-.9z"}),n.jsx("path",{d:"M2.5 4.5L4 2.5h8l1.5 2"}),n.jsx("path",{d:"M6 7v4"}),n.jsx("path",{d:"M10 7v4"})]})}function Kp(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M2.5 4h11"}),n.jsx("path",{d:"M6 4V2.5h4V4"}),n.jsx("path",{d:"M4 4l.7 9.2a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L12 4"}),n.jsx("path",{d:"M6.5 7v5"}),n.jsx("path",{d:"M9.5 7v5"})]})}function Jp(r){return!Number.isFinite(r)||r<0?"— objects":r===1?"1 object":`${r} objects`}function Hp(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function Wp(r){const l=new Date(r);if(Number.isNaN(l.getTime()))return"created —";const i=Date.now()-l.getTime(),u=60*1e3,d=60*u,p=24*d,g=7*p,y=30*p,v=365*p;return i{if(d(),!r){N({status:"idle"});return}let C=!1;return N({status:"loading"}),Zp(r,v.trim()).then(({objects:I})=>{C||N({status:"success",objects:I})}).catch(I=>{C||N({status:"error",message:I.message})}),()=>{C=!0}},[r,d,v,u]),!r)return n.jsx(_,{title:"Select a bucket",description:"Choose a bucket to browse its objects."});const $=w.status==="success"?w.objects:[];return n.jsxs("div",{className:"s3-browser",children:[n.jsxs("div",{className:"s3-browser-toolbar",children:[n.jsx(im,{prefix:v,onChange:S}),n.jsx(om,{disabled:i,onUploadObject:g})]}),n.jsx(lm,{bucketName:r,disabled:i}),w.status==="loading"?n.jsx(_,{title:"Loading objects",description:`Reading objects in ${r}.`}):null,w.status==="error"?n.jsx(_,{title:"Objects unavailable",description:w.message}):null,w.status==="success"&&$.length===0?n.jsx(_,{title:"No objects",description:v.trim()===""?`Objects uploaded to ${r} will appear here.`:`No objects in ${r} match this prefix.`}):null,w.status==="success"&&$.length>0?n.jsx("div",{className:"s3-object-table-wrap",children:n.jsxs("table",{className:"s3-object-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Key"}),n.jsx("th",{scope:"col",children:"Size"}),n.jsx("th",{scope:"col",children:"Modified"}),n.jsx("th",{scope:"col",children:"Type"}),n.jsx("th",{"aria-label":"Row actions",className:"s3-object-actions-head",scope:"col"})]})}),n.jsx("tbody",{children:$.map(C=>{const I=C.key===l;return n.jsxs("tr",{className:I?"s3-object-row active":"s3-object-row",children:[n.jsx("td",{children:n.jsx("button",{className:"s3-object-key",onClick:()=>y(C),title:C.key,type:"button",children:C.key})}),n.jsx("td",{className:"s3-object-numeric",children:um(C.size)}),n.jsx("td",{className:"s3-object-numeric",children:Vu(C.lastModified)}),n.jsx("td",{className:"s3-object-type",children:C.contentType||"application/octet-stream"}),n.jsxs("td",{className:"s3-object-actions",children:[n.jsx("a",{"aria-label":`Download ${C.key}`,className:"s3-icon-button neutral",href:dm(C.downloadUrl),title:"Download",children:n.jsx(mm,{})}),n.jsx("button",{"aria-label":`Delete ${C.key}`,className:"s3-icon-button danger",disabled:i,onClick:()=>p(C),title:"Delete",type:"button",children:n.jsx(hm,{})})]})]},C.key)})})]})}):null]})}function lm({bucketName:r,disabled:l}){const[i,u]=m.useState({status:"loading"}),[d,p]=m.useState(0),[g,y]=m.useState(""),v=Er();m.useEffect(()=>{let w=!1;return u({status:"loading"}),tm(r).then(({uploads:N})=>{w||u({status:"success",uploads:N})}).catch(N=>{w||u({status:"error",message:N.message})}),()=>{w=!0}},[r,d]);async function S(w){if(!(l||g!==""||!await v(wn({title:"Abort multipart upload",description:"Pending parts will be discarded and the upload will be unrecoverable.",target:w.uploadId,confirmLabel:"Abort upload"})))){y(w.uploadId);try{await nm(r,w.uploadId),p($=>$+1)}finally{y("")}}}return i.status!=="success"||i.uploads.length===0?null:n.jsxs("details",{className:"s3-multipart-details",children:[n.jsxs("summary",{children:[n.jsx("span",{children:"Incomplete multipart uploads"}),n.jsx("span",{className:"s3-multipart-count",children:i.uploads.length})]}),n.jsx("ul",{className:"s3-multipart-list",children:i.uploads.map(w=>n.jsxs("li",{className:"s3-multipart-item",children:[n.jsxs("div",{className:"s3-multipart-meta",children:[n.jsx("strong",{children:w.key}),n.jsx("code",{children:w.uploadId}),n.jsx("span",{children:Vu(w.initiated)})]}),n.jsx(re,{"aria-label":`Abort multipart upload ${w.uploadId} with confirmation`,className:"danger",disabled:l||g!=="",onClick:()=>void S(w),children:g===w.uploadId?"Aborting":"Abort"})]},w.uploadId))})]})}function im({prefix:r,onChange:l}){return n.jsxs("label",{className:"s3-search",children:[n.jsx(fm,{}),n.jsx("input",{"aria-label":"Filter objects by prefix",onChange:i=>l(i.target.value),placeholder:"Filter by prefix (e.g. docs/)",type:"search",value:r})]})}function om({disabled:r,onUploadObject:l}){const i=m.useRef(null),[u,d]=m.useState(""),[p,g]=m.useState(""),[y,v]=m.useState(""),[S,w]=m.useState(!1),N=m.useRef(null);async function $(C){var E,F;C.preventDefault();const I=(F=(E=i.current)==null?void 0:E.files)==null?void 0:F[0];if(!(r||S||u.trim()===""||!I)){w(!0);try{await l({key:u.trim(),file:I,contentType:p.trim()||I.type||"application/octet-stream",metadata:cm(y)}),d(""),g(""),v(""),i.current&&(i.current.value=""),N.current&&(N.current.open=!1)}finally{w(!1)}}}return n.jsxs("details",{className:"s3-upload-details",ref:N,children:[n.jsxs("summary",{children:[n.jsx(pm,{}),n.jsx("span",{children:"Upload object"})]}),n.jsxs("form",{className:"s3-upload-form",onSubmit:$,children:[n.jsxs("label",{className:"s3-field",children:[n.jsx("span",{children:"Key"}),n.jsx("input",{"aria-label":"S3 upload object key",className:"s3-text-input",disabled:r||S,onChange:C=>d(C.target.value),placeholder:"docs/readme.txt",value:u})]}),n.jsxs("label",{className:"s3-field",children:[n.jsx("span",{children:"Content-Type"}),n.jsx("input",{"aria-label":"S3 upload content type",className:"s3-text-input",disabled:r||S,onChange:C=>g(C.target.value),placeholder:"text/plain",value:p})]}),n.jsxs("label",{className:"s3-field s3-field-full",children:[n.jsx("span",{children:"File"}),n.jsx("input",{"aria-label":"S3 upload file",className:"s3-file-input",disabled:r||S,ref:i,type:"file"})]}),n.jsxs("label",{className:"s3-field s3-field-full",children:[n.jsx("span",{children:"Metadata (x-amz-meta)"}),n.jsx("textarea",{"aria-label":"S3 upload metadata",className:"s3-text-input",disabled:r||S,onChange:C=>v(C.target.value),placeholder:`source=dashboard -purpose=local-test`,rows:2,value:y})]}),n.jsx("div",{className:"s3-upload-actions",children:n.jsx(re,{disabled:r||S||u.trim()==="",type:"submit",children:S?"Uploading…":"Upload"})})]})]})}function cm(r){return r.split(/\r?\n/).reduce((l,i)=>{const u=i.trim();if(u==="")return l;const[d,...p]=u.split("="),g=d.trim(),y=p.join("=").trim();return g!==""&&(l[g]=y),l},{})}function um(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function Vu(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function dm(r){return r.startsWith("/api/s3/")?r:"#"}function fm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("circle",{cx:"7",cy:"7",r:"4.5"}),n.jsx("path",{d:"M13.5 13.5L10.5 10.5"})]})}function pm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.8",strokeLinecap:"round",children:[n.jsx("path",{d:"M8 3v10"}),n.jsx("path",{d:"M3 8h10"})]})}function mm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M8 2.5v8"}),n.jsx("path",{d:"M4.5 7L8 10.5 11.5 7"}),n.jsx("path",{d:"M3 13h10"})]})}function hm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M2.5 4h11"}),n.jsx("path",{d:"M6 4V2.5h4V4"}),n.jsx("path",{d:"M4 4l.7 9.2a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L12 4"}),n.jsx("path",{d:"M6.5 7v5"}),n.jsx("path",{d:"M9.5 7v5"})]})}function gm({bucketName:r,disabled:l,object:i,onCopyObject:u}){if(!i)return n.jsxs("div",{className:"s3-inspector-empty",children:[n.jsx(bm,{}),n.jsx("p",{className:"s3-inspector-empty-title",children:"No object selected"}),n.jsx("p",{className:"s3-inspector-empty-body",children:r?`Pick an object in ${r} to see metadata, copy, and download actions.`:"Select a bucket first, then click an object to inspect it here."})]});const d=Object.entries(i.metadata??{});return n.jsxs("div",{className:"s3-inspector",children:[n.jsxs("div",{className:"s3-inspector-section",children:[n.jsx("span",{className:"s3-field-label",children:"Object"}),n.jsx("code",{className:"s3-inspector-key",title:i.key,children:i.key}),n.jsx("code",{className:"s3-inspector-uri",title:i.s3Uri,children:i.s3Uri})]}),n.jsxs("dl",{className:"s3-inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Size"}),n.jsx("dd",{children:ym(i.size)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Content type"}),n.jsx("dd",{children:i.contentType||"application/octet-stream"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Last modified"}),n.jsx("dd",{children:jm(i.lastModified)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"ETag"}),n.jsx("dd",{children:n.jsx("code",{children:i.etag||"unknown"})})]})]}),n.jsxs("div",{className:"s3-inspector-section",children:[n.jsx("span",{className:"s3-field-label",children:"Metadata"}),d.length===0?n.jsx("p",{className:"s3-inspector-muted",children:"No user metadata."}):n.jsx("dl",{className:"s3-inspector-metadata",children:d.map(([p,g])=>n.jsxs("div",{children:[n.jsx("dt",{children:p}),n.jsx("dd",{children:g})]},p))})]}),n.jsxs("a",{className:"s3-inspector-download",href:vm(i.downloadUrl),children:[n.jsx(Sm,{}),n.jsx("span",{children:"Download object"})]}),n.jsx(xm,{disabled:l,object:i,onCopyObject:u})]})}function xm({disabled:r,object:l,onCopyObject:i}){const[u,d]=m.useState(`${l.key}.copy`),[p,g]=m.useState(!1);m.useEffect(()=>{d(`${l.key}.copy`)},[l.key]);async function y(v){v.preventDefault();const S=u.trim();if(!(r||p||S==="")){g(!0);try{await i(S)}finally{g(!1)}}}return n.jsxs("form",{className:"s3-inspector-copy",onSubmit:y,children:[n.jsxs("label",{className:"s3-field",children:[n.jsx("span",{children:"Copy to key"}),n.jsx("input",{"aria-label":"S3 copy destination key",className:"s3-text-input",disabled:r||p,onChange:v=>d(v.target.value),value:u})]}),n.jsx(re,{disabled:r||p||u.trim()==="",type:"submit",children:p?"Copying…":"Copy"})]})}function ym(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function jm(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function vm(r){return r.startsWith("/api/s3/")?r:"#"}function bm(){return n.jsxs("svg",{"aria-hidden":!0,width:"36",height:"36",viewBox:"0 0 36 36",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M7 11l11-6 11 6v14L18 31 7 25z"}),n.jsx("path",{d:"M7 11l11 6 11-6"}),n.jsx("path",{d:"M18 17v14"})]})}function Sm(){return n.jsxs("svg",{"aria-hidden":!0,width:"14",height:"14",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M8 2.5v8"}),n.jsx("path",{d:"M4.5 7L8 10.5 11.5 7"}),n.jsx("path",{d:"M3 13h10"})]})}function Nm({service:r}){var ae;const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(),[y,v]=m.useState(""),[S,w]=m.useState(""),[N,$]=m.useState(0),C=(r==null?void 0:r.status)==="disabled",I=Er(),E=m.useCallback(()=>{if(C){i({status:"success",buckets:[]}),d(void 0),g(void 0);return}i({status:"loading"}),Gp().then(({buckets:z})=>{i({status:"success",buckets:z}),d(X=>{var J;return X&&z.some(ve=>ve.name===X)?X:(J=z[0])==null?void 0:J.name})}).catch(z=>{i({status:"error",message:z.message})})},[C]);m.useEffect(()=>{E()},[E]);const F=m.useCallback(z=>{E(),z.type.startsWith("s3.object.")&&$(X=>X+1)},[E]);_t({topics:["s3"],onEvent:F,enabled:!C});const q=l.status==="success"?l.buckets:[],Y=(ae=q.find(z=>z.name===u))==null?void 0:ae.name,L=p&&Y?p:void 0,ne=m.useCallback(()=>{g(void 0)},[]);if(C)return n.jsx(pe,{title:"S3",children:n.jsx(_,{title:"S3 is disabled",description:"Enable the S3 service in devcloud config to browse buckets and objects."})});function ge(z){d(z),g(void 0)}function xe(){const z=y.trim();C||z===""||Yp(z).then(X=>{w(`Created bucket ${X.name}`),v(""),d(X.name),E()}).catch(X=>w(X.message))}async function ye(z){C||!await I(wn({title:"Delete bucket",description:"All objects and metadata in this bucket will be removed. This cannot be undone.",target:z}))||Xp(z).then(()=>{w(`Deleted bucket ${z}`),d(J=>J===z?void 0:J),g(void 0),E()}).catch(J=>w(J.message))}async function Z(z){!Y||C||!await I(wn({title:"Delete object",description:"This object will be permanently removed from the bucket.",target:z.key}))||em(Y,z.key).then(()=>{w(`Deleted object ${z.s3Uri}`),g(void 0),$(J=>J+1),E()}).catch(J=>w(J.message))}async function de(z){if(!(!Y||C))try{const X=await sm({bucketName:Y,objectKey:z.key,body:z.file,contentType:z.contentType,metadata:z.metadata});w(`Uploaded object ${X.s3Uri}`),g(X),$(J=>J+1),E()}catch(X){throw w(X instanceof Error?X.message:"S3 upload failed"),X}}async function De(z){if(!(!Y||!L||C))try{const X=await rm({sourceBucketName:Y,sourceObjectKey:L.key,destinationBucketName:Y,destinationObjectKey:z});w(`Copied object ${L.s3Uri} to ${X.s3Uri}`),g(X),$(J=>J+1),E()}catch(X){throw w(X instanceof Error?X.message:"S3 copy failed"),X}}return n.jsxs("div",{className:"s3-workspace",children:[n.jsxs(pe,{title:"Buckets",children:[n.jsxs("div",{className:"s3-bucket-toolbar",children:[n.jsx("span",{className:"s3-bucket-count",children:q.length===1?"1 bucket":`${q.length} buckets`}),n.jsx("button",{"aria-label":"Refresh buckets",className:"s3-icon-button neutral",onClick:E,type:"button",children:n.jsx(km,{})})]}),n.jsx(wm,{bucketName:y,disabled:C,onChange:v,onCreate:xe}),l.status==="loading"?n.jsx(_,{title:"Loading buckets",description:"Reading the local S3 bucket registry."}):null,l.status==="error"?n.jsx(_,{title:"S3 buckets unavailable",description:l.message,actionLabel:"Retry",onAction:E}):null,l.status==="success"?n.jsx(qp,{buckets:q,activeBucket:Y,disabled:C,onDeleteBucket:ye,onSelectBucket:ge}):null]}),n.jsx(pe,{title:Y?`Objects · ${Y}`:"Objects",children:n.jsx(am,{bucketName:Y,activeObjectKey:L==null?void 0:L.key,disabled:C,refreshNonce:N,onClearObject:ne,onDeleteObject:Z,onUploadObject:de,onSelectObject:g})}),n.jsx(pe,{title:"Inspector",children:n.jsx(gm,{bucketName:Y,disabled:C,object:L,onCopyObject:De})}),S?n.jsx("p",{className:"s3-status-banner",children:S}):null]})}function wm({bucketName:r,disabled:l,onChange:i,onCreate:u}){return n.jsxs("form",{className:"s3-bucket-create",onSubmit:d=>{d.preventDefault(),u()},children:[n.jsx("input",{"aria-label":"New bucket name",className:"s3-text-input",disabled:l,onChange:d=>i(d.target.value),placeholder:"New bucket name",value:r}),n.jsx(re,{disabled:l||r.trim()==="",type:"submit",children:"Create"})]})}function km(){return n.jsxs("svg",{"aria-hidden":!0,width:"16",height:"16",viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"1.6",strokeLinecap:"round",strokeLinejoin:"round",children:[n.jsx("path",{d:"M13.5 8a5.5 5.5 0 1 1-1.61-3.89"}),n.jsx("path",{d:"M13.5 3v3h-3"})]})}async function Cm(){return U("/api/dynamodb/status")}async function Dm(){return U("/api/dynamodb/tables")}async function Rm(r){return U("/api/dynamodb/tables",{method:"POST",body:{input:r}})}async function Im(r,l=100){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/items?limit=${encodeURIComponent(String(l))}`)}async function Em(r,l){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/items`,{method:"POST",body:{input:l}})}async function Tm(r,l){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/items/update`,{method:"POST",body:{input:l}})}async function $m(r,l,i){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/items/delete`,{method:"POST",body:{input:l,confirmation:i}})}async function Lm(r,l){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/query`,{method:"POST",body:{input:l}})}async function Pm(r,l){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/scan`,{method:"POST",body:{input:l}})}async function Mm(r){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}`)}async function Om(r){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/indexes`)}async function Am(r){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/ttl`)}async function _m(r,l){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/ttl`,{method:"POST",body:{input:l}})}async function Bm(r,l,i){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/delete`,{method:"POST",body:{input:l,confirmation:i}})}async function Fm(r){return U(`/api/dynamodb/tables/${encodeURIComponent(r)}/streams`)}const Li="devcloud.dynamodb.recentOperations.v1",Oi=10,zm=`{ - "TableName": "Demo", - "AttributeDefinitions": [ - { "AttributeName": "pk", "AttributeType": "S" } - ], - "KeySchema": [ - { "AttributeName": "pk", "KeyType": "HASH" } - ], - "BillingMode": "PAY_PER_REQUEST" -}`,Qm=`{ - "Item": { - "pk": { "S": "user#1" }, - "name": { "S": "Ada" } - } -}`,Um=`{ - "Key": { - "pk": { "S": "user#1" } - }, - "UpdateExpression": "SET #name = :name", - "ExpressionAttributeNames": { - "#name": "name" - }, - "ExpressionAttributeValues": { - ":name": { "S": "Grace" } - } -}`,qm=`{ - "Key": { - "pk": { "S": "user#1" } - } -}`,Vm=`{ - "TimeToLiveSpecification": { - "Enabled": true, - "AttributeName": "expiresAt" - } -}`,Km=`{ - ":pk": { "S": "user#1" } -}`;function Jm(r){return{status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:18000",region:"us-east-1",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/dynamodb",tableCount:0}}function Ai(r){const l=r.keySchema??[];return l.length===0?"No key schema":l.map(i=>`${i.AttributeName} ${i.KeyType}`).join(" / ")}function Hm(r){return(r.globalSecondaryIndexes??[]).length+(r.localSecondaryIndexes??[]).length}function Wm(r){const l=r.attributeDefinitions??[];return l.length===0?"none":l.map(i=>`${i.AttributeName} ${i.AttributeType}`).join(", ")}function Gm(r){const l=r.timeToLiveDescription;return!l||l.TimeToLiveStatus===""?"not configured":l.AttributeName?`${l.TimeToLiveStatus} on ${l.AttributeName}`:l.TimeToLiveStatus}function Ym(r){var i;if(!r.streamEnabled)return"disabled";const l=((i=r.streamSpecification)==null?void 0:i.StreamViewType)??"enabled";return r.latestStreamLabel?`${l} (${r.latestStreamLabel})`:l}function Ri(r){const l=Number.parseInt(r,10);return!Number.isFinite(l)||l<=0?100:Math.min(l,1e3)}function us(r){try{const l=JSON.parse(r);return!l||Array.isArray(l)||typeof l!="object"?{ok:!1,message:"Input must be a JSON object."}:{ok:!0,value:l}}catch(l){return{ok:!1,message:l instanceof Error?l.message:"Input must be valid JSON."}}}function Xm(r){if(r.trim()==="")return{ok:!0};const l=us(r);return l.ok?{ok:!0,value:l.value}:l}function Zm(r){return r==null?"null":typeof r=="object"?JSON.stringify(r):String(r)}function eh(r){return r==null?"":typeof r=="string"?r:typeof r=="number"||typeof r=="boolean"?String(r):JSON.stringify(r)}function th(r){return!Number.isFinite(r)||r<0?"unknown":r<1024?`${r} B`:`${(r/1024).toFixed(1)} KB`}function nh(){if(typeof window>"u")return[];try{const r=window.localStorage.getItem(Li);if(!r)return[];const l=JSON.parse(r);return Array.isArray(l)?l.filter(rh).slice(0,Oi):[]}catch{return[]}}function sh(r){if(!(typeof window>"u"))try{if(r.length===0){window.localStorage.removeItem(Li);return}window.localStorage.setItem(Li,JSON.stringify(r.slice(0,Oi)))}catch{}}function rh(r){if(!r||Array.isArray(r)||typeof r!="object")return!1;const l=r;return(l.operation==="Query"||l.operation==="Scan")&&typeof l.id=="string"&&typeof l.tableName=="string"&&typeof l.limit=="number"&&typeof l.expressionSummary=="string"&&typeof l.count=="number"&&typeof l.scannedCount=="number"&&typeof l.page=="number"&&typeof l.hasMore=="boolean"&&typeof l.createdAt=="string"}function ah(r){return{id:`${Date.now()}-${r.mode}-${r.tableName}-${r.page}`,operation:r.mode,tableName:r.tableName,indexName:r.indexName.trim()||void 0,limit:r.limit,expressionSummary:r.usesExpressionAttributeValues?"expression values present":"no expression values",count:r.count,scannedCount:r.scannedCount,page:r.page,hasMore:r.hasMore,createdAt:new Date().toISOString()}}function lh(r){const l=new Date(r);return Number.isNaN(l.getTime())?"unknown":l.toLocaleString()}function ih({activeIndex:r,items:l,itemsState:i,onSelectIndex:u,tableName:d}){return d?i.status==="loading"?n.jsx(_,{title:"Loading items",description:`Reading items from ${d}.`}):i.status==="error"?n.jsx(_,{title:"DynamoDB items unavailable",description:i.message}):l.length===0?n.jsx(_,{title:"No items",description:`No loaded items in ${d} match the current filter.`}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Key"}),n.jsx("th",{scope:"col",children:"Attributes"}),n.jsx("th",{scope:"col",children:"Size"})]})}),n.jsx("tbody",{children:l.map((p,g)=>n.jsxs("tr",{className:g===r?"item-row active":"item-row",onClick:()=>u(g),children:[n.jsx("td",{children:n.jsx("code",{children:JSON.stringify(p.key)})}),n.jsx("td",{children:n.jsx(Ku,{item:p.item})}),n.jsx("td",{children:th(JSON.stringify(p.item).length)})]},`${JSON.stringify(p.key)}-${g}`))})]})}):n.jsx(_,{title:"No table selected",description:"Choose a table to inspect its stored items."})}function Ku({item:r}){const l=Object.entries(r).filter(([i])=>i!=="pk"&&i!=="sk").slice(0,6);return l.length===0?n.jsx("span",{className:"service-status",children:"key only"}):n.jsx("span",{className:"attribute-preview",children:l.map(([i,u])=>n.jsxs("span",{className:"attribute-chip",children:[i,": ",Zm(u)]},i))})}function oh({message:r,onFind:l,onUpdateValue:i,table:u,values:d}){const p=(u==null?void 0:u.keySchema)??[];return!u||p.length===0?null:n.jsxs("div",{className:"dynamodb-key-lookup",children:[n.jsx("span",{className:"inspector-label",children:"Key lookup"}),n.jsxs("div",{className:"pubsub-action-row",children:[p.map(g=>n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:[g.AttributeName," ",g.KeyType]}),n.jsx("input",{"aria-label":`DynamoDB key lookup ${g.AttributeName}`,onChange:y=>i(g.AttributeName,y.target.value),placeholder:g.AttributeName,value:d[g.AttributeName]??""})]},g.AttributeName)),n.jsx(re,{onClick:l,children:"Find loaded item"})]}),r?n.jsx("p",{className:"inspector-muted",children:r}):null]})}function ch({activeTableName:r,busyOperation:l,createTableJSON:i,putItemJSON:u,updateItemJSON:d,deleteItemJSON:p,ttlJSON:g,deleteTableJSON:y,deleteItemConfirmation:v,deleteTableConfirmation:S,deleteItemAcknowledged:w,deleteTableAcknowledged:N,onCreateTable:$,onPutItem:C,onUpdateItem:I,onDeleteItem:E,onUpdateTTL:F,onDeleteTable:q,setCreateTableJSON:Y,setPutItemJSON:L,setUpdateItemJSON:ne,setDeleteItemJSON:ge,setTTLJSON:xe,setDeleteTableJSON:ye,setDeleteItemConfirmation:Z,setDeleteTableConfirmation:de,setDeleteItemAcknowledged:De,setDeleteTableAcknowledged:ae}){const z=!r;return n.jsxs("div",{className:"dynamodb-operation-stack",children:[n.jsx(Fs,{buttonLabel:"Create table",disabled:l==="create-table",json:i,label:"CreateTable input",onChange:Y,onSubmit:$}),n.jsx(Fs,{buttonLabel:"Put item",disabled:z||l==="put-item",json:u,label:"PutItem input",onChange:L,onSubmit:C}),n.jsx(Fs,{buttonLabel:"Update item",disabled:z||l==="update-item",json:d,label:"UpdateItem input",onChange:ne,onSubmit:I}),n.jsx(Fs,{buttonLabel:"Update TTL",disabled:z||l==="ttl",json:g,label:"UpdateTimeToLive input",onChange:xe,onSubmit:F}),n.jsx(Fs,{buttonClassName:"danger",buttonLabel:"Delete item",confirmation:v,destructiveAcknowledgement:w,destructiveAcknowledgementLabel:"Step 1: I understand this deletes the item identified by the JSON key.",confirmationLabel:`Type ${r??"table name"} to delete item`,disabled:z||!w||v!==r||l==="delete-item",json:p,label:"DeleteItem input",onChange:ge,onDestructiveAcknowledgementChange:De,onConfirmationChange:Z,onSubmit:E}),n.jsx(Fs,{buttonClassName:"danger",buttonLabel:"Delete table",confirmation:S,destructiveAcknowledgement:N,destructiveAcknowledgementLabel:"Step 1: I understand this deletes the selected table and its local items.",confirmationLabel:`Type ${r??"table name"} to delete table`,disabled:z||!N||S!==r||l==="delete-table",json:y,label:"DeleteTable input",onChange:ye,onDestructiveAcknowledgementChange:ae,onConfirmationChange:de,onSubmit:q})]})}function Fs({buttonClassName:r,buttonLabel:l,confirmation:i,confirmationLabel:u,destructiveAcknowledgement:d,destructiveAcknowledgementLabel:p,disabled:g,json:y,label:v,onChange:S,onConfirmationChange:w,onDestructiveAcknowledgementChange:N,onSubmit:$}){return n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:$,children:[n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:v}),n.jsx("textarea",{"aria-label":v,onChange:C=>S(C.target.value),spellCheck:!1,value:y})]}),N?n.jsxs("label",{className:"destructive-confirmation",children:[n.jsx("input",{"aria-label":p,checked:!!d,onChange:C=>N(C.target.checked),type:"checkbox"}),n.jsx("span",{children:p})]}):null,w?n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:["Step 2: ",u]}),n.jsx("input",{"aria-label":u,onChange:C=>w(C.target.value),value:i??""})]}):null,n.jsx(re,{className:r,disabled:g,type:"submit",children:l})]})}function uh({activeTableName:r,busyOperation:l,error:i,expressionAttributeValues:u,filterExpression:d,indexName:p,keyConditionExpression:g,limit:y,message:v,mode:S,onNextPage:w,onPreviousPage:N,onSubmit:$,pageIndex:C,result:I,selectedItemIndex:E,setExpressionAttributeValues:F,setFilterExpression:q,setIndexName:Y,setKeyConditionExpression:L,setLimit:ne,setMode:ge,setSelectedItemIndex:xe}){const ye=!r||l==="query"||l==="scan",Z=(I==null?void 0:I.Items)??[],de=C>0,De=!!(I!=null&&I.LastEvaluatedKey),ae=Z[Math.min(E,Math.max(Z.length-1,0))];return n.jsxs("div",{className:"dynamodb-query-scan",children:[i?n.jsx("p",{className:"operation-message error",children:i}):null,v?n.jsx("p",{className:"operation-message success",children:v}):null,n.jsxs("form",{className:"dynamodb-query-scan-form",onSubmit:$,children:[n.jsxs("div",{className:"segmented-control","aria-label":"DynamoDB read operation",children:[n.jsx("button",{className:S==="Query"?"active":"",onClick:()=>ge("Query"),type:"button",children:"Query"}),n.jsx("button",{className:S==="Scan"?"active":"",onClick:()=>ge("Scan"),type:"button",children:"Scan"})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"TableName"}),n.jsx("input",{"aria-label":"DynamoDB Query Scan table name",disabled:!0,value:r??""})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"IndexName"}),n.jsx("input",{"aria-label":"DynamoDB Query Scan index name",onChange:z=>Y(z.target.value),placeholder:"optional",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Limit"}),n.jsx("input",{"aria-label":"DynamoDB Query Scan limit",inputMode:"numeric",onChange:z=>ne(z.target.value),value:y})]}),S==="Query"?n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"KeyConditionExpression"}),n.jsx("input",{"aria-label":"DynamoDB Query key condition expression",onChange:z=>L(z.target.value),placeholder:"pk = :pk",value:g})]}):n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"FilterExpression"}),n.jsx("input",{"aria-label":"DynamoDB Scan filter expression",onChange:z=>q(z.target.value),placeholder:"optional",value:d})]}),n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"ExpressionAttributeValues JSON"}),n.jsx("textarea",{"aria-label":"DynamoDB Query Scan expression attribute values JSON",onChange:z=>F(z.target.value),spellCheck:!1,value:u})]}),n.jsxs(re,{disabled:ye,type:"submit",children:["Run ",S]})]}),I?n.jsxs("div",{className:"redshift-query-result",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("span",{className:"toolbar-count",children:["Page ",C+1," / Count ",I.Count??Z.length," / Scanned ",I.ScannedCount??0]}),n.jsx("span",{className:"toolbar-count",children:De?"More results available":"End of results"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsx(re,{disabled:ye||!de,onClick:N,type:"button",children:"Previous page"}),n.jsx(re,{disabled:ye||!De,onClick:w,type:"button",children:"Next page"})]})]}),Z.length>0?n.jsxs("div",{className:"dynamodb-query-result-grid",children:[n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"#"}),n.jsx("th",{scope:"col",children:"Item preview"})]})}),n.jsx("tbody",{children:Z.map((z,X)=>n.jsxs("tr",{className:X===E?"item-row active":"item-row",onClick:()=>xe(X),children:[n.jsx("td",{children:X+1}),n.jsx("td",{children:n.jsx(Ku,{item:z})})]},`${C}-${X}-${JSON.stringify(z).length}`))})]})}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected result item JSON"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(ae,null,2)})]})]}):n.jsx("p",{className:"inspector-muted",children:"No items returned."})]}):null]})}function dh({operations:r,onClear:l}){return n.jsxs("section",{className:"dynamodb-recent-operations",children:[n.jsxs("div",{className:"inspector-heading",children:[n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"Recent operation history"}),n.jsx("p",{className:"inspector-muted",children:"Stored locally without item payloads, credentials, or pagination keys."})]}),n.jsx(re,{disabled:r.length===0,onClick:l,type:"button",children:"Clear"})]}),r.length===0?n.jsx("p",{className:"inspector-muted",children:"Run Query or Scan to record local operation metadata."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"When"}),n.jsx("th",{scope:"col",children:"Operation"}),n.jsx("th",{scope:"col",children:"Table"}),n.jsx("th",{scope:"col",children:"Result"})]})}),n.jsx("tbody",{children:r.map(i=>n.jsxs("tr",{children:[n.jsx("td",{children:lh(i.createdAt)}),n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:i.operation}),n.jsxs("span",{className:"attribute-chip",children:["page ",i.page]}),i.indexName?n.jsxs("span",{className:"attribute-chip",children:["index: ",i.indexName]}):null]})}),n.jsx("td",{children:n.jsx("code",{children:i.tableName})}),n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsxs("span",{className:"attribute-chip",children:["count: ",i.count]}),n.jsxs("span",{className:"attribute-chip",children:["scanned: ",i.scannedCount]}),n.jsxs("span",{className:"attribute-chip",children:["limit: ",i.limit]}),n.jsx("span",{className:"attribute-chip",children:i.hasMore?"has next page":"last page"}),n.jsx("span",{className:"attribute-chip",children:i.expressionSummary})]})})]},i.id))})]})})]})}function fh({detailState:r,table:l,item:i,onRefreshDetail:u,status:d}){var w;if(!l)return n.jsx(_,{title:"Inspector",description:"Table schema and selected item JSON will appear here."});const p=r.status==="success"?r.table:l,g=r.status==="success"?r.globalSecondaryIndexes:l.globalSecondaryIndexes??[],y=r.status==="success"?r.localSecondaryIndexes:l.localSecondaryIndexes??[],v=r.status==="success"?r.ttl:l.timeToLiveDescription,S=r.status==="success"?r.streams:{tableName:l.tableName,streamEnabled:((w=l.streamSpecification)==null?void 0:w.StreamEnabled)??!1,latestStreamArn:l.latestStreamArn,latestStreamLabel:l.latestStreamLabel,streamSpecification:l.streamSpecification};return n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsxs("div",{className:"inspector-heading",children:[n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"Table"}),n.jsx("h3",{children:p.tableName})]}),n.jsx(re,{onClick:u,children:"Refresh detail"})]}),r.status==="loading"?n.jsx("p",{className:"inspector-muted",children:"Loading detail metadata."}):null,r.status==="error"?n.jsx("p",{className:"operation-message error",children:r.message}):null,n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Status"}),n.jsx("dd",{children:p.tableStatus})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(d==null?void 0:d.endpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:(d==null?void 0:d.region)??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Key schema"}),n.jsx("dd",{children:Ai(p)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Attributes"}),n.jsx("dd",{children:Wm(p)})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"TTL"}),n.jsx("dd",{children:Gm({...p,timeToLiveDescription:v})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Streams"}),n.jsx("dd",{children:Ym(S)})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Indexes"}),n.jsx(ph,{globalSecondaryIndexes:g,localSecondaryIndexes:y})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Streams"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(S,null,2)})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected item"}),i?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(i.item,null,2)}):n.jsx("p",{className:"inspector-muted",children:"Select an item row to inspect JSON."})]})]})}function ph({globalSecondaryIndexes:r,localSecondaryIndexes:l}){const i=[...r.map(u=>({...u,type:"GSI"})),...l.map(u=>({...u,type:"LSI"}))];return i.length===0?n.jsx("p",{className:"inspector-muted",children:"No secondary indexes."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Type"}),n.jsx("th",{scope:"col",children:"Name"}),n.jsx("th",{scope:"col",children:"Key schema"}),n.jsx("th",{scope:"col",children:"Items"})]})}),n.jsx("tbody",{children:i.map(u=>n.jsxs("tr",{children:[n.jsx("td",{children:u.type}),n.jsx("td",{children:u.IndexName}),n.jsx("td",{children:Ai({tableName:u.IndexName,keySchema:u.KeySchema})}),n.jsx("td",{children:u.ItemCount??0})]},`${u.type}-${u.IndexName}`))})]})})}function mh({tables:r,activeTableName:l,onSelectTable:i}){return r.length===0?n.jsx(_,{title:"No tables",description:"Tables created through the DynamoDB API will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"DynamoDB tables",children:r.map(u=>{var d;return n.jsxs("button",{className:u.tableName===l?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>i(u.tableName),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:u.tableName}),n.jsx("span",{className:"count-pill",children:u.itemCount})]}),n.jsx("span",{className:"table-row-meta",children:Ai(u)}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:u.tableStatus}),n.jsxs("span",{children:[Hm(u)," indexes"]}),n.jsx("span",{children:(d=u.streamSpecification)!=null&&d.StreamEnabled?"streams on":"streams off"})]})]},u.tableName)})})}function hh({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState({status:"idle"}),[y,v]=m.useState(),[S,w]=m.useState(0),[N,$]=m.useState(""),[C,I]=m.useState(""),[E,F]=m.useState("100"),[q,Y]=m.useState({}),[L,ne]=m.useState(""),[ge,xe]=m.useState(zm),[ye,Z]=m.useState(Qm),[de,De]=m.useState(Um),[ae,z]=m.useState(qm),[X,J]=m.useState(Vm),[ve,Ie]=m.useState("{}"),[je,O]=m.useState(""),[K,Q]=m.useState(""),[b,T]=m.useState(!1),[oe,fe]=m.useState(!1),[Ne,we]=m.useState(""),[Re,ce]=m.useState(""),[Ee,Qe]=m.useState(),[nt,Nt]=m.useState("Query"),[ee,$e]=m.useState(""),[ke,ue]=m.useState("25"),[be,wt]=m.useState("pk = :pk"),[Kt,Bt]=m.useState(""),[kt,Ze]=m.useState(Km),[Ct,V]=m.useState(""),[Be,Ue]=m.useState(""),[Pe,lt]=m.useState(),[qe,_e]=m.useState({pageHistory:[void 0],pageIndex:0,selectedItemIndex:0}),[ut,Cn]=m.useState(nh),dt=(r==null?void 0:r.status)==="disabled";m.useEffect(()=>{sh(ut)},[ut]);const Jt=m.useCallback(()=>{if(dt){i({status:"success",statusPayload:Jm(r),tables:[]}),d({status:"idle"}),g({status:"idle"}),v(void 0);return}i({status:"loading"}),Promise.all([Cm(),Dm()]).then(([W,{tables:le}])=>{i({status:"success",statusPayload:W,tables:le}),v(Ae=>{var et;return Ae&&le.some(Ve=>Ve.tableName===Ae)?Ae:(et=le[0])==null?void 0:et.tableName})}).catch(W=>{i({status:"error",message:W.message})})},[dt,r]);m.useEffect(()=>{Jt()},[Jt]),_t({topics:["dynamodb"],onEvent:Jt,enabled:!dt});const Ht=l.status==="success"?l.tables:[],st=Ht.find(W=>W.tableName===y),sn=m.useCallback(()=>{if(!y||dt){d({status:"idle"});return}d({status:"loading"}),Im(y,Ri(E)).then(({items:W})=>{w(0),d({status:"success",items:W})}).catch(W=>{d({status:"error",message:W.message})})},[y,dt,E]);m.useEffect(()=>{sn()},[sn]);const rn=m.useCallback(()=>{if(!y||dt){g({status:"idle"});return}g({status:"loading"}),Promise.all([Mm(y),Om(y),Am(y),Fm(y)]).then(([W,le,Ae,et])=>{g({status:"success",table:W.table,globalSecondaryIndexes:le.globalSecondaryIndexes??[],localSecondaryIndexes:le.localSecondaryIndexes??[],ttl:Ae.timeToLiveDescription,streams:et})}).catch(W=>{g({status:"error",message:W.message})})},[y,dt]);m.useEffect(()=>{rn()},[rn]);const Wt=m.useMemo(()=>{const W=N.trim().toLowerCase();return W===""?Ht:Ht.filter(le=>le.tableName.toLowerCase().includes(W))},[Ht,N]),an=m.useMemo(()=>{const W=u.status==="success"?u.items:[],le=C.trim().toLowerCase();return le===""?W:W.filter(Ae=>JSON.stringify(Ae).toLowerCase().includes(le))},[u,C]),ln=an[Math.min(S,Math.max(an.length-1,0))];if(dt)return n.jsx(pe,{title:"DynamoDB",children:n.jsx(_,{title:"DynamoDB is disabled",description:"Enable the DynamoDB service in devcloud config to inspect tables and items."})});function on(W){v(W),w(0),I(""),Y({}),ne(""),T(!1),fe(!1),O(""),Q("")}function fs(W,le){Y(Ae=>({...Ae,[W]:le})),ne("")}function ps(){var et;if(!st||u.status!=="success"){ne("Load table items before using key lookup.");return}const le=(((et=st.keySchema)==null?void 0:et.map(Ve=>Ve.AttributeName))??[]).map(Ve=>{var We;return[Ve,((We=q[Ve])==null?void 0:We.trim())??""]}).filter(([,Ve])=>Ve!=="");if(le.length===0){ne("Enter at least one key value.");return}const Ae=u.items.findIndex(Ve=>le.every(([We,xs])=>eh(Ve.item[We])===xs));if(Ae<0){ne(`No loaded item matched ${le.map(([Ve])=>Ve).join(" / ")}.`);return}I(""),w(Ae),ne(`Selected loaded item ${Ae+1}.`)}async function Ce(W,le){Qe(W),ce(""),we("");try{const Ae=await le();we(Ae),Jt(),sn(),rn()}catch(Ae){ce(Ae instanceof Error?Ae.message:"DynamoDB operation failed")}finally{Qe(void 0)}}function Le(W){W.preventDefault();const le=us(ge);if(!le.ok){ce(le.message);return}const Ae=typeof le.value.TableName=="string"?le.value.TableName:"";if(Ae.trim()===""){ce("CreateTable input requires TableName.");return}Ce("create-table",async()=>(await Rm(le.value),v(Ae),`Created table ${Ae}.`))}function Fe(W){if(W.preventDefault(),!y){ce("Select a table before putting an item.");return}const le=us(ye);if(!le.ok){ce(le.message);return}Ce("put-item",async()=>(await Em(y,le.value),`Put item in ${y}.`))}function Dn(W){if(W.preventDefault(),!y){ce("Select a table before updating an item.");return}const le=us(de);if(!le.ok){ce(le.message);return}Ce("update-item",async()=>(await Tm(y,le.value),`Updated item in ${y}.`))}function ms(W){if(W.preventDefault(),!y){ce("Select a table before deleting an item.");return}if(!b){ce("Acknowledge the DeleteItem destructive action before confirming the table name.");return}if(je!==y){ce("DeleteItem confirmation must match the selected table name.");return}const le=us(ae);if(!le.ok){ce(le.message);return}Ce("delete-item",async()=>(await $m(y,le.value,je),O(""),T(!1),`Deleted item from ${y}.`))}function Hn(W){if(W.preventDefault(),!y){ce("Select a table before updating TTL.");return}const le=us(X);if(!le.ok){ce(le.message);return}Ce("ttl",async()=>(await _m(y,le.value),`Updated TTL for ${y}.`))}function qs(W){if(W.preventDefault(),!y){ce("Select a table before deleting it.");return}if(!oe){ce("Acknowledge the DeleteTable destructive action before confirming the table name.");return}if(K!==y){ce("DeleteTable confirmation must match the selected table name.");return}const le=us(ve);if(!le.ok){ce(le.message);return}Ce("delete-table",async()=>(await Bm(y,le.value,K),Q(""),fe(!1),v(void 0),`Deleted table ${y}.`))}function Wn(W){if(!y)return{ok:!1,message:"Select a table before running Query or Scan."};const le=Ri(ke),Ae=Xm(kt);if(!Ae.ok)return{ok:!1,message:Ae.message};const et={Limit:le},Ve=ee.trim();if(Ve!==""&&(et.IndexName=Ve),Ae.value&&(et.ExpressionAttributeValues=Ae.value),nt==="Query"){const We=be.trim();if(We==="")return{ok:!1,message:"Query requires KeyConditionExpression."};et.KeyConditionExpression=We}else{const We=Kt.trim();We!==""&&(et.FilterExpression=We)}return W&&(et.ExclusiveStartKey=W),{ok:!0,value:et}}async function Vs(W,le,Ae){var Ve;if(!y){Ue("Select a table before running Query or Scan.");return}const et=Wn(W);if(!et.ok){Ue(et.message);return}Qe(nt.toLowerCase()),Ue(""),V(""),lt(void 0);try{const We=nt==="Query"?await Lm(y,et.value):await Pm(y,et.value);lt(We),_e({pageHistory:Ae,pageIndex:le,selectedItemIndex:0}),Cn(xs=>{var Tr;return[ah({count:We.Count??((Tr=We.Items)==null?void 0:Tr.length)??0,hasMore:!!We.LastEvaluatedKey,indexName:ee,limit:Ri(ke),mode:nt,page:le+1,scannedCount:We.ScannedCount??0,tableName:y,usesExpressionAttributeValues:!!et.value.ExpressionAttributeValues}),...xs].slice(0,Oi)}),V(`${nt} page ${le+1} returned ${We.Count??((Ve=We.Items)==null?void 0:Ve.length)??0} item(s).`)}catch(We){Ue(We instanceof Error?We.message:`${nt} failed`)}finally{Qe(void 0)}}async function Gn(W){W.preventDefault(),await Vs(void 0,0,[void 0])}function hs(){if(!(Pe!=null&&Pe.LastEvaluatedKey))return;const W=qe.pageIndex+1,le=qe.pageHistory.slice(0,W);le[W]=Pe.LastEvaluatedKey,Vs(Pe.LastEvaluatedKey,W,le)}function gs(){if(qe.pageIndex<=0)return;const W=qe.pageIndex-1,le=qe.pageHistory[W];Vs(le,W,qe.pageHistory)}return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(pe,{title:"Tables",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter DynamoDB tables",onChange:W=>$(W.target.value),placeholder:"table name",type:"search",value:N})]}),n.jsx(re,{onClick:Jt,children:"Refresh"})]}),l.status==="loading"?n.jsx(_,{title:"Loading tables",description:"Reading local DynamoDB table metadata."}):null,l.status==="error"?n.jsx(_,{title:"DynamoDB tables unavailable",description:l.message,actionLabel:"Retry",onAction:Jt}):null,l.status==="success"?n.jsx(mh,{tables:Wt,activeTableName:y,onSelectTable:on}):null]}),n.jsxs(pe,{title:"Items",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:st?`${an.length} shown / ${st.itemCount} reported`:"Select a table"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter DynamoDB items",disabled:!st,onChange:W=>{w(0),I(W.target.value)},placeholder:"attribute value",type:"search",value:C})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Limit"}),n.jsx("input",{"aria-label":"Limit DynamoDB items",disabled:!st,inputMode:"numeric",onChange:W=>F(W.target.value),value:E})]}),n.jsx(re,{disabled:!st,onClick:sn,children:"Refresh"})]}),n.jsx(ih,{activeIndex:S,items:an,itemsState:u,onSelectIndex:w,tableName:y}),n.jsx(oh,{message:L,onFind:ps,onUpdateValue:fs,table:st,values:q})]}),n.jsxs(pe,{title:"Operations",children:[Re?n.jsx("p",{className:"operation-message error",children:Re}):null,Ne?n.jsx("p",{className:"operation-message success",children:Ne}):null,n.jsx(ch,{activeTableName:y,busyOperation:Ee,createTableJSON:ge,deleteItemConfirmation:je,deleteItemAcknowledged:b,deleteItemJSON:ae,deleteTableConfirmation:K,deleteTableAcknowledged:oe,deleteTableJSON:ve,onCreateTable:Le,onDeleteItem:ms,onDeleteTable:qs,onPutItem:Fe,onUpdateItem:Dn,onUpdateTTL:Hn,putItemJSON:ye,setCreateTableJSON:xe,setDeleteItemConfirmation:O,setDeleteItemAcknowledged:T,setDeleteItemJSON:z,setDeleteTableConfirmation:Q,setDeleteTableAcknowledged:fe,setDeleteTableJSON:Ie,setPutItemJSON:Z,setTTLJSON:J,setUpdateItemJSON:De,ttlJSON:X,updateItemJSON:de})]}),n.jsxs(pe,{title:"Query / Scan",children:[n.jsx(uh,{activeTableName:y,busyOperation:Ee,expressionAttributeValues:kt,filterExpression:Kt,indexName:ee,keyConditionExpression:be,limit:ke,message:Ct,mode:nt,onNextPage:hs,onPreviousPage:gs,onSubmit:W=>{Gn(W)},pageIndex:qe.pageIndex,result:Pe,selectedItemIndex:qe.selectedItemIndex,setExpressionAttributeValues:Ze,setFilterExpression:Bt,setIndexName:$e,setKeyConditionExpression:wt,setLimit:ue,setMode:Nt,setSelectedItemIndex:W=>_e(le=>({...le,selectedItemIndex:W})),error:Be}),n.jsx(dh,{operations:ut,onClear:()=>Cn([])})]}),n.jsx(pe,{title:"Inspector",children:n.jsx(fh,{detailState:p,item:ln,onRefreshDetail:rn,status:l.status==="success"?l.statusPayload:void 0,table:st})})]})}async function gh(){return U("/api/bigquery/status")}async function xh(){return U("/api/bigquery/projects")}async function yh(r){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets`)}async function jh(r,l,i,u=100){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables/${encodeURIComponent(i)}/rows?limit=${encodeURIComponent(String(u))}`)}async function vh(r){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/jobs`)}async function bh(r,l){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/jobs/${encodeURIComponent(l)}`)}async function Sh(r,l){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/queries`,{method:"POST",body:l,timeoutMs:15e3})}async function Nh(r,l){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets`,{method:"POST",body:l})}async function wh(r,l,i){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables`,{method:"POST",body:i})}async function kh(r,l,i,u){const d=u?"?deleteContents=true":"";return mn(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}${d}`,{method:"DELETE",body:{confirmation:i}})}async function Ch(r,l,i,u){return mn(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables/${encodeURIComponent(i)}`,{method:"DELETE",body:{confirmation:u}})}async function Dh(r,l,i,u){return U(`/api/bigquery/projects/${encodeURIComponent(r)}/datasets/${encodeURIComponent(l)}/tables/${encodeURIComponent(i)}/insertAll`,{method:"POST",body:u})}function Rh({activeDatasetId:r,activeTableId:l,datasets:i,onSelectDataset:u,onSelectTable:d}){return i.length===0?n.jsx(_,{title:"No datasets",description:"Datasets created through the BigQuery API will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"BigQuery datasets",children:i.map(p=>n.jsxs("section",{className:p.datasetId===r?"dynamodb-table-row active":"dynamodb-table-row",children:[n.jsxs("button",{className:"object-select",onClick:()=>u(p),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:p.datasetId}),n.jsx("span",{className:"count-pill",children:p.tables.length})]}),n.jsxs("span",{className:"table-row-meta",children:[p.location||"default location"," dataset"]})]}),n.jsxs("span",{className:"table-row-tags",children:[p.tables.length===0?n.jsx("span",{children:"no tables"}):null,p.tables.map(g=>n.jsxs("button",{className:"attribute-chip",onClick:()=>d(g),type:"button",children:[g.tableId===l?">":""," ",g.tableId," (",g.numRows,")"]},g.tableId))]})]},p.datasetId))})}function Ih(r){return{status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:19050",project:"devcloud",location:"US",authMode:"relaxed",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/bigquery",datasetCount:0,jobCount:0}}function Eh(r){const l=r.fields??[];return l.length===0?"No schema fields":l.map(i=>`${i.name} ${i.type??"STRING"} ${i.mode??"NULLABLE"}`).join(" / ")}function Th(r){return[...r].sort((l,i)=>ku(i)-ku(l)).slice(0,8)}function ku(r){var u,d;const l=(d=(u=r.job)==null?void 0:u.statistics)==null?void 0:d.creationTime;if(!l)return 0;const i=Number.parseInt(l,10);return Number.isFinite(i)?i:0}function $h(r){var p,g;const l=(g=(p=r.job)==null?void 0:p.statistics)==null?void 0:g.query,i=(l==null?void 0:l.totalRows)??"0",u=l!=null&&l.dryRun?"dry run":"executed",d=l!=null&&l.cacheHit?"cache hit":"cache miss";return`${u} / ${i} rows / ${d}`}function Lh(r){const l=r.job??r,i=JSON.parse(JSON.stringify(l)),u=i.configuration;return Us(u)&&Us(u.query)&&delete u.query.queryParameters,i}function Ph(r,l){return l?l.f.reduce((i,u,d)=>{var p;return i[((p=r[d])==null?void 0:p.name)??`column_${d+1}`]=u.v,i},{}):{}}function Cu(r,l){try{const i=JSON.parse(r);return Us(i)?i:new Error(`${l} must be a JSON object.`)}catch{return new Error(`${l} has a JSON validation error.`)}}function Mh(r){const l=r.split(` -`).map(u=>u.trim()).filter(Boolean).map(u=>{const[d,p="STRING",g]=u.split(":").map(y=>y.trim());return d?{name:d,type:p||"STRING",mode:g||void 0}:new Error("Schema field names are required.")}),i=l.find(u=>u instanceof Error);return i||{fields:l}}function Oh(r,l){let i;try{i=JSON.parse(r)}catch{return new Error("Row JSON has a JSON validation error.")}if(Array.isArray(i)){const u=[];for(const[d,p]of i.entries()){if(!Us(p))return new Error(`Row ${d+1} must be a JSON object.`);u.push({json:p})}return u}return Us(i)?[{insertId:l||void 0,json:i}]:new Error("Row JSON must be a JSON object or an array of JSON objects.")}function Du(r,l){let i=r;for(const u of l){if(!Us(i))return;i=i[u]}return typeof i=="string"&&i.trim()!==""?i.trim():void 0}function Us(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}function Ju(r){return r==null?"null":typeof r=="object"?JSON.stringify(r):String(r)}function Ah({dataset:r,jobs:l,project:i,row:u,status:d,table:p}){const[g,y]=m.useState(),[v,S]=m.useState({status:"idle"}),w=m.useMemo(()=>Th(l),[l]);return m.useEffect(()=>{if(l.length===0){y(void 0),S({status:"idle"});return}y(N=>N&&l.some($=>$.jobId===N)?N:l[0].jobId)},[l]),m.useEffect(()=>{if(!g){S({status:"idle"});return}S({status:"loading",jobId:g}),bh(i,g).then(N=>{S({status:"success",job:N.job})}).catch(N=>{S({status:"error",message:N.message})})},[i,g]),r?n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Catalog"}),n.jsx("h3",{children:p?`${r.datasetId}.${p.tableId}`:r.datasetId}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Project"}),n.jsx("dd",{children:i})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(d==null?void 0:d.endpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Location"}),n.jsx("dd",{children:(d==null?void 0:d.location)??r.location??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Rows"}),n.jsx("dd",{children:(p==null?void 0:p.numRows)??"select a table"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Schema"}),n.jsx("dd",{children:p?Eh(p.schema):"select a table"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Jobs"}),n.jsx("dd",{children:l.length===0?"none":l.map(N=>`${N.jobId} ${N.state}`).join(", ")})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Recent query metadata"}),w.length===0?n.jsx("p",{className:"inspector-muted",children:"Query jobs will appear after running SQL."}):n.jsx("div",{className:"dynamodb-table-list compact-list","aria-label":"BigQuery recent query jobs",children:w.map(N=>n.jsxs("button",{className:N.jobId===g?"object-select active":"object-select",onClick:()=>y(N.jobId),type:"button",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:N.jobId}),n.jsx("span",{className:"count-pill",children:N.state})]}),n.jsx("span",{className:"table-row-meta",children:$h(N)})]},N.jobId))})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected job JSON"}),v.status==="loading"?n.jsx("p",{className:"inspector-muted",children:"Loading job detail."}):null,v.status==="error"?n.jsx("p",{className:"operation-message error",children:v.message}):null,v.status==="success"?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(Lh(v.job),null,2)}):null]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected row"}),u?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(u.json,null,2)}):n.jsx("p",{className:"inspector-muted",children:"Select a row to inspect JSON."})]})]}):n.jsx(_,{title:"Inspector",description:"Dataset, table schema, selected row, and jobs will appear here."})}function _h({activeDatasetId:r,activeTableId:l,disabled:i,onMutationSuccess:u,projectId:d}){var Ct;const[p,g]=m.useState(""),[y,v]=m.useState("US"),[S,w]=m.useState(""),[N,$]=m.useState(!1),[C,I]=m.useState(`{ - "datasetReference": { - "datasetId": "dashboard_ops" - }, - "location": "US" -}`),[E,F]=m.useState(""),[q,Y]=m.useState(""),[L,ne]=m.useState(""),[ge,xe]=m.useState(`event_id:STRING:REQUIRED -created_at:TIMESTAMP -payload:STRING`),[ye,Z]=m.useState(!1),[de,De]=m.useState(`{ - "tableReference": { - "tableId": "events" - }, - "schema": { - "fields": [ - { "name": "event_id", "type": "STRING", "mode": "REQUIRED" } - ] - } -}`),[ae,z]=m.useState(""),[X,J]=m.useState(""),[ve,Ie]=m.useState(""),[je,O]=m.useState(`{ - "event_id": "evt-1", - "payload": "local test" -}`),[K,Q]=m.useState(""),[b,T]=m.useState(!1),[oe,fe]=m.useState(!1),[Ne,we]=m.useState(""),[Re,ce]=m.useState(""),[Ee,Qe]=m.useState(""),[nt,Nt]=m.useState(!1),[ee,$e]=m.useState(""),[ke,ue]=m.useState({status:"idle"});m.useEffect(()=>{r&&(F(V=>V||r),z(V=>V||r),Q(V=>V||r),ce(V=>V||r))},[r]),m.useEffect(()=>{l&&(J(V=>V||l),Qe(V=>V||l))},[l]);const be=i||ke.status==="running";function wt(V){V.preventDefault();const Be=N?Cu(C,"Dataset raw JSON"):{datasetReference:{datasetId:p.trim()},location:y.trim()||void 0,description:S.trim()||void 0};if(Be instanceof Error){ue({status:"error",message:Be.message});return}if(!Du(Be,["datasetReference","datasetId"])){ue({status:"error",message:"Dataset ID is required."});return}ue({status:"running",label:"Creating dataset"}),Nh(d,Be).then(Pe=>{ue({status:"success",message:`Created dataset ${Pe.datasetReference.datasetId}.`}),F(Pe.datasetReference.datasetId),z(Pe.datasetReference.datasetId),u()}).catch(Pe=>{ue({status:"error",message:Pe.message})})}function Kt(V){V.preventDefault();const Be=E.trim()||r;if(!Be){ue({status:"error",message:"Choose a dataset before creating a table."});return}const Ue=ye?void 0:Mh(ge);if(Ue instanceof Error){ue({status:"error",message:Ue.message});return}const Pe=ye?Cu(de,"Table raw JSON"):{tableReference:{tableId:q.trim()},schema:Ue,description:L.trim()||void 0};if(Pe instanceof Error){ue({status:"error",message:Pe.message});return}if(!Du(Pe,["tableReference","tableId"])){ue({status:"error",message:"Table ID is required."});return}ue({status:"running",label:"Creating table"}),wh(d,Be,Pe).then(qe=>{ue({status:"success",message:`Created table ${Be}.${qe.tableReference.tableId}.`}),z(Be),J(qe.tableReference.tableId),u()}).catch(qe=>{ue({status:"error",message:qe.message})})}function Bt(V){V.preventDefault();const Be=ae.trim()||r,Ue=X.trim()||l;if(!Be||!Ue){ue({status:"error",message:"Choose a dataset and table before inserting rows."});return}const Pe=Oh(je,ve.trim());if(Pe instanceof Error){ue({status:"error",message:Pe.message});return}ue({status:"running",label:"Inserting rows"}),Dh(d,Be,Ue,{rows:Pe}).then(lt=>{var _e;const qe=(_e=lt.insertErrors)==null?void 0:_e.map(ut=>{const Cn=ut.errors.map(dt=>dt.message).join("; ");return`row ${ut.index}: ${Cn}`});ue({status:"success",message:qe!=null&&qe.length?"Insert completed with partial insert errors.":"Inserted row data.",insertErrors:qe}),u()}).catch(lt=>{ue({status:"error",message:lt.message})})}function kt(V){V.preventDefault();const Be=K.trim();if(!Be){ue({status:"error",message:"Choose a dataset to delete."});return}if(!oe){ue({status:"error",message:"Acknowledge the delete-dataset destructive action before confirming the dataset ID."});return}if(Ne!==Be){ue({status:"error",message:"Delete-dataset confirmation must match the dataset ID."});return}ue({status:"running",label:"Deleting dataset"}),kh(d,Be,Ne,b).then(()=>{ue({status:"success",message:`Deleted dataset ${Be}.`}),we(""),fe(!1),u()}).catch(Ue=>{ue({status:"error",message:Ue.message})})}function Ze(V){V.preventDefault();const Be=Re.trim(),Ue=Ee.trim();if(!Be||!Ue){ue({status:"error",message:"Choose a dataset and table to delete."});return}if(!nt){ue({status:"error",message:"Acknowledge the delete-table destructive action before confirming the table ID."});return}if(ee!==Ue){ue({status:"error",message:"Delete-table confirmation must match the table ID."});return}ue({status:"running",label:"Deleting table"}),Ch(d,Be,Ue,ee).then(()=>{ue({status:"success",message:`Deleted table ${Be}.${Ue}.`}),$e(""),Nt(!1),u()}).catch(Pe=>{ue({status:"error",message:Pe.message})})}return n.jsxs("section",{className:"redshift-query-runner","aria-label":"BigQuery management controls",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Local management"}),n.jsx("span",{className:"toolbar-count",children:"datasets.insert / tables.insert / tabledata.insertAll"})]}),i?n.jsx("p",{className:"inspector-muted",children:"BigQuery management controls are disabled until the service is active."}):null,ke.status==="running"?n.jsxs("p",{className:"operation-message",children:[ke.label,"."]}):null,ke.status==="success"?n.jsxs("div",{className:"operation-message success",children:[ke.message,(Ct=ke.insertErrors)!=null&&Ct.length?n.jsx("ul",{children:ke.insertErrors.map(V=>n.jsx("li",{children:V},V))}):null]}):null,ke.status==="error"?n.jsx("p",{className:"operation-message error",children:ke.message}):null,n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:wt,children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Create dataset"}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"raw JSON"}),n.jsx("input",{"aria-label":"Use raw JSON for BigQuery dataset",checked:N,disabled:be,onChange:V=>$(V.target.checked),type:"checkbox"})]})]}),N?n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Dataset raw JSON"}),n.jsx("textarea",{"aria-label":"BigQuery dataset raw JSON",disabled:be,onChange:V=>I(V.target.value),rows:7,spellCheck:!1,value:C})]}):n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery dataset ID",disabled:be,onChange:V=>g(V.target.value),placeholder:"dashboard_ops",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Location"}),n.jsx("input",{"aria-label":"BigQuery dataset location",disabled:be,onChange:V=>v(V.target.value),placeholder:"US",value:y})]}),n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Description"}),n.jsx("input",{"aria-label":"BigQuery dataset description",disabled:be,onChange:V=>w(V.target.value),placeholder:"local dashboard dataset",value:S})]})]}),n.jsx(re,{disabled:be,type:"submit",children:"Create dataset"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:Kt,children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Create table"}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"raw JSON"}),n.jsx("input",{"aria-label":"Use raw JSON for BigQuery table",checked:ye,disabled:be,onChange:V=>Z(V.target.checked),type:"checkbox"})]})]}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery table dataset ID",disabled:be,onChange:V=>F(V.target.value),placeholder:r??"dataset",value:E})]}),ye?null:n.jsxs(n.Fragment,{children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Table ID"}),n.jsx("input",{"aria-label":"BigQuery table ID",disabled:be,onChange:V=>Y(V.target.value),placeholder:"events",value:q})]}),n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Description"}),n.jsx("input",{"aria-label":"BigQuery table description",disabled:be,onChange:V=>ne(V.target.value),placeholder:"local events table",value:L})]})]})]}),ye?n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Table raw JSON"}),n.jsx("textarea",{"aria-label":"BigQuery table raw JSON",disabled:be,onChange:V=>De(V.target.value),rows:9,spellCheck:!1,value:de})]}):n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Schema fields"}),n.jsx("textarea",{"aria-label":"BigQuery table schema fields",disabled:be,onChange:V=>xe(V.target.value),rows:4,spellCheck:!1,value:ge})]}),n.jsx(re,{disabled:be,type:"submit",children:"Create table"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:Bt,children:[n.jsx("span",{className:"inspector-label",children:"Insert row"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery insert dataset ID",disabled:be,onChange:V=>z(V.target.value),placeholder:r??"dataset",value:ae})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Table ID"}),n.jsx("input",{"aria-label":"BigQuery insert table ID",disabled:be,onChange:V=>J(V.target.value),placeholder:l??"table",value:X})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Insert ID"}),n.jsx("input",{"aria-label":"BigQuery insert ID",disabled:be,onChange:V=>Ie(V.target.value),placeholder:"optional",value:ve})]})]}),n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Row JSON"}),n.jsx("textarea",{"aria-label":"BigQuery insert row JSON",disabled:be,onChange:V=>O(V.target.value),rows:6,spellCheck:!1,value:je})]}),n.jsx("p",{className:"inspector-muted",children:"JSON validation runs locally before tabledata.insertAll. Row payloads are not written to logs."}),n.jsx(re,{disabled:be,type:"submit",children:"Insert row"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:kt,children:[n.jsx("span",{className:"inspector-label",children:"Delete dataset"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery delete dataset ID",disabled:be,onChange:V=>Q(V.target.value),placeholder:r??"dataset",value:K})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Delete contents"}),n.jsx("input",{"aria-label":"Delete BigQuery dataset contents",checked:b,disabled:be,onChange:V=>T(V.target.checked),type:"checkbox"})]})]}),n.jsxs("label",{className:"destructive-confirmation",children:[n.jsx("input",{"aria-label":"I understand this deletes the dataset and, if selected, its tables.",checked:oe,disabled:be,onChange:V=>fe(V.target.checked),type:"checkbox"}),n.jsxs("span",{children:["Step 1: I understand this deletes the dataset",b?" and all of its tables":"","."]})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:["Step 2: Type ",K||"dataset id"," to delete dataset"]}),n.jsx("input",{"aria-label":"BigQuery delete dataset confirmation",disabled:be,onChange:V=>we(V.target.value),value:Ne})]}),n.jsx(re,{className:"danger",disabled:be||!oe||Ne!==K.trim()||K.trim()==="",type:"submit",children:"Delete dataset"})]}),n.jsxs("form",{className:"dynamodb-operation-form",onSubmit:Ze,children:[n.jsx("span",{className:"inspector-label",children:"Delete table"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Dataset ID"}),n.jsx("input",{"aria-label":"BigQuery delete table dataset ID",disabled:be,onChange:V=>ce(V.target.value),placeholder:r??"dataset",value:Re})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Table ID"}),n.jsx("input",{"aria-label":"BigQuery delete table ID",disabled:be,onChange:V=>Qe(V.target.value),placeholder:l??"table",value:Ee})]})]}),n.jsxs("label",{className:"destructive-confirmation",children:[n.jsx("input",{"aria-label":"I understand this deletes the table and its local rows.",checked:nt,disabled:be,onChange:V=>Nt(V.target.checked),type:"checkbox"}),n.jsx("span",{children:"Step 1: I understand this deletes the table and its local rows."})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsxs("span",{children:["Step 2: Type ",Ee||"table id"," to delete table"]}),n.jsx("input",{"aria-label":"BigQuery delete table confirmation",disabled:be,onChange:V=>$e(V.target.value),value:ee})]}),n.jsx(re,{className:"danger",disabled:be||!nt||ee!==Ee.trim()||Ee.trim()==="",type:"submit",children:"Delete table"})]})]})}function Bh({disabled:r,projectId:l,onQuerySuccess:i}){const[u,d]=m.useState("SELECT * FROM `analytics.people` LIMIT 10"),[p,g]=m.useState("25"),[y,v]=m.useState(!1),[S,w]=m.useState({status:"idle"}),[N,$]=m.useState(0),C=S.status==="success"?S.response:void 0,I=(C==null?void 0:C.rows)??[],E=I[Math.min(N,Math.max(I.length-1,0))];function F(q){q.preventDefault();const Y=u.trim();if(Y===""){w({status:"error",message:"Query text is required."});return}const L=Number.parseInt(p,10);if(!Number.isInteger(L)||L<1||L>1e3){w({status:"error",message:"Max results must be between 1 and 1000."});return}w({status:"running"}),Sh(l,{query:Y,maxResults:L,dryRun:y,useLegacySql:!1}).then(ne=>{$(0),w({status:"success",response:ne}),i()}).catch(ne=>{w({status:"error",message:ne.message})})}return n.jsxs("section",{className:"redshift-query-runner","aria-label":"BigQuery SQL query runner",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"SQL query runner"}),n.jsx("span",{className:"toolbar-count",children:C?`${C.totalRows} rows / ${C.jobReference.jobId}`:"useLegacySql=false"})]}),n.jsxs("form",{className:"redshift-query-form",onSubmit:F,children:[n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"Query text"}),n.jsx("textarea",{"aria-label":"BigQuery SQL query text",disabled:r||S.status==="running",onChange:q=>d(q.target.value),rows:5,spellCheck:!1,value:u})]}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Max results"}),n.jsx("input",{"aria-label":"BigQuery query max results",disabled:r||S.status==="running",max:1e3,min:1,onChange:q=>g(q.target.value),type:"number",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Dry run"}),n.jsx("input",{"aria-label":"BigQuery dry run",checked:y,disabled:r||S.status==="running",onChange:q=>v(q.target.checked),type:"checkbox"})]}),n.jsx(re,{disabled:r||S.status==="running",type:"submit",children:S.status==="running"?"Running":y?"Dry run":"Run query"})]})]}),r?n.jsx("p",{className:"inspector-muted",children:"BigQuery query controls are disabled until the service is active."}):null,S.status==="error"?n.jsx("p",{className:"operation-message error",children:S.message}):null,C?n.jsx(Fh,{response:C,selectedRow:E,selectedRowIndex:N,onSelectRow:$}):null]})}function Fh({onSelectRow:r,response:l,selectedRow:i,selectedRowIndex:u}){var v,S;const d=((v=l.schema)==null?void 0:v.fields)??[],p=l.rows??[],g=Math.max(d.length,((S=p[0])==null?void 0:S.f.length)??0),y=Array.from({length:g},(w,N)=>{var $,C;return{name:(($=d[N])==null?void 0:$.name)??`column_${N+1}`,type:((C=d[N])==null?void 0:C.type)??"UNKNOWN"}});return n.jsxs("div",{className:"redshift-query-result",children:[n.jsxs("div",{className:"attribute-preview","aria-label":"BigQuery query summary",children:[n.jsx("span",{className:"attribute-chip",children:l.jobComplete?"complete":"running"}),n.jsx("span",{className:"attribute-chip",children:l.cacheHit?"cache hit":"cache miss"}),n.jsxs("span",{className:"attribute-chip",children:[l.totalRows," rows"]}),n.jsx("span",{className:"attribute-chip",children:l.jobReference.jobId})]}),p.length===0?n.jsx("p",{className:"inspector-muted",children:"Query completed without loaded result rows."}):n.jsxs("div",{className:"dynamodb-query-result-grid",children:[n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table compact",children:[n.jsx("thead",{children:n.jsx("tr",{children:y.map(w=>n.jsxs("th",{scope:"col",children:[w.name,n.jsx("span",{className:"query-column-type",children:w.type})]},w.name))})}),n.jsx("tbody",{children:p.map((w,N)=>n.jsx("tr",{className:N===u?"item-row active":"item-row",onClick:()=>r(N),children:y.map(($,C)=>{var I;return n.jsx("td",{children:Ju((I=w.f[C])==null?void 0:I.v)},$.name)})},`${l.jobReference.jobId}-${N}`))})]})}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected result JSON"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(Ph(d,i),null,2)})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Job reference"}),n.jsx("pre",{className:"mail-preview",children:JSON.stringify(l.jobReference,null,2)})]})]})}function zh({activeIndex:r,rows:l,rowsState:i,tableName:u,onSelectIndex:d}){return u?i.status==="loading"?n.jsx(_,{title:"Loading rows",description:`Reading rows from ${u}.`}):i.status==="error"?n.jsx(_,{title:"BigQuery rows unavailable",description:i.message}):l.length===0?n.jsx(_,{title:"No rows",description:`No loaded rows in ${u} match the current filter.`}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Insert ID"}),n.jsx("th",{scope:"col",children:"Values"}),n.jsx("th",{scope:"col",children:"Inserted"})]})}),n.jsx("tbody",{children:l.map((p,g)=>n.jsxs("tr",{className:g===r?"item-row active":"item-row",onClick:()=>d(g),children:[n.jsx("td",{children:n.jsx("code",{children:p.insertId??g+1})}),n.jsx("td",{children:n.jsx(Qh,{item:p.json})}),n.jsx("td",{children:p.insertedAt??"unknown"})]},`${p.insertId??"row"}-${g}`))})]})}):n.jsx(_,{title:"No table selected",description:"Choose a BigQuery table to inspect stored rows."})}function Qh({item:r}){const l=Object.entries(r).slice(0,6);return l.length===0?n.jsx("span",{className:"service-status",children:"empty row"}):n.jsx("span",{className:"attribute-preview",children:l.map(([i,u])=>n.jsxs("span",{className:"attribute-chip",children:[i,": ",Ju(u)]},i))})}function Uh({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState(),[y,v]=m.useState(),[S,w]=m.useState(0),[N,$]=m.useState(""),[C,I]=m.useState(""),E=(r==null?void 0:r.status)==="disabled",F=m.useCallback(()=>{if(E){i({status:"success",statusPayload:Ih(r),datasets:[],jobs:[]}),d({status:"idle"}),g(void 0),v(void 0);return}i({status:"loading"}),Promise.all([gh(),xh()]).then(async([ae,z])=>{const X=z.projects[0],J=(X==null?void 0:X.projectId)??ae.project,[{datasets:ve},{jobs:Ie}]=await Promise.all([yh(J),vh(J)]);i({status:"success",statusPayload:ae,datasets:ve,jobs:Ie}),g(je=>{var O;return je&&ve.some(K=>K.datasetId===je)?je:(O=ve[0])==null?void 0:O.datasetId}),v(je=>{var K;const O=ve.find(Q=>Q.datasetId===p)??ve[0];return je&&(O!=null&&O.tables.some(Q=>Q.tableId===je))?je:(K=O==null?void 0:O.tables[0])==null?void 0:K.tableId})}).catch(ae=>{i({status:"error",message:ae.message})})},[p,E,r]);m.useEffect(()=>{F()},[F]),_t({topics:["bigquery"],onEvent:F,enabled:!E});const q=l.status==="success"?l.datasets:[],Y=q.find(ae=>ae.datasetId===p),L=Y==null?void 0:Y.tables.find(ae=>ae.tableId===y),ne=l.status==="success"?l.statusPayload.project:r==null?void 0:r.endpoint,ge=m.useCallback(()=>{if(!Y||!L||l.status!=="success"||E){d({status:"idle"});return}d({status:"loading"}),jh(l.statusPayload.project,Y.datasetId,L.tableId).then(({rows:ae})=>{w(0),d({status:"success",rows:ae})}).catch(ae=>{d({status:"error",message:ae.message})})},[Y,L,l,E]);m.useEffect(()=>{ge()},[ge]);const xe=m.useMemo(()=>{const ae=N.trim().toLowerCase();return ae===""?q:q.filter(z=>z.datasetId.toLowerCase().includes(ae))},[q,N]),ye=m.useMemo(()=>{const ae=u.status==="success"?u.rows:[],z=C.trim().toLowerCase();return z===""?ae:ae.filter(X=>JSON.stringify(X).toLowerCase().includes(z))},[u,C]),Z=ye[Math.min(S,Math.max(ye.length-1,0))];if(E)return n.jsx(pe,{title:"BigQuery",children:n.jsx(_,{title:"BigQuery is disabled",description:"Enable the BigQuery service in devcloud config to inspect projects, datasets, tables, rows, and jobs."})});function de(ae){var z;g(ae.datasetId),v((z=ae.tables[0])==null?void 0:z.tableId),w(0),I("")}function De(ae){v(ae.tableId),w(0),I("")}return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(pe,{title:"Datasets",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter BigQuery datasets",onChange:ae=>$(ae.target.value),placeholder:"dataset id",type:"search",value:N})]}),n.jsx(re,{onClick:F,children:"Refresh"})]}),l.status==="loading"?n.jsx(_,{title:"Loading datasets",description:"Reading local BigQuery catalog metadata."}):null,l.status==="error"?n.jsx(_,{title:"BigQuery catalog unavailable",description:l.message,actionLabel:"Retry",onAction:F}):null,l.status==="success"?n.jsx(Rh,{activeDatasetId:p,activeTableId:y,datasets:xe,onSelectDataset:de,onSelectTable:De}):null]}),n.jsxs(pe,{title:"Rows",children:[n.jsx(Bh,{disabled:E||l.status!=="success",projectId:ne??"devcloud",onQuerySuccess:F}),n.jsx(_h,{activeDatasetId:Y==null?void 0:Y.datasetId,activeTableId:L==null?void 0:L.tableId,disabled:E||l.status!=="success",projectId:ne??"devcloud",onMutationSuccess:()=>{F(),ge()}}),n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:L?`${ye.length} shown / ${L.numRows} reported`:"Select a table"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter BigQuery rows",disabled:!L,onChange:ae=>{w(0),I(ae.target.value)},placeholder:"row value",type:"search",value:C})]}),n.jsx(re,{disabled:!L,onClick:ge,children:"Refresh"})]}),n.jsx(zh,{activeIndex:S,rows:ye,rowsState:u,tableName:L==null?void 0:L.tableId,onSelectIndex:w})]}),n.jsx(pe,{title:"Inspector",children:n.jsx(Ah,{dataset:Y,jobs:l.status==="success"?l.jobs:[],project:ne??"unknown",row:Z,status:l.status==="success"?l.statusPayload:void 0,table:L})})]})}async function qh(){return U("/api/redshift/status")}async function Vh(){return U("/api/redshift/clusters")}async function Kh(){return U("/api/redshift/catalog")}async function Jh(){return U("/api/redshift/statements")}async function Hh(r){return U("/api/redshift/query",{method:"POST",body:r,timeoutMs:15e3})}function Wh({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(""),y=(r==null?void 0:r.status)==="disabled",v=m.useCallback(()=>{if(y){i({status:"success",statusPayload:tg(r),clusters:[],catalog:{database:"dev",schemas:[],tables:[],columns:[]},statements:[]}),d(void 0);return}i({status:"loading"}),Promise.all([qh(),Vh(),Kh(),Jh()]).then(([I,E,F,q])=>{const Y=F.catalog.tables;i({status:"success",statusPayload:I,clusters:E.clusters,catalog:F.catalog,statements:q.statements}),d(L=>L&&Y.some(ne=>zs(ne)===L)?L:zs(Y[0]))}).catch(I=>{i({status:"error",message:I.message})})},[y,r]);m.useEffect(()=>{v()},[v]),_t({topics:["redshift"],onEvent:v,enabled:!y});const S=l.status==="success"?l.catalog:void 0,w=(S==null?void 0:S.tables)??[],N=m.useMemo(()=>{const I=p.trim().toLowerCase();return I===""?w:w.filter(E=>`${E.schema}.${E.name}`.toLowerCase().includes(I))},[p,w]),$=w.find(I=>zs(I)===u),C=(S==null?void 0:S.columns.filter(I=>$&&I.schema===$.schema&&I.table===$.name))??[];return y?n.jsx(pe,{title:"Redshift",children:n.jsx(_,{title:"Redshift is disabled",description:"Enable the Redshift service in devcloud config to inspect clusters, catalog metadata, and statements."})}):n.jsxs("div",{className:"redshift-workspace",children:[n.jsxs(pe,{title:"Clusters",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.clusters.length} clusters`:"Loading"}),n.jsx(re,{onClick:v,children:"Refresh"})]}),l.status==="loading"?n.jsx(_,{title:"Loading Redshift",description:"Reading local Redshift metadata."}):null,l.status==="error"?n.jsx(_,{title:"Redshift unavailable",description:l.message,actionLabel:"Retry",onAction:v}):null,l.status==="success"?n.jsx(Gh,{clusters:l.clusters,status:l.statusPayload}):null]}),n.jsxs(pe,{title:"Catalog",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:`${N.length} shown / ${w.length} tables`}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter Redshift tables",onChange:I=>g(I.target.value),placeholder:"schema or table",type:"search",value:p})]})]}),n.jsx(Yh,{activeTableKey:u,tables:N,onSelectTable:I=>d(zs(I))})]}),n.jsx(pe,{title:"Inspector",children:n.jsx(Xh,{columns:C,onQuerySuccess:v,statements:l.status==="success"?l.statements:[],status:l.status==="success"?l.statusPayload:void 0,table:$})})]})}function Gh({clusters:r,status:l}){return r.length===0?n.jsx(_,{title:"No clusters",description:"Local Redshift cluster metadata will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Redshift clusters",children:r.map(i=>n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:i.clusterIdentifier}),n.jsx("span",{className:"count-pill",children:i.clusterStatus})]}),n.jsxs("span",{className:"table-row-meta",children:[i.databaseName," on ",i.endpoint.address,":",i.endpoint.port]}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:i.nodeType}),n.jsxs("span",{children:[i.numberOfNodes," node"]}),n.jsx("span",{children:l.region})]})]},i.clusterIdentifier))})}function Yh({activeTableKey:r,tables:l,onSelectTable:i}){return l.length===0?n.jsx(_,{title:"No tables",description:"Tables created through Redshift SQL or Data API clients will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Redshift tables",children:l.map(u=>n.jsxs("button",{className:zs(u)===r?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>i(u),type:"button",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:u.name}),n.jsx("span",{className:"count-pill",children:u.rowCount})]}),n.jsxs("span",{className:"table-row-meta",children:[u.schema," schema"]}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:[u.columnCount," columns"]}),u.distKey?n.jsxs("span",{children:["distkey ",u.distKey]}):null,u.sortKeys.length>0?n.jsxs("span",{children:["sortkey ",u.sortKeys.join(", ")]}):null]})]},zs(u)))})}function Xh({columns:r,onQuerySuccess:l,statements:i,status:u,table:d}){return n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsx(Zh,{onQuerySuccess:l}),d?null:n.jsx(_,{title:"Inspector",description:"Table columns and recent statements will appear here."}),d?n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Table"}),n.jsx("h3",{children:`${d.schema}.${d.name}`}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"SQL endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(u==null?void 0:u.sqlEndpoint)??"127.0.0.1:15439"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"API endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(u==null?void 0:u.apiEndpoint)??"http://127.0.0.1:19099"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Rows"}),n.jsx("dd",{children:d.rowCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Distribution"}),n.jsxs("dd",{children:[d.distStyle||"auto"," ",d.distKey?`on ${d.distKey}`:""]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Sort keys"}),n.jsx("dd",{children:d.sortKeys.length>0?d.sortKeys.join(", "):"none"})]})]})]}):null,d?n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Columns"}),r.length===0?n.jsx("p",{className:"inspector-muted",children:"No column metadata recorded."}):n.jsx("div",{className:"attribute-preview",children:r.map(p=>n.jsxs("span",{className:"attribute-chip",children:[p.name," ",p.dataType,p.encoding?` encode ${p.encoding}`:"",p.identity?" identity":""]},`${p.table}-${p.name}`))})]}):null,n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Recent statements"}),i.length===0?n.jsx("p",{className:"inspector-muted",children:"No Redshift statements recorded."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"ID"}),n.jsx("th",{scope:"col",children:"Status"}),n.jsx("th",{scope:"col",children:"Rows"}),n.jsx("th",{scope:"col",children:"SQL"})]})}),n.jsx("tbody",{children:i.slice(0,8).map(p=>n.jsxs("tr",{className:"item-row",children:[n.jsx("td",{children:n.jsx("code",{children:p.redshiftQueryId})}),n.jsx("td",{children:p.status}),n.jsx("td",{children:p.resultRows}),n.jsxs("td",{children:[p.queryPreview,p.queryRedacted||p.queryTruncated?n.jsx("span",{className:"attribute-chip",children:p.queryRedacted?"redacted":"truncated"}):null]})]},p.id))})]})})]})]})}function Zh({onQuerySuccess:r}){const[l,i]=m.useState("select 1"),[u,d]=m.useState("100"),[p,g]=m.useState(),[y,v]=m.useState(),[S,w]=m.useState(),[N,$]=m.useState(!1),C=I=>{if(I.preventDefault(),l.trim()===""){g(void 0),w(void 0),v("SQL is required.");return}const E=Number.parseInt(u,10),F=Number.isFinite(E)?Math.max(1,E):100;d(String(F)),$(!0),v(void 0),w(void 0),Hh({sql:l,maxRows:F}).then(q=>{g(q.result),w(`${q.result.statement.status}: ${q.result.commandTag||"statement completed"}`),r()}).catch(()=>{g(void 0),w(void 0),v("Query failed. Check SQL syntax and local Redshift state.")}).finally(()=>$(!1))};return n.jsxs("section",{className:"redshift-query-runner","aria-label":"Redshift query runner",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"inspector-label",children:"Query runner"}),n.jsx("span",{className:"toolbar-count",children:p?`${p.rowCount} rows`:"Ready"})]}),n.jsxs("form",{className:"redshift-query-form",onSubmit:C,children:[n.jsxs("label",{className:"redshift-sql-editor",children:[n.jsx("span",{children:"SQL"}),n.jsx("textarea",{"aria-label":"Redshift SQL",onChange:I=>i(I.target.value),rows:5,spellCheck:!1,value:l})]}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Max rows"}),n.jsx("input",{min:1,onChange:I=>d(I.target.value),type:"number",value:u})]}),n.jsx(re,{disabled:N,type:"submit",children:N?"Running":"Run query"})]})]}),y?n.jsx("p",{className:"operation-message error",children:y}):null,S?n.jsx("p",{className:"operation-message success",children:S}):null,p?n.jsx(eg,{queryResult:p}):null]})}function eg({queryResult:r}){return n.jsxs("div",{className:"redshift-query-result",children:[n.jsxs("div",{className:"attribute-preview","aria-label":"Redshift query summary",children:[n.jsx("span",{className:"attribute-chip",children:r.statement.status}),n.jsx("span",{className:"attribute-chip",children:r.commandTag}),n.jsxs("span",{className:"attribute-chip",children:[r.rowCount," rows"]})]}),r.columns.length===0?n.jsx("p",{className:"inspector-muted",children:"Statement completed without a result set."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsx("tr",{children:r.columns.map(l=>n.jsxs("th",{scope:"col",children:[l.name,n.jsx("span",{className:"query-column-type",children:l.typeName})]},l.name))})}),n.jsx("tbody",{children:r.rows.length===0?n.jsx("tr",{children:n.jsx("td",{colSpan:r.columns.length,children:"No rows returned."})}):r.rows.map((l,i)=>n.jsx("tr",{className:"item-row",children:r.columns.map((u,d)=>n.jsx("td",{children:String(l[d]??"")},`${i}-${u.name}`))},i))})]})})]})}function tg(r){return{service:"redshift",status:"disabled",running:!1,sqlEndpoint:"127.0.0.1:15439",apiEndpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:19099",region:"us-east-1",clusterCount:0,storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/redshift",backendKind:"postgres",backendMode:"managed"}}function zs(r){if(r)return`${r.schema}.${r.name}`}async function ng(){return U("/api/redis/status")}async function Ru(r=0,l="*",i=100){const u=new URLSearchParams({cursor:String(r),match:l,count:String(i)});return U(`/api/redis/keys?${u.toString()}`)}async function Iu(r){return U(`/api/redis/keys/${encodeURIComponent(r)}`)}async function sg(r){return U("/api/redis/command",{method:"POST",body:r})}async function rg(r){return U(`/api/redis/keys/${encodeURIComponent(r)}`,{method:"DELETE"})}async function ag(r,l){return U(`/api/redis/keys/${encodeURIComponent(r)}/expire`,{method:"POST",body:{ttlSeconds:l}})}async function lg(){return U("/api/redis/keys?confirm=FLUSHDB",{method:"DELETE"})}async function ig(r){return U("/api/redis/select-db",{method:"POST",body:{db:r}})}const Eu=/("(?:\\.|[^"\\])*")(\s*:)?|(\b(?:true|false|null)\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;function Hu(r){const l=[];let i=0,u=0;Eu.lastIndex=0;let d;for(;(d=Eu.exec(r))!==null;){const[p,g,y,v,S]=d,w=d.index;if(w>i&&l.push(r.slice(i,w)),g!==void 0){const N=y!==void 0;l.push(n.jsx("span",{className:N?"json-token-key":"json-token-string",children:g},`t${u++}`)),y&&l.push(y)}else if(v!==void 0){const N=v==="null"?"json-token-null":"json-token-bool";l.push(n.jsx("span",{className:N,children:v},`t${u++}`))}else S!==void 0?l.push(n.jsx("span",{className:"json-token-number",children:S},`t${u++}`)):l.push(p);i=w+p.length}return i{if(I){u({status:"success",statusPayload:Dg(r),keys:[],nextCursor:0}),p(void 0),y(void 0);return}u({status:"loading"}),Promise.all([ng(),Ru(0,w)]).then(([Z,de])=>{u({status:"success",statusPayload:Z,keys:de.keys,nextCursor:de.nextCursor}),p(De=>{var ae;return De&&de.keys.some(z=>z.key===De)?De:(ae=de.keys[0])==null?void 0:ae.key})}).catch(Z=>u({status:"error",message:Z.message}))},[I,w,r]),F=m.useCallback(()=>{i.status!=="success"||i.nextCursor===0||I||Ru(i.nextCursor,w).then(Z=>{u(de=>de.status!=="success"?de:{...de,keys:[...de.keys,...Z.keys],nextCursor:Z.nextCursor})}).catch(Z=>C({tone:"error",text:Z.message}))},[I,w,i]);m.useEffect(()=>{E()},[E]),_t({topics:["redis"],onEvent:E,enabled:!I}),m.useEffect(()=>{if(!d||I){y(void 0),S(void 0);return}let Z=!1;return S(void 0),Iu(d).then(de=>{Z||y(de)}).catch(de=>{Z||(y(void 0),S(de.message))}),()=>{Z=!0}},[d,I]);const q=i.status==="success"?i.keys:[],Y=i.status==="success"?i.nextCursor:0,L=i.status==="success"?i.statusPayload:void 0;async function ne(Z){await l(wn({title:"Delete Redis key",description:"This key will be permanently removed from the selected Redis database.",target:Z}))&&rg(Z).then(De=>{C({tone:"success",text:`Deleted ${Z} (${De.deleted} key)`}),p(void 0),y(void 0),E()}).catch(De=>C({tone:"error",text:De.message}))}function ge(Z,de){ag(Z,de).then(De=>{C({tone:De.updated?"success":"warning",text:De.updated?`Set TTL on ${Z} to ${de}s`:`TTL not updated on ${Z} (key may be missing)`}),Iu(Z).then(y).catch(()=>{}),E()}).catch(De=>C({tone:"error",text:De.message}))}function xe(Z){ig(Z).then(()=>{p(void 0),y(void 0),S(void 0),C({tone:"success",text:`Switched to db${Z}`}),E()}).catch(de=>C({tone:"error",text:de.message}))}async function ye(){await l(wn({title:"Flush Redis DB",description:"This removes every key from the selected Redis database. FLUSHALL is never used.",target:"FLUSHDB",confirmLabel:"Flush DB"}))&&lg().then(de=>{C({tone:"success",text:`FLUSHDB ${de.result}`}),p(void 0),y(void 0),E()}).catch(de=>C({tone:"error",text:de.message}))}return I?n.jsx(pe,{title:"Redis",children:n.jsx(_,{title:"Redis is disabled",description:"Enable services.redis.enabled in .devcloud/config.yaml and restart devcloud."})}):n.jsxs("div",{className:"redis-shell",children:[n.jsx(fg,{status:L,loading:i.status==="loading",error:i.status==="error"?i.message:void 0,onSelectDB:xe}),n.jsxs("div",{className:"redis-workspace",children:[n.jsxs(pe,{title:"Keys",children:[n.jsxs("div",{className:"redis-keys-toolbar",children:[n.jsx(dg,{loading:i.status==="loading",keys:q.length,total:L==null?void 0:L.currentDBKeys}),n.jsxs("div",{className:"redis-toolbar-actions",children:[n.jsx(re,{onClick:E,children:"Refresh"}),n.jsx(re,{disabled:i.status!=="success"||Y===0,onClick:F,children:"Load more"}),n.jsx(re,{className:"danger",disabled:q.length===0,onClick:ye,children:"Flush DB"})]})]}),n.jsxs("label",{className:"redis-match-field",children:[n.jsx("span",{children:"Match pattern"}),n.jsx("input",{"aria-label":"Match Redis keys",onChange:Z=>N(Z.target.value),placeholder:"* | prefix:* | exact-key",spellCheck:!1,type:"search",value:w})]}),$?n.jsx(ug,{message:$,onDismiss:()=>C(void 0)}):null,i.status==="loading"?n.jsx(_,{title:"Loading Redis",description:"Reading key metadata via SCAN."}):null,i.status==="error"?n.jsx(_,{title:"Redis unavailable",description:i.message,actionLabel:"Retry",onAction:E}):null,i.status==="success"?n.jsx(pg,{activeKey:d,keys:q,onSelectKey:p}):null]}),n.jsx(pe,{title:"Inspector",children:n.jsx(mg,{detail:g,error:v,onDeleteKey:ne,onApplyTTL:ge})}),n.jsx(pe,{title:"Command Runner",children:n.jsx(Cg,{})})]})]})}function ug({message:r,onDismiss:l}){return n.jsxs("div",{className:`redis-message redis-message-${r.tone}`,role:"status",children:[n.jsx("span",{children:r.text}),n.jsx("button",{"aria-label":"Dismiss",className:"redis-message-close",onClick:l,type:"button",children:"×"})]})}function dg({keys:r,total:l,loading:i}){if(i)return n.jsx("span",{className:"redis-keys-count",children:"Loading…"});const u=l!==void 0&&l>=0?` / ${l}`:"";return n.jsxs("span",{className:"redis-keys-count",children:[n.jsx("strong",{children:r}),n.jsxs("span",{className:"redis-keys-count-total",children:[u," key",r===1?"":"s"]})]})}function fg({status:r,loading:l,error:i,onSelectDB:u}){if(l&&!r)return n.jsx("header",{className:"redis-status-bar redis-status-bar-loading",children:n.jsx("span",{children:"Connecting to Redis…"})});if(i&&!r)return n.jsxs("header",{className:"redis-status-bar redis-status-bar-error",role:"alert",children:[n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"State"}),n.jsx("span",{className:"cell-value",children:"unreachable"})]}),n.jsxs("span",{className:"redis-status-cell redis-status-cell-wide",children:[n.jsx("span",{className:"cell-label",children:"Reason"}),n.jsx("span",{className:"cell-value",children:i})]})]});if(!r)return n.jsx("header",{className:"redis-status-bar redis-status-bar-loading",children:n.jsx("span",{children:"—"})});const d=r.databaseCount>0?r.databaseCount:1;return n.jsxs("header",{className:"redis-status-bar",children:[n.jsxs("span",{className:`redis-status-cell redis-mode-pill redis-mode-${r.mode||"unknown"}`,children:[n.jsx("span",{className:"cell-label",children:"Mode"}),n.jsx("span",{className:"cell-value",children:r.mode||"unknown"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Address"}),n.jsx("code",{children:r.address||"—"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Server"}),n.jsx("span",{className:"cell-value",children:r.serverVersion||"—"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Clients"}),n.jsx("span",{className:"cell-value",children:r.connectedClients})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Memory"}),n.jsx("span",{className:"cell-value",children:r.usedMemoryHuman||"—"})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:"Database"}),n.jsx("select",{"aria-label":"Select Redis database",className:"redis-db-select",disabled:d<=1,onChange:p=>u(Number(p.target.value)),value:r.currentDB,children:Array.from({length:d},(p,g)=>n.jsx("option",{value:g,children:`db${g}`},g))})]}),n.jsxs("span",{className:"redis-status-cell",children:[n.jsx("span",{className:"cell-label",children:`Keys (db${r.currentDB})`}),n.jsx("span",{className:"cell-value",children:r.currentDBKeys})]})]})}function pg({activeKey:r,keys:l,onSelectKey:i}){return l.length===0?n.jsx(_,{title:"No keys",description:"Send SET / HSET / LPUSH from a Redis client and refresh."}):n.jsx("ul",{className:"redis-key-list","aria-label":"Redis keys",children:l.map(u=>{const d=u.key===r;return n.jsx("li",{children:n.jsxs("button",{"aria-current":d?"true":void 0,className:d?"redis-key-row active":"redis-key-row",onClick:()=>i(u.key),title:u.key,type:"button",children:[n.jsxs("span",{className:"redis-key-row-top",children:[n.jsx(Gu,{type:u.type}),n.jsx(Yu,{ttlSeconds:u.ttlSeconds,compact:!0})]}),n.jsx("span",{className:"redis-key-row-name",children:u.key})]})},u.key)})})}function mg({detail:r,error:l,onApplyTTL:i,onDeleteKey:u}){return l?n.jsx(_,{title:"Key unavailable",description:l}):r?n.jsxs("div",{className:"redis-inspector",children:[n.jsx(hg,{detail:r}),n.jsx(gg,{detail:r}),n.jsx(wg,{detail:r,onApplyTTL:i,onDeleteKey:u})]}):n.jsx(_,{title:"Select a key",description:"Choose a key on the left to inspect its type, TTL, and value."})}function hg({detail:r}){const[l,i]=m.useState(!1);function u(){navigator.clipboard&&navigator.clipboard.writeText(r.key).then(()=>{i(!0),window.setTimeout(()=>i(!1),1500)}).catch(()=>{})}return n.jsxs("header",{className:"redis-inspector-header",children:[n.jsx("span",{className:"redis-inspector-eyebrow",children:"Key"}),n.jsxs("div",{className:"redis-key-title-row",children:[n.jsx("code",{className:"redis-key-title",children:r.key}),n.jsx("button",{className:"redis-copy-button",onClick:u,title:"Copy key",type:"button",children:l?"copied":"copy"})]}),n.jsxs("div",{className:"redis-key-badges",children:[n.jsx(Gu,{type:r.type}),n.jsx(Yu,{ttlSeconds:r.ttlSeconds}),n.jsx(kg,{detail:r})]})]})}function gg({detail:r}){return!r.preview||r.preview.length===0?n.jsxs("section",{className:"redis-inspector-section",children:[n.jsx("span",{className:"redis-inspector-eyebrow",children:"Value"}),n.jsx(_,{title:"Empty value",description:"The key exists but has no previewable content in this slice."})]}):n.jsxs("section",{className:"redis-inspector-section",children:[n.jsx("header",{className:"redis-section-head",children:n.jsx("span",{className:"redis-inspector-eyebrow",children:"Value"})}),xg(r)]})}function xg(r){switch(r.type){case"string":return n.jsx(yg,{raw:r.preview[0]??""});case"list":return n.jsx(jg,{items:r.preview});case"hash":return n.jsx(vg,{entries:r.preview});case"set":return n.jsx(Sg,{members:r.preview});case"zset":return n.jsx(Ng,{items:r.preview});default:return n.jsx("pre",{className:"redis-value-pre",children:r.preview.join(` -`)})}}function yg({raw:r}){const l=m.useMemo(()=>Wu(r),[r]),[i,u]=m.useState(l?"pretty":"raw"),d=i==="pretty"&&l!==void 0;return n.jsxs("div",{className:"redis-value-string",children:[n.jsxs("div",{className:"redis-value-toolbar",children:[n.jsxs("span",{className:"redis-value-meta",children:[r.length.toLocaleString()," char",r.length===1?"":"s"]}),l?n.jsxs("div",{role:"tablist","aria-label":"Value format",className:"redis-segmented",children:[n.jsx("button",{type:"button",role:"tab","aria-selected":i==="pretty",className:i==="pretty"?"active":void 0,onClick:()=>u("pretty"),children:"Pretty"}),n.jsx("button",{type:"button",role:"tab","aria-selected":i==="raw",className:i==="raw"?"active":void 0,onClick:()=>u("raw"),children:"Raw"})]}):null]}),n.jsx("pre",{className:"redis-value-pre json-code",children:d?Hu(l):r})]})}function jg({items:r}){return n.jsx("ol",{className:"redis-list-view",children:r.map((l,i)=>n.jsxs("li",{children:[n.jsx("span",{className:"redis-list-index",children:i}),n.jsx("code",{className:"redis-list-value",children:l})]},i))})}function vg({entries:r}){return n.jsxs("table",{className:"redis-hash-table","aria-label":"Hash fields",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Field"}),n.jsx("th",{scope:"col",children:"Value"})]})}),n.jsx("tbody",{children:r.map((l,i)=>{const[u,d]=Xu(l,": ");return n.jsxs("tr",{children:[n.jsx("th",{scope:"row",children:n.jsx("code",{children:u})}),n.jsx("td",{children:n.jsx(bg,{raw:d})})]},i)})})]})}function bg({raw:r}){const l=m.useMemo(()=>Wu(r),[r]);return l===void 0?n.jsx("code",{children:r}):n.jsx("pre",{className:"redis-value-pre json-code redis-value-pre-inline",children:Hu(l)})}function Sg({members:r}){return n.jsx("ul",{className:"redis-set-view","aria-label":"Set members",children:r.map((l,i)=>n.jsx("li",{children:n.jsx("code",{children:l})},i))})}function Ng({items:r}){return n.jsxs("table",{className:"redis-zset-table","aria-label":"Sorted set members",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Member"}),n.jsx("th",{scope:"col",children:"Score"})]})}),n.jsx("tbody",{children:r.map((l,i)=>{const[u,d]=Xu(l,": ");return n.jsxs("tr",{children:[n.jsx("th",{scope:"row",children:n.jsx("code",{children:u})}),n.jsx("td",{className:"redis-zset-score",children:d})]},i)})})]})}function wg({detail:r,onApplyTTL:l,onDeleteKey:i}){const[u,d]=m.useState("60"),p=Number(u),g=Number.isInteger(p)&&p>0;function y(S){S.preventDefault(),g&&l(r.key,p)}function v(){l(r.key,-1)}return n.jsxs("section",{className:"redis-inspector-section",children:[n.jsx("header",{className:"redis-section-head",children:n.jsx("span",{className:"redis-inspector-eyebrow",children:"Actions"})}),n.jsxs("form",{className:"redis-ttl-form",onSubmit:y,children:[n.jsxs("label",{children:[n.jsx("span",{children:"TTL seconds"}),n.jsx("input",{"aria-label":"TTL seconds",min:1,onChange:S=>d(S.target.value),type:"number",value:u})]}),n.jsx(re,{disabled:!g,type:"submit",children:"Set TTL"}),n.jsx(re,{className:"ghost",onClick:v,type:"button",children:"Persist (no TTL)"})]}),n.jsx("div",{className:"redis-danger-row",children:n.jsx(re,{className:"danger",onClick:()=>i(r.key),type:"button",children:"Delete key"})})]})}function Gu({type:r}){const l=(r||"unknown").toLowerCase();return n.jsx("span",{className:`redis-badge redis-type-${l}`,children:l})}function Yu({ttlSeconds:r,compact:l}){return r===-2?n.jsx("span",{className:"redis-badge redis-ttl-missing",children:l?"✕":"missing"}):r===-1?n.jsx("span",{className:"redis-badge redis-ttl-persistent",children:l?"∞":"persistent"}):n.jsx("span",{className:"redis-badge redis-ttl-active",title:`${r}s`,children:l?`${$u(r)}`:`TTL ${$u(r)}`})}function kg({detail:r}){if(!r.preview||r.preview.length===0)return null;const l=r.preview.length;let i;switch(r.type){case"string":i=`${(r.preview[0]??"").length} char${(r.preview[0]??"").length===1?"":"s"}`;break;case"list":i=`${l} item${l===1?"":"s"}`;break;case"hash":i=`${l} field${l===1?"":"s"}`;break;case"set":i=`${l} member${l===1?"":"s"}`;break;case"zset":i=`${l} entr${l===1?"y":"ies"}`;break;default:i=`${l}`}return n.jsx("span",{className:"redis-badge redis-size",children:i})}function Cg(){const[r,l]=m.useState("GET your-key"),[i,u]=m.useState([]),d=m.useRef(0),p=m.useMemo(()=>Tu(r),[r]);function g(N){d.current+=1;const $=d.current;u(C=>[{id:$,...N},...C].slice(0,og))}function y(N,$){if(!N.command){g({input:$,outcome:"error",detail:"No command supplied"});return}sg(N).then(C=>{g({input:$,outcome:"success",detail:`${C.command} (${C.class})`,rows:C.rows??[]})}).catch(C=>{g({input:$,outcome:"error",detail:C.message})})}function v(N){N.preventDefault(),y(p,r)}function S(N){(N.metaKey||N.ctrlKey)&&N.key==="Enter"&&(N.preventDefault(),y(p,r))}function w(N){l(N),y(Tu(N),N)}return n.jsxs("div",{className:"redis-runner",children:[n.jsxs("form",{className:"redis-runner-form",onSubmit:v,children:[n.jsxs("label",{className:"redis-runner-label",children:[n.jsx("span",{children:"Command"}),n.jsx("textarea",{"aria-label":"Redis command",onChange:N=>l(N.target.value),onKeyDown:S,rows:3,spellCheck:!1,value:r})]}),n.jsxs("div",{className:"redis-runner-toolbar",children:[n.jsxs("span",{className:"redis-runner-hint",children:["Allowlisted commands only · ",n.jsx("kbd",{children:"⌘"}),"/",n.jsx("kbd",{children:"Ctrl"}),"+",n.jsx("kbd",{children:"Enter"})," to run"]}),n.jsx(re,{type:"submit",children:"Run"})]})]}),i.length===0?n.jsx(_,{title:"No commands yet",description:"Try GET, HGETALL, LRANGE, SCAN 0 MATCH * COUNT 50, or TYPE."}):null,i.length>0?n.jsx("ol",{className:"redis-runner-history","aria-label":"Command history",children:i.map(N=>n.jsxs("li",{className:`redis-runner-entry redis-runner-${N.outcome}`,children:[n.jsxs("header",{className:"redis-runner-entry-head",children:[n.jsx("code",{className:"redis-runner-input",children:N.input}),n.jsx("button",{className:"redis-runner-rerun",onClick:()=>w(N.input),title:"Run again",type:"button",children:"rerun"})]}),n.jsxs("p",{className:"redis-runner-detail",children:[n.jsx("span",{className:"redis-runner-tag",children:N.outcome==="success"?"OK":"ERR"}),n.jsx("span",{children:N.detail})]}),N.rows&&N.rows.length>0?n.jsx("pre",{className:"redis-runner-result",children:N.rows.join(` -`)}):null]},N.id))}):null]})}function Dg(r){var l;return{service:"redis",status:"disabled",running:!1,mode:"managed",address:((l=r==null?void 0:r.endpoint)==null?void 0:l.replace(/^redis:\/\//,""))??"127.0.0.1:16379",serverVersion:"",connectedClients:0,usedMemoryHuman:"",currentDB:0,databaseCount:0,currentDBKeys:0,storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/redis"}}function Tu(r){const l=r.trim().split(/\s+/).filter(d=>d.length>0),[i="",...u]=l;return{command:i,args:u}}function Xu(r,l){const i=r.indexOf(l);return i===-1?[r,""]:[r.slice(0,i),r.slice(i+l.length)]}function $u(r){return r<60?`${r}s`:r<3600?`${Math.floor(r/60)}m`:r<86400?`${Math.floor(r/3600)}h`:`${Math.floor(r/86400)}d`}async function Rg(){return U("/api/sqs/status")}async function Ig(){return U("/api/sqs/queues")}async function Eg(r){return U("/api/sqs/queues",{method:"POST",body:{input:r}})}async function Tg(r){return U(`/api/sqs/queues/${encodeURIComponent(r)}/messages`)}async function $g(r,l){return U(`/api/sqs/queues/${encodeURIComponent(r)}/messages`,{method:"POST",body:{input:l}})}async function Lg(r,l){return U(`/api/sqs/queues/${encodeURIComponent(r)}/receive`,{method:"POST",body:{input:l}})}async function Pg(r,l){await U(`/api/sqs/queues/${encodeURIComponent(r)}/delete`,{method:"POST",body:{input:l}})}async function Mg(r,l){await U(`/api/sqs/queues/${encodeURIComponent(r)}/visibility`,{method:"POST",body:{input:l}})}async function Og(r){return U(`/api/sqs/queues/${encodeURIComponent(r)}/leases`)}async function Ag(r){return U(`/api/sqs/queues/${encodeURIComponent(r)}/dlq`)}async function _g(r){return mn(`/api/sqs/queues/${encodeURIComponent(r)}/purge`,{method:"POST"})}function Bg({busyAction:r,newQueueName:l,newQueueKind:i,newQueueVisibility:u,newQueueDelay:d,onSubmit:p,setNewQueueName:g,setNewQueueKind:y,setNewQueueVisibility:v,setNewQueueDelay:S}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:p,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Queue name"}),n.jsx("input",{"aria-label":"New SQS queue name",onChange:w=>g(w.target.value),placeholder:i==="fifo"?"jobs.fifo":"jobs",value:l})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Type"}),n.jsxs("select",{"aria-label":"New SQS queue type",onChange:w=>y(w.target.value==="fifo"?"fifo":"standard"),value:i,children:[n.jsx("option",{value:"standard",children:"Standard"}),n.jsx("option",{value:"fifo",children:"FIFO"})]})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Visibility"}),n.jsx("input",{"aria-label":"New SQS queue visibility timeout seconds",inputMode:"numeric",onChange:w=>v(w.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Delay"}),n.jsx("input",{"aria-label":"New SQS queue delay seconds",inputMode:"numeric",onChange:w=>S(w.target.value),value:d})]}),n.jsx(re,{disabled:r==="create-queue",type:"submit",children:"Create"})]})}function Fg(r){return{service:"sqs",status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:19324",region:"us-east-1",authMode:"relaxed",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/sqs",queueCount:0}}function Lu(r){const l=Number.parseInt(r,10);if(!(!Number.isFinite(l)||l<0))return String(l)}function Pu(r){const l=Number.parseInt(r,10);if(!(!Number.isFinite(l)||l<=0))return l}function Ii(r){const l=Number.parseInt(r,10);if(!(!Number.isFinite(l)||l<0))return l}function zg(r){return Object.fromEntries(Object.entries(r).filter(l=>typeof l[1]=="string"))}function Qg(r){const l=r.trim();if(l==="")return;let i;try{i=JSON.parse(l)}catch{return new Error("Message attributes JSON is invalid")}if(!za(i))return new Error("Message attributes must be a JSON object");for(const[u,d]of Object.entries(i))if(!za(d)||typeof d.DataType!="string"||d.DataType.trim()==="")return new Error(`Message attribute ${u} must include DataType`);return i}function Mu(r){const l=r.split(",").map(i=>i.trim()).filter(Boolean);return l.length>0?l:void 0}function Ug(r){if(r)try{const l=JSON.parse(r);if(!za(l))return;const i=l.deadLetterTargetArn,u=l.maxReceiveCount;return typeof i!="string"?void 0:{deadLetterTargetArn:i,maxReceiveCount:typeof u=="string"?u:String(u??"")}}catch{return}}function qg(r){if(r)try{const l=JSON.parse(r);return!za(l)||typeof l.redrivePermission!="string"?void 0:{redrivePermission:l.redrivePermission}}catch{return}}function Zu(r){return r.length<=12?r===""?"":"...":`${r.slice(0,6)}...${r.slice(-6)}`}function za(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}function Vg({activeQueue:r,busyAction:l,purgeConfirmation:i,onSubmit:u,setPurgeConfirmation:d}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:u,children:[n.jsx("span",{className:"toolbar-count",children:r?r.name:"No queue selected"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Purge confirmation"}),n.jsx("input",{"aria-label":"Confirm SQS purge queue",disabled:!r,onChange:p=>d(p.target.value),placeholder:(r==null?void 0:r.name)??"queue name",value:i})]}),n.jsx(re,{className:"danger",disabled:!r||i!==r.name||l==="purge-queue",type:"submit",children:"Purge"})]})}function Kg({activeQueue:r,busyAction:l,selectedReceiptHandle:i,visibilityTimeout:u,visibilityConfirmation:d,onSubmit:p,setVisibilityTimeout:g,setVisibilityConfirmation:y}){const v=!r||i==="";return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:p,children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Visibility timeout"}),n.jsx("input",{"aria-label":"SQS change visibility timeout seconds",disabled:v,inputMode:"numeric",onChange:S=>g(S.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Confirm"}),n.jsx("input",{"aria-label":"Confirm SQS change message visibility",disabled:v,onChange:S=>y(S.target.value),placeholder:"visibility",value:d})]}),n.jsx(re,{disabled:v||d!=="visibility"||l==="change-visibility",type:"submit",children:"Change visibility"})]})}function Jg({activeQueue:r,busyAction:l,selectedReceivedMessage:i,selectedReceiptHandle:u,pastedReceiptHandle:d,deleteConfirmation:p,onSubmit:g,setPastedReceiptHandle:y,setDeleteConfirmation:v}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:g,children:[n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Receipt handle"}),n.jsx("input",{"aria-label":"SQS delete receipt handle",disabled:!r,onChange:S=>y(S.target.value),placeholder:i?Zu(i.ReceiptHandle):"paste receipt handle or select received message",value:d})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Confirm"}),n.jsx("input",{"aria-label":"Confirm SQS delete message",disabled:!r||u==="",onChange:S=>v(S.target.value),placeholder:"delete",value:p})]}),n.jsx(re,{className:"danger",disabled:!r||u===""||p!=="delete"||l==="delete-message",type:"submit",children:"Delete"})]})}function Hg({activeIndex:r,detailState:l,messages:i,onSelectIndex:u,queueName:d}){return d?l.status==="loading"?n.jsx(_,{title:"Loading messages",description:`Reading messages from ${d}.`}):l.status==="error"?n.jsx(_,{title:"SQS messages unavailable",description:l.message}):i.length===0?n.jsx(_,{title:"No messages",description:`No retained messages in ${d} match the current filter.`}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"State"}),n.jsx("th",{scope:"col",children:"Body"}),n.jsx("th",{scope:"col",children:"Receive"})]})}),n.jsx("tbody",{children:i.map((p,g)=>n.jsxs("tr",{className:g===r?"item-row active":"item-row",onClick:()=>u(g),children:[n.jsx("td",{children:p.state}),n.jsx("td",{children:n.jsx(Wg,{message:p})}),n.jsx("td",{children:p.receiveCount})]},`${p.messageId}-${g}`))})]})}):n.jsx(_,{title:"No queue selected",description:"Choose a queue to inspect retained messages."})}function Wg({message:r}){return n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:r.body||"(empty body)"}),r.messageGroupId?n.jsxs("span",{className:"attribute-chip",children:["group: ",r.messageGroupId]}):null,r.sequenceNumber?n.jsxs("span",{className:"attribute-chip",children:["seq: ",r.sequenceNumber]}):null]})}function Gg({activeQueue:r,busyAction:l,sendBody:i,sendDelay:u,sendAttributesJSON:d,sendGroupId:p,sendDeduplicationId:g,onSubmit:y,setSendBody:v,setSendDelay:S,setSendAttributesJSON:w,setSendGroupId:N,setSendDeduplicationId:$}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:y,children:[n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Message body"}),n.jsx("textarea",{"aria-label":"SQS message body",disabled:!r,onChange:C=>v(C.target.value),placeholder:"message body",rows:3,value:i})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Delay"}),n.jsx("input",{"aria-label":"SQS message delay seconds",disabled:!r,inputMode:"numeric",onChange:C=>S(C.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Attributes JSON"}),n.jsx("input",{"aria-label":"SQS message attributes JSON",disabled:!r,onChange:C=>w(C.target.value),placeholder:'{"kind":{"DataType":"String","StringValue":"test"}}',value:d})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Group"}),n.jsx("input",{"aria-label":"SQS FIFO message group ID",disabled:!r,onChange:C=>N(C.target.value),placeholder:"FIFO only",value:p})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Dedup"}),n.jsx("input",{"aria-label":"SQS FIFO message deduplication ID",disabled:!r,onChange:C=>$(C.target.value),placeholder:"optional",value:g})]}),n.jsx(re,{disabled:!r||l==="send-message",type:"submit",children:"Send"})]})}function Yg({activeQueue:r,busyAction:l,receiveMaxMessages:i,receiveVisibilityTimeout:u,receiveWaitTime:d,receiveAttributeNames:p,receiveMessageAttributeNames:g,onSubmit:y,setReceiveMaxMessages:v,setReceiveVisibilityTimeout:S,setReceiveWaitTime:w,setReceiveAttributeNames:N,setReceiveMessageAttributeNames:$}){return n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:y,children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Max"}),n.jsx("input",{"aria-label":"SQS receive max messages",disabled:!r,inputMode:"numeric",onChange:C=>v(C.target.value),value:i})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Visibility"}),n.jsx("input",{"aria-label":"SQS receive visibility timeout seconds",disabled:!r,inputMode:"numeric",onChange:C=>S(C.target.value),value:u})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Wait"}),n.jsx("input",{"aria-label":"SQS receive wait time seconds",disabled:!r,inputMode:"numeric",onChange:C=>w(C.target.value),value:d})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Attrs"}),n.jsx("input",{"aria-label":"SQS receive attribute names",disabled:!r,onChange:C=>N(C.target.value),placeholder:"All or comma-separated names",value:p})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Msg attrs"}),n.jsx("input",{"aria-label":"SQS receive message attribute names",disabled:!r,onChange:C=>$(C.target.value),placeholder:"All or comma-separated names",value:g})]}),n.jsx(re,{disabled:!r||l==="receive-message",type:"submit",children:"Receive"})]})}function Xg({activeQueueName:r,onSelectQueue:l,queues:i}){return i.length===0?n.jsx(_,{title:"No queues",description:"Queues created through the SQS API will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"SQS queues",children:i.map(u=>n.jsxs("button",{className:u.name===r?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>l(u.name),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:u.name}),n.jsx("span",{className:"count-pill",children:u.totalRetainedMessages})]}),n.jsx("span",{className:"table-row-meta",children:u.url}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:[u.visibleMessages," visible"]}),n.jsxs("span",{children:[u.notVisibleMessages," in flight"]}),n.jsxs("span",{children:[u.delayedMessages," delayed"]})]})]},u.name))})}function Zg({messages:r,onSelectIndex:l,selectedIndex:i}){return r.length===0?null:n.jsx("div",{className:"dynamodb-item-table-wrap","aria-label":"Received SQS messages",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Use"}),n.jsx("th",{scope:"col",children:"Message"}),n.jsx("th",{scope:"col",children:"Receipt handle"})]})}),n.jsx("tbody",{children:r.map((u,d)=>n.jsxs("tr",{className:d===i?"item-row active":"item-row",onClick:()=>l(d),children:[n.jsx("td",{children:d===i?"selected":"select"}),n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:u.Body||"(empty body)"}),n.jsx("span",{className:"attribute-chip",children:u.MessageId})]})}),n.jsx("td",{children:n.jsx("code",{children:Zu(u.ReceiptHandle)})})]},`${u.MessageId}-${d}`))})]})})}function ex({detailState:r,message:l,queue:i,status:u}){var v;if(!i)return n.jsx(_,{title:"Inspector",description:"Queue attributes and selected message JSON will appear here."});const d=r.status==="success"?r.leases:[],p=r.status==="success"?r.dlq:void 0,g=Ug(i.attributes.RedrivePolicy),y=qg(i.attributes.RedriveAllowPolicy);return n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Queue"}),n.jsx("h3",{children:i.name}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:(u==null?void 0:u.endpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:(u==null?void 0:u.region)??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"ARN"}),n.jsx("dd",{children:n.jsx("code",{children:i.arn})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Visibility"}),n.jsxs("dd",{children:[i.attributes.VisibilityTimeout??"unknown","s"]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Leases"}),n.jsx("dd",{children:d.length})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"DLQ sources"}),n.jsx("dd",{children:(p==null?void 0:p.deadLetterSourceQueues.map(S=>S.name).join(", "))||"none"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"DLQ target"}),n.jsx("dd",{children:((v=p==null?void 0:p.deadLetterQueue)==null?void 0:v.name)??(g==null?void 0:g.deadLetterTargetArn)??"none"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Redrive"}),n.jsxs("dd",{children:[g?`maxReceiveCount ${g.maxReceiveCount}`:"none",y?`, allow ${y.redrivePermission}`:""]})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Selected message"}),l?n.jsx("pre",{className:"mail-preview",children:JSON.stringify(l,null,2)}):n.jsx("p",{className:"inspector-muted",children:"Select a message row to inspect JSON."})]})]})}function tx({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState(),[y,v]=m.useState(0),[S,w]=m.useState(""),[N,$]=m.useState(""),[C,I]=m.useState(""),[E,F]=m.useState("standard"),[q,Y]=m.useState("30"),[L,ne]=m.useState("0"),[ge,xe]=m.useState(""),[ye,Z]=m.useState("0"),[de,De]=m.useState(""),[ae,z]=m.useState(""),[X,J]=m.useState(""),[ve,Ie]=m.useState("1"),[je,O]=m.useState("30"),[K,Q]=m.useState("0"),[b,T]=m.useState("All"),[oe,fe]=m.useState("All"),[Ne,we]=m.useState([]),[Re,ce]=m.useState(0),[Ee,Qe]=m.useState(""),[nt,Nt]=m.useState(""),[ee,$e]=m.useState("0"),[ke,ue]=m.useState(""),[be,wt]=m.useState(""),[Kt,Bt]=m.useState(""),[kt,Ze]=m.useState(""),[Ct,V]=m.useState(),[Be,Ue]=m.useState(),Pe=(r==null?void 0:r.status)==="disabled",lt=m.useCallback(()=>{if(Pe){i({status:"success",statusPayload:Fg(r),queues:[]}),d({status:"idle"}),g(void 0);return}i({status:"loading"}),Promise.all([Rg(),Ig()]).then(([Ce,{queues:Le}])=>{i({status:"success",statusPayload:Ce,queues:Le}),g(Fe=>{var Dn;return Fe&&Le.some(ms=>ms.name===Fe)?Fe:(Dn=Le[0])==null?void 0:Dn.name})}).catch(Ce=>{i({status:"error",message:Ce.message})})},[Pe,r]);m.useEffect(()=>{lt()},[lt]),_t({topics:["sqs"],onEvent:lt,enabled:!Pe});const qe=l.status==="success"?l.queues:[],_e=qe.find(Ce=>Ce.name===p),ut=m.useCallback(()=>{if(!p||Pe){d({status:"idle"});return}d({status:"loading"}),Promise.all([Tg(p),Og(p),Ag(p)]).then(([Ce,Le,Fe])=>{v(0),d({status:"success",messages:Ce.messages,leases:Le.leases,dlq:Fe})}).catch(Ce=>{d({status:"error",message:Ce.message})})},[p,Pe]);m.useEffect(()=>{ut()},[ut]);const Cn=m.useMemo(()=>{const Ce=S.trim().toLowerCase();return Ce===""?qe:qe.filter(Le=>Le.name.toLowerCase().includes(Ce))},[qe,S]),dt=m.useMemo(()=>{const Ce=u.status==="success"?u.messages:[],Le=N.trim().toLowerCase();return Le===""?Ce:Ce.filter(Fe=>JSON.stringify(Fe).toLowerCase().includes(Le))},[u,N]),Jt=dt[Math.min(y,Math.max(dt.length-1,0))],Ht=Ne[Math.min(Re,Math.max(Ne.length-1,0))],st=Ee.trim()||(Ht==null?void 0:Ht.ReceiptHandle)||"";if(Pe)return n.jsx(pe,{title:"SQS",children:n.jsx(_,{title:"SQS is disabled",description:"Enable the SQS service in devcloud config to inspect queues and messages."})});function sn(Ce){g(Ce),v(0),we([]),ce(0),Qe(""),Nt(""),ue(""),wt(""),$(""),Ue(void 0)}function rn(Ce){if(Ce.preventDefault(),!!p){if(be!==p){Ue("Type the queue name to confirm purge confirmation");return}Ue(void 0),_g(p).then(()=>{wt(""),lt(),ut()}).catch(Le=>{Ue(Le.message)})}}async function Wt(Ce,Le){V(Ce),Ze(""),Bt("");try{const Fe=await Le();Bt(Fe),lt(),ut()}catch(Fe){Ze(Fe instanceof Error?Fe.message:"SQS operation failed")}finally{V(void 0)}}function an(Ce){Ce.preventDefault();const Le=C.trim();if(Le===""){Ze("Queue name is required");return}const Fe=E==="fifo"&&!Le.endsWith(".fifo")?`${Le}.fifo`:Le;Wt("create-queue",async()=>(await Eg({QueueName:Fe,Attributes:zg({VisibilityTimeout:Lu(q),DelaySeconds:Lu(L),FifoQueue:E==="fifo"?"true":void 0,ContentBasedDeduplication:E==="fifo"?"true":void 0}),Tags:{source:"dashboard"}}),I(""),g(Fe),`Created queue ${Fe}`))}function ln(Ce){if(Ce.preventDefault(),!_e){Ze("Queue is required");return}if(ge===""){Ze("Message body is required");return}const Le=Qg(de);if(Le instanceof Error){Ze(Le.message);return}Wt("send-message",async()=>{const Fe=await $g(_e.name,{MessageBody:ge,DelaySeconds:Pu(ye),MessageAttributes:Le,MessageGroupId:ae.trim()||void 0,MessageDeduplicationId:X.trim()||void 0});return xe(""),De(""),`Sent message ${Fe.MessageId}`})}function on(Ce){if(Ce.preventDefault(),!_e){Ze("Queue is required");return}Wt("receive-message",async()=>{const Fe=(await Lg(_e.name,{MaxNumberOfMessages:Pu(ve),VisibilityTimeout:Ii(je),WaitTimeSeconds:Ii(K),AttributeNames:Mu(b),MessageAttributeNames:Mu(oe)})).Messages??[];return we(Fe),ce(0),Qe(""),Nt(""),Fe.length===1?"Received 1 message":`Received ${Fe.length} messages`})}function fs(Ce){if(Ce.preventDefault(),!_e){Ze("Queue is required");return}if(st===""){Ze("Receipt handle is required");return}if(nt!=="delete"){Ze("Type delete to confirm DeleteMessage");return}Wt("delete-message",async()=>(await Pg(_e.name,{ReceiptHandle:st}),we(Le=>Le.filter(Fe=>Fe.ReceiptHandle!==st)),ce(0),Qe(""),Nt(""),ue(""),"Deleted message for the selected receipt handle"))}function ps(Ce){if(Ce.preventDefault(),!_e){Ze("Queue is required");return}if(st===""){Ze("Receipt handle is required");return}if(ke!=="visibility"){Ze("Type visibility to confirm ChangeMessageVisibility");return}const Le=Ii(ee);if(typeof Le!="number"){Ze("Visibility timeout must be a non-negative number");return}Wt("change-visibility",async()=>(await Mg(_e.name,{ReceiptHandle:st,VisibilityTimeout:Le}),ue(""),`Changed visibility timeout to ${Le}s`))}return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(pe,{title:"Queues",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter SQS queues",onChange:Ce=>w(Ce.target.value),placeholder:"queue name",type:"search",value:S})]}),n.jsx(re,{onClick:lt,children:"Refresh"})]}),n.jsx(Bg,{busyAction:Ct,newQueueDelay:L,newQueueKind:E,newQueueName:C,newQueueVisibility:q,onSubmit:an,setNewQueueDelay:ne,setNewQueueKind:F,setNewQueueName:I,setNewQueueVisibility:Y}),l.status==="loading"?n.jsx(_,{title:"Loading queues",description:"Reading local SQS queue metadata."}):null,l.status==="error"?n.jsx(_,{title:"SQS queues unavailable",description:l.message,actionLabel:"Retry",onAction:lt}):null,l.status==="success"?n.jsx(Xg,{activeQueueName:p,onSelectQueue:sn,queues:Cn}):null]}),n.jsxs(pe,{title:"Messages",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:_e?`${dt.length} shown / ${_e.totalRetainedMessages} retained`:"Select a queue"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter SQS messages",disabled:!_e,onChange:Ce=>{v(0),$(Ce.target.value)},placeholder:"body or attribute",type:"search",value:N})]}),n.jsx(re,{disabled:!_e,onClick:ut,children:"Refresh"})]}),n.jsx(Gg,{activeQueue:!!_e,busyAction:Ct,onSubmit:ln,sendAttributesJSON:de,sendBody:ge,sendDeduplicationId:X,sendDelay:ye,sendGroupId:ae,setSendAttributesJSON:De,setSendBody:xe,setSendDeduplicationId:J,setSendDelay:Z,setSendGroupId:z}),n.jsx(Hg,{activeIndex:y,detailState:u,messages:dt,onSelectIndex:v,queueName:p}),n.jsx(Yg,{activeQueue:!!_e,busyAction:Ct,onSubmit:on,receiveAttributeNames:b,receiveMaxMessages:ve,receiveMessageAttributeNames:oe,receiveVisibilityTimeout:je,receiveWaitTime:K,setReceiveAttributeNames:T,setReceiveMaxMessages:Ie,setReceiveMessageAttributeNames:fe,setReceiveVisibilityTimeout:O,setReceiveWaitTime:Q}),n.jsx(Zg,{messages:Ne,onSelectIndex:ce,selectedIndex:Re})]}),n.jsxs(pe,{title:"Inspector",children:[kt?n.jsx("p",{className:"operation-message error",children:kt}):null,Kt?n.jsx("p",{className:"operation-message success",children:Kt}):null,n.jsx(Vg,{activeQueue:_e,busyAction:Ct,onSubmit:rn,purgeConfirmation:be,setPurgeConfirmation:wt}),Be?n.jsx("p",{className:"inspector-muted",children:Be}):null,n.jsx(Kg,{activeQueue:!!_e,busyAction:Ct,onSubmit:ps,selectedReceiptHandle:st,setVisibilityConfirmation:ue,setVisibilityTimeout:$e,visibilityConfirmation:ke,visibilityTimeout:ee}),n.jsx(Jg,{activeQueue:!!_e,busyAction:Ct,deleteConfirmation:nt,onSubmit:fs,pastedReceiptHandle:Ee,selectedReceiptHandle:st,selectedReceivedMessage:Ht,setDeleteConfirmation:Nt,setPastedReceiptHandle:Qe}),n.jsx(ex,{detailState:u,message:Jt,queue:_e,status:l.status==="success"?l.statusPayload:void 0})]})]})}async function nx(){return U("/api/pubsub/status")}async function sx(){return U("/api/pubsub/topics")}async function rx(r){return U("/api/pubsub/topics",{method:"POST",body:{topicId:r}})}async function ax(){return U("/api/pubsub/subscriptions")}async function lx(r){return U("/api/pubsub/subscriptions",{method:"POST",body:r})}async function ix(r){return U(`/api/pubsub/topics/${encodeURIComponent(r.topicId)}/publish`,{method:"POST",body:{messages:[{data:r.data,attributes:r.attributes,orderingKey:r.orderingKey}]}})}async function ox(r,l){return U(`/api/pubsub/subscriptions/${encodeURIComponent(r)}/pull`,{method:"POST",body:{maxMessages:l}})}async function cx(r,l){await U(`/api/pubsub/subscriptions/${encodeURIComponent(r)}/ack`,{method:"POST",body:{ackIds:l}})}function ux({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(),[y,v]=m.useState(""),[S,w]=m.useState(""),[N,$]=m.useState(""),[C,I]=m.useState(""),[E,F]=m.useState(""),[q,Y]=m.useState("10"),[L,ne]=m.useState(""),[ge,xe]=m.useState(""),[ye,Z]=m.useState("1"),[de,De]=m.useState([]),[ae,z]=m.useState(""),[X,J]=m.useState(""),[ve,Ie]=m.useState(""),[je,O]=m.useState(),K=(r==null?void 0:r.status)==="disabled",Q=m.useCallback(()=>{if(K){i({status:"success",statusPayload:yx(r),topics:[],subscriptions:[]}),g(void 0);return}i({status:"loading"}),Promise.all([nx(),sx(),ax()]).then(([ee,$e,ke])=>{i({status:"success",statusPayload:ee,topics:$e.topics,subscriptions:ke.subscriptions}),d(ue=>{var be;return ue&&$e.topics.some(wt=>wt.name===ue)?ue:(be=$e.topics[0])==null?void 0:be.name}),F(ue=>{var be;return ue||Lt(((be=$e.topics[0])==null?void 0:be.name)??"")}),g(ue=>{var be;return ue&&ke.subscriptions.some(wt=>wt.name===ue)?ue:(be=ke.subscriptions[0])==null?void 0:be.name})}).catch(ee=>{i({status:"error",message:ee.message})})},[K,r]);m.useEffect(()=>{Q()},[Q]),_t({topics:["pubsub"],onEvent:Q,enabled:!K});const b=l.status==="success"?l.topics:[],T=l.status==="success"?l.subscriptions:[],oe=b.find(ee=>ee.name===u),fe=T.find(ee=>ee.name===p),Ne=m.useMemo(()=>{const ee=y.trim().toLowerCase();return ee===""?b:b.filter($e=>$e.name.toLowerCase().includes(ee))},[b,y]),we=m.useMemo(()=>{const ee=S.trim().toLowerCase();return ee===""?T:T.filter($e=>JSON.stringify($e).toLowerCase().includes(ee))},[T,S]);async function Re(ee,$e){O(ee),Ie(""),J("");try{const ke=await $e();J(ke),Q()}catch(ke){Ie(ke instanceof Error?ke.message:"Pub/Sub operation failed")}finally{O(void 0)}}function ce(ee){ee.preventDefault();const $e=N.trim();if($e===""){Ie("Topic ID is required");return}Re("create-topic",async()=>{const ke=await rx($e);return $(""),d(ke.name),F(Lt(ke.name)),`Created topic ${Lt(ke.name)}`})}function Ee(ee){ee.preventDefault();const $e=C.trim(),ke=E.trim()||Lt((oe==null?void 0:oe.name)??"");if($e===""||ke===""){Ie("Subscription ID and topic are required");return}Re("create-subscription",async()=>{const ue=await lx({subscriptionId:$e,topicId:ke,ackDeadlineSeconds:Number.parseInt(q,10)||void 0});return I(""),g(ue.name),`Created subscription ${Lt(ue.name)}`})}function Qe(ee){ee.preventDefault();const $e=Lt((oe==null?void 0:oe.name)??"");if($e===""||L===""){Ie("Topic and message are required");return}Re("publish",async()=>{const ke=await ix({topicId:$e,data:mx(L),attributes:{source:"dashboard"},orderingKey:ge.trim()||void 0});return ne(""),xe(""),`Published ${ke.messageIds.length} message`})}function nt(){const ee=Lt((fe==null?void 0:fe.name)??"");if(ee===""){Ie("Subscription is required");return}Re("pull",async()=>{var ue;const ke=(await ox(ee,Number.parseInt(ye,10)||1)).receivedMessages??[];return De(ke),z(((ue=ke[0])==null?void 0:ue.ackId)??""),`Pulled ${ke.length} message`})}function Nt(){const ee=Lt((fe==null?void 0:fe.name)??""),$e=ae?[ae]:de.map(ke=>ke.ackId).filter(Boolean);if(ee===""||$e.length===0){Ie("Ack ID is required");return}Re("ack",async()=>(await cx(ee,$e),De(ke=>ke.filter(ue=>!$e.includes(ue.ackId))),z(""),`Acknowledged ${$e.length} message`))}return K?n.jsx(pe,{title:"Pub/Sub",children:n.jsx(_,{title:"Pub/Sub is disabled",description:"Enable the Pub/Sub service in devcloud config to inspect topics and subscriptions."})}):n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(pe,{title:"Topics",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter Pub/Sub topics",onChange:ee=>v(ee.target.value),placeholder:"topic name",type:"search",value:y})]}),n.jsx(re,{onClick:Q,children:"Refresh"})]}),n.jsxs("form",{className:"pubsub-action-form",onSubmit:ce,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Topic ID"}),n.jsx("input",{"aria-label":"New Pub/Sub topic ID",onChange:ee=>$(ee.target.value),placeholder:"orders",value:N})]}),n.jsx(re,{disabled:je==="create-topic",type:"submit",children:"Create"})]}),l.status==="loading"?n.jsx(_,{title:"Loading topics",description:"Reading local Pub/Sub resource metadata."}):null,l.status==="error"?n.jsx(_,{title:"Pub/Sub unavailable",description:l.message,actionLabel:"Retry",onAction:Q}):null,l.status==="success"?n.jsx(dx,{activeTopicName:u,onSelectTopic:d,topics:Ne}):null,n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:Qe,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Message"}),n.jsx("input",{"aria-label":"Pub/Sub publish message",disabled:!oe,onChange:ee=>ne(ee.target.value),placeholder:"message body",value:L})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Ordering key"}),n.jsx("input",{"aria-label":"Pub/Sub publish ordering key",disabled:!oe,onChange:ee=>xe(ee.target.value),placeholder:"optional",value:ge})]}),n.jsx(re,{disabled:!oe||je==="publish",type:"submit",children:"Publish"})]})]}),n.jsxs(pe,{title:"Subscriptions",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${we.length} shown / ${T.length} subscriptions`:"Loading"}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Filter"}),n.jsx("input",{"aria-label":"Filter Pub/Sub subscriptions",onChange:ee=>w(ee.target.value),placeholder:"subscription or topic",type:"search",value:S})]})]}),n.jsxs("form",{className:"pubsub-action-form",onSubmit:Ee,children:[n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Subscription ID"}),n.jsx("input",{"aria-label":"New Pub/Sub subscription ID",onChange:ee=>I(ee.target.value),placeholder:"orders-sub",value:C})]}),n.jsxs("label",{className:"compact-filter",children:[n.jsx("span",{children:"Topic"}),n.jsxs("select",{"aria-label":"Topic for new Pub/Sub subscription",onChange:ee=>F(ee.target.value),value:E,children:[n.jsx("option",{value:"",children:"Select topic"}),b.map(ee=>n.jsx("option",{value:Lt(ee.name),children:Lt(ee.name)},ee.name))]})]}),n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Ack deadline"}),n.jsx("input",{"aria-label":"New Pub/Sub subscription ack deadline seconds",inputMode:"numeric",onChange:ee=>Y(ee.target.value),value:q})]}),n.jsx(re,{disabled:je==="create-subscription",type:"submit",children:"Create"})]}),l.status==="success"?n.jsx(fx,{activeSubscriptionName:p,onSelectSubscription:g,subscriptions:we}):null]}),n.jsxs(pe,{title:"Inspector",children:[ve?n.jsx("p",{className:"operation-message error",children:ve}):null,X?n.jsx("p",{className:"operation-message success",children:X}):null,n.jsx(px,{busyAction:je,onAck:Nt,onPull:nt,pulledMessages:de,pullMaxMessages:ye,selectedAckId:ae,setPullMaxMessages:Z,setSelectedAckId:z,status:l.status==="success"?l.statusPayload:void 0,subscription:fe,topicCount:b.length})]})]})}function dx({activeTopicName:r,onSelectTopic:l,topics:i}){return i.length===0?n.jsx(_,{title:"No topics",description:"Topics created through Pub/Sub REST or SDK clients will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Pub/Sub topics",children:i.map(u=>n.jsxs("button",{className:u.name===r?"dynamodb-table-row active":"dynamodb-table-row",onClick:()=>l(u.name),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:Lt(u.name)}),n.jsx("span",{className:"count-pill",children:u.subscriptionCount})]}),n.jsx("span",{className:"table-row-meta",children:u.name}),n.jsx("span",{className:"table-row-tags",children:n.jsxs("span",{children:[u.subscriptionCount," subscriptions"]})})]},u.name))})}function fx({activeSubscriptionName:r,onSelectSubscription:l,subscriptions:i}){return i.length===0?n.jsx(_,{title:"No subscriptions",description:"Subscriptions created through Pub/Sub REST or SDK clients will appear here."}):n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Subscription"}),n.jsx("th",{scope:"col",children:"Backlog"}),n.jsx("th",{scope:"col",children:"In flight"}),n.jsx("th",{scope:"col",children:"Attempts"})]})}),n.jsx("tbody",{children:i.map(u=>n.jsxs("tr",{className:u.name===r?"item-row active":"item-row",onClick:()=>l(u.name),children:[n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:Lt(u.name)}),n.jsx("span",{className:"attribute-chip",children:Lt(u.topic)})]})}),n.jsx("td",{children:u.backlogMessages}),n.jsx("td",{children:u.inFlightMessages}),n.jsx("td",{children:u.maxDeliveryAttemptSeen})]},u.name))})]})})}function px({busyAction:r,onAck:l,onPull:i,pulledMessages:u,pullMaxMessages:d,selectedAckId:p,setPullMaxMessages:g,setSelectedAckId:y,status:v,subscription:S,topicCount:w}){return S?n.jsxs("div",{className:"dynamodb-inspector",children:[n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Service"}),n.jsx("h3",{children:Lt(S.name)}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"REST"}),n.jsx("dd",{children:n.jsx("code",{children:(v==null?void 0:v.restEndpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"gRPC"}),n.jsx("dd",{children:n.jsx("code",{children:(v==null?void 0:v.grpcEndpoint)??"unknown"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Project"}),n.jsx("dd",{children:(v==null?void 0:v.project)??"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Topics"}),n.jsx("dd",{children:w})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Ack deadline"}),n.jsxs("dd",{children:[S.ackDeadlineSeconds,"s"]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Retained"}),n.jsx("dd",{children:S.totalRetainedMessages})]})]})]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Actions"}),n.jsxs("div",{className:"pubsub-action-row",children:[n.jsxs("label",{className:"compact-filter small",children:[n.jsx("span",{children:"Pull max"}),n.jsx("input",{"aria-label":"Pub/Sub pull max messages",inputMode:"numeric",onChange:N=>g(N.target.value),value:d})]}),n.jsx(re,{disabled:r==="pull",onClick:i,children:"Pull"}),n.jsx(re,{disabled:u.length===0||r==="ack",onClick:l,children:"Ack"})]}),u.length>0?n.jsx("div",{className:"pulled-message-list",children:u.map(N=>n.jsxs("label",{className:"pulled-message",children:[n.jsx("input",{checked:p===N.ackId,name:"pubsub-ack-id",onChange:()=>y(N.ackId),type:"radio"}),n.jsxs("span",{children:[n.jsx("strong",{children:N.message.messageId??"pulled message"}),n.jsx("code",{children:hx(N.message.data??"")})]})]},N.ackId))}):null]}),n.jsxs("section",{children:[n.jsx("span",{className:"inspector-label",children:"Recent deliveries"}),S.recentDeliveries&&S.recentDeliveries.length>0?n.jsx(gx,{deliveries:S.recentDeliveries}):n.jsx("p",{className:"inspector-muted",children:"No retained delivery records for this subscription."})]})]}):n.jsx(_,{title:"Inspector",description:"Subscription backlog, leases, and recent deliveries will appear here."})}function mx(r){return window.btoa(unescape(encodeURIComponent(r)))}function hx(r){if(r==="")return"";try{return decodeURIComponent(escape(window.atob(r)))}catch{return r}}function gx({deliveries:r}){return n.jsx("div",{className:"dynamodb-item-table-wrap",children:n.jsxs("table",{className:"dynamodb-item-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Message"}),n.jsx("th",{scope:"col",children:"State"}),n.jsx("th",{scope:"col",children:"Attempt"}),n.jsx("th",{scope:"col",children:"Ready"})]})}),n.jsx("tbody",{children:r.map(l=>n.jsxs("tr",{className:"item-row",children:[n.jsx("td",{children:n.jsxs("span",{className:"attribute-preview",children:[n.jsx("span",{className:"attribute-chip",children:l.messageId}),l.orderingKey?n.jsx("span",{className:"attribute-chip",children:l.orderingKey}):null]})}),n.jsx("td",{children:l.state}),n.jsx("td",{children:l.deliveryAttempt}),n.jsx("td",{children:xx(l)})]},`${l.messageId}-${l.deliveryAttempt}-${l.state}`))})]})})}function xx(r){return r.leaseDeadline?`lease until ${Ei(r.leaseDeadline)}`:r.nextDeliveryTime?`retry at ${Ei(r.nextDeliveryTime)}`:r.publishTime?`published ${Ei(r.publishTime)}`:"available"}function yx(r){return{service:"pubsub",status:"disabled",running:!1,grpcEndpoint:"127.0.0.1:18085",restEndpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:18086",project:"devcloud",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/pubsub",topicCount:0,subscriptionCount:0}}function Lt(r){return r.split("/").pop()??r}function Ei(r){const l=new Date(r);return Number.isNaN(l.getTime())?r:l.toLocaleString()}async function jx(){return U("/api/gcs/status")}async function vx(){return U("/api/gcs/buckets")}async function bx(r){return U("/api/gcs/buckets",{method:"POST",body:{name:r}})}async function Sx(r){return mn(`/api/gcs/buckets/${encodeURIComponent(r)}`,{method:"DELETE"})}async function ed(r,l=""){const i=new URLSearchParams;l!==""&&i.set("prefix",l);const u=i.toString();return U(`/api/gcs/buckets/${encodeURIComponent(r)}/objects${u?`?${u}`:""}`)}async function Nx(r,l){return U(`/api/gcs/buckets/${encodeURIComponent(r)}/objects/${encodeURIComponent(l)}`)}async function wx(r,l){return mn(`/api/gcs/buckets/${encodeURIComponent(r)}/objects/${encodeURIComponent(l)}`,{method:"DELETE"})}async function kx(){return U("/api/gcs/upload-sessions")}async function Cx(r){return mn(`/api/gcs/uploads/${encodeURIComponent(r)}`,{method:"DELETE"})}function Dx({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState({status:"idle"}),[p,g]=m.useState(),[y,v]=m.useState(),[S,w]=m.useState(""),[N,$]=m.useState(""),[C,I]=m.useState(),[E,F]=m.useState(0),q=(r==null?void 0:r.status)==="disabled",Y=Er(),L=m.useCallback(()=>{if(q){i({status:"success",statusPayload:Au(r),buckets:[],sessions:[]}),d({status:"idle"}),g(void 0),v(void 0);return}i({status:"loading"}),Promise.all([jx(),vx(),kx()]).then(([J,ve,Ie])=>{i({status:"success",statusPayload:J,buckets:ve.buckets,sessions:Ie.sessions}),g(je=>{var O;return je&&ve.buckets.some(K=>K.name===je)?je:(O=ve.buckets[0])==null?void 0:O.name})}).catch(J=>{i({status:"error",message:J.message})})},[q,r]);m.useEffect(()=>{L()},[L]);const ne=m.useCallback(J=>{L(),J.type.startsWith("gcs.object.")&&F(ve=>ve+1)},[L]);_t({topics:["gcs"],onEvent:ne,enabled:!q}),m.useEffect(()=>{if(v(void 0),!p||q){d({status:"idle"});return}let J=!1;return d({status:"loading"}),ed(p,S.trim()).then(({objects:ve})=>{J||d({status:"success",objects:ve})}).catch(ve=>{J||d({status:"error",message:ve.message})}),()=>{J=!0}},[p,q,S,E]);const ge=l.status==="success"?l.buckets:[],xe=l.status==="success"?l.sessions:[],ye=l.status==="success"?l.statusPayload:void 0;function Z(J){g(J),v(void 0)}function de(J){p&&Nx(p,J.name).then(v).catch(ve=>I(ve.message))}function De(){const J=N.trim();!J||q||bx(J).then(()=>{$(""),I(`Created bucket ${J}`),L()}).catch(ve=>I(ve.message))}async function ae(J){q||!await Y(wn({title:"Delete bucket",description:"All objects and metadata in this bucket will be removed. This cannot be undone.",target:J}))||Sx(J).then(()=>{I(`Deleted bucket ${J}`),L()}).catch(Ie=>I(Ie.message))}async function z(J){!p||q||!await Y(wn({title:"Delete object",description:"This object will be permanently removed from the bucket.",target:J.name}))||wx(p,J.name).then(()=>{I(`Deleted object ${J.gcsUri}`),v(void 0),w(Ie=>Ie),Lx(p,S,d)}).catch(Ie=>I(Ie.message))}async function X(J){q||!await Y(wn({title:"Delete upload session",description:"In-progress upload state will be discarded.",target:J.id}))||Cx(J.id).then(()=>{I(`Deleted upload session ${J.id}`),L()}).catch(Ie=>I(Ie.message))}return q?n.jsxs(pe,{title:"devcloud GCS",children:[n.jsx(_,{title:"GCS is disabled",description:"Enable the GCS service in devcloud config to inspect local buckets and objects."}),n.jsx(Ou,{status:ye??Au(r)})]}):n.jsxs("div",{className:"gcs-workspace",children:[n.jsxs(pe,{title:"devcloud GCS",children:[n.jsxs("div",{className:"s3-toolbar",children:[n.jsxs("span",{children:[ge.length," buckets"]}),n.jsx(re,{onClick:L,children:"Refresh"})]}),l.status==="loading"?n.jsx(_,{title:"Loading GCS",description:"Reading local GCS status."}):null,l.status==="error"?n.jsx(_,{title:"GCS unavailable",description:l.message,actionLabel:"Retry",onAction:L}):null,l.status==="success"?n.jsxs("div",{className:"gcs-sidebar",children:[n.jsx(Ou,{status:l.statusPayload}),n.jsx(Rx,{bucketName:N,disabled:q,onChange:$,onCreate:De}),n.jsx(Ix,{activeBucket:p,buckets:ge,disabled:q,onDeleteBucket:ae,onSelectBucket:Z})]}):null]}),n.jsx(pe,{title:"Objects",children:n.jsx(Ex,{activeObjectName:y==null?void 0:y.name,bucketName:p,disabled:q,objectsState:u,onDeleteObject:z,onInspectObject:de,onPrefixChange:w,prefix:S})}),n.jsx(pe,{title:"Inspector",children:n.jsx(Tx,{bucketName:p,object:y})}),n.jsx(pe,{title:"Upload sessions",children:n.jsx($x,{disabled:q,onDeleteSession:X,sessions:xe})}),C?n.jsx("p",{className:"inspector-muted gcs-message",children:C}):null]})}function Ou({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Status"}),n.jsx("dd",{children:r.running?"running":"disabled"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Project"}),n.jsx("dd",{children:r.project})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Upload sessions"}),n.jsx("dd",{children:n.jsx("code",{children:r.uploadSessionPath})})]})]})}function Rx({bucketName:r,disabled:l,onChange:i,onCreate:u}){return n.jsxs("div",{className:"gcs-create-bucket",children:[n.jsxs("label",{className:"prefix-filter",children:[n.jsx("span",{children:"Create bucket"}),n.jsx("input",{"aria-label":"GCS bucket name",disabled:l,onChange:d=>i(d.target.value),placeholder:"local-bucket",value:r})]}),n.jsx(re,{disabled:l||r.trim()==="",onClick:u,children:"Create"})]})}function Ix({activeBucket:r,buckets:l,disabled:i,onDeleteBucket:u,onSelectBucket:d}){return l.length===0?n.jsx(_,{title:"No buckets",description:"Buckets created through the GCS API will appear here."}):n.jsx("div",{className:"bucket-list",children:l.map(p=>n.jsxs("div",{className:p.name===r?"bucket-item active gcs-bucket-row":"bucket-item gcs-bucket-row",children:[n.jsxs("button",{className:"object-select",onClick:()=>d(p.name),children:[n.jsx("span",{className:"bucket-name",children:p.name}),n.jsx("span",{className:"bucket-meta",children:p.gcsUri}),n.jsxs("span",{className:"bucket-meta",children:["Created ",_i(p.timeCreated)]})]}),n.jsx("span",{className:"count-pill",children:p.objectCount}),n.jsx(re,{className:"danger",disabled:i,onClick:()=>u(p.name),children:"Delete bucket"})]},p.name))})}function Ex({activeObjectName:r,bucketName:l,disabled:i,objectsState:u,onDeleteObject:d,onInspectObject:p,onPrefixChange:g,prefix:y}){return l?n.jsxs("div",{className:"object-browser",children:[n.jsxs("label",{className:"prefix-filter",children:[n.jsx("span",{children:"Prefix"}),n.jsx("input",{"aria-label":"Filter GCS objects by prefix",onChange:v=>g(v.target.value),placeholder:"docs/",type:"search",value:y})]}),u.status==="loading"?n.jsx(_,{title:"Loading objects",description:`Reading objects in ${l}.`}):null,u.status==="error"?n.jsx(_,{title:"Objects unavailable",description:u.message}):null,u.status==="success"&&u.objects.length===0?n.jsx(_,{title:"No objects",description:`No objects in ${l} match the current prefix.`}):null,u.status==="success"&&u.objects.length>0?n.jsx("div",{className:"object-table-wrap",children:n.jsxs("table",{className:"object-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Name"}),n.jsx("th",{scope:"col",children:"Size"}),n.jsx("th",{scope:"col",children:"Updated"}),n.jsx("th",{scope:"col",children:"Class"}),n.jsx("th",{scope:"col",children:"Actions"})]})}),n.jsx("tbody",{children:u.objects.map(v=>n.jsxs("tr",{className:v.name===r?"object-row active":"object-row",children:[n.jsxs("td",{children:[n.jsx("button",{className:"object-select",onClick:()=>p(v),children:n.jsx("span",{className:"object-key",children:v.name})}),n.jsx("code",{children:v.gcsUri})]}),n.jsx("td",{children:td(v.size)}),n.jsx("td",{children:_i(v.updated)}),n.jsx("td",{children:v.storageClass||"STANDARD"}),n.jsxs("td",{children:[n.jsx("a",{className:"object-action",href:nd(v.downloadUrl),children:"Download"}),n.jsx(re,{className:"danger",disabled:i,onClick:()=>d(v),children:"Delete object"})]})]},v.name))})]})}):null]}):n.jsx(_,{title:"Select a bucket",description:"Choose a bucket to browse objects and metadata."})}function Tx({bucketName:r,object:l}){if(!l)return n.jsx(_,{title:"Inspector",description:r?`Select an object in ${r} to inspect GCS metadata.`:"Object metadata, generation, and download links will appear here."});const i=Object.entries(l.metadata??{});return n.jsxs("div",{className:"object-inspector",children:[n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"gs:// URI"}),n.jsx("code",{children:l.gcsUri})]}),n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"generation"}),n.jsx("dd",{children:l.generation})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"metageneration"}),n.jsx("dd",{children:l.metageneration})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"storageClass"}),n.jsx("dd",{children:l.storageClass||"STANDARD"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"crc32c"}),n.jsx("dd",{children:l.crc32c||"unknown"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"contentType"}),n.jsx("dd",{children:l.contentType||"application/octet-stream"})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"ETag"}),n.jsx("dd",{children:n.jsx("code",{children:l.etag||"unknown"})})]})]}),n.jsxs("div",{children:[n.jsx("span",{className:"inspector-label",children:"metadata"}),i.length===0?n.jsx("p",{className:"inspector-muted",children:"No user metadata."}):n.jsx("dl",{className:"metadata-list",children:i.map(([u,d])=>n.jsxs("div",{children:[n.jsx("dt",{children:u}),n.jsx("dd",{children:d})]},u))})]}),n.jsx("a",{className:"compat-link inspector-download",href:nd(l.downloadUrl),children:"Download object"})]})}function $x({disabled:r,onDeleteSession:l,sessions:i}){return i.length===0?n.jsx(_,{title:"No upload sessions",description:"Active resumable upload sessions will appear here."}):n.jsx("div",{className:"object-table-wrap",children:n.jsxs("table",{className:"object-table",children:[n.jsx("thead",{children:n.jsxs("tr",{children:[n.jsx("th",{scope:"col",children:"Session"}),n.jsx("th",{scope:"col",children:"Object"}),n.jsx("th",{scope:"col",children:"Received"}),n.jsx("th",{scope:"col",children:"Created"}),n.jsx("th",{scope:"col",children:"Action"})]})}),n.jsx("tbody",{children:i.map(u=>n.jsxs("tr",{children:[n.jsx("td",{children:n.jsx("code",{children:u.id})}),n.jsxs("td",{children:[n.jsx("span",{className:"object-key",children:u.name}),n.jsx("code",{children:`gs://${u.bucket}/${u.name}`})]}),n.jsx("td",{children:td(u.receivedBytes)}),n.jsx("td",{children:_i(u.createdAt)}),n.jsx("td",{children:n.jsx(re,{className:"danger",disabled:r,onClick:()=>l(u),children:"Delete session"})})]},u.id))})]})})}function Lx(r,l,i){i({status:"loading"}),ed(r,l.trim()).then(({objects:u})=>i({status:"success",objects:u})).catch(u=>i({status:"error",message:u.message}))}function Au(r){return{status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:14443",project:"devcloud",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/s3",uploadSessionPath:".devcloud/data/gcs/upload_sessions"}}function td(r){if(!Number.isFinite(r)||r<0)return"unknown";if(r<1024)return`${r} B`;const l=["KB","MB","GB","TB"];let i=r/1024,u=0;for(;i>=1024&&u=10?0:1)} ${l[u]}`}function _i(r){const l=new Date(r);return Number.isNaN(l.getTime())?r||"unknown":l.toLocaleString()}function nd(r){return r.startsWith("/api/gcs/")?r:"#"}async function Px(){return U("/api/applicationautoscaling/status")}async function Mx(){return U("/api/applicationautoscaling/scalable-targets")}async function Ox(){return U("/api/applicationautoscaling/scaling-policies")}async function Ax(){return U("/api/applicationautoscaling/scheduled-actions")}function _x({service:r}){const[l,i]=m.useState({status:"loading"}),u=(r==null?void 0:r.status)==="disabled",d=m.useCallback(()=>{if(u){i({status:"success",statusPayload:Ux(r),scalableTargets:[],scalingPolicies:[],scheduledActions:[]});return}i({status:"loading"}),Promise.all([Px(),Mx(),Ox(),Ax()]).then(([p,g,y,v])=>{i({status:"success",statusPayload:p,scalableTargets:g.scalableTargets,scalingPolicies:y.scalingPolicies,scheduledActions:v.scheduledActions})}).catch(p=>{i({status:"error",message:p.message})})},[u,r]);return m.useEffect(()=>{d()},[d]),_t({topics:["applicationautoscaling"],onEvent:d,enabled:!u}),u?n.jsx(pe,{title:"Application Auto Scaling",children:n.jsx(_,{title:"Application Auto Scaling is disabled",description:"Enable the Application Auto Scaling service in devcloud config to inspect scalable targets, policies, and scheduled actions."})}):n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(pe,{title:"Status",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.statusPayload.status} / ${l.statusPayload.region}`:"Loading"}),n.jsx(re,{onClick:d,children:"Refresh"})]}),l.status==="loading"?n.jsx(_,{title:"Loading Application Auto Scaling",description:"Reading local scalable targets, policies, and scheduled actions."}):null,l.status==="error"?n.jsx(_,{title:"Application Auto Scaling unavailable",description:l.message,actionLabel:"Retry",onAction:d}):null,l.status==="success"?n.jsx(Bx,{status:l.statusPayload}):null]}),n.jsx(pe,{title:"Scalable targets",children:n.jsx(Fx,{targets:l.status==="success"?l.scalableTargets:[]})}),n.jsx(pe,{title:"Scaling policies",children:n.jsx(zx,{policies:l.status==="success"?l.scalingPolicies:[]})}),n.jsx(pe,{title:"Scheduled actions",children:n.jsx(Qx,{actions:l.status==="success"?l.scheduledActions:[]})})]})}function Bx({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:r.region})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Storage path"}),n.jsx("dd",{children:n.jsx("code",{children:r.storagePath})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Scalable targets"}),n.jsx("dd",{children:r.scalableTargetCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Scaling policies"}),n.jsx("dd",{children:r.scalingPolicyCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Scheduled actions"}),n.jsx("dd",{children:r.scheduledActionCount})]})]})}function Fx({targets:r}){return r.length===0?n.jsx(_,{title:"No scalable targets",description:"Targets registered via RegisterScalableTarget will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Application Auto Scaling scalable targets",children:r.map(l=>{var i,u,d;return n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:l.ResourceId}),n.jsxs("span",{className:"count-pill",children:[l.MinCapacity,"-",l.MaxCapacity]})]}),n.jsx("span",{className:"table-row-meta",children:l.ScalableDimension}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:l.ServiceNamespace}),(i=l.SuspendedState)!=null&&i.DynamicScalingInSuspended||(u=l.SuspendedState)!=null&&u.DynamicScalingOutSuspended||(d=l.SuspendedState)!=null&&d.ScheduledScalingSuspended?n.jsx("span",{children:"suspended"}):null]})]},`${l.ResourceId}-${l.ScalableDimension}`)})})}function zx({policies:r}){return r.length===0?n.jsx(_,{title:"No scaling policies",description:"Policies created via PutScalingPolicy will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Application Auto Scaling scaling policies",children:r.map(l=>n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:l.PolicyName}),n.jsx("span",{className:"count-pill",children:l.PolicyType})]}),n.jsx("span",{className:"table-row-meta",children:l.ResourceId}),n.jsx("span",{className:"table-row-tags",children:n.jsx("span",{children:l.ScalableDimension})})]},l.PolicyARN))})}function Qx({actions:r}){return r.length===0?n.jsx(_,{title:"No scheduled actions",description:"Actions created via PutScheduledAction will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Application Auto Scaling scheduled actions",children:r.map(l=>n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsx("span",{className:"table-row-top",children:n.jsx("span",{className:"table-row-name",children:l.ScheduledActionName})}),n.jsx("span",{className:"table-row-meta",children:l.ResourceId}),n.jsxs("span",{className:"table-row-tags",children:[n.jsx("span",{children:l.Schedule}),l.Timezone?n.jsx("span",{children:l.Timezone}):null]})]},l.ScheduledActionName))})}function Ux(r){return{service:"applicationautoscaling",status:"disabled",running:!1,endpoint:(r==null?void 0:r.endpoint)??"http://127.0.0.1:18030",region:"us-east-1",storagePath:(r==null?void 0:r.storagePath)??".devcloud/data/applicationautoscaling",scalableTargetCount:0,scalingPolicyCount:0,scheduledActionCount:0}}async function qx(){return U("/api/lambda/status")}async function Vx(){return U("/api/lambda/functions")}async function Kx(){return U("/api/lambda/invocations")}async function Jx(r,l){return U(`/api/lambda/functions/${encodeURIComponent(r)}/invoke`,{method:"POST",rawBody:l,headers:{"Content-Type":"application/json"},timeoutMs:91e4})}function Hx({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState("{}"),[y,v]=m.useState(()=>new Map),S=m.useRef(new Map),w=m.useRef(0),N=(r==null?void 0:r.status)==="disabled",$=m.useCallback(()=>{N||Promise.all([qx(),Vx(),Kx()]).then(([L,ne,ge])=>{i({status:"success",statusPayload:L,functions:ne.functions,invocations:ge.invocations}),d(xe=>{var ye;return xe&&ne.functions.some(Z=>Z.FunctionName===xe)?xe:(ye=ne.functions[0])==null?void 0:ye.FunctionName})}).catch(L=>{i({status:"error",message:L.message})})},[N]);if(m.useEffect(()=>{$()},[$]),_t({topics:["lambda"],onEvent:$,enabled:!N}),N)return n.jsx(pe,{title:"Lambda",children:n.jsx(_,{title:"Lambda is disabled",description:"Enable the Lambda service in devcloud config to deploy and invoke local functions."})});const C=l.status==="success"?l.functions:[],I=l.status==="success"?l.invocations:[],E=C.find(L=>L.FunctionName===u),F=E&&y.get(E.FunctionName)||{status:"idle"},q=(L,ne,ge)=>{S.current.get(L)===ne&&v(xe=>new Map(xe).set(L,ge))},Y=L=>{if(L.preventDefault(),!E)return;const ne=E.FunctionName;w.current+=1;const ge=w.current;S.current.set(ne,ge);try{JSON.parse(p||"{}")}catch(xe){q(ne,ge,{status:"error",message:`Event is not valid JSON: ${xe.message}`});return}q(ne,ge,{status:"running"}),Jx(ne,p||"{}").then(xe=>q(ne,ge,{status:"done",result:xe})).catch(xe=>q(ne,ge,{status:"error",message:xe.message}))};return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(pe,{title:"Status",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.statusPayload.status} / ${l.statusPayload.region}`:"Loading"}),n.jsx(re,{onClick:$,children:"Refresh"})]}),l.status==="loading"?n.jsx(_,{title:"Loading Lambda",description:"Reading local functions and recent invocations."}):null,l.status==="error"?n.jsx(_,{title:"Lambda unavailable",description:l.message,actionLabel:"Retry",onAction:$}):null,l.status==="success"?n.jsx(Wx,{status:l.statusPayload}):null]}),n.jsx(pe,{title:"Functions",children:C.length===0?n.jsx(_,{title:"No functions",description:"Functions created via CreateFunction will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Lambda functions",children:C.map(L=>n.jsxs("button",{type:"button",className:`dynamodb-table-row${L.FunctionName===u?" active":""}`,"aria-pressed":L.FunctionName===u,onClick:()=>d(L.FunctionName),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:L.FunctionName}),n.jsx("span",{className:"count-pill",children:L.Runtime})]}),n.jsx("span",{className:"table-row-meta",children:L.Handler}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:[L.MemorySize," MB"]}),n.jsxs("span",{children:[L.Timeout,"s"]}),n.jsx("span",{children:L.State})]})]},L.FunctionArn))})}),E?n.jsxs(pe,{title:`Function: ${E.FunctionName}`,children:[n.jsx(Gx,{fn:E}),n.jsxs("form",{className:"pubsub-action-form stacked",onSubmit:Y,children:[n.jsxs("label",{className:"compact-filter wide",children:[n.jsx("span",{children:"Test event (JSON)"}),n.jsx("textarea",{"aria-label":"Lambda test event JSON",onChange:L=>g(L.target.value),rows:4,value:p})]}),n.jsx(re,{type:"submit",disabled:F.status==="running",children:F.status==="running"?"Invoking…":"Invoke"})]}),n.jsx(Yx,{state:F})]}):null,n.jsx(pe,{title:"Recent invocations",children:I.length===0?n.jsx(_,{title:"No invocations yet",description:"Invoke a function to see its result and log tail here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Lambda invocations",children:I.map(L=>n.jsxs("details",{className:"dynamodb-table-row",children:[n.jsxs("summary",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:L.functionName}),n.jsx("span",{className:"count-pill",children:L.status})]}),n.jsxs("span",{className:"table-row-meta",children:[L.startedAt," · ",L.invocationType," · ",L.durationMs.toFixed(1)," ms"]}),n.jsx("pre",{className:"redis-value-pre",children:L.logTail||"(no log output)"})]},L.requestId))})})]})}function Wx({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Region"}),n.jsx("dd",{children:r.region})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Storage path"}),n.jsx("dd",{children:n.jsx("code",{children:r.storagePath})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Functions"}),n.jsx("dd",{children:r.functionCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Recorded invocations"}),n.jsx("dd",{children:r.invocationCount})]})]})}function Gx({fn:r}){var i;const l=Object.keys(((i=r.Environment)==null?void 0:i.Variables)??{});return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"ARN"}),n.jsx("dd",{children:n.jsx("code",{children:r.FunctionArn})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Role"}),n.jsx("dd",{children:n.jsx("code",{children:r.Role})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Code"}),n.jsxs("dd",{children:[r.CodeSize," bytes · ",n.jsx("code",{children:r.CodeSha256})]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Last modified"}),n.jsx("dd",{children:r.LastModified})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Environment keys"}),n.jsx("dd",{children:l.length>0?l.join(", "):"none"})]})]})}function Yx({state:r}){if(r.status==="idle"||r.status==="running")return null;if(r.status==="error")return n.jsx(_,{title:"Invoke failed",description:r.message});const{result:l}=r;return n.jsxs("div",{"aria-label":"Lambda invoke result",children:[n.jsx("p",{className:"inspector-muted",children:l.functionError?`Function error: ${l.functionError}`:`Succeeded (HTTP ${l.statusCode})`}),n.jsx("pre",{className:"redis-value-pre",children:l.payload}),n.jsx("pre",{className:"redis-value-pre",children:l.log})]})}async function Xx(){return U("/api/cloudrun/status")}async function Zx(){return U("/api/cloudrun/services")}async function ey(){return U("/api/cloudrun/instances")}function sd(r){const[,l,,i,,u]=r.split("/");return[l,i,u].map(encodeURIComponent).join("/")}async function ty(r){return U(`/api/cloudrun/services/${sd(r)}/revisions`)}async function ny(r){return U(`/api/cloudrun/services/${sd(r)}/logs`)}function Ba(r){return r.split("/").pop()??r}function sy({service:r}){const[l,i]=m.useState({status:"loading"}),[u,d]=m.useState(),[p,g]=m.useState(),y=(r==null?void 0:r.status)==="disabled",v=m.useCallback(()=>{y||Promise.all([Xx(),Zx(),ey()]).then(([I,E,F])=>{i({status:"success",statusPayload:I,services:E.services,instances:F.instances}),d(q=>{var Y;return q&&E.services.some(L=>L.name===q)?q:(Y=E.services[0])==null?void 0:Y.name})}).catch(I=>{i({status:"error",message:I.message})})},[y]);if(m.useEffect(()=>{v()},[v]),m.useEffect(()=>{if(!u||y){g(void 0);return}let I=!1;return Promise.all([ty(u),ny(u)]).then(([E,F])=>{I||g({service:u,revisions:E.revisions,logs:F.lines})}).catch(()=>{I||g({service:u,revisions:[],logs:[]})}),()=>{I=!0}},[u,y,l]),_t({topics:["cloudrun"],onEvent:v,enabled:!y}),y)return n.jsx(pe,{title:"Cloud Run",children:n.jsx(_,{title:"Cloud Run is disabled",description:"Enable the Cloud Run service in devcloud config to deploy and run local services."})});const S=l.status==="success"?l.services:[],w=l.status==="success"?l.instances:[],N=S.find(I=>I.name===u),$=w.find(I=>I.service===u),C=p&&p.service===u?p:void 0;return n.jsxs("div",{className:"dynamodb-workspace",children:[n.jsxs(pe,{title:"Status",children:[n.jsxs("div",{className:"dynamodb-toolbar",children:[n.jsx("span",{className:"toolbar-count",children:l.status==="success"?`${l.statusPayload.status} / ${l.statusPayload.project} / ${l.statusPayload.region}`:"Loading"}),n.jsx(re,{onClick:v,children:"Refresh"})]}),l.status==="loading"?n.jsx(_,{title:"Loading Cloud Run",description:"Reading local services and running instances."}):null,l.status==="error"?n.jsx(_,{title:"Cloud Run unavailable",description:l.message,actionLabel:"Retry",onAction:v}):null,l.status==="success"?n.jsx(ry,{status:l.statusPayload}):null]}),n.jsx(pe,{title:"Services",children:S.length===0?n.jsx(_,{title:"No services",description:"Services created via the Cloud Run Admin API v2 will appear here."}):n.jsx("div",{className:"dynamodb-table-list","aria-label":"Cloud Run services",children:S.map(I=>{const E=w.some(F=>F.service===I.name);return n.jsxs("button",{type:"button",className:`dynamodb-table-row${I.name===u?" active":""}`,"aria-pressed":I.name===u,onClick:()=>d(I.name),children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:Ba(I.name)}),n.jsx("span",{className:"count-pill",children:E?"running":"idle"})]}),n.jsx("span",{className:"table-row-meta",children:I.name.split("/services/")[0]}),n.jsxs("span",{className:"table-row-tags",children:[n.jsxs("span",{children:["gen ",I.generation]}),n.jsx("span",{children:Ba(I.latestReadyRevision)})]})]},I.name)})})}),N?n.jsxs(pe,{title:`Service: ${Ba(N.name)}`,children:[n.jsx(ay,{svc:N,instance:$}),n.jsx("h3",{className:"inspector-heading",children:"Revisions"}),C&&C.revisions.length>0?n.jsx("div",{className:"dynamodb-table-list","aria-label":"Cloud Run revisions",children:C.revisions.map(I=>{var E;return n.jsxs("section",{className:"dynamodb-table-row",children:[n.jsxs("span",{className:"table-row-top",children:[n.jsx("span",{className:"table-row-name",children:Ba(I.name)}),I.name===N.latestReadyRevision?n.jsx("span",{className:"count-pill",children:"latest"}):null]}),n.jsx("span",{className:"table-row-meta",children:I.createTime}),n.jsx("span",{className:"table-row-tags",children:n.jsx("span",{children:(E=I.containers[0])==null?void 0:E.image})})]},I.name)})}):n.jsx("p",{className:"inspector-muted",children:C?"No revisions.":"Loading revisions…"}),n.jsx("h3",{className:"inspector-heading",children:"Instance logs"}),n.jsx("pre",{className:"redis-value-pre","aria-label":"Cloud Run instance logs",children:C&&C.logs.length>0?C.logs.join(` -`):"(no output yet — send a request to start the instance)"})]}):null]})}function ry({status:r}){return n.jsxs("dl",{className:"inspector-list",children:[n.jsxs("div",{children:[n.jsx("dt",{children:"Endpoint"}),n.jsx("dd",{children:n.jsx("code",{children:r.endpoint})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Default project / region"}),n.jsxs("dd",{children:[r.project," / ",r.region]})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Storage path"}),n.jsx("dd",{children:n.jsx("code",{children:r.storagePath})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Services"}),n.jsx("dd",{children:r.serviceCount})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Running instances"}),n.jsx("dd",{children:r.instanceCount})]})]})}function ay({svc:r,instance:l}){var u;const i=r.template.containers[0];return n.jsxs("dl",{className:"inspector-list",children:[(r.urls??[r.uri]).map(d=>n.jsxs("div",{children:[n.jsx("dt",{children:"URL"}),n.jsx("dd",{children:n.jsx("a",{href:d,target:"_blank",rel:"noreferrer",children:n.jsx("code",{children:d})})})]},d)),n.jsxs("div",{children:[n.jsx("dt",{children:"Image"}),n.jsx("dd",{children:n.jsx("code",{children:i==null?void 0:i.image})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Command"}),n.jsx("dd",{children:n.jsx("code",{children:(u=i==null?void 0:i.command)!=null&&u.length?[...i.command,...i.args??[]].join(" "):"(image entrypoint)"})})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Ingress"}),n.jsx("dd",{children:r.ingress})]}),n.jsxs("div",{children:[n.jsx("dt",{children:"Instance"}),n.jsx("dd",{children:l?`${l.mode} · port ${l.port}${l.pid?` · pid ${l.pid}`:""} · ${l.requestCount} requests`:"not running (starts on first request)"})]})]})}function ly({services:r,path:l}){return l==="/mail"?n.jsx($p,{service:r.find(i=>i.id==="mail")}):l==="/s3"?n.jsx(Nm,{service:r.find(i=>i.id==="s3")}):l==="/gcs"?n.jsx(Dx,{service:r.find(i=>i.id==="gcs")}):l==="/dynamodb"?n.jsx(hh,{service:r.find(i=>i.id==="dynamodb")}):l==="/bigquery"?n.jsx(Uh,{service:r.find(i=>i.id==="bigquery")}):l==="/redshift"?n.jsx(Wh,{service:r.find(i=>i.id==="redshift")}):l==="/redis"?n.jsx(cg,{service:r.find(i=>i.id==="redis")}):l==="/sqs"?n.jsx(tx,{service:r.find(i=>i.id==="sqs")}):l==="/pubsub"?n.jsx(ux,{service:r.find(i=>i.id==="pubsub")}):l==="/applicationautoscaling"?n.jsx(_x,{service:r.find(i=>i.id==="applicationautoscaling")}):l==="/lambda"?n.jsx(Hx,{service:r.find(i=>i.id==="lambda")}):l==="/cloudrun"?n.jsx(sy,{service:r.find(i=>i.id==="cloudrun")}):n.jsx(yp,{services:r})}function iy({services:r}){const l=Rr(window.location.pathname);return n.jsxs("nav",{className:"service-switcher","aria-label":"Services",children:[n.jsx("a",{"aria-current":l==="/"?"page":void 0,className:l==="/"?"active":void 0,href:Fa("/"),children:"Services"}),r.map(i=>n.jsxs("a",{"aria-current":l===Rr(i.path)?"page":void 0,"aria-label":`${i.name}: ${i.status}`,className:l===Rr(i.path)?"active":void 0,href:Fa(i.path),children:[n.jsx("span",{children:i.name}),n.jsxs("span",{className:`switcher-status ${oy(i.status)}`,children:[n.jsx("span",{className:"status-dot"}),i.status]})]},i.id))]})}function oy(r){return r==="running"?"running":"disabled"}function cy({services:r}){const l=r.filter(d=>d.status==="running").length,i=`${l}/${r.length} running`,u=r.length>0&&l===r.length?"running":"attention";return n.jsxs("div",{className:`status-pill ${u}`,"aria-label":`Service status: ${i}`,children:[n.jsx("span",{className:"status-dot"}),i]})}function uy({services:r}){const l=Rr(window.location.pathname),i=r.find(p=>p.path===l),[u,d]=m.useState(()=>dp());return m.useEffect(()=>fp(d),[]),n.jsxs("footer",{className:"activity-footer",children:[n.jsxs("span",{children:["Last request: ",(u==null?void 0:u.path)??"/api/dashboard/services"]}),n.jsxs("span",{children:["Active: ",(i==null?void 0:i.name)??"Service index"]}),n.jsxs("span",{children:["API status: ",n.jsx(dy,{activity:u,fallback:(i==null?void 0:i.status)??"running"})]}),n.jsxs("span",{children:["Storage: ",(i==null?void 0:i.storagePath)??"registry"]})]})}function dy({activity:r,fallback:l}){if(!r)return n.jsx("span",{children:l});const i=r.status==="success"&&r.statusCode?`${r.status} ${r.statusCode}`:r.status;return n.jsx("span",{className:`activity-status ${r.status}`,title:r.message,children:i})}function fy({notifications:r}){const{permission:l,enabled:i,setEnabled:u,requestPermission:d}=r;return l==="unsupported"?null:l==="default"?n.jsx(re,{className:"notifications-toggle",onClick:()=>{d()},title:"Show desktop notifications when local services emit events",children:"Enable notifications"}):l==="denied"?n.jsx(re,{className:"notifications-toggle is-disabled",disabled:!0,title:"Notifications are blocked. Allow them in your browser site settings to enable.",children:"Notifications blocked"}):n.jsx(re,{"aria-pressed":i,className:i?"notifications-toggle is-on":"notifications-toggle",onClick:()=>u(!i),title:i?"Click to mute desktop notifications":"Click to enable desktop notifications",children:i?"Notifications: on":"Notifications: off"})}const rd="devcloud.notifications.enabled";function _u(){return typeof window>"u"||typeof Notification>"u"?"unsupported":Notification.permission}function py(){if(typeof window>"u")return!1;try{return window.localStorage.getItem(rd)==="1"}catch{return!1}}function my(r){if(!(typeof window>"u"))try{window.localStorage.setItem(rd,r?"1":"0")}catch{}}function hy(){const[r,l]=m.useState(()=>_u()),[i,u]=m.useState(()=>py()),d=m.useCallback(g=>{u(g),my(g)},[]),p=m.useCallback(async()=>{if(typeof Notification>"u")return"unsupported";try{const y=await Notification.requestPermission();return l(y),y==="granted"&&d(!0),y}catch{return _u()}},[d]);return{permission:r,enabled:i,setEnabled:d,requestPermission:p}}const gy={mail:"Mail",s3:"S3",gcs:"GCS",redis:"Redis",dynamodb:"DynamoDB",bigquery:"BigQuery",sqs:"SQS",pubsub:"Pub/Sub",redshift:"Redshift"};function xy(r){return gy[r]??r}function Qs(r,l){const i=r==null?void 0:r[l];if(typeof i=="string")return i;if(typeof i=="number"&&Number.isFinite(i))return String(i)}function ad(r,l){const i=r==null?void 0:r[l];return Array.isArray(i)?i.filter(u=>typeof u=="string"):[]}function ld(r){return r.map(l=>kn(l)).filter(l=>l.length>0).join(", ")}function yy(r){if(r===void 0)return;const l=Number(r);if(!(!Number.isFinite(l)||l<0))return l<1024?`${l} B`:l<1024*1024?`${(l/1024).toFixed(1)} KB`:l<1024*1024*1024?`${(l/1024/1024).toFixed(1)} MB`:`${(l/1024/1024/1024).toFixed(2)} GB`}const jy=180;function vy(r){return r.replace(/<(script|style)[\s\S]*?<\/\1>/gi," ").replace(/<[^>]+>/g," ").replace(/ /gi," ").replace(/&/gi,"&").replace(/</gi,"<").replace(/>/gi,">").replace(/"/gi,'"').replace(/'/gi,"'").replace(/\s+/g," ").trim()}function by(r,l){const i=r.replace(/\r/g,"").replace(/\n{3,}/g,` - -`).trim();return i.length<=l?i:`${i.slice(0,l).trimEnd()}…`}function id(r){const l=Qs(r.payload,"from")??"(unknown sender)",i=Qs(r.payload,"subject")??"",u=kn(l)||l,d=ld(ad(r.payload,"to")),p=ds(i).trim()||"(no subject)",g=Qs(r.payload,"messageID"),y=[`From: ${u}`];return d&&y.push(`To: ${d}`),{title:p,body:y.join(` -`),tag:`mail.received:${g??p}`}}async function Sy(r){var u,d;if(typeof Notification>"u")return;const l=Qs(r.payload,"messageID");let i=id(r);if(l)try{const p=await Cp(l),g=ds(p.subject??"").trim()||"(no subject)",y=((u=p.from)==null?void 0:u.trim())||Qs(r.payload,"from")||"(unknown sender)",v=kn(y)||y,S=ld(p.to??ad(r.payload,"to")),w=((d=p.textBody)==null?void 0:d.trim())||vy(p.htmlBody??""),N=w?by(w,jy):"",$=S?`From: ${v} -To: ${S}`:`From: ${v}`;i={title:g,body:N?`${$} - -${N}`:$,tag:`mail.received:${l}`}}catch{}try{new Notification(i.title,{body:i.body,tag:i.tag})}catch{}}function Ny(r){const l=r.payload,i=d=>Qs(l,d),u=xy(r.service);switch(r.type){case"mail.received":return id(r);case"mail.deleted":return{title:"Mail message deleted",body:"A message was removed from the inbox.",tag:`mail.deleted:${i("messageID")??r.timestamp}`};case"mail.cleared":return{title:"Mail inbox cleared",body:"All messages were removed.",tag:"mail.cleared"};case"s3.object.put":case"gcs.object.put":{const d=i("bucket")??"?",p=i("key")??"?",g=yy(i("contentLength")),y=g?`${d}/${p} -${g}`:`${d}/${p}`;return{title:`${u} object uploaded`,body:y,tag:`${r.type}:${d}/${p}`}}case"s3.object.deleted":case"gcs.object.deleted":{const d=i("bucket")??"?",p=i("key")??"?";return{title:`${u} object deleted`,body:`${d}/${p}`,tag:`${r.type}:${d}/${p}`}}case"s3.bucket.created":case"gcs.bucket.created":{const d=i("bucket")??"?";return{title:`${u} bucket created`,body:d,tag:`${r.type}:${d}`}}case"s3.bucket.deleted":case"gcs.bucket.deleted":{const d=i("bucket")??"?";return{title:`${u} bucket deleted`,body:d,tag:`${r.type}:${d}`}}case"dynamodb.table.created":case"dynamodb.table.deleted":{const d=i("table")??"?";return{title:`DynamoDB table ${r.type.endsWith(".created")?"created":"deleted"}`,body:d,tag:`${r.type}:${d}`}}case"dynamodb.item.put":case"dynamodb.item.updated":case"dynamodb.item.deleted":{const d=i("table")??"?";return{title:`DynamoDB item ${r.type.split(".").pop()??"changed"}`,body:`Table: ${d}`,tag:`${r.type}:${d}`}}case"bigquery.dataset.created":case"bigquery.dataset.deleted":{const d=i("project")??"?",p=i("dataset")??"?";return{title:`BigQuery dataset ${r.type.endsWith(".created")?"created":"deleted"}`,body:`${d}.${p}`,tag:`${r.type}:${d}.${p}`}}case"bigquery.table.created":case"bigquery.table.deleted":{const d=i("project")??"?",p=i("dataset")??"?",g=i("table")??"?";return{title:`BigQuery table ${r.type.endsWith(".created")?"created":"deleted"}`,body:`${d}.${p}.${g}`,tag:`${r.type}:${d}.${p}.${g}`}}case"bigquery.job.inserted":{const d=i("project")??"?",p=i("jobType")??"job";return{title:`BigQuery ${p} job started`,body:`Project: ${d}`,tag:`bigquery.job.inserted:${d}:${p}:${r.timestamp}`}}case"sqs.message.sent":case"sqs.message.deleted":{const d=i("queue")??"?",p=i("count"),g=r.type.endsWith(".sent")?"sent":"deleted",y=p?`Queue: ${d} (${p} message${p==="1"?"":"s"})`:`Queue: ${d}`;return{title:`SQS message ${g}`,body:y,tag:`${r.type}:${d}:${r.timestamp}`}}case"pubsub.topic.created":case"pubsub.topic.deleted":{const d=i("topic")??"?";return{title:`Pub/Sub topic ${r.type.endsWith(".created")?"created":"deleted"}`,body:d,tag:`${r.type}:${d}`}}case"pubsub.subscription.created":{const d=i("subscription")??"?",p=i("topic")??"?";return{title:"Pub/Sub subscription created",body:`${d} -on topic ${p}`,tag:`${r.type}:${d}`}}case"pubsub.message.published":{const d=i("topic")??"?",p=i("count")??"1";return{title:"Pub/Sub message published",body:`${p} message${p==="1"?"":"s"} → ${d}`,tag:`${r.type}:${d}:${r.timestamp}`}}case"pubsub.message.pulled":{const d=i("subscription")??"?";return{title:"Pub/Sub messages pulled",body:`${i("count")??"0"} from ${d}`,tag:`${r.type}:${d}:${r.timestamp}`}}case"redshift.statement.executed":case"redshift.statement.batch_executed":{const d=i("statementID")??"?";return{title:`Redshift ${(r.type.endsWith(".batch_executed")?"Batch statement":"Statement").toLowerCase()} executed`,body:`ID: ${d}`,tag:`${r.type}:${d}`}}case"redis.command.mutation":{const d=i("command")??"?",p=i("key");return{title:`Redis ${d}`,body:p?`Key: ${p}`:"Mutation executed",tag:`redis.command.mutation:${d}:${p??""}:${r.timestamp}`}}default:return{title:`${u} event`,body:r.type,tag:`${r.type}:${r.timestamp}`}}}function wy({enabled:r,permission:l,suppressWhileVisible:i=!1}){const u=r&&l==="granted",d=m.useCallback(p=>{if(typeof Notification>"u"||i&&typeof document<"u"&&document.visibilityState==="visible")return;if(p.type==="mail.received"){Sy(p);return}const g=Ny(p);if(g)try{new Notification(g.title,{body:g.body,tag:g.tag})}catch{}},[i]);_t({topics:[],onEvent:d,enabled:u})}function ky({services:r,children:l}){const i=hy();return wy({enabled:i.enabled,permission:i.permission}),n.jsxs("div",{className:"app-shell",children:[n.jsxs("header",{className:"top-bar",children:[n.jsxs("div",{className:"brand-block",children:[n.jsx("a",{className:"brand-title",href:Fa("/"),children:"devcloud"}),n.jsx(cy,{services:r})]}),n.jsxs("div",{className:"top-actions",children:[n.jsx(fy,{notifications:i}),n.jsx(iy,{services:r}),n.jsx(re,{onClick:()=>window.location.reload(),children:"Refresh"})]})]}),n.jsx("main",{className:"service-surface",children:l}),n.jsx(uy,{services:r})]})}class Cy extends m.Component{constructor(){super(...arguments);cs(this,"state",{})}static getDerivedStateFromError(i){return{error:i}}render(){return this.state.error?n.jsx(_,{title:"Dashboard request failed",description:this.state.error.message,actionLabel:"Retry",onAction:()=>{xp(),this.setState({error:void 0})}}):this.props.children}}function Dy(){const{services:r}=gp(),l=Rr(window.location.pathname);return n.jsx(ky,{services:r,children:ly({services:r,path:l})})}function Ry(){return n.jsx(Cy,{children:n.jsx(m.Suspense,{fallback:n.jsx(_,{title:"Loading dashboard",description:"Checking local service status."}),children:n.jsx(jp,{children:n.jsx(Dy,{})})})})}up.createRoot(document.getElementById("root")).render(n.jsx(sp.StrictMode,{children:n.jsx(Ry,{})})); diff --git a/services/dashboard/assets/react/index.html b/services/dashboard/assets/react/index.html index 92d21ed..305ad52 100644 --- a/services/dashboard/assets/react/index.html +++ b/services/dashboard/assets/react/index.html @@ -4,7 +4,7 @@ devcloud Dashboard - + diff --git a/services/dashboard/src/config.rs b/services/dashboard/src/config.rs index 1b820fc..20a204d 100644 --- a/services/dashboard/src/config.rs +++ b/services/dashboard/src/config.rs @@ -33,6 +33,9 @@ pub struct Config { /// Base URL of the SQS service HTTP listener (provider protocol + /// `/_introspect/`), e.g. `http://127.0.0.1:19324`. Empty == disabled. pub sqs_base: String, + /// Display endpoint, including when disabled. Empty falls back to the base + /// URL or the default SQS port for callers using the legacy env contract. + pub sqs_endpoint: String, pub sqs_region: String, pub sqs_auth_mode: String, pub sqs_storage_path: String, @@ -44,6 +47,9 @@ pub struct Config { pub mail_endpoint: String, pub mail_storage_path: String, pub mail_disabled: bool, + pub mail_auth_mode: String, + pub mail_username: String, + pub mail_password: String, pub s3_base: String, pub s3_endpoint: String, pub s3_storage_path: String, @@ -64,6 +70,8 @@ pub struct Config { pub redis_endpoint: String, pub redis_storage_path: String, pub redis_enabled: bool, + pub redis_auth_mode: String, + pub redis_password: String, pub pubsub_base: String, pub pubsub_endpoint: String, pub pubsub_storage_path: String, @@ -100,6 +108,7 @@ impl Config { event_relay_endpoint: env("DEVCLOUD_DASHBOARD_EVENT_RELAY"), sqs_base: env("DEVCLOUD_DASHBOARD_SQS_BASE"), + sqs_endpoint: env("DEVCLOUD_DASHBOARD_SQS_ENDPOINT"), sqs_region: env_or("DEVCLOUD_DASHBOARD_SQS_REGION", "us-east-1"), sqs_auth_mode: env_or("DEVCLOUD_DASHBOARD_SQS_AUTH_MODE", "relaxed"), sqs_storage_path: env_or("DEVCLOUD_DASHBOARD_SQS_STORAGE", ".devcloud/data/sqs"), @@ -108,6 +117,9 @@ impl Config { mail_endpoint: env_or("DEVCLOUD_DASHBOARD_MAIL_ENDPOINT", "smtp://127.0.0.1:11025"), mail_storage_path: env_or("DEVCLOUD_DASHBOARD_MAIL_STORAGE", ".devcloud/data/mail"), mail_disabled: env("DEVCLOUD_DASHBOARD_MAIL_DISABLED") == "1", + mail_auth_mode: env_or("DEVCLOUD_DASHBOARD_MAIL_AUTH_MODE", "off"), + mail_username: env("DEVCLOUD_DASHBOARD_MAIL_USERNAME"), + mail_password: env("DEVCLOUD_DASHBOARD_MAIL_PASSWORD"), s3_base: env("DEVCLOUD_DASHBOARD_S3_BASE"), s3_endpoint: env_or("DEVCLOUD_DASHBOARD_S3_ENDPOINT", "http://127.0.0.1:14566"), s3_storage_path: env_or("DEVCLOUD_DASHBOARD_S3_STORAGE", ".devcloud/data/s3"), @@ -152,6 +164,8 @@ impl Config { ), redis_storage_path: env_or("DEVCLOUD_DASHBOARD_REDIS_STORAGE", ".devcloud/data/redis"), redis_enabled: env("DEVCLOUD_DASHBOARD_REDIS_ENABLED") == "1", + redis_auth_mode: env_or("DEVCLOUD_DASHBOARD_REDIS_AUTH_MODE", "relaxed"), + redis_password: env("DEVCLOUD_DASHBOARD_REDIS_PASSWORD"), pubsub_base: env("DEVCLOUD_DASHBOARD_PUBSUB_BASE"), pubsub_endpoint: env_or( "DEVCLOUD_DASHBOARD_PUBSUB_ENDPOINT", diff --git a/services/dashboard/src/forward.rs b/services/dashboard/src/forward.rs index 3eac87b..35f051b 100644 --- a/services/dashboard/src/forward.rs +++ b/services/dashboard/src/forward.rs @@ -11,7 +11,8 @@ use std::collections::HashMap; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use base64::Engine; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; /// A forwarded request to a downstream service. @@ -55,6 +56,21 @@ pub enum ForwardError { const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +/// The local Mail and Redis control APIs use Basic authentication in strict +/// mode. Credentials stay on the server-to-server request, never in the UI. +pub(crate) fn basic_auth_headers( + mode: &str, + username: &str, + password: &str, +) -> Vec<(String, String)> { + if !mode.trim().eq_ignore_ascii_case("strict") { + return Vec::new(); + } + let credentials = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + vec![("Authorization".into(), format!("Basic {credentials}"))] +} + /// Performs a single HTTP/1.1 request to `req.base + req.path` and returns the /// parsed response. Connection is one-shot (`Connection: close`). pub async fn forward(req: ForwardRequest<'_>) -> Result { @@ -95,11 +111,16 @@ pub async fn forward(req: ForwardRequest<'_>) -> Result Result { +async fn read_response( + stream: &mut S, +) -> Result { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > 64 * 1024 { + return Err(ForwardError::BadResponse); + } break pos; } if buf.len() > 64 * 1024 { @@ -128,14 +149,24 @@ async fn read_response(stream: &mut TcpStream) -> Result 0, + Some(raw) => match raw.parse::() { + Ok(length) if raw.bytes().all(|b| b.is_ascii_digit()) => length, + _ => return Err(ForwardError::BadResponse), + }, + }; let mut body = buf[header_end + 4..].to_vec(); if content_length > MAX_RESPONSE_BYTES { @@ -148,7 +179,7 @@ async fn read_response(stream: &mut TcpStream) -> Result Option { mod tests { use super::*; + #[tokio::test] + async fn rejects_truncated_or_ambiguous_downstream_responses() { + for raw in [ + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nabc", + "HTTP/1.1 200 OK\r\nContent-Length: invalid\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: +3\r\n\r\nabc", + "HTTP/1.1 200 OK\r\nContent-Length: \r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nContent-Length: 0\r\n\r\nabc", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n", + ] { + assert!(matches!( + read_response(&mut raw.as_bytes()).await, + Err(ForwardError::BadResponse) + )); + } + let raw = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", + MAX_RESPONSE_BYTES + 1 + ); + assert!(matches!( + read_response(&mut raw.as_bytes()).await, + Err(ForwardError::BadResponse) + )); + } + + #[tokio::test] + async fn accepts_complete_fixed_length_and_close_delimited_responses() { + for raw in [ + "HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc", + "HTTP/1.1 200 OK\r\n\r\nabc", + ] { + assert_eq!( + read_response(&mut raw.as_bytes()).await.unwrap().body, + b"abc" + ); + } + } + + #[tokio::test] + async fn response_header_terminator_cannot_cross_the_limit() { + let mut raw = b"HTTP/1.1 200 OK\r\nX-Large: ".to_vec(); + raw.resize(64 * 1024 - 1, b'a'); + raw.extend_from_slice(b"\r\n\r\n"); + assert!(matches!( + read_response(&mut raw.as_slice()).await, + Err(ForwardError::BadResponse) + )); + } + #[test] fn parse_authority_with_port() { let (c, h) = parse_authority("http://127.0.0.1:19324").unwrap(); diff --git a/services/dashboard/src/http.rs b/services/dashboard/src/http.rs index d428336..076b2db 100644 --- a/services/dashboard/src/http.rs +++ b/services/dashboard/src/http.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::config::Config; @@ -399,11 +399,14 @@ fn legacy_redirect(req: &Request, target: &str) -> Response { Response::redirect(301, target) } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut S) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -431,14 +434,26 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let name = k.trim().to_ascii_lowercase(); + if name == "content-length" && headers.contains_key(&name) { + return Ok(None); + } + headers.insert(name, v.trim().to_string()); } } - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + // These one-shot endpoints support fixed-length bodies only. Reject + // ambiguous framing before a mutation can see a partial or empty body. + if headers.contains_key("transfer-encoding") { + return Ok(None); + } + let content_length = match headers.get("content-length") { + None => 0, + Some(raw) => match raw.parse::() { + Ok(length) if raw.bytes().all(|b| b.is_ascii_digit()) => length, + _ => return Ok(None), + }, + }; if content_length > MAX_BODY_BYTES { return Ok(None); } @@ -447,7 +462,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -660,6 +675,49 @@ fn hex_val(b: u8) -> Option { mod tests { use super::*; + #[tokio::test] + async fn transport_rejects_incomplete_or_ambiguous_bodies() { + for raw in [ + "POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\nabc", + "POST / HTTP/1.1\r\nContent-Length: invalid\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: +3\r\n\r\nabc", + "POST / HTTP/1.1\r\nContent-Length: \r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 99999999999999999999999999\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 3\r\nContent-Length: 0\r\n\r\nabc", + "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + ] { + assert!(read_request(&mut raw.as_bytes()).await.unwrap().is_none()); + } + let oversized = format!( + "POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n", + MAX_BODY_BYTES + 1 + ); + assert!(read_request(&mut oversized.as_bytes()) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn transport_accepts_complete_bodies_and_enforces_the_header_boundary() { + for raw in [ + "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc", + "GET / HTTP/1.1\r\n\r\n", + ] { + assert!(read_request(&mut raw.as_bytes()).await.unwrap().is_some()); + } + // The terminator arrives in the read that crosses the cap. Checking + // only the buffer length before looking for it misses this case. + let mut oversized = b"GET / HTTP/1.1\r\nX-Large: ".to_vec(); + oversized.resize(MAX_HEADER_BYTES - 1, b'a'); + oversized.extend_from_slice(b"\r\n\r\n"); + assert!(read_request(&mut oversized.as_slice()) + .await + .unwrap() + .is_none()); + } + #[tokio::test] async fn api_mutations_and_rebinding_hosts_are_refused() { let cfg = Config::default(); diff --git a/services/dashboard/src/mail.rs b/services/dashboard/src/mail.rs index 972b420..d4837c0 100644 --- a/services/dashboard/src/mail.rs +++ b/services/dashboard/src/mail.rs @@ -17,7 +17,7 @@ //! `/api/messages` shapes stay identical. use crate::config::Config; -use crate::forward::{forward, ForwardError, ForwardRequest, ForwardResponse}; +use crate::forward::{basic_auth_headers, forward, ForwardError, ForwardRequest, ForwardResponse}; use crate::http::{path_segment_decode, Request, Response}; /// `/api/messages` — GET lists (limit=100), DELETE clears all. @@ -86,7 +86,11 @@ async fn introspect(config: &Config, path: &str) -> Result Result, ) -> Result { + let mut headers = basic_auth_headers(&config.redis_auth_mode, "dev", &config.redis_password); + headers.push(("Content-Type".to_string(), "application/json".to_string())); forward(ForwardRequest { base: &config.redis_base, method, path, - headers: vec![("Content-Type".to_string(), "application/json".to_string())], + headers, body, }) .await diff --git a/services/dashboard/src/services.rs b/services/dashboard/src/services.rs index 4f13b62..ec1f2d6 100644 --- a/services/dashboard/src/services.rs +++ b/services/dashboard/src/services.rs @@ -181,9 +181,12 @@ fn build(c: &Config) -> Vec { } impl Config { - /// The SQS registry endpoint mirrors the legacy default of the SQS HTTP base. - fn sqs_endpoint_display(&self) -> String { - if self.sqs_base.is_empty() { + /// Preserve configured display metadata independently of the enabled base, + /// while retaining the legacy base/default fallback for standalone callers. + pub(crate) fn sqs_endpoint_display(&self) -> String { + if !self.sqs_endpoint.is_empty() { + self.sqs_endpoint.clone() + } else if self.sqs_base.is_empty() { "http://127.0.0.1:19324".to_string() } else { self.sqs_base.clone() @@ -253,7 +256,7 @@ mod tests { #[test] fn sqs_running_when_base_configured() { let mut cfg = Config::default(); - cfg.sqs_base = "http://127.0.0.1:19324".to_string(); + cfg.sqs_base = "http://127.0.0.1:29324".to_string(); let resp = handle(&cfg, &req()); let v: serde_json::Value = serde_json::from_slice(&resp.body).unwrap(); let sqs = v["services"] @@ -263,6 +266,7 @@ mod tests { .find(|s| s["id"] == "sqs") .unwrap(); assert_eq!(sqs["status"], "running"); + assert_eq!(sqs["endpoint"], "http://127.0.0.1:29324"); } #[test] @@ -277,6 +281,7 @@ mod tests { .find(|s| s["id"] == "sqs") .unwrap(); assert_eq!(sqs["status"], "disabled"); + assert_eq!(sqs["endpoint"], "http://127.0.0.1:19324"); } #[test] diff --git a/services/dashboard/src/sqs.rs b/services/dashboard/src/sqs.rs index fb30217..476dd6b 100644 --- a/services/dashboard/src/sqs.rs +++ b/services/dashboard/src/sqs.rs @@ -80,11 +80,7 @@ pub async fn handle_status(config: &Config, req: &Request) -> Response { } } - let endpoint = if config.sqs_base.is_empty() { - "http://127.0.0.1:19324".to_string() - } else { - config.sqs_base.clone() - }; + let endpoint = config.sqs_endpoint_display(); let region = if region.is_empty() { "us-east-1".to_string() } else { diff --git a/services/dashboard/tests/forwarding.rs b/services/dashboard/tests/forwarding.rs index 4e61427..89ea5b7 100644 --- a/services/dashboard/tests/forwarding.rs +++ b/services/dashboard/tests/forwarding.rs @@ -110,6 +110,69 @@ fn req_q(method: &str, path: &str, query: &str, body: &[u8]) -> Request { // ── Redis: a `/_control/` mutation service ────────────────────────────────── +#[tokio::test] +async fn strict_mail_and_redis_forward_credentials_for_reads_and_mutations() { + use base64::Engine; + for (service, method, path) in [ + ("mail", "GET", "/api/messages"), + ("mail", "DELETE", "/api/messages/one"), + ("redis", "GET", "/api/redis/keys"), + ("redis", "POST", "/api/redis/command"), + ] { + let (base, record) = mock_upstream(200, "{}").await; + let cfg = Config { + mail_base: base.clone(), + mail_auth_mode: " STRICT ".into(), + mail_username: "mail-user".into(), + mail_password: "mail:password".into(), + redis_base: base, + redis_auth_mode: "strict".into(), + redis_password: "redis:password".into(), + ..Config::default() + }; + assert_eq!(route(&cfg, &req(method, path, b"{}")).await.status, 200); + let record = record.lock().unwrap(); + let header = record + .headers + .get("authorization") + .expect("strict forwarding requires authentication"); + let encoded = header + .strip_prefix("Basic ") + .expect("local control APIs require Basic authentication"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let expected = if service == "mail" { + b"mail-user:mail:password".as_slice() + } else { + b"dev:redis:password".as_slice() + }; + assert!( + bytes == expected, + "forwarding must use the configured service credentials" + ); + } +} + +#[tokio::test] +async fn relaxed_mail_and_redis_do_not_send_configured_credentials() { + for path in ["/api/messages", "/api/redis/keys"] { + let (base, record) = mock_upstream(200, "{}").await; + let cfg = Config { + mail_base: base.clone(), + mail_auth_mode: "relaxed".into(), + mail_username: "unused-user".into(), + mail_password: "unused-password".into(), + redis_base: base, + redis_auth_mode: "relaxed".into(), + redis_password: "unused-password".into(), + ..Config::default() + }; + assert_eq!(route(&cfg, &req("GET", path, b"")).await.status, 200); + assert!(!record.lock().unwrap().headers.contains_key("authorization")); + } +} + #[tokio::test] async fn redis_command_forwards_to_control_exec() { let (base, record) = diff --git a/services/dynamodb/Cargo.toml b/services/dynamodb/Cargo.toml index 2099e13..08599a6 100644 --- a/services/dynamodb/Cargo.toml +++ b/services/dynamodb/Cargo.toml @@ -20,4 +20,4 @@ serde_json = "1" sha2 = "0.10" hmac = "0.12" hex = "0.4" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "time"] } diff --git a/services/dynamodb/src/http.rs b/services/dynamodb/src/http.rs index 19bb7f2..cece891 100644 --- a/services/dynamodb/src/http.rs +++ b/services/dynamodb/src/http.rs @@ -12,7 +12,7 @@ use std::collections::BTreeMap; use std::sync::Mutex; use serde::de::DeserializeOwned; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::errors::ApiError; @@ -371,11 +371,14 @@ fn verify_sigv4(server: &Server, auth_mode: &str, signed: &SignedRequest) -> Opt .map(|sig| Outcome::error(sig.status, sig.code, sig.code)) } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -402,15 +405,32 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = BTreeMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let key = k.trim().to_ascii_lowercase(); + if matches!(key.as_str(), "content-length" | "transfer-encoding") + && headers.contains_key(&key) + { + return Ok(None); + } + headers.insert(key, v.trim().to_string()); } } let host = headers.get("host").cloned().unwrap_or_default(); - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + // This transport supports fixed-length request bodies only. Never + // dispatch an unsupported transfer coding as an empty request body. + if headers.contains_key("transfer-encoding") { + return Ok(None); + } + let content_length: usize = match headers.get("content-length") { + None => 0, + Some(value) if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => { + match value.parse() { + Ok(length) => length, + Err(_) => return Ok(None), + } + } + Some(_) => return Ok(None), + }; if content_length > MAX_BODY_BYTES { return Ok(None); } @@ -418,7 +438,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -471,3 +491,61 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { } haystack.windows(needle.len()).position(|w| w == needle) } + +#[cfg(test)] +mod request_framing_tests { + use super::*; + + #[tokio::test] + async fn incomplete_fixed_length_body_is_rejected() { + // A complete JSON value is still an incomplete HTTP message. It must + // never reach a mutating handler after the client disconnects early. + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 9\r\n\r\n{}"[..]; + assert!(read_request(&mut wire).await.unwrap().is_none()); + } + + #[tokio::test] + async fn invalid_content_length_is_rejected() { + for value in ["bad", "-1", "+2", "", "18446744073709551616"] { + let wire = format!("POST / HTTP/1.1\r\nContent-Length: {value}\r\n\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{value:?}" + ); + } + } + + #[tokio::test] + async fn ambiguous_or_unsupported_body_framing_is_rejected() { + for headers in [ + "Transfer-Encoding: gzip\r\n", + "Transfer-Encoding: xchunked\r\n", + "Transfer-Encoding: chunked, gzip\r\n", + "Content-Length: 9\r\nContent-Length: 2\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ] { + let wire = format!("POST / HTTP/1.1\r\n{headers}\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{headers:?}" + ); + } + } + + #[tokio::test] + async fn complete_fixed_length_body_is_preserved() { + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn oversized_header_is_rejected_even_when_terminator_arrives() { + let wire = format!( + "POST / HTTP/1.1\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ); + assert!(read_request(&mut wire.as_bytes()).await.unwrap().is_none()); + } +} diff --git a/services/dynamodb/src/sigv4.rs b/services/dynamodb/src/sigv4.rs index 05bcaa1..dc84509 100644 --- a/services/dynamodb/src/sigv4.rs +++ b/services/dynamodb/src/sigv4.rs @@ -318,8 +318,11 @@ fn url_decode(value: &str) -> String { while i < bytes.len() { match bytes[i] { b'%' if i + 2 < bytes.len() => { - if let Ok(b) = u8::from_str_radix(&value[i + 1..i + 3], 16) { - out.push(b); + if let (Some(hi), Some(lo)) = ( + (bytes[i + 1] as char).to_digit(16), + (bytes[i + 2] as char).to_digit(16), + ) { + out.push((hi * 16 + lo) as u8); i += 3; continue; } @@ -511,3 +514,20 @@ mod tests { ); } } + +#[cfg(test)] +mod percent_decode_tests { + use super::*; + + #[test] + fn malformed_percent_escapes_next_to_unicode_do_not_panic() { + for value in ["%あ", "%aあ", "猫%猫", "%é", "%💾", "%", "%2", "%zz"] { + assert_eq!(url_decode(value), value); + } + } + + #[test] + fn valid_percent_escapes_and_plus_are_preserved() { + assert_eq!(url_decode("%E7%8C%AB+%2B%2F"), "猫 +/"); + } +} diff --git a/services/dynamodb/tests/persist_cache_parity.rs b/services/dynamodb/tests/persist_cache_parity.rs index aa65f25..29fdd23 100644 --- a/services/dynamodb/tests/persist_cache_parity.rs +++ b/services/dynamodb/tests/persist_cache_parity.rs @@ -145,34 +145,37 @@ fn dirty_tracking_persists_multiple_tables_independently() { /// a mutation whose persist fails must never leave the per-table cache (or /// dirty flag) in a state that lets a later, unrelated persist write the /// failed mutation's (rolled-back) data to disk. -#[cfg(unix)] #[test] fn persist_failure_rollback_then_next_persist_writes_pre_mutation_state() { - use std::os::unix::fs::PermissionsExt; - let dir = tempdir(); let mut s = server(&dir); create_table(&mut s, "A"); put(&mut s, "A", item("k", "before")).expect("initial put"); + let committed = std::fs::read(dir.join("state.json")).expect("read committed state"); - // Force the next persist to fail: creating a new `state.json.tmp` entry - // requires write permission on the directory itself. - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)) - .expect("make dir read-only"); + // A directory at the temporary file's path makes the write fail for every + // user, including root. Permission-only failure injection is ineffective + // with elevated privileges and is unavailable on non-Unix platforms. + let blocked = dir.join("state.json.tmp"); + std::fs::create_dir(&blocked).expect("block temporary state file"); let err = put(&mut s, "A", item("k", "after-failed")) - .expect_err("persist must fail when the directory is read-only"); + .expect_err("persist must fail when its temporary path is a directory"); assert_eq!(err.name, "InternalServerError"); + assert_eq!( + std::fs::read(dir.join("state.json")).expect("read state after failed persist"), + committed, + "a failed persist must leave the committed file unchanged" + ); // Caller-side rollback (pre-existing behavior) must already have // restored the in-memory item. assert_eq!(get_val(&s, "A", "k").as_deref(), Some("before")); - // Restore permissions and drive a real persist via an unrelated write to + // Remove the obstruction and drive a real persist via an unrelated write to // the same table, so the fix under test — not leaving a stale cache/dirty // entry from the failed attempt — is what's exercised. - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)) - .expect("restore dir permissions"); + std::fs::remove_dir(&blocked).expect("unblock temporary state file"); put(&mut s, "A", item("k2", "unrelated")).expect("put after recovery"); let after = read_state(&dir); diff --git a/services/event-relay/src/lib.rs b/services/event-relay/src/lib.rs index fc110ef..b27f2d8 100644 --- a/services/event-relay/src/lib.rs +++ b/services/event-relay/src/lib.rs @@ -56,6 +56,7 @@ const CLIENT_QUEUE_SIZE: usize = 256; /// Keepalive ping interval — mirrors legacy `pingInterval = 15 * time.Second`. const PING_INTERVAL: Duration = Duration::from_secs(15); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); // --------------------------------------------------------------------------- // Frame (wire format) @@ -394,18 +395,6 @@ async fn serve_client( // Skip the first immediate tick — we just sent the hello frame. ping_interval.tick().await; - // Drain inbound frames so close frames are observed (mirrors legacy drain goroutine). - let (client_closed_tx, mut client_closed_rx) = mpsc::channel::<()>(1); - tokio::spawn(async move { - while let Some(msg) = ws_rx.next().await { - match msg { - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - let _ = client_closed_tx.send(()).await; - }); - loop { tokio::select! { // Shutdown signal. @@ -415,9 +404,12 @@ async fn serve_client( break; } } - // Client disconnected. - _ = client_closed_rx.recv() => { - break; + // Keep both socket halves in this task so cancellation closes the + // whole connection instead of leaving a detached reader alive. + incoming = ws_rx.next() => { + if matches!(incoming, None | Some(Ok(Message::Close(_))) | Some(Err(_))) { + break; + } } // Keepalive ping. _ = ping_interval.tick() => { @@ -475,13 +467,21 @@ async fn serve_client( /// be bound. pub async fn run( addr: String, - mut events: UnboundedReceiver, + events: UnboundedReceiver, shutdown: impl std::future::Future + Send + 'static, ) -> Result<(), String> { let listener = TcpListener::bind(&addr) .await .map_err(|e| format!("event-relay: bind {addr}: {e}"))?; + run_listener(listener, events, shutdown).await +} + +async fn run_listener( + listener: TcpListener, + mut events: UnboundedReceiver, + shutdown: impl std::future::Future + Send + 'static, +) -> Result<(), String> { let state = RelayState::new(); // Shutdown watch channel — broadcast `true` when the shutdown future resolves. @@ -530,11 +530,13 @@ pub async fn run( // the HTTP upgrade request during the handshake (mirrors legacy parseTopics // call inside ServeHTTP). let mut srx_accept = shutdown_rx.clone(); + let mut clients = tokio::task::JoinSet::new(); loop { tokio::select! { _ = srx_accept.changed() => { if *srx_accept.borrow() { break; } } + _ = clients.join_next(), if !clients.is_empty() => {} accepted = listener.accept() => { let (stream, _): (tokio::net::TcpStream, SocketAddr) = match accepted { Ok(p) => p, @@ -591,19 +593,28 @@ pub async fn run( Ok(resp) }; - let ws = match tokio_tungstenite::accept_hdr_async(stream, callback).await { - Ok(ws) => ws, - Err(_) => continue, // bad handshake — skip - }; - - let topics = topics_cell.lock().unwrap().clone(); let state_clone = Arc::clone(&state); let srx = shutdown_rx.clone(); - tokio::spawn(serve_client(ws, state_clone, topics, srx)); + // A peer that opens TCP without sending an upgrade must not + // block every other client or prevent shutdown of the relay. + clients.spawn(async move { + let ws = match time::timeout( + HANDSHAKE_TIMEOUT, + tokio_tungstenite::accept_hdr_async(stream, callback), + ).await { + Ok(Ok(ws)) => ws, + _ => return, + }; + let topics = topics_cell.lock().unwrap().clone(); + serve_client(ws, state_clone, topics, srx).await; + }); } } } + clients.abort_all(); + while clients.join_next().await.is_some() {} + Ok(()) } @@ -699,6 +710,67 @@ fn allowed_hosts(list: &str, name: &str) -> bool { mod tests { use super::*; + #[tokio::test] + async fn incomplete_handshake_does_not_block_events_or_shutdown() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (events_tx, events_rx) = mpsc::unbounded_channel(); + let (stop_tx, stop_rx) = tokio::sync::oneshot::channel(); + let relay = tokio::spawn(run_listener(listener, events_rx, async move { + let _ = stop_rx.await; + })); + + let mut idle = tokio::net::TcpStream::connect(addr).await.unwrap(); + idle.write_all(b"GET /_events HTTP/1.1\r\n").await.unwrap(); + let (mut ws, _) = time::timeout( + Duration::from_secs(2), + tokio_tungstenite::connect_async(format!("ws://{addr}/_events?topics=mail")), + ) + .await + .expect("an idle handshake must not block later connections") + .unwrap(); + let hello = time::timeout(Duration::from_secs(2), ws.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let hello: serde_json::Value = serde_json::from_str(hello.to_text().unwrap()).unwrap(); + assert_eq!(hello["type"], "hello"); + + events_tx + .send(r#"{"type":"s3.object.put","service":"s3"}"#.into()) + .unwrap(); + events_tx + .send( + r#"{"type":"mail.received","service":"mail","payload":{"messageID":"one"}}"#.into(), + ) + .unwrap(); + let frame = time::timeout(Duration::from_secs(2), ws.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let frame: serde_json::Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(frame["type"], "event"); + assert_eq!(frame["event"]["service"], "mail"); + assert_eq!(frame["event"]["payload"]["messageID"], "one"); + assert!(frame["event"]["timestamp"].is_string()); + + stop_tx.send(()).unwrap(); + time::timeout(Duration::from_secs(2), relay) + .await + .expect("shutdown must cancel pending handshakes") + .unwrap() + .unwrap(); + let mut byte = [0u8; 1]; + let closed = time::timeout(Duration::from_secs(2), idle.read(&mut byte)) + .await + .unwrap(); + assert!(matches!(closed, Ok(0)) || closed.is_err()); + } + #[test] fn websocket_handshakes_from_other_sites_are_refused() { assert!(loopback_origin("")); diff --git a/services/gcs/Cargo.toml b/services/gcs/Cargo.toml index 9ae3df4..48756b5 100644 --- a/services/gcs/Cargo.toml +++ b/services/gcs/Cargo.toml @@ -17,4 +17,4 @@ devcloud-s3 = { path = "../s3" } hex = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "time", "sync"] } diff --git a/services/gcs/src/http.rs b/services/gcs/src/http.rs index f524e8d..7198954 100644 --- a/services/gcs/src/http.rs +++ b/services/gcs/src/http.rs @@ -22,11 +22,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use devcloud_s3::base64; use devcloud_s3::model::{Bucket, Object}; use devcloud_s3::objops::{PutObjectInput, UpdateObjectMetadataInput}; -use devcloud_s3::store::{FileBucketStore, StoreError}; +use devcloud_s3::store::{ConditionalError, FileBucketStore, StoreError}; use devcloud_s3::time_fmt::{now_rfc3339nano, parse_rfc3339}; use devcloud_s3::wire_json; use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; const MAX_HEADER_BYTES: usize = 64 * 1024; @@ -880,11 +880,19 @@ fn read_object( key: &str, ) -> Result<(Object, Vec), Response> { let (object, body) = fetch_object(server, bucket, key)?; - if !generation_matches(req, &object)? { + check_existing_object(req, Some(&object))?; + Ok((object, body)) +} + +/// Reads, metadata updates and deletes must apply both the selector and the +/// preconditions to the same object. Mutations call this again with the current +/// metadata while holding the shared S3/GCS store's write lock. +fn check_existing_object(req: &Request, found: Option<&Object>) -> Result<(), Response> { + let object = found.ok_or_else(|| json_error(404, "notFound", "object not found"))?; + if !generation_matches(req, object)? { return Err(json_error(404, "notFound", "object not found")); } - check_object_preconditions(server, req, bucket, key)?; - Ok((object, body)) + check_preconditions(Some(object), &preconditions_from_request(req)) } fn handle_object(server: &mut Server, req: &Request, bucket: &str, key: &str) -> Response { @@ -916,9 +924,8 @@ fn handle_object(server: &mut Server, req: &Request, bucket: &str, key: &str) -> Err(_) if req.body.is_empty() => ObjectMetadataRequest::default(), Err(_) => return json_error(400, "invalid", "invalid json request"), }; - match server - .store - .update_object_metadata(UpdateObjectMetadataInput { + match server.store.update_object_metadata_if( + UpdateObjectMetadataInput { bucket: bucket.to_string(), key: key.to_string(), content_type: parsed.content_type, @@ -926,17 +933,23 @@ fn handle_object(server: &mut Server, req: &Request, bucket: &str, key: &str) -> cache_control: parsed.cache_control, content_disposition: parsed.content_disposition, metadata: parsed.metadata, - }) { + }, + |current| check_existing_object(req, current), + ) { Ok(Some(object)) => Response::json(200, &object_resource(&object)), Ok(None) => json_error(404, "notFound", "object not found"), - Err(_) => json_error(404, "notFound", "bucket not found"), + Err(ConditionalError::Condition(response)) => response, + Err(ConditionalError::Store(_)) => json_error(404, "notFound", "bucket not found"), } } "DELETE" => { if let Err(r) = read_object(server, req, bucket, key) { return r; } - match server.store.delete_object(bucket, key) { + match server + .store + .delete_object_if(bucket, key, |current| check_existing_object(req, current)) + { Ok(true) => { emit_dashboard_event( "gcs.object.deleted", @@ -945,7 +958,8 @@ fn handle_object(server: &mut Server, req: &Request, bucket: &str, key: &str) -> Response::empty(204) } Ok(false) => json_error(404, "notFound", "object not found"), - Err(_) => json_error(404, "notFound", "bucket not found"), + Err(ConditionalError::Condition(response)) => response, + Err(ConditionalError::Store(_)) => json_error(404, "notFound", "bucket not found"), } } _ => method_not_allowed("GET, PATCH, DELETE"), @@ -1034,21 +1048,13 @@ fn handle_compose_object( if source.name.is_empty() { return json_error(400, "required", "source object name is required"); } - let (object, payload) = match server.store.get_object(bucket, &source.name) { + let (object, payload) = match server.store.get_object_snapshot(bucket, &source.name) { Ok(Some(found)) => found, Ok(None) => return json_error(404, "notFound", "source object not found"), Err(_) => return json_error(404, "notFound", "bucket not found"), }; - let actual_generation = generation(&object); - if (!source.generation.is_empty() && source.generation != actual_generation) - || (!source.object_preconditions.if_generation_match.is_empty() - && source.object_preconditions.if_generation_match != actual_generation) - { - return json_error( - 412, - "conditionNotMet", - "source generation precondition failed", - ); + if let Err(response) = check_compose_source_preconditions(Some(&object), source) { + return response; } if first_source.is_none() { first_source = Some(object); @@ -1056,20 +1062,58 @@ fn handle_compose_object( body.extend_from_slice(&payload); } let first_source = first_source.unwrap_or_default(); - match server.store.put_object(PutObjectInput { - bucket: bucket.to_string(), - key: dest_key.clone(), - body, - content_type: first_non_empty(&parsed.destination.content_type, &first_source.content_type), - content_encoding: parsed.destination.content_encoding, - cache_control: parsed.destination.cache_control, - content_disposition: parsed.destination.content_disposition, - metadata: parsed.destination.metadata.unwrap_or_default(), - ..Default::default() - }) { + match server.store.put_object_if( + PutObjectInput { + bucket: bucket.to_string(), + key: dest_key.clone(), + body, + content_type: first_non_empty( + &parsed.destination.content_type, + &first_source.content_type, + ), + content_encoding: parsed.destination.content_encoding, + cache_control: parsed.destination.cache_control, + content_disposition: parsed.destination.content_disposition, + metadata: parsed.destination.metadata.unwrap_or_default(), + ..Default::default() + }, + |current| { + // A source can also be the destination. Its conditions then guard + // this mutation and must still hold when the destination is saved. + for source in &parsed.source_objects { + if source.name == dest_key { + check_compose_source_preconditions(current, source)?; + } + } + check_preconditions(current, &preconditions_from_request(req)) + }, + ) { Ok(object) => Response::json(200, &object_resource(&object)), - Err(e) => json_error(404, "notFound", &store_error_text(&e, bucket, &dest_key)), + Err(ConditionalError::Condition(response)) => response, + Err(ConditionalError::Store(e)) => { + json_error(404, "notFound", &store_error_text(&e, bucket, &dest_key)) + } + } +} + +fn check_compose_source_preconditions( + found: Option<&Object>, + source: &ComposeSourceObject, +) -> Result<(), Response> { + let actual_generation = found.map(generation); + if (!source.generation.is_empty() + && Some(source.generation.as_str()) != actual_generation.as_deref()) + || (!source.object_preconditions.if_generation_match.is_empty() + && Some(source.object_preconditions.if_generation_match.as_str()) + != actual_generation.as_deref()) + { + return Err(json_error( + 412, + "conditionNotMet", + "source generation precondition failed", + )); } + Ok(()) } fn copy_object( @@ -1103,45 +1147,56 @@ fn copy_object( "invalid destination object name", )); } - let (source, body) = match server.store.get_object(source_bucket, &source_key) { + let (source, body) = match server.store.get_object_snapshot(source_bucket, &source_key) { Ok(Some(found)) => found, Ok(None) => return Err(json_error(404, "notFound", "source object not found")), Err(_) => return Err(json_error(404, "notFound", "source bucket not found")), }; - check_stored_preconditions( - server, - source_bucket, - &source_key, - &source_preconditions_from_request(req), - )?; + let source_preconditions = source_preconditions_from_request(req); + check_preconditions(Some(&source), &source_preconditions)?; check_object_preconditions(server, req, &dest_bucket, &dest_key)?; + let same_object = source_bucket == dest_bucket && source_key == dest_key; let metadata = destination .metadata .unwrap_or_else(|| source.metadata.clone()); - match server.store.put_object(PutObjectInput { - bucket: dest_bucket, - key: dest_key, - body, - content_type: first_non_empty(&destination.content_type, &source.content_type), - content_encoding: first_non_empty(&destination.content_encoding, &source.content_encoding), - cache_control: first_non_empty(&destination.cache_control, &source.cache_control), - content_disposition: first_non_empty( - &destination.content_disposition, - &source.content_disposition, - ), - metadata, - ..Default::default() - }) { + match server.store.put_object_if( + PutObjectInput { + bucket: dest_bucket, + key: dest_key, + body, + content_type: first_non_empty(&destination.content_type, &source.content_type), + content_encoding: first_non_empty( + &destination.content_encoding, + &source.content_encoding, + ), + cache_control: first_non_empty(&destination.cache_control, &source.cache_control), + content_disposition: first_non_empty( + &destination.content_disposition, + &source.content_disposition, + ), + metadata, + ..Default::default() + }, + |current| { + if same_object { + check_preconditions(current, &source_preconditions)?; + } + check_preconditions(current, &preconditions_from_request(req)) + }, + ) { Ok(object) => Ok(object), - Err(_) => Err(json_error(404, "notFound", "destination bucket not found")), + Err(ConditionalError::Condition(response)) => Err(response), + Err(ConditionalError::Store(_)) => { + Err(json_error(404, "notFound", "destination bucket not found")) + } } } /// Object fetch with the legacy single-object handler error mapping: any store /// error reads as a missing bucket. fn fetch_object(server: &Server, bucket: &str, key: &str) -> Result<(Object, Vec), Response> { - match server.store.get_object(bucket, key) { + match server.store.get_object_snapshot(bucket, key) { Ok(Some(found)) => Ok(found), Ok(None) => Err(json_error(404, "notFound", "object not found")), Err(_) => Err(json_error(404, "notFound", "bucket not found")), @@ -1176,6 +1231,7 @@ fn handle_media_upload(server: &mut Server, req: &Request, bucket: &str) -> Resp } put_object_response( server, + req, PutObjectInput { bucket: bucket.to_string(), key: name, @@ -1207,6 +1263,7 @@ fn handle_multipart_upload(server: &mut Server, req: &Request, bucket: &str) -> } put_object_response( server, + req, PutObjectInput { bucket: bucket.to_string(), key: name, @@ -1224,7 +1281,7 @@ fn handle_multipart_upload(server: &mut Server, req: &Request, bucket: &str) -> fn handle_resumable_upload(server: &mut Server, req: &Request, bucket: &str) -> Response { match req.method.as_str() { "POST" => create_resumable_upload(server, req, bucket), - "PUT" => put_resumable_upload(server, req), + "PUT" => put_resumable_upload(server, req, bucket), _ => method_not_allowed("POST, PUT"), } } @@ -1279,12 +1336,32 @@ fn create_resumable_upload(server: &mut Server, req: &Request, bucket: &str) -> r } -fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { +fn put_resumable_upload(server: &mut Server, req: &Request, bucket: &str) -> Response { let id = req.query_value("upload_id").to_string(); if id.is_empty() { return json_error(400, "required", "upload_id is required"); } - let Some(mut session) = server.sessions.get(&id).cloned() else { + let pending = server.sessions.get(&id).cloned(); + if pending + .as_ref() + .is_some_and(|session| session.bucket != bucket) + { + return json_error(404, "notFound", "upload session not found"); + } + // Completion is checked before status/offset validation and preconditions: + // a create-only upload must not reject a retry because of its own object. + match server.store.get_resumable_upload_result( + bucket, + pending.as_ref().map(|session| session.name.as_str()), + &id, + ) { + Ok(Some(object)) => { + return complete_resumable_upload(server, &id, &object); + } + Ok(None) => {} + Err(_) => return json_error(500, "backendError", "internal error"), + } + let Some(mut session) = pending else { return json_error(404, "notFound", "upload session not found"); }; if is_status_query(req.header("content-range")) { @@ -1297,21 +1374,12 @@ fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { } return r; } - if let Err(r) = check_stored_preconditions( - server, - &session.bucket.clone(), - &session.name.clone(), - &session.preconditions, - ) { - return r; - } let mut payload = req.body.clone(); - // Set when the final chunk has already been durably appended (`body.part` - // and `session.json` on disk reflect the post-chunk state) so the - // `put_object` failure branch below can roll both back to this pre-chunk - // offset. `None` covers one-shot and headerless commits, where nothing was - // persisted before `put_object` and no rollback is needed. - let mut committed_chunk_rollback: Option = None; + // The final chunk may be appended to `body.part`, but its session checkpoint + // must stay at the previous offset until the object is committed. A restart + // can then discard the uncommitted tail and accept the same chunk again. + // One-shot and headerless commits do not stage data before `put_object`. + let mut staged_chunk_rollback: Option = None; if !req.header("content-range").trim().is_empty() { let upload_range = match parse_resumable_content_range( req.header("content-range"), @@ -1329,16 +1397,33 @@ fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { } let prior_received_bytes = session.received_bytes; session.received_bytes = upload_range.end + 1; - let one_shot = upload_range.start == 0 && session.received_bytes == upload_range.total; + let is_final = session.received_bytes == upload_range.total; + let one_shot = upload_range.start == 0 && is_final; + if !is_final { + if let Err(response) = check_stored_preconditions( + server, + &session.bucket, + &session.name, + &session.preconditions, + ) { + return response; + } + } if !one_shot { - if server.append_session_body(&id, &payload).is_err() - || server.save_session(&id, &session).is_err() + if server + .append_session_body(&id, prior_received_bytes, &payload) + .is_err() + || (!is_final && server.save_session(&id, &session).is_err()) { + // An append may succeed (or write a prefix) before the + // metadata write fails. The in-memory offset is unchanged; + // discard these uncommitted bytes before a client retries. + let _ = server.truncate_session_body(&id, prior_received_bytes); return json_error(500, "backendError", "internal error"); } - committed_chunk_rollback = Some(prior_received_bytes); + staged_chunk_rollback = Some(prior_received_bytes); } - if session.received_bytes < upload_range.total { + if !is_final { server.sessions.insert(id, session); let mut r = Response::empty(308); r.headers @@ -1348,58 +1433,81 @@ fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { if !one_shot { payload = match server.read_session_body(&id) { Ok(v) => v, - Err(_) => return json_error(500, "backendError", "internal error"), + Err(_) => { + let _ = server.truncate_session_body(&id, prior_received_bytes); + return json_error(500, "backendError", "internal error"); + } }; } } - match server.store.put_object(PutObjectInput { - bucket: session.bucket.clone(), - key: session.name.clone(), - body: payload, - content_type: first_non_empty(req.header("content-type"), &session.content_type), - content_encoding: session.content_encoding.clone(), - cache_control: session.cache_control.clone(), - content_disposition: session.content_disposition.clone(), - metadata: session.metadata.clone().unwrap_or_default(), - ..Default::default() - }) { - Ok(object) => { - server.sessions.remove(&id); - let _ = server.delete_session(&id); - emit_dashboard_event( - "gcs.object.put", - serde_json::json!({ - "bucket": object.bucket, - "key": object.key, - "etag": object.etag, - "contentLength": object.size, - }), - ); - Response::json(200, &object_resource(&object)) - } - Err(e) => { - let message = store_error_text(&e, &session.bucket, &session.name); - if let Some(prior_received_bytes) = committed_chunk_rollback { - // Only chunks that were durably appended before this failed - // `put_object` need undoing here — otherwise a retried final - // chunk re-appends onto bytes already persisted, doubling the - // stored object. - session.received_bytes = prior_received_bytes; + // A final write checks its conditions only inside the store lock, after + // replaying any completion that appeared since the initial lookup. + match server.store.put_resumable_object_if( + PutObjectInput { + bucket: session.bucket.clone(), + key: session.name.clone(), + body: payload, + content_type: first_non_empty(req.header("content-type"), &session.content_type), + content_encoding: session.content_encoding.clone(), + cache_control: session.cache_control.clone(), + content_disposition: session.content_disposition.clone(), + metadata: session.metadata.clone().unwrap_or_default(), + ..Default::default() + }, + &id, + |current| check_preconditions(current, &session.preconditions), + ) { + Ok(object) => complete_resumable_upload(server, &id, &object), + Err(error) => { + if let Some(prior_received_bytes) = staged_chunk_rollback { + // The in-memory and on-disk checkpoints still contain this + // offset. Discard the staged tail so a retry cannot duplicate + // it, without requiring another metadata write to roll back. let _ = server.truncate_session_body(&id, prior_received_bytes); - let _ = server.save_session(&id, &session); - server.sessions.insert(id, session); } - json_error(404, "notFound", &message) + match error { + ConditionalError::Condition(response) => response, + ConditionalError::Store(e) => json_error( + 404, + "notFound", + &store_error_text(&e, &session.bucket, &session.name), + ), + } } } } +fn complete_resumable_upload(server: &mut Server, id: &str, object: &Object) -> Response { + let notify = match server.store.claim_resumable_upload_event(object) { + Ok(notify) => notify, + Err(_) => return json_error(500, "backendError", "internal error"), + }; + server.sessions.remove(id); + let _ = server.delete_session(id); + if notify { + // The relay is best-effort across process stops. A stop between the + // claim and send is repaired by the dashboard's reconnect resync. + emit_dashboard_event( + "gcs.object.put", + serde_json::json!({ + "bucket": object.bucket, + "key": object.key, + "etag": object.etag, + "contentLength": object.size, + }), + ); + } + Response::json(200, &object_resource(object)) +} + /// Stores the object and writes the legacy upload-handler response: the object /// resource on success, or `404 notFound` with the store error text (matching /// legacy `writeError(w, http.StatusNotFound, "notFound", err.Error())`). -fn put_object_response(server: &mut Server, input: PutObjectInput) -> Response { +fn put_object_response(server: &mut Server, req: &Request, input: PutObjectInput) -> Response { let (bucket, key) = (input.bucket.clone(), input.key.clone()); - match server.store.put_object(input) { + match server.store.put_object_if(input, |current| { + check_preconditions(current, &preconditions_from_request(req)) + }) { Ok(object) => { emit_dashboard_event( "gcs.object.put", @@ -1412,7 +1520,10 @@ fn put_object_response(server: &mut Server, input: PutObjectInput) -> Response { ); Response::json(200, &object_resource(&object)) } - Err(e) => json_error(404, "notFound", &store_error_text(&e, &bucket, &key)), + Err(ConditionalError::Condition(response)) => response, + Err(ConditionalError::Store(e)) => { + json_error(404, "notFound", &store_error_text(&e, &bucket, &key)) + } } } @@ -1527,7 +1638,7 @@ fn check_stored_preconditions( key: &str, preconditions: &ObjectPreconditions, ) -> Result<(), Response> { - let found = match server.store.get_object(bucket, key) { + let found = match server.store.get_object_snapshot(bucket, key) { Ok(found) => found.map(|(object, _)| object), Err(e) => { return Err(json_error( @@ -1537,8 +1648,17 @@ fn check_stored_preconditions( )) } }; - let generation = found.as_ref().map(generation_value).unwrap_or(0); - let metageneration = found.as_ref().map(|o| o.metageneration.max(1)).unwrap_or(0); + check_preconditions(found.as_ref(), preconditions) +} + +/// Evaluates conditions against one snapshot. Store mutations use this inside +/// their shared write lock, after any resumable completion has been replayed. +fn check_preconditions( + found: Option<&Object>, + preconditions: &ObjectPreconditions, +) -> Result<(), Response> { + let generation = found.map(generation_value).unwrap_or(0); + let metageneration = found.map(|o| o.metageneration.max(1)).unwrap_or(0); for (name, expected, actual, invert) in [ ( "ifGenerationMatch", @@ -1659,10 +1779,21 @@ impl Server { return Ok(()); }; fs::create_dir_all(&dir)?; - fs::write(dir.join("session.json"), wire_json::to_vec_indent(session)) + let temporary = dir.join("session.json.tmp"); + let result = fs::write(&temporary, wire_json::to_vec_indent(session)) + .and_then(|()| fs::rename(&temporary, dir.join("session.json"))); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result } - fn append_session_body(&self, id: &str, payload: &[u8]) -> std::io::Result<()> { + fn append_session_body( + &self, + id: &str, + committed_bytes: i64, + payload: &[u8], + ) -> std::io::Result<()> { use std::io::Write; let Some(dir) = self.session_dir(id) else { return Err(std::io::Error::new( @@ -1675,6 +1806,18 @@ impl Server { .create(true) .append(true) .open(dir.join("body.part"))?; + let committed_bytes = u64::try_from(committed_bytes).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid upload offset") + })?; + if f.metadata()?.len() < committed_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "committed upload data is incomplete", + )); + } + // Recover an interrupted append using the offset committed in + // session.json, including after a process restart. + f.set_len(committed_bytes)?; f.write_all(payload) } @@ -1702,7 +1845,16 @@ impl Server { let f = fs::OpenOptions::new() .write(true) .open(dir.join("body.part"))?; - f.set_len(len as u64) + let len = u64::try_from(len).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid upload offset") + })?; + if f.metadata()?.len() < len { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "committed upload data is incomplete", + )); + } + f.set_len(len) } fn delete_session(&self, id: &str) -> std::io::Result<()> { @@ -2163,11 +2315,14 @@ async fn handle_conn(mut stream: TcpStream, server: Arc>) -> std:: write_response(&mut stream, response).await } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -2190,28 +2345,45 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = BTreeMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let key = k.trim().to_ascii_lowercase(); + if matches!(key.as_str(), "content-length" | "transfer-encoding") + && headers.contains_key(&key) + { + return Ok(None); + } + headers.insert(key, v.trim().to_string()); } } + let chunked = match headers.get("transfer-encoding") { + None => false, + Some(value) + if value.eq_ignore_ascii_case("chunked") && !headers.contains_key("content-length") => + { + true + } + Some(_) => return Ok(None), + }; let mut body = buf[header_end + 4..].to_vec(); - if headers - .get("transfer-encoding") - .map(|v| v.to_ascii_lowercase().contains("chunked")) - .unwrap_or(false) - { + if chunked { body = read_chunked_body(stream, body).await?; } else { - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + let content_length: usize = match headers.get("content-length") { + None => 0, + Some(value) if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => { + match value.parse() { + Ok(length) => length, + Err(_) => return Ok(None), + } + } + Some(_) => return Ok(None), + }; if content_length > MAX_BODY_BYTES { return Ok(None); } while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -2227,40 +2399,119 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result })) } -async fn read_chunked_body(stream: &mut TcpStream, mut buf: Vec) -> std::io::Result> { +async fn read_chunked_body( + stream: &mut (impl AsyncRead + Unpin), + mut buf: Vec, +) -> std::io::Result> { let mut out = Vec::new(); + let mut framing_bytes = 0usize; let mut tmp = [0u8; 4096]; - let mut pos = 0; loop { - let line_end = loop { - if let Some(i) = find_subslice(&buf[pos..], b"\r\n") { - break pos + i; + let line = read_chunk_line(stream, &mut buf, MAX_HEADER_BYTES).await?; + framing_bytes += line.len() + 2; + // One-byte chunks use five framing bytes per data byte. Reserve that + // legal overhead separately from the additional extension budget. + if framing_bytes > 5 * out.len() + MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk framing too large", + )); + } + let size_hex = line.split(|b| *b == b';').next().unwrap_or_default(); + if size_hex.is_empty() || !size_hex.iter().all(u8::is_ascii_hexdigit) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid chunk size", + )); + } + // ASCII validation above makes this conversion infallible. Reject a + // huge declaration before reading its payload or adding byte offsets. + let size = + usize::from_str_radix(std::str::from_utf8(size_hex).unwrap(), 16).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid chunk size") + })?; + if size > MAX_BODY_BYTES - out.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request body too large", + )); + } + if size == 0 { + // The zero chunk is followed by a complete, bounded trailer + // section, ending in an empty line. EOF is never a terminator. + let mut remaining = MAX_HEADER_BYTES; + loop { + let trailer = read_chunk_line(stream, &mut buf, remaining).await?; + remaining -= trailer.len() + 2; + framing_bytes += trailer.len() + 2; + if framing_bytes > 5 * out.len() + MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk framing too large", + )); + } + if trailer.is_empty() { + return Ok(out); + } } + } + // `size` is now bounded by MAX_BODY_BYTES, so the addition is safe. + while buf.len() < size + 2 { let n = stream.read(&mut tmp).await?; if n == 0 { - return Ok(out); + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete chunk body", + )); } buf.extend_from_slice(&tmp[..n]); - }; - let size_line = String::from_utf8_lossy(&buf[pos..line_end]); - let size_hex = size_line.split(';').next().unwrap_or("").trim(); - let size = usize::from_str_radix(size_hex, 16).unwrap_or(0); - pos = line_end + 2; - if size == 0 { - return Ok(out); } - while buf.len() < pos + size + 2 { - let n = stream.read(&mut tmp).await?; - if n == 0 { - return Ok(out); + if &buf[size..size + 2] != b"\r\n" { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid chunk terminator", + )); + } + out.extend_from_slice(&buf[..size]); + framing_bytes += 2; + // Discard consumed framing as well as data, so many small chunks do + // not retain an unbounded second copy of the request on the heap. + buf.drain(..size + 2); + } +} + +async fn read_chunk_line( + stream: &mut (impl AsyncRead + Unpin), + buf: &mut Vec, + limit: usize, +) -> std::io::Result> { + let mut tmp = [0u8; 4096]; + loop { + if let Some(end) = find_subslice(buf, b"\r\n") { + if end + 2 > limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk headers too large", + )); } - buf.extend_from_slice(&tmp[..n]); + let line = buf[..end].to_vec(); + buf.drain(..end + 2); + return Ok(line); + } + if buf.len() >= limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk headers too large", + )); } - out.extend_from_slice(&buf[pos..pos + size]); - if out.len() > MAX_BODY_BYTES { - return Ok(Vec::new()); + let n = stream.read(&mut tmp).await?; + if n == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete chunk headers", + )); } - pos += size + 2; + buf.extend_from_slice(&tmp[..n]); } } @@ -2287,8 +2538,11 @@ fn url_decode(value: &str) -> String { while i < bytes.len() { match bytes[i] { b'%' if i + 2 < bytes.len() => { - if let Ok(b) = u8::from_str_radix(&value[i + 1..i + 3], 16) { - out.push(b); + if let (Some(hi), Some(lo)) = ( + (bytes[i + 1] as char).to_digit(16), + (bytes[i + 2] as char).to_digit(16), + ) { + out.push((hi * 16 + lo) as u8); i += 3; continue; } @@ -2531,3 +2785,163 @@ mod tests { ); } } + +#[cfg(test)] +mod request_framing_tests { + use super::*; + + #[tokio::test] + async fn incomplete_fixed_length_body_is_rejected() { + // A complete JSON value is still an incomplete HTTP message. It must + // never reach a mutating handler after the client disconnects early. + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 9\r\n\r\n{}"[..]; + assert!(read_request(&mut wire).await.unwrap().is_none()); + } + + #[tokio::test] + async fn invalid_content_length_is_rejected() { + for value in ["bad", "-1", "+2", "", "18446744073709551616"] { + let wire = format!("POST / HTTP/1.1\r\nContent-Length: {value}\r\n\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{value:?}" + ); + } + } + + #[tokio::test] + async fn ambiguous_or_unsupported_body_framing_is_rejected() { + for headers in [ + "Transfer-Encoding: gzip\r\n", + "Transfer-Encoding: xchunked\r\n", + "Transfer-Encoding: chunked, gzip\r\n", + "Content-Length: 9\r\nContent-Length: 2\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ] { + let wire = format!("POST / HTTP/1.1\r\n{headers}\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{headers:?}" + ); + } + } + + #[tokio::test] + async fn complete_fixed_length_body_is_preserved() { + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn oversized_header_is_rejected_even_when_terminator_arrives() { + let wire = format!( + "POST / HTTP/1.1\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ); + assert!(read_request(&mut wire.as_bytes()).await.unwrap().is_none()); + } +} + +#[cfg(test)] +mod chunk_framing_tests { + use super::*; + + #[tokio::test] + async fn malformed_or_incomplete_chunks_never_return_a_partial_body() { + for wire in [ + &b"2\r\n{}\r\n"[..], + &b"2\r\n{}\r\nxyz\r\n"[..], + &b"5\r\n{}"[..], + &b"2\r\n{}XX0\r\n\r\n"[..], + &b"2\r\n{}\r\n0\r\n"[..], + &b"2\r\n{}\r\n0\r\nX-Trailer: unfinished"[..], + &b"+2\r\n{}\r\n0\r\n\r\n"[..], + &b"\r\n"[..], + ] { + assert!( + read_chunked_body(&mut &b""[..], wire.to_vec()) + .await + .is_err(), + "{wire:?}" + ); + } + } + + #[tokio::test] + async fn oversized_chunk_is_rejected_before_reading_its_payload() { + for size in [MAX_BODY_BYTES + 1, usize::MAX] { + let wire = format!("{size:x}\r\n"); + assert!(read_chunked_body(&mut &b""[..], wire.into_bytes()) + .await + .is_err()); + } + } + + #[tokio::test] + async fn chunk_lines_and_trailers_are_bounded() { + for wire in [ + format!("1;{}", "x".repeat(MAX_HEADER_BYTES)), + format!("0\r\nX-Trailer: {}", "x".repeat(MAX_HEADER_BYTES)), + ] { + assert!(read_chunked_body(&mut &b""[..], wire.into_bytes()) + .await + .is_err()); + } + } + + #[tokio::test] + async fn cumulative_chunk_framing_is_bounded() { + let chunk = format!("1;{}\r\nx\r\n", "x".repeat(MAX_HEADER_BYTES / 2)); + let wire = format!("{chunk}{chunk}{chunk}0\r\n\r\n"); + assert!(read_chunked_body(&mut wire.as_bytes(), Vec::new()) + .await + .is_err()); + } + + #[tokio::test] + async fn complete_chunked_request_is_preserved() { + let mut wire = + &b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}\r\n0\r\n\r\n"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn many_small_chunks_are_accepted() { + // The minimum legal data chunk carries five framing bytes per byte + // of content. Fragmentation alone must not exhaust the extension cap. + let wire = format!("{}0\r\n\r\n", "1\r\nx\r\n".repeat(20_000)); + let body = read_chunked_body(&mut wire.as_bytes(), Vec::new()) + .await + .unwrap(); + assert_eq!(body, vec![b'x'; 20_000]); + } + + #[tokio::test] + async fn complete_chunks_extensions_and_trailers_are_decoded() { + let wire = b"2;part=first\r\nab\r\n2\r\ncd\r\n0\r\nX-Trailer: ok\r\n\r\n"; + let body = read_chunked_body(&mut wire.as_slice(), Vec::new()) + .await + .unwrap(); + assert_eq!(body, b"abcd"); + } +} + +#[cfg(test)] +mod percent_decode_tests { + use super::*; + + #[test] + fn malformed_percent_escapes_next_to_unicode_do_not_panic() { + for value in ["%あ", "%aあ", "猫%猫", "%é", "%💾", "%", "%2", "%zz"] { + assert_eq!(url_decode(value), value); + } + } + + #[test] + fn valid_percent_escapes_and_plus_are_preserved() { + assert_eq!(url_decode("%E7%8C%AB+%2B%2F"), "猫 +/"); + } +} diff --git a/services/gcs/tests/conditional_writes.rs b/services/gcs/tests/conditional_writes.rs new file mode 100644 index 0000000..bae8638 --- /dev/null +++ b/services/gcs/tests/conditional_writes.rs @@ -0,0 +1,338 @@ +//! Conditional GCS mutations against the object state shared with S3. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use devcloud_gcs::http::{route, Config, Request, Response, Server}; +use devcloud_s3::model::Object; +use devcloud_s3::objops::{PutObjectInput, UpdateObjectMetadataInput}; +use devcloud_s3::store::FileBucketStore; + +static NEXT_ID: AtomicU64 = AtomicU64::new(0); + +struct Fixture { + root: PathBuf, + gcs: Server, + s3: FileBucketStore, +} + +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "devcloud-gcs-conditional-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT_ID.fetch_add(1, Ordering::Relaxed), + )); + let mut gcs_store = FileBucketStore::new(root.join("buckets")); + gcs_store.set_fixed_now("2026-10-12T02:00:00Z"); + let mut gcs = Server::new( + Config { + upload_session_path: root.join("sessions").to_string_lossy().into_owned(), + ..Default::default() + }, + gcs_store, + ); + let created = perform( + &mut gcs, + "POST", + "/storage/v1/b?project=devcloud", + br#"{"name":"demo-bucket"}"#, + ); + assert_eq!(created.status, 200); + let mut s3 = FileBucketStore::new(root.join("buckets")); + s3.set_fixed_now("2026-10-12T02:00:01Z"); + Self { root, gcs, s3 } + } + + fn s3_put(&self, key: &str, body: &[u8]) -> Object { + self.s3 + .put_object(PutObjectInput { + bucket: "demo-bucket".to_string(), + key: key.to_string(), + body: body.to_vec(), + content_type: "application/s3".to_string(), + metadata: BTreeMap::from([("writer".to_string(), "s3".to_string())]), + ..Default::default() + }) + .unwrap() + } + + fn object(&self, key: &str) -> (Object, Vec) { + self.s3.get_object("demo-bucket", key).unwrap().unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn perform(server: &mut Server, method: &str, target: &str, body: &[u8]) -> Response { + let mut request = Request::new(method, target, body.to_vec()); + request + .headers + .insert("content-type".to_string(), "application/json".to_string()); + route(server, &request) +} + +fn json(response: &Response) -> serde_json::Value { + serde_json::from_slice(&response.body).unwrap() +} + +#[test] +fn upload_copy_rewrite_and_compose_preserve_an_existing_s3_object() { + let mut fixture = Fixture::new(); + fixture.s3_put("source", b"source data"); + + for operation in ["media", "multipart", "copyTo", "rewriteTo", "compose"] { + let key = format!("destination-{operation}"); + let expected = fixture.s3_put(&key, b"s3 winner"); + let (target, body) = match operation { + "media" => ( + format!("/upload/storage/v1/b/demo-bucket/o?uploadType=media&name={key}&ifGenerationMatch=0"), + b"source data".to_vec(), + ), + "multipart" => ( + format!("/upload/storage/v1/b/demo-bucket/o?uploadType=multipart&name={key}&ifGenerationMatch=0"), + concat!( + "--boundary\r\nContent-Type: application/json\r\n\r\n{}\r\n", + "--boundary\r\nContent-Type: text/plain\r\n\r\nsource data\r\n", + "--boundary--\r\n", + ).as_bytes().to_vec(), + ), + "compose" => ( + format!("/storage/v1/b/demo-bucket/o/{key}/compose?ifGenerationMatch=0"), + br#"{"sourceObjects":[{"name":"source"}]}"#.to_vec(), + ), + _ => ( + format!("/storage/v1/b/demo-bucket/o/source/{operation}/b/demo-bucket/o/{key}?ifGenerationMatch=0"), + b"{}".to_vec(), + ), + }; + let mut request = Request::new("POST", &target, body); + request.headers.insert( + "content-type".to_string(), + if operation == "multipart" { + "multipart/related; boundary=boundary" + } else { + "application/json" + } + .to_string(), + ); + let response = route(&mut fixture.gcs, &request); + assert_eq!(response.status, 412, "{operation}: {:?}", response); + assert_eq!( + fixture.object(&key), + (expected, b"s3 winner".to_vec()), + "{operation}" + ); + + // A matching condition must still permit the same operation. + let current = perform( + &mut fixture.gcs, + "GET", + &format!("/storage/v1/b/demo-bucket/o/{key}"), + b"", + ); + request.query.insert( + "ifGenerationMatch".to_string(), + json(¤t)["generation"].as_str().unwrap().to_string(), + ); + let allowed = route(&mut fixture.gcs, &request); + assert_eq!(allowed.status, 200, "{operation}: {:?}", allowed); + assert_eq!(fixture.object(&key).1, b"source data", "{operation}"); + } +} + +#[test] +fn metadata_and_delete_conditions_preserve_a_newer_s3_generation() { + let mut fixture = Fixture::new(); + let original = perform( + &mut fixture.gcs, + "POST", + "/upload/storage/v1/b/demo-bucket/o?uploadType=media&name=target", + b"original", + ); + assert_eq!(original.status, 200); + let old_generation = json(&original)["generation"].as_str().unwrap().to_string(); + fixture.s3_put("target", b"s3 winner"); + let expected = fixture + .s3 + .update_object_metadata(UpdateObjectMetadataInput { + bucket: "demo-bucket".to_string(), + key: "target".to_string(), + content_type: "application/s3-new".to_string(), + ..Default::default() + }) + .unwrap() + .unwrap(); + assert_eq!(expected.metageneration, 2); + let current = perform( + &mut fixture.gcs, + "GET", + "/storage/v1/b/demo-bucket/o/target", + b"", + ); + let new_generation = json(¤t)["generation"].as_str().unwrap().to_string(); + assert_ne!(old_generation, new_generation); + + for method in ["PATCH", "DELETE"] { + for (query, status) in [ + (format!("generation={old_generation}"), 404), + (format!("ifGenerationMatch={old_generation}"), 412), + ("ifMetagenerationMatch=1".to_string(), 412), + ( + format!("generation={new_generation}&ifMetagenerationMatch=1"), + 412, + ), + ("generation=bad".to_string(), 400), + ( + format!("generation={new_generation}&ifMetagenerationMatch=bad"), + 400, + ), + ] { + let response = perform( + &mut fixture.gcs, + method, + &format!("/storage/v1/b/demo-bucket/o/target?{query}"), + br#"{"contentType":"application/stale","metadata":{"writer":"stale"}}"#, + ); + assert_eq!(response.status, status, "{method} {query}: {:?}", response); + assert_eq!( + fixture.object("target"), + (expected.clone(), b"s3 winner".to_vec()) + ); + } + } + + let patched = perform( + &mut fixture.gcs, + "PATCH", + &format!("/storage/v1/b/demo-bucket/o/target?generation={new_generation}&ifMetagenerationMatch=2"), + br#"{"contentType":"application/allowed"}"#, + ); + assert_eq!(patched.status, 200); + assert_eq!(json(&patched)["generation"], new_generation); + assert_eq!(json(&patched)["metageneration"], "3"); + assert_eq!(fixture.object("target").1, b"s3 winner"); + let deleted = perform( + &mut fixture.gcs, + "DELETE", + &format!("/storage/v1/b/demo-bucket/o/target?generation={new_generation}&ifMetagenerationMatch=3"), + b"", + ); + assert_eq!(deleted.status, 204); + assert!(fixture + .s3 + .get_object("demo-bucket", "target") + .unwrap() + .is_none()); +} + +#[test] +fn rejected_resumable_commit_preserves_the_checkpoint_and_can_be_retried() { + let mut fixture = Fixture::new(); + let started = perform( + &mut fixture.gcs, + "POST", + "/upload/storage/v1/b/demo-bucket/o?uploadType=resumable&name=target&ifGenerationMatch=0", + b"{}", + ); + assert_eq!(started.status, 200); + let target = started.headers["Location"].clone(); + let id = started.headers["X-GUploader-UploadID"].clone(); + let mut chunk = Request::new("PUT", &target, b"first".to_vec()); + chunk + .headers + .insert("content-range".to_string(), "bytes 0-4/10".to_string()); + assert_eq!(route(&mut fixture.gcs, &chunk).status, 308); + + let expected = fixture.s3_put("target", b"s3 winner"); + chunk.body = b"last!".to_vec(); + chunk + .headers + .insert("content-range".to_string(), "bytes 5-9/10".to_string()); + let rejected = route(&mut fixture.gcs, &chunk); + assert_eq!(rejected.status, 412, "{rejected:?}"); + assert_eq!(fixture.object("target"), (expected, b"s3 winner".to_vec())); + let session_dir = fixture.root.join("sessions").join(id); + assert_eq!(fs::read(session_dir.join("body.part")).unwrap(), b"first"); + let checkpoint: serde_json::Value = + serde_json::from_slice(&fs::read(session_dir.join("session.json")).unwrap()).unwrap(); + assert_eq!(checkpoint["ReceivedBytes"], 5); + let mut progress = Request::new("PUT", &target, Vec::new()); + progress + .headers + .insert("content-range".to_string(), "bytes */10".to_string()); + let progress = route(&mut fixture.gcs, &progress); + assert_eq!(progress.status, 308); + assert_eq!(progress.headers["Range"], "bytes=0-4"); + + fixture.s3.delete_object("demo-bucket", "target").unwrap(); + let committed = route(&mut fixture.gcs, &chunk); + assert_eq!(committed.status, 200, "{committed:?}"); + assert_eq!(fixture.object("target").1, b"firstlast!"); + assert!(!session_dir.exists()); + let replay = route(&mut fixture.gcs, &chunk); + assert_eq!(replay.status, 200); + assert_eq!(replay.body, committed.body); +} + +#[test] +fn self_copy_rewrite_and_compose_apply_source_and_destination_conditions() { + let mut fixture = Fixture::new(); + for operation in ["copyTo", "rewriteTo", "compose"] { + let key = format!("self-{operation}"); + fixture.s3_put(&key, b"self data"); + let current = perform( + &mut fixture.gcs, + "GET", + &format!("/storage/v1/b/demo-bucket/o/{key}"), + b"", + ); + let generation = json(¤t)["generation"].as_str().unwrap().to_string(); + let (target, body) = if operation == "compose" { + ( + format!("/storage/v1/b/demo-bucket/o/{key}/compose?ifGenerationMatch={generation}"), + format!( + r#"{{"sourceObjects":[{{"name":"{key}","generation":"{generation}","objectPreconditions":{{"ifGenerationMatch":"{generation}"}}}}]}}"# + ), + ) + } else { + ( + format!("/storage/v1/b/demo-bucket/o/{key}/{operation}/b/demo-bucket/o/{key}?ifGenerationMatch={generation}&ifSourceGenerationMatch={generation}&ifSourceMetagenerationMatch=1"), + "{}".to_string(), + ) + }; + let response = perform(&mut fixture.gcs, "POST", &target, body.as_bytes()); + assert_eq!(response.status, 200, "{operation}: {response:?}"); + assert_eq!(fixture.object(&key).1, b"self data"); + + // The prior source generation is now stale even if the destination + // condition matches the replacement's current generation. + let replaced = fixture.object(&key); + let current = perform( + &mut fixture.gcs, + "GET", + &format!("/storage/v1/b/demo-bucket/o/{key}"), + b"", + ); + let new_generation = json(¤t)["generation"].as_str().unwrap().to_string(); + assert_ne!(generation, new_generation); + let mut stale = Request::new("POST", &target, body.into_bytes()); + stale + .query + .insert("ifGenerationMatch".to_string(), new_generation); + let rejected = route(&mut fixture.gcs, &stale); + assert_eq!(rejected.status, 412, "{operation}: {rejected:?}"); + assert_eq!(fixture.object(&key), replaced); + } +} diff --git a/services/gcs/tests/http_parity.rs b/services/gcs/tests/http_parity.rs index 989e959..7c26700 100644 --- a/services/gcs/tests/http_parity.rs +++ b/services/gcs/tests/http_parity.rs @@ -1414,6 +1414,629 @@ fn resumable_upload_rolls_back_final_chunk_after_commit_failure() { ); } +/// A failed metadata write must not leave uncommitted chunk bytes behind: +/// retries use the same offset and must produce exactly one copy of the body. +#[test] +fn resumable_upload_rolls_back_chunk_when_session_metadata_cannot_be_saved() { + let root = temp_root("resumable-session-write-rollback"); + let sessions = root.join("sessions"); + let buckets = root.join("buckets"); + let mut s = Server::new( + Config { + upload_session_path: sessions.to_string_lossy().into_owned(), + ..Default::default() + }, + FileBucketStore::new(&buckets), + ); + create_bucket(&mut s, "demo-bucket"); + let init = perform( + &mut s, + "POST", + "/upload/storage/v1/b/demo-bucket/o?uploadType=resumable&name=retry.txt", + "", + ); + assert_eq!(init.status, 200, "{}", body_str(&init)); + let target = session_target(&init); + let session_dir = sessions.join(header(&init, "X-GUploader-UploadID")); + let metadata = session_dir.join("session.json"); + let backup = session_dir.join("session.backup"); + fs::rename(&metadata, &backup).unwrap(); + fs::create_dir(&metadata).unwrap(); + + let failed = perform_h( + &mut s, + "PUT", + &target, + "first", + &[("Content-Range", "bytes 0-4/10")], + ); + assert_eq!(failed.status, 500, "{}", body_str(&failed)); + assert_eq!( + fs::metadata(session_dir.join("body.part")).unwrap().len(), + 0 + ); + + fs::remove_dir(&metadata).unwrap(); + fs::rename(&backup, &metadata).unwrap(); + drop(s); + let mut restarted = Server::new( + Config { + upload_session_path: sessions.to_string_lossy().into_owned(), + ..Default::default() + }, + FileBucketStore::new(&buckets), + ); + let retried = perform_h( + &mut restarted, + "PUT", + &target, + "first", + &[("Content-Range", "bytes 0-4/10")], + ); + assert_eq!(retried.status, 308, "{}", body_str(&retried)); + let committed = perform_h( + &mut restarted, + "PUT", + &target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(committed.status, 200, "{}", body_str(&committed)); + let downloaded = perform( + &mut restarted, + "GET", + "/download/storage/v1/b/demo-bucket/o/retry.txt?alt=media", + "", + ); + assert_eq!(downloaded.body, b"firstlast!"); + fs::remove_dir_all(root).unwrap(); +} + +/// The final offset must stay uncommitted until the object exists. Otherwise +/// a crash in between leaves a full session that cannot accept its final retry. +#[test] +fn final_chunk_does_not_commit_session_offset_before_object() { + let root = temp_root("resumable-final-offset"); + let sessions = root.join("sessions"); + let buckets = root.join("buckets"); + let mut s = Server::new( + Config { + upload_session_path: sessions.to_string_lossy().into_owned(), + ..Default::default() + }, + FileBucketStore::new(&buckets), + ); + create_bucket(&mut s, "demo-bucket"); + let init = perform( + &mut s, + "POST", + "/upload/storage/v1/b/demo-bucket/o?uploadType=resumable&name=final.txt", + "", + ); + assert_eq!(init.status, 200, "{}", body_str(&init)); + let target = session_target(&init); + let chunk = perform_h( + &mut s, + "PUT", + &target, + "first", + &[("Content-Range", "bytes 0-4/10")], + ); + assert_eq!(chunk.status, 308, "{}", body_str(&chunk)); + let session_dir = sessions.join(header(&init, "X-GUploader-UploadID")); + let metadata: serde_json::Value = + serde_json::from_slice(&fs::read(session_dir.join("session.json")).unwrap()).unwrap(); + assert_eq!(metadata["ReceivedBytes"], 5); + // Finalization must proceed directly to the object store, without first + // publishing the final offset to the session checkpoint. + fs::create_dir(session_dir.join("session.json.tmp")).unwrap(); + let committed = perform_h( + &mut s, + "PUT", + &target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(committed.status, 200, "{}", body_str(&committed)); + let downloaded = perform( + &mut s, + "GET", + "/download/storage/v1/b/demo-bucket/o/final.txt?alt=media", + "", + ); + assert_eq!(downloaded.body, b"firstlast!"); + assert!(!session_dir.exists()); + fs::remove_dir_all(root).unwrap(); +} + +struct CommittedUpload { + root: std::path::PathBuf, + config: Config, + target: String, + id: String, + key: String, + response: Response, +} + +impl CommittedUpload { + fn store(&self) -> FileBucketStore { + let mut store = FileBucketStore::new(self.root.join("buckets")); + // A second write must get a visibly different generation; no sleeps + // or dependence on the resolution of the wall clock are needed. + store.set_fixed_now("2026-10-12T02:00:01Z"); + store + } + + fn restart(&self) -> Server { + Server::new(self.config.clone(), self.store()) + } + + fn receipt_path(&self) -> std::path::PathBuf { + self.root + .join("buckets") + .join(format!(".gcs-upload-{}.json", self.id)) + } +} + +/// Reconstruct the persisted boundary after the object commit but before +/// either the completion receipt or session cleanup. The object comes from +/// the real upload handler, including any commit identity it writes. +fn committed_upload_before_cleanup( + key: &str, + create_only: bool, + versioned: bool, +) -> CommittedUpload { + let root = temp_root("resumable-committed-retry"); + let sessions = root.join("sessions"); + let config = Config { + upload_session_path: sessions.to_string_lossy().into_owned(), + ..Default::default() + }; + let mut store = FileBucketStore::new(root.join("buckets")); + store.set_fixed_now("2026-10-12T02:00:00Z"); + let mut server = Server::new(config.clone(), store); + create_bucket(&mut server, "demo-bucket"); + if versioned { + FileBucketStore::new(root.join("buckets")) + .put_bucket_versioning("demo-bucket", "Enabled") + .unwrap(); + } + let precondition = if create_only { + "&ifGenerationMatch=0" + } else { + "" + }; + let init = perform( + &mut server, + "POST", + &format!( + "/upload/storage/v1/b/demo-bucket/o?uploadType=resumable&name={key}{precondition}" + ), + "", + ); + assert_eq!(init.status, 200, "{}", body_str(&init)); + let id = header(&init, "X-GUploader-UploadID").to_string(); + let target = session_target(&init); + let first = perform_h( + &mut server, + "PUT", + &target, + "first", + &[("Content-Range", "bytes 0-4/10")], + ); + assert_eq!(first.status, 308, "{}", body_str(&first)); + let session_dir = sessions.join(&id); + let pending_metadata = fs::read(session_dir.join("session.json")).unwrap(); + let response = perform_h( + &mut server, + "PUT", + &target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(response.status, 200, "{}", body_str(&response)); + fs::create_dir_all(&session_dir).unwrap(); + fs::write(session_dir.join("session.json"), pending_metadata).unwrap(); + fs::write(session_dir.join("body.part"), b"firstlast!").unwrap(); + let fixture = CommittedUpload { + root, + config, + target, + id, + key: key.to_string(), + response, + }; + match fs::remove_file(fixture.receipt_path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("remove completion receipt: {error}"), + } + fixture +} + +#[test] +fn resumable_upload_replays_committed_result_without_new_generation_or_event() { + let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); + devcloud_gcs::set_event_sink(tx); + for create_only in [false, true] { + let key = format!("committed-retry-{create_only}.txt"); + let upload = committed_upload_before_cleanup(&key, create_only, true); + let mut event_count = 0; + while let Ok(event) = events.try_recv() { + let event: serde_json::Value = serde_json::from_str(&event).unwrap(); + if event["type"] == "gcs.object.put" && event["payload"]["key"] == key { + event_count += 1; + } + } + assert_eq!(event_count, 1); + let mut restarted = upload.restart(); + // A status query must also recognize the completed upload, before + // re-evaluating a create-only precondition against its own object. + let status = perform_h( + &mut restarted, + "PUT", + &upload.target, + "", + &[("Content-Range", "bytes */10")], + ); + assert_eq!(status.status, 200, "{}", body_str(&status)); + assert_eq!(status.body, upload.response.body); + let retry = perform_h( + &mut restarted, + "PUT", + &upload.target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(retry.status, 200, "{}", body_str(&retry)); + assert_eq!(retry.body, upload.response.body); + assert!(!body_str(&retry).contains("_resumableUploadId")); + let (object, body) = upload + .store() + .get_object("demo-bucket", &key) + .unwrap() + .unwrap(); + assert_eq!(body, b"firstlast!"); + assert_eq!(object.last_modified, "2026-10-12T02:00:00Z"); + let versions = upload + .store() + .list_object_versions("demo-bucket", &key) + .unwrap() + .unwrap(); + assert_eq!(versions.len(), 1); + while let Ok(event) = events.try_recv() { + let event: serde_json::Value = serde_json::from_str(&event).unwrap(); + assert!(event["type"] != "gcs.object.put" || event["payload"]["key"] != key); + } + fs::remove_dir_all(upload.root).unwrap(); + } + + // The process continues through post-commit I/O errors. Recovery must emit + // the previously unannounced completion once, then suppress further retries. + for failure in ["version", "notification"] { + let root = temp_root("resumable-completion-io"); + let config = Config { + upload_session_path: root.join("sessions").to_string_lossy().into_owned(), + ..Default::default() + }; + let store = FileBucketStore::new(root.join("buckets")); + let mut server = Server::new(config, FileBucketStore::new(root.join("buckets"))); + create_bucket(&mut server, "demo-bucket"); + store + .put_bucket_versioning("demo-bucket", "Enabled") + .unwrap(); + let key = format!("completion-io-{failure}.txt"); + let init = perform( + &mut server, + "POST", + &format!("/upload/storage/v1/b/demo-bucket/o?uploadType=resumable&name={key}&ifGenerationMatch=0"), + "", + ); + assert_eq!(init.status, 200, "{}", body_str(&init)); + let target = session_target(&init); + let id = header(&init, "X-GUploader-UploadID"); + let blocker = if failure == "version" { + let path = store.object_path("demo-bucket", &key); + fs::create_dir_all(&path).unwrap(); + fs::write(path.join("versions"), b"blocked").unwrap(); + path.join("versions") + } else { + let path = root + .join("buckets") + .join(format!(".gcs-upload-{id}.notified")); + fs::create_dir(&path).unwrap(); + path + }; + let failed = perform_h( + &mut server, + "PUT", + &target, + "body", + &[("Content-Range", "bytes 0-3/4")], + ); + assert!(failed.status >= 400, "{failure}"); + let (committed, body) = store.get_object("demo-bucket", &key).unwrap().unwrap(); + assert_eq!(body, b"body"); + while let Ok(event) = events.try_recv() { + let event: serde_json::Value = serde_json::from_str(&event).unwrap(); + assert!(event["type"] != "gcs.object.put" || event["payload"]["key"] != key); + } + if failure == "version" { + fs::remove_file(blocker).unwrap(); + } else { + fs::remove_dir(blocker).unwrap(); + } + let recovered = perform_h( + &mut server, + "PUT", + &target, + "body", + &[("Content-Range", "bytes 0-3/4")], + ); + assert_eq!(recovered.status, 200, "{}", body_str(&recovered)); + let (after, body) = store.get_object("demo-bucket", &key).unwrap().unwrap(); + assert_eq!(after, committed); + assert_eq!(body, b"body"); + let (_, version_body) = store + .get_object_version("demo-bucket", &key, &after.version_id) + .unwrap() + .unwrap(); + assert_eq!(version_body, b"body"); + let repeated = perform_h( + &mut server, + "PUT", + &target, + "body", + &[("Content-Range", "bytes 0-3/4")], + ); + assert_eq!(repeated.status, 200); + assert_eq!(repeated.body, recovered.body); + let mut notifications = 0; + while let Ok(event) = events.try_recv() { + let event: serde_json::Value = serde_json::from_str(&event).unwrap(); + if event["type"] == "gcs.object.put" && event["payload"]["key"] == key { + notifications += 1; + } + } + assert_eq!(notifications, 1, "{failure}"); + fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn resumable_upload_completion_survives_later_object_changes() { + use devcloud_s3::objops::{PutObjectInput, UpdateObjectMetadataInput}; + + for change in ["overwrite", "metadata", "delete", "delete-version"] { + let upload = committed_upload_before_cleanup( + &format!("committed-before-{change}.txt"), + true, + change == "delete-version", + ); + let store = upload.store(); + match change { + "overwrite" => { + store + .put_object(PutObjectInput { + bucket: "demo-bucket".to_string(), + key: upload.key.clone(), + body: b"newer contents".to_vec(), + ..Default::default() + }) + .unwrap(); + } + "metadata" => { + store + .update_object_metadata(UpdateObjectMetadataInput { + bucket: "demo-bucket".to_string(), + key: upload.key.clone(), + content_type: "text/plain".to_string(), + ..Default::default() + }) + .unwrap(); + } + "delete" => { + store.delete_object("demo-bucket", &upload.key).unwrap(); + store.delete_bucket("demo-bucket").unwrap(); + } + "delete-version" => { + let (object, _) = store + .get_object("demo-bucket", &upload.key) + .unwrap() + .unwrap(); + store + .delete_object_version("demo-bucket", &upload.key, &object.version_id, false) + .unwrap(); + } + _ => unreachable!(), + } + let before_retry = if change == "delete" { + assert!(matches!( + store.get_object("demo-bucket", &upload.key), + Err(devcloud_s3::store::StoreError::BucketNotExist) + )); + None + } else { + let found = store.get_object("demo-bucket", &upload.key).unwrap(); + match change { + "overwrite" => { + let (object, body) = found.as_ref().unwrap(); + assert_eq!(body, b"newer contents"); + assert_eq!(object.last_modified, "2026-10-12T02:00:01Z"); + assert!(object.resumable_upload_id.is_empty()); + } + "metadata" => { + let (object, body) = found.as_ref().unwrap(); + assert_eq!(body, b"firstlast!"); + assert_eq!(object.content_type, "text/plain"); + assert_eq!(object.metageneration, 2); + } + "delete-version" => assert!(found.is_none()), + _ => unreachable!(), + } + found + }; + let mut restarted = upload.restart(); + let retry = perform_h( + &mut restarted, + "PUT", + &upload.target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(retry.status, 200, "{change}: {}", body_str(&retry)); + assert_eq!(retry.body, upload.response.body, "{change}"); + if change == "delete" { + assert!(matches!( + store.get_object("demo-bucket", &upload.key), + Err(devcloud_s3::store::StoreError::BucketNotExist) + )); + } else { + assert_eq!( + store.get_object("demo-bucket", &upload.key).unwrap(), + before_retry, + "{change}" + ); + } + // A receipt in the root must never appear as a bucket or prevent a + // bucket whose objects have been deleted from being removed. + assert!(store + .list_buckets() + .unwrap() + .iter() + .all(|bucket| bucket.name == "demo-bucket")); + fs::remove_dir_all(upload.root).unwrap(); + } +} + +#[test] +fn resumable_upload_replays_create_only_commit_before_cleanup() { + let upload = committed_upload_before_cleanup("committed-create-only.txt", true, false); + let mut restarted = upload.restart(); + let retry = perform_h( + &mut restarted, + "PUT", + &upload.target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(retry.status, 200, "{}", body_str(&retry)); + assert_eq!(retry.body, upload.response.body); + fs::remove_dir_all(upload.root).unwrap(); +} + +/// A crash between appending bytes and recording their offset leaves a tail +/// outside the committed range. Recover from that persisted offset on retry. +#[test] +fn resumable_upload_discards_uncommitted_tail_after_restart() { + let root = temp_root("resumable-uncommitted-tail"); + let sessions = root.join("sessions"); + let buckets = root.join("buckets"); + let config = Config { + upload_session_path: sessions.to_string_lossy().into_owned(), + ..Default::default() + }; + let mut s = Server::new(config.clone(), FileBucketStore::new(&buckets)); + create_bucket(&mut s, "demo-bucket"); + let init = perform( + &mut s, + "POST", + "/upload/storage/v1/b/demo-bucket/o?uploadType=resumable&name=recovered.txt", + "", + ); + assert_eq!(init.status, 200, "{}", body_str(&init)); + let target = session_target(&init); + let chunk = perform_h( + &mut s, + "PUT", + &target, + "first", + &[("Content-Range", "bytes 0-4/10")], + ); + assert_eq!(chunk.status, 308, "{}", body_str(&chunk)); + let session_dir = sessions.join(header(&init, "X-GUploader-UploadID")); + // Simulate stopping after the complete final chunk was appended but + // before the object was committed. The last acknowledged offset is five. + fs::write(session_dir.join("body.part"), b"firstlast!").unwrap(); + let metadata: serde_json::Value = + serde_json::from_slice(&fs::read(session_dir.join("session.json")).unwrap()).unwrap(); + assert_eq!(metadata["ReceivedBytes"], 5); + drop(s); + + let mut restarted = Server::new(config, FileBucketStore::new(&buckets)); + let committed = perform_h( + &mut restarted, + "PUT", + &target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(committed.status, 200, "{}", body_str(&committed)); + let downloaded = perform( + &mut restarted, + "GET", + "/download/storage/v1/b/demo-bucket/o/recovered.txt?alt=media", + "", + ); + assert_eq!(downloaded.body, b"firstlast!"); + fs::remove_dir_all(root).unwrap(); +} + +/// Missing committed bytes are corruption, not room to pad the file with +/// zeroes. Reject the next chunk and preserve the persisted offset and data. +#[test] +fn resumable_upload_rejects_missing_committed_bytes_without_padding() { + let root = temp_root("resumable-missing-bytes"); + let sessions = root.join("sessions"); + let buckets = root.join("buckets"); + let mut s = Server::new( + Config { + upload_session_path: sessions.to_string_lossy().into_owned(), + ..Default::default() + }, + FileBucketStore::new(&buckets), + ); + create_bucket(&mut s, "demo-bucket"); + let init = perform( + &mut s, + "POST", + "/upload/storage/v1/b/demo-bucket/o?uploadType=resumable&name=truncated.txt", + "", + ); + assert_eq!(init.status, 200, "{}", body_str(&init)); + let target = session_target(&init); + let chunk = perform_h( + &mut s, + "PUT", + &target, + "first", + &[("Content-Range", "bytes 0-4/10")], + ); + assert_eq!(chunk.status, 308, "{}", body_str(&chunk)); + let session_dir = sessions.join(header(&init, "X-GUploader-UploadID")); + fs::write(session_dir.join("body.part"), b"fir").unwrap(); + let metadata = fs::read(session_dir.join("session.json")).unwrap(); + + let failed = perform_h( + &mut s, + "PUT", + &target, + "last!", + &[("Content-Range", "bytes 5-9/10")], + ); + assert_eq!(failed.status, 500, "{}", body_str(&failed)); + assert_eq!(fs::read(session_dir.join("body.part")).unwrap(), b"fir"); + assert_eq!( + fs::read(session_dir.join("session.json")).unwrap(), + metadata + ); + assert!(FileBucketStore::new(&buckets) + .get_object("demo-bucket", "truncated.txt") + .unwrap() + .is_none()); + fs::remove_dir_all(root).unwrap(); +} + /// legacy: TestResumableUploadRejectsMalformedCommitRequests #[test] fn resumable_upload_rejects_malformed_commit_requests() { diff --git a/services/lambda/src/body.rs b/services/lambda/src/body.rs index 928de36..3bdfe36 100644 --- a/services/lambda/src/body.rs +++ b/services/lambda/src/body.rs @@ -95,11 +95,15 @@ impl Input<'_, R> { const MAX_LINE: usize = 8 * 1024; loop { if let Some(i) = self.buf[self.pos..].windows(2).position(|w| w == b"\r\n") { + if i > MAX_LINE { + return Ok(Err(BodyError::Malformed("chunk line too long"))); + } let line = String::from_utf8_lossy(&self.buf[self.pos..self.pos + i]).into_owned(); self.pos += i + 2; return Ok(Ok(line)); } - if self.buf.len() - self.pos > MAX_LINE { + // A maximum-length line may have received its CR but not its LF. + if self.buf.len() - self.pos > MAX_LINE + 1 { return Ok(Err(BodyError::Malformed("chunk line too long"))); } let pending = self.buf.len() - self.pos + 1; @@ -214,6 +218,42 @@ mod tests { assert_eq!(body.unwrap(), b"{\"value\":1} "); } + #[tokio::test] + async fn chunk_line_limits_do_not_depend_on_read_boundaries() { + let te = [("transfer-encoding", "chunked")]; + let valid = format!("1;{}\r\nx\r\n0\r\n\r\n", "a".repeat(8190)); + for split in [0, 8193, valid.len()] { + assert_eq!( + read( + &valid.as_bytes()[..split], + &valid.as_bytes()[split..], + &te, + 1 + ) + .await + .unwrap(), + b"x" + ); + } + for invalid in [ + format!("1;{}\r\nx\r\n0\r\n\r\n", "a".repeat(8191)), + format!("0\r\nX: {}\r\n\r\n", "a".repeat(8190)), + ] { + for split in [0, 8192, invalid.len()] { + assert_eq!( + read( + &invalid.as_bytes()[..split], + &invalid.as_bytes()[split..], + &te, + 1, + ) + .await, + Err(BodyError::Malformed("chunk line too long")) + ); + } + } + } + #[tokio::test] async fn content_length_and_errors() { assert_eq!( diff --git a/services/mail/src/browser_guard.rs b/services/mail/src/browser_guard.rs new file mode 100644 index 0000000..51ba2ba --- /dev/null +++ b/services/mail/src/browser_guard.rs @@ -0,0 +1,181 @@ +//! Browser boundary for local introspection/control APIs. SDK/CLI requests +//! without Origin remain usable; browser mutations and rebinding hosts follow +//! the same policy as the dashboard. + +pub(crate) fn allowed(method: &str, host: &str, origin: &str, fetch_site: &str) -> bool { + if !trusted_host(host) { + return false; + } + if matches!(method, "GET" | "HEAD" | "OPTIONS") { + return true; + } + if origin.is_empty() { + return !fetch_site.eq_ignore_ascii_case("cross-site"); + } + let Some((scheme, authority)) = origin.split_once("://") else { + return false; + }; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return false; + } + let Some(name) = hostname(authority) else { + return false; + }; + authority.eq_ignore_ascii_case(host.trim()) + || name == "localhost" + || name == "127.0.0.1" + || name == "::1" + || name.ends_with(".localhost") +} + +fn hostname(authority: &str) -> Option { + let authority = authority.trim(); + if authority.is_empty() + || authority + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) + || authority.contains(['/', '\\', '@', '?', '#']) + { + return None; + } + let valid_port = |port: &str| { + !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) && port.parse::().is_ok() + }; + let name = if let Some(v6) = authority.strip_prefix('[') { + let (ip, suffix) = v6.split_once(']')?; + if ip.parse::().is_err() + || (!suffix.is_empty() && !suffix.strip_prefix(':').is_some_and(valid_port)) + { + return None; + } + ip + } else if let Some((name, port)) = authority.rsplit_once(':') { + if name.contains(':') || !valid_port(port) { + return None; + } + name + } else { + authority + }; + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-_:".contains(&b)) + { + return None; + } + Some(name.trim_end_matches('.').to_ascii_lowercase()) +} + +fn trusted_host(host: &str) -> bool { + // In-process requests and HTTP/1.0 clients need not send Host. + if host.trim().is_empty() { + return true; + } + let Some(name) = hostname(host) else { + return false; + }; + if name.parse::().is_ok() || !name.contains('.') { + return !name.is_empty(); + } + if [ + ".localhost", + ".internal", + ".local", + ".test", + ".example", + ".invalid", + ".home.arpa", + ] + .iter() + .any(|suffix| name.ends_with(suffix)) + { + return true; + } + allowed_hosts( + &std::env::var("DEVCLOUD_ALLOWED_HOSTS").unwrap_or_default(), + &name, + ) +} + +fn allowed_hosts(list: &str, name: &str) -> bool { + list.split(',') + .map(|entry| entry.trim().trim_end_matches('.').to_ascii_lowercase()) + .filter(|entry| !entry.is_empty()) + .any(|entry| { + entry == "*" + || entry == name + || (entry.starts_with('.') && (name.ends_with(&entry) || name == &entry[1..])) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_existing_local_clients_and_same_origin_access() { + for host in [ + "", + "localhost:16380", + "127.0.0.1:18026", + "[::1]:18026", + "redis:16380", + "host.docker.internal:16380", + "devcloud.local:18026", + "192.168.1.2:18026", + ] { + assert!(allowed("POST", host, "", "")); + } + assert!(allowed( + "POST", + "127.0.0.1:16380", + "http://localhost:18025", + "same-site" + )); + assert!(allowed( + "DELETE", + "devcloud.local:18026", + "http://devcloud.local:18026", + "same-origin" + )); + assert!(allowed( + "GET", + "127.0.0.1:18026", + "https://evil.com", + "cross-site" + )); + assert!(allowed_hosts( + "mail.acme.com,.dev.acme.com", + "api.dev.acme.com" + )); + assert!(allowed_hosts("mail.acme.com", "mail.acme.com")); + assert!(allowed_hosts("*", "any.acme.com")); + assert!(!allowed_hosts(".dev.acme.com", "notdev.acme.com")); + } + + #[test] + fn rejects_cross_site_mutations_rebinding_and_malformed_authorities() { + for method in ["POST", "DELETE", "PUT"] { + for origin in [ + "https://evil.com", + "null", + "http://localhost:123@evil.com", + "http://[::1]evil.com", + "file://localhost", + ] { + assert!(!allowed(method, "127.0.0.1:16380", origin, "cross-site")); + } + assert!(!allowed(method, "127.0.0.1:16380", "", "cross-site")); + } + for host in [ + "evil.com", + "[::1]evil.com", + "local/evil.com", + "local:abc", + "local:999999", + ] { + assert!(!allowed("GET", host, "", "same-origin")); + } + } +} diff --git a/services/mail/src/http.rs b/services/mail/src/http.rs index ca2ba70..240d232 100644 --- a/services/mail/src/http.rs +++ b/services/mail/src/http.rs @@ -26,7 +26,7 @@ use std::collections::HashMap; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::service::Service; @@ -200,6 +200,14 @@ async fn handle_conn( /// Top-level routing, mirroring `ServeHTTP`: intercept the two prefixes before /// anything else, run `verify_http` first, then 404 for everything else. fn dispatch(service: &Service, auth: &HttpAuth, req: &Request) -> Response { + if !crate::browser_guard::allowed( + &req.method, + req.header("host"), + req.header("origin"), + req.header("sec-fetch-site"), + ) { + return Response::text(403, "cross-site requests or untrusted Host are not allowed"); + } let path = if req.path.is_empty() { "/" } else { &req.path }; if path.starts_with(INTROSPECT_PREFIX) { if let Some(resp) = verify_http(auth, req) { @@ -376,11 +384,14 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { // --- HTTP/1.1 transport (hand-rolled, same pattern as redis-control/http.rs) --- -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut S) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -409,16 +420,28 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let name = k.trim().to_ascii_lowercase(); + if name == "content-length" && headers.contains_key(&name) { + return Ok(None); + } + headers.insert(name, v.trim().to_string()); } } // Drain any request body (none of the routes consume it) so a client that // sent a body does not see a truncated read; bounded by MAX_BODY_BYTES. - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + // These one-shot endpoints support fixed-length bodies only. Reject + // ambiguous framing before a mutation can see a partial or empty body. + if headers.contains_key("transfer-encoding") { + return Ok(None); + } + let content_length = match headers.get("content-length") { + None => 0, + Some(raw) => match raw.parse::() { + Ok(length) if raw.bytes().all(|b| b.is_ascii_digit()) => length, + _ => return Ok(None), + }, + }; if content_length > MAX_BODY_BYTES { return Ok(None); } @@ -426,7 +449,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while got < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } got += n; } @@ -564,6 +587,79 @@ fn base64_decode(input: &str) -> Option> { mod tests { use super::*; + #[test] + fn direct_mail_reads_and_deletes_refuse_rebinding_and_cross_site_requests() { + let service = Service::new(Arc::new(crate::store::RecordingStore::new())); + let auth = HttpAuth { + auth_mode: "relaxed".into(), + username: String::new(), + password: String::new(), + }; + for (method, path) in [ + ("GET", "/_introspect/messages"), + ("DELETE", "/_control/messages"), + ] { + let mut req = Request { + method: method.into(), + path: path.into(), + query: HashMap::new(), + headers: HashMap::new(), + }; + req.headers.insert("host".into(), "evil.com:18026".into()); + assert_eq!(dispatch(&service, &auth, &req).status, 403); + req.headers.insert("host".into(), "127.0.0.1:18026".into()); + assert_ne!(dispatch(&service, &auth, &req).status, 403); + if method == "DELETE" { + req.headers + .insert("origin".into(), "https://evil.com".into()); + assert_eq!(dispatch(&service, &auth, &req).status, 403); + } + } + } + + #[tokio::test] + async fn transport_rejects_incomplete_or_ambiguous_bodies() { + for raw in [ + "POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\nabc", + "POST / HTTP/1.1\r\nContent-Length: invalid\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: +3\r\n\r\nabc", + "POST / HTTP/1.1\r\nContent-Length: \r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 99999999999999999999999999\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 3\r\nContent-Length: 0\r\n\r\nabc", + "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + ] { + assert!(read_request(&mut raw.as_bytes()).await.unwrap().is_none()); + } + let oversized = format!( + "POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n", + MAX_BODY_BYTES + 1 + ); + assert!(read_request(&mut oversized.as_bytes()) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn transport_accepts_complete_bodies_and_enforces_the_header_boundary() { + for raw in [ + "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc", + "GET / HTTP/1.1\r\n\r\n", + ] { + assert!(read_request(&mut raw.as_bytes()).await.unwrap().is_some()); + } + // The terminator arrives in the read that crosses the cap. Checking + // only the buffer length before looking for it misses this case. + let mut oversized = b"GET / HTTP/1.1\r\nX-Large: ".to_vec(); + oversized.resize(MAX_HEADER_BYTES - 1, b'a'); + oversized.extend_from_slice(b"\r\n\r\n"); + assert!(read_request(&mut oversized.as_slice()) + .await + .unwrap() + .is_none()); + } + #[test] fn limit_defaults_then_parses() { assert_eq!(parse_limit(""), 100); diff --git a/services/mail/src/lib.rs b/services/mail/src/lib.rs index bfdf774..5b5c152 100644 --- a/services/mail/src/lib.rs +++ b/services/mail/src/lib.rs @@ -30,6 +30,7 @@ pub(crate) fn event_sink() -> Option<&'static UnboundedSender> { } pub mod blob; +mod browser_guard; pub mod http; pub mod model; pub mod parser; diff --git a/services/mailbox/src/main.rs b/services/mailbox/src/main.rs index 11225fb..6cb77cf 100644 --- a/services/mailbox/src/main.rs +++ b/services/mailbox/src/main.rs @@ -166,7 +166,17 @@ mod tests { super::install_panic_hook(); panic!("private-panic-payload-marker"); } - let output = std::process::Command::new(std::env::current_exe().unwrap()) + // Linux containers without /proc cannot resolve current_exe, but the + // test runner still provides the executable path in argv[0]. + let executable = std::env::current_exe() + .or_else(|err| { + std::env::args_os() + .next() + .map(std::path::PathBuf::from) + .ok_or(err) + }) + .unwrap(); + let output = std::process::Command::new(executable) .args(["--exact", "tests::panic_hook_omits_payload", "--nocapture"]) .env("MAILBOX_PANIC_HOOK_PROBE", "1") .output() diff --git a/services/pubsub/src/http.rs b/services/pubsub/src/http.rs index 18347b5..ddbfe01 100644 --- a/services/pubsub/src/http.rs +++ b/services/pubsub/src/http.rs @@ -10,7 +10,7 @@ use std::collections::BTreeMap; use std::sync::Mutex; use serde_json::Value; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::errors::ApiError; @@ -464,11 +464,14 @@ async fn handle_conn( write_response(&mut stream, response).await } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -491,13 +494,30 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = BTreeMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let key = k.trim().to_ascii_lowercase(); + if matches!(key.as_str(), "content-length" | "transfer-encoding") + && headers.contains_key(&key) + { + return Ok(None); + } + headers.insert(key, v.trim().to_string()); } } - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + // This transport supports fixed-length request bodies only. Never + // dispatch an unsupported transfer coding as an empty request body. + if headers.contains_key("transfer-encoding") { + return Ok(None); + } + let content_length: usize = match headers.get("content-length") { + None => 0, + Some(value) if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => { + match value.parse() { + Ok(length) => length, + Err(_) => return Ok(None), + } + } + Some(_) => return Ok(None), + }; if content_length > MAX_BODY_BYTES { return Ok(None); } @@ -505,7 +525,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -545,8 +565,11 @@ fn url_decode(value: &str) -> String { while i < bytes.len() { match bytes[i] { b'%' if i + 2 < bytes.len() => { - if let Ok(b) = u8::from_str_radix(&value[i + 1..i + 3], 16) { - out.push(b); + if let (Some(hi), Some(lo)) = ( + (bytes[i + 1] as char).to_digit(16), + (bytes[i + 2] as char).to_digit(16), + ) { + out.push((hi * 16 + lo) as u8); i += 3; continue; } @@ -609,3 +632,78 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { } haystack.windows(needle.len()).position(|w| w == needle) } + +#[cfg(test)] +mod request_framing_tests { + use super::*; + + #[tokio::test] + async fn incomplete_fixed_length_body_is_rejected() { + // A complete JSON value is still an incomplete HTTP message. It must + // never reach a mutating handler after the client disconnects early. + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 9\r\n\r\n{}"[..]; + assert!(read_request(&mut wire).await.unwrap().is_none()); + } + + #[tokio::test] + async fn invalid_content_length_is_rejected() { + for value in ["bad", "-1", "+2", "", "18446744073709551616"] { + let wire = format!("POST / HTTP/1.1\r\nContent-Length: {value}\r\n\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{value:?}" + ); + } + } + + #[tokio::test] + async fn ambiguous_or_unsupported_body_framing_is_rejected() { + for headers in [ + "Transfer-Encoding: gzip\r\n", + "Transfer-Encoding: xchunked\r\n", + "Transfer-Encoding: chunked, gzip\r\n", + "Content-Length: 9\r\nContent-Length: 2\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ] { + let wire = format!("POST / HTTP/1.1\r\n{headers}\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{headers:?}" + ); + } + } + + #[tokio::test] + async fn complete_fixed_length_body_is_preserved() { + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn oversized_header_is_rejected_even_when_terminator_arrives() { + let wire = format!( + "POST / HTTP/1.1\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ); + assert!(read_request(&mut wire.as_bytes()).await.unwrap().is_none()); + } +} + +#[cfg(test)] +mod percent_decode_tests { + use super::*; + + #[test] + fn malformed_percent_escapes_next_to_unicode_do_not_panic() { + for value in ["%あ", "%aあ", "猫%猫", "%é", "%💾", "%", "%2", "%zz"] { + assert_eq!(url_decode(value), value); + } + } + + #[test] + fn valid_percent_escapes_and_plus_are_preserved() { + assert_eq!(url_decode("%E7%8C%AB+%2B%2F"), "猫 +/"); + } +} diff --git a/services/redis-control/src/browser_guard.rs b/services/redis-control/src/browser_guard.rs new file mode 100644 index 0000000..51ba2ba --- /dev/null +++ b/services/redis-control/src/browser_guard.rs @@ -0,0 +1,181 @@ +//! Browser boundary for local introspection/control APIs. SDK/CLI requests +//! without Origin remain usable; browser mutations and rebinding hosts follow +//! the same policy as the dashboard. + +pub(crate) fn allowed(method: &str, host: &str, origin: &str, fetch_site: &str) -> bool { + if !trusted_host(host) { + return false; + } + if matches!(method, "GET" | "HEAD" | "OPTIONS") { + return true; + } + if origin.is_empty() { + return !fetch_site.eq_ignore_ascii_case("cross-site"); + } + let Some((scheme, authority)) = origin.split_once("://") else { + return false; + }; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return false; + } + let Some(name) = hostname(authority) else { + return false; + }; + authority.eq_ignore_ascii_case(host.trim()) + || name == "localhost" + || name == "127.0.0.1" + || name == "::1" + || name.ends_with(".localhost") +} + +fn hostname(authority: &str) -> Option { + let authority = authority.trim(); + if authority.is_empty() + || authority + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) + || authority.contains(['/', '\\', '@', '?', '#']) + { + return None; + } + let valid_port = |port: &str| { + !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) && port.parse::().is_ok() + }; + let name = if let Some(v6) = authority.strip_prefix('[') { + let (ip, suffix) = v6.split_once(']')?; + if ip.parse::().is_err() + || (!suffix.is_empty() && !suffix.strip_prefix(':').is_some_and(valid_port)) + { + return None; + } + ip + } else if let Some((name, port)) = authority.rsplit_once(':') { + if name.contains(':') || !valid_port(port) { + return None; + } + name + } else { + authority + }; + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-_:".contains(&b)) + { + return None; + } + Some(name.trim_end_matches('.').to_ascii_lowercase()) +} + +fn trusted_host(host: &str) -> bool { + // In-process requests and HTTP/1.0 clients need not send Host. + if host.trim().is_empty() { + return true; + } + let Some(name) = hostname(host) else { + return false; + }; + if name.parse::().is_ok() || !name.contains('.') { + return !name.is_empty(); + } + if [ + ".localhost", + ".internal", + ".local", + ".test", + ".example", + ".invalid", + ".home.arpa", + ] + .iter() + .any(|suffix| name.ends_with(suffix)) + { + return true; + } + allowed_hosts( + &std::env::var("DEVCLOUD_ALLOWED_HOSTS").unwrap_or_default(), + &name, + ) +} + +fn allowed_hosts(list: &str, name: &str) -> bool { + list.split(',') + .map(|entry| entry.trim().trim_end_matches('.').to_ascii_lowercase()) + .filter(|entry| !entry.is_empty()) + .any(|entry| { + entry == "*" + || entry == name + || (entry.starts_with('.') && (name.ends_with(&entry) || name == &entry[1..])) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_existing_local_clients_and_same_origin_access() { + for host in [ + "", + "localhost:16380", + "127.0.0.1:18026", + "[::1]:18026", + "redis:16380", + "host.docker.internal:16380", + "devcloud.local:18026", + "192.168.1.2:18026", + ] { + assert!(allowed("POST", host, "", "")); + } + assert!(allowed( + "POST", + "127.0.0.1:16380", + "http://localhost:18025", + "same-site" + )); + assert!(allowed( + "DELETE", + "devcloud.local:18026", + "http://devcloud.local:18026", + "same-origin" + )); + assert!(allowed( + "GET", + "127.0.0.1:18026", + "https://evil.com", + "cross-site" + )); + assert!(allowed_hosts( + "mail.acme.com,.dev.acme.com", + "api.dev.acme.com" + )); + assert!(allowed_hosts("mail.acme.com", "mail.acme.com")); + assert!(allowed_hosts("*", "any.acme.com")); + assert!(!allowed_hosts(".dev.acme.com", "notdev.acme.com")); + } + + #[test] + fn rejects_cross_site_mutations_rebinding_and_malformed_authorities() { + for method in ["POST", "DELETE", "PUT"] { + for origin in [ + "https://evil.com", + "null", + "http://localhost:123@evil.com", + "http://[::1]evil.com", + "file://localhost", + ] { + assert!(!allowed(method, "127.0.0.1:16380", origin, "cross-site")); + } + assert!(!allowed(method, "127.0.0.1:16380", "", "cross-site")); + } + for host in [ + "evil.com", + "[::1]evil.com", + "local/evil.com", + "local:abc", + "local:999999", + ] { + assert!(!allowed("GET", host, "", "same-origin")); + } + } +} diff --git a/services/redis-control/src/http.rs b/services/redis-control/src/http.rs index 41d7094..8030f72 100644 --- a/services/redis-control/src/http.rs +++ b/services/redis-control/src/http.rs @@ -31,7 +31,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::json; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::command_allowlist::{command_allowed, CommandClass}; @@ -187,6 +187,14 @@ async fn handle_conn( /// Top-level routing, mirroring `ServeHTTP`: intercept the two prefixes before /// anything else, run `verify_http` first, then 404 for everything else. async fn dispatch(server: &Server, auth: &HttpAuth, req: &Request) -> Response { + if !crate::browser_guard::allowed( + &req.method, + req.header("host"), + req.header("origin"), + req.header("sec-fetch-site"), + ) { + return Response::text(403, "cross-site requests or untrusted Host are not allowed"); + } let path = if req.path.is_empty() { "/" } else { &req.path }; if path.starts_with(INTROSPECT_PREFIX) { if let Some(resp) = verify_http(auth, req) { @@ -557,11 +565,14 @@ fn key_detail_response(detail: &crate::server::KeyDetail) -> serde_json::Value { // --- HTTP/1.1 transport (hand-rolled, same pattern as sqs/http.rs) --- -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut S) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -590,14 +601,26 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let name = k.trim().to_ascii_lowercase(); + if name == "content-length" && headers.contains_key(&name) { + return Ok(None); + } + headers.insert(name, v.trim().to_string()); } } - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + // These one-shot endpoints support fixed-length bodies only. Reject + // ambiguous framing before a mutation can see a partial or empty body. + if headers.contains_key("transfer-encoding") { + return Ok(None); + } + let content_length = match headers.get("content-length") { + None => 0, + Some(raw) => match raw.parse::() { + Ok(length) if raw.bytes().all(|b| b.is_ascii_digit()) => length, + _ => return Ok(None), + }, + }; if content_length > MAX_BODY_BYTES { return Ok(None); } @@ -606,7 +629,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -746,6 +769,92 @@ fn base64_decode(input: &str) -> Option> { mod tests { use super::*; + #[tokio::test] + async fn direct_control_rejects_simple_cross_site_posts_before_running_commands() { + let server = Server::new( + "127.0.0.1:0".into(), + String::new(), + "managed".into(), + 16, + Box::new(|_, _| {}), + ); + let auth = HttpAuth { + auth_mode: "relaxed".into(), + password: String::new(), + }; + for (path, body) in [ + ( + "/_control/exec", + r#"{"command":"SET","args":["test-key","test-value"]}"#, + ), + ("/_control/select-db", r#"{"db":1}"#), + ("/_control/keys/test-key/expire", r#"{"ttlSeconds":10}"#), + ] { + let mut req = test_request("POST", path); + req.body = body.as_bytes().to_vec(); + req.headers.insert("host".into(), "127.0.0.1:16380".into()); + req.headers + .insert("content-type".into(), "text/plain".into()); + req.headers + .insert("origin".into(), "https://evil.com".into()); + assert_eq!(dispatch(&server, &auth, &req).await.status, 403); + req.headers.remove("origin"); + req.headers + .insert("sec-fetch-site".into(), "cross-site".into()); + assert_eq!(dispatch(&server, &auth, &req).await.status, 403); + } + let mut req = test_request("GET", "/_introspect/keys"); + req.headers.insert("host".into(), "evil.com:16380".into()); + assert_eq!(dispatch(&server, &auth, &req).await.status, 403); + + // A CLI request without browser headers still reaches normal parsing. + let req = test_request("POST", "/_control/exec"); + assert_eq!(dispatch(&server, &auth, &req).await.status, 400); + } + + #[tokio::test] + async fn transport_rejects_incomplete_or_ambiguous_bodies() { + for raw in [ + "POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\nabc", + "POST / HTTP/1.1\r\nContent-Length: invalid\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: +3\r\n\r\nabc", + "POST / HTTP/1.1\r\nContent-Length: \r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 99999999999999999999999999\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 3\r\nContent-Length: 0\r\n\r\nabc", + "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + "POST / HTTP/1.1\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + ] { + assert!(read_request(&mut raw.as_bytes()).await.unwrap().is_none()); + } + let oversized = format!( + "POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n", + MAX_BODY_BYTES + 1 + ); + assert!(read_request(&mut oversized.as_bytes()) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn transport_accepts_complete_bodies_and_enforces_the_header_boundary() { + for raw in [ + "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc", + "GET / HTTP/1.1\r\n\r\n", + ] { + assert!(read_request(&mut raw.as_bytes()).await.unwrap().is_some()); + } + // The terminator arrives in the read that crosses the cap. Checking + // only the buffer length before looking for it misses this case. + let mut oversized = b"GET / HTTP/1.1\r\nX-Large: ".to_vec(); + oversized.resize(MAX_HEADER_BYTES - 1, b'a'); + oversized.extend_from_slice(b"\r\n\r\n"); + assert!(read_request(&mut oversized.as_slice()) + .await + .unwrap() + .is_none()); + } + #[test] fn count_clamps_and_defaults() { assert_eq!(parse_count(""), 100); diff --git a/services/redis-control/src/lib.rs b/services/redis-control/src/lib.rs index 7b9f259..1098c93 100644 --- a/services/redis-control/src/lib.rs +++ b/services/redis-control/src/lib.rs @@ -27,6 +27,7 @@ use std::sync::Arc; use tokio::net::TcpListener; use tokio::sync::mpsc::UnboundedSender; +mod browser_guard; mod command_allowlist; mod http; mod resp; @@ -41,11 +42,12 @@ pub use command_allowlist::CommandClass; /// | field | orchestrator source | /// |------------------|------------------------------------------------------| /// | `http_addr` | loopback + `cfg.server.redis_http_port` | -/// | `redis_addr` | loopback + `cfg.server.redis_port` (data plane) | -/// | `redis_password` | `cfg.auth.redis.password` | +/// | `redis_addr` | managed loopback or external URL host/port | +/// | `redis_password` | managed strict password or external URL password | /// | `control_auth_mode` | `cfg.auth.redis.mode` ("relaxed" | "strict") | -/// | `control_password` | `cfg.auth.redis.password` (legacy reuses it) | +/// | `control_password` | `cfg.auth.redis.password` for HTTP Basic | /// | `mode` | redis mode display string ("managed" | "external") | +/// | `initial_db` | external URL database, otherwise 0 | /// | `database_count` | 0 = probe via `CONFIG GET databases` (default 16) | #[derive(Debug, Clone)] pub struct Config { @@ -53,15 +55,17 @@ pub struct Config { pub http_addr: String, /// `host:port` of the upstream redis data plane. pub redis_addr: String, - /// Upstream redis password (sent via `AUTH`; never logged). Empty in relaxed. + /// Upstream redis password (sent via `AUTH`; never logged). pub redis_password: String, /// Control-surface auth mode: "relaxed" (open) or "strict" (HTTP Basic). pub control_auth_mode: String, - /// Control-surface password checked in strict mode (HTTP Basic). The legacy - /// reference reuses the redis password; the orchestrator passes the same. + /// Control-surface password checked in strict mode (HTTP Basic). External + /// Redis may use a different password, supplied through its URL. pub control_password: String, /// Display-only mode string for the status response ("managed"/"external"). pub mode: String, + /// Initial database from the external URL (managed mode uses 0). + pub initial_db: i64, /// Database count; 0 probes the upstream (`CONFIG GET databases`, default 16). pub database_count: i64, /// Optional in-process dashboard event sink. When set, mutation events are @@ -79,6 +83,14 @@ impl Config { } } +/// Validates the configured external upstream with AUTH, SELECT and PING. +/// Keeps the RESP transport private while allowing the orchestrator's external +/// lifecycle to fail clearly when the configured endpoint is unavailable. +pub async fn validate_upstream(addr: &str, password: &str, database: i64) -> Result<(), String> { + let mut conn = resp::RespConn::connect(addr, password, database).await?; + conn.command(&["PING"]).await.map(|_| ()) +} + /// Runs the redis control/introspection HTTP surface until `shutdown` resolves. /// /// Binds `cfg.http_addr`, then serves only `/_introspect/` (reads) and @@ -108,6 +120,11 @@ pub async fn run( // upstream is not fatal here — reads/mutations surface 502 per request, same // as the legacy listener which always runs regardless of upstream liveness. server.refresh_database_count().await; + if cfg.initial_db != 0 { + server.set_initial_db(cfg.initial_db).await.map_err(|_| { + "redis control: failed to select the configured initial database".to_string() + })?; + } http::serve(listener, server, auth, shutdown) .await diff --git a/services/redis-control/src/server.rs b/services/redis-control/src/server.rs index 785f73a..4e91709 100644 --- a/services/redis-control/src/server.rs +++ b/services/redis-control/src/server.rs @@ -318,6 +318,20 @@ impl Server { if count > 0 && db >= count { return Err(format!("db {db} out of range (0..{})", count - 1)); } + self.select_db(db).await + } + + /// The configured initial database is validated by Redis itself. CONFIG + /// may be forbidden, so its fallback count must not reject a valid SELECT. + pub async fn set_initial_db(&self, db: i64) -> Result<(), String> { + self.select_db(db).await + } + + async fn select_db(&self, db: i64) -> Result<(), String> { + let minimum_count = db + .checked_add(1) + .filter(|count| *count > 0) + .ok_or_else(|| format!("invalid database index {db}"))?; // Ensure a connection exists and is live for this db. self.with_conn(db, |mut conn| async move { let reply = conn.command(&["PING"]).await.map(|_| ()); @@ -325,7 +339,11 @@ impl Server { }) .await .map_err(|e| format!("ping redis db {db}: {e}"))?; - self.state.lock().unwrap().current_db = db; + let mut state = self.state.lock().unwrap(); + state.current_db = db; + // A successful SELECT proves this database exists even when CONFIG is + // unavailable. Keep the status/UI range consistent with the selection. + state.database_count = state.database_count.max(minimum_count); Ok(()) } diff --git a/services/redis/Cargo.toml b/services/redis/Cargo.toml index 4cab9bc..d371363 100644 --- a/services/redis/Cargo.toml +++ b/services/redis/Cargo.toml @@ -13,4 +13,4 @@ name = "devcloud-redis" path = "src/main.rs" [dependencies] -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "process", "signal"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "process", "signal", "time"] } diff --git a/services/redis/src/lib.rs b/services/redis/src/lib.rs index 23ccd93..ced90d6 100644 --- a/services/redis/src/lib.rs +++ b/services/redis/src/lib.rs @@ -1,5 +1,31 @@ use std::net::ToSocketAddrs; use std::path::PathBuf; +#[cfg(unix)] +use std::time::Duration; + +/// Gives managed Redis time to save its RDB snapshot before forcing shutdown. +/// Callers must spawn with `kill_on_drop(true)` so cancellation of this wait +/// also terminates the owned child. Every completed path reaps the process. +pub async fn stop_child(child: &mut tokio::process::Child) -> std::io::Result<()> { + if child.try_wait()?.is_some() { + return Ok(()); + } + #[cfg(unix)] + if let Some(pid) = child.id() { + extern "C" { + fn kill(pid: std::os::raw::c_int, signal: std::os::raw::c_int) -> std::os::raw::c_int; + } + let signal = tokio::signal::unix::SignalKind::terminate().as_raw_value(); + // SAFETY: the PID belongs to the child we still own, and `kill` does + // not access Rust memory. An unreaped child cannot have its PID reused. + if unsafe { kill(pid as std::os::raw::c_int, signal) } == 0 { + if let Ok(result) = tokio::time::timeout(Duration::from_secs(5), child.wait()).await { + return result.map(|_| ()); + } + } + } + child.kill().await +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct Config { diff --git a/services/redis/src/main.rs b/services/redis/src/main.rs index 753b7c0..519050d 100644 --- a/services/redis/src/main.rs +++ b/services/redis/src/main.rs @@ -40,6 +40,7 @@ fn main() { runtime.block_on(async move { let mut child = match Command::new(&config.binary) .args(args) + .kill_on_drop(true) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -67,8 +68,10 @@ fn main() { } } _ = shutdown_signal() => { - let _ = child.start_kill(); - let _ = child.wait().await; + if let Err(e) = devcloud_redis::stop_child(&mut child).await { + eprintln!("devcloud-redis: stop redis-server: {e}"); + std::process::exit(1); + } } } }); diff --git a/services/redshift/Cargo.toml b/services/redshift/Cargo.toml index db2c562..82d1c59 100644 --- a/services/redshift/Cargo.toml +++ b/services/redshift/Cargo.toml @@ -22,4 +22,4 @@ serde = { version = "1", features = ["derive"] } # serde_json's default Map is a BTreeMap (sorted keys), matching legacy sorted-key # map marshaling. Do NOT enable preserve_order. serde_json = "1" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "time", "sync"] } diff --git a/services/redshift/src/http.rs b/services/redshift/src/http.rs index a4940e2..336f6d3 100644 --- a/services/redshift/src/http.rs +++ b/services/redshift/src/http.rs @@ -10,7 +10,7 @@ use std::collections::BTreeMap; use std::future::Future; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::server::Server; @@ -81,11 +81,14 @@ struct Request { body: Vec, } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -110,20 +113,41 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = BTreeMap::new(); let mut content_length: usize = 0; + let mut has_content_length = false; let mut chunked = false; for line in lines { if let Some((k, v)) = line.split_once(':') { let key = k.trim().to_ascii_lowercase(); let value = v.trim().to_string(); match key.as_str() { - "content-length" => content_length = value.parse().unwrap_or(0), - "transfer-encoding" => chunked = value.to_ascii_lowercase().contains("chunked"), + "content-length" => { + if has_content_length { + return Ok(None); + } + has_content_length = true; + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { + return Ok(None); + } + content_length = match value.parse() { + Ok(length) => length, + Err(_) => return Ok(None), + }; + } + "transfer-encoding" => { + if chunked || !value.eq_ignore_ascii_case("chunked") { + return Ok(None); + } + chunked = true; + } _ => {} } headers.insert(key, value); } } + if chunked && has_content_length { + return Ok(None); + } let mut body = buf[header_end + 4..].to_vec(); if chunked { body = read_chunked_body(stream, body).await?; @@ -134,7 +158,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -150,40 +174,119 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result })) } -async fn read_chunked_body(stream: &mut TcpStream, mut buf: Vec) -> std::io::Result> { +async fn read_chunked_body( + stream: &mut (impl AsyncRead + Unpin), + mut buf: Vec, +) -> std::io::Result> { let mut out = Vec::new(); + let mut framing_bytes = 0usize; let mut tmp = [0u8; 4096]; - let mut pos = 0; loop { - let line_end = loop { - if let Some(i) = find_subslice(&buf[pos..], b"\r\n") { - break pos + i; + let line = read_chunk_line(stream, &mut buf, MAX_HEADER_BYTES).await?; + framing_bytes += line.len() + 2; + // One-byte chunks use five framing bytes per data byte. Reserve that + // legal overhead separately from the additional extension budget. + if framing_bytes > 5 * out.len() + MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk framing too large", + )); + } + let size_hex = line.split(|b| *b == b';').next().unwrap_or_default(); + if size_hex.is_empty() || !size_hex.iter().all(u8::is_ascii_hexdigit) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid chunk size", + )); + } + // ASCII validation above makes this conversion infallible. Reject a + // huge declaration before reading its payload or adding byte offsets. + let size = + usize::from_str_radix(std::str::from_utf8(size_hex).unwrap(), 16).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid chunk size") + })?; + if size > MAX_BODY_BYTES - out.len() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request body too large", + )); + } + if size == 0 { + // The zero chunk is followed by a complete, bounded trailer + // section, ending in an empty line. EOF is never a terminator. + let mut remaining = MAX_HEADER_BYTES; + loop { + let trailer = read_chunk_line(stream, &mut buf, remaining).await?; + remaining -= trailer.len() + 2; + framing_bytes += trailer.len() + 2; + if framing_bytes > 5 * out.len() + MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk framing too large", + )); + } + if trailer.is_empty() { + return Ok(out); + } } + } + // `size` is now bounded by MAX_BODY_BYTES, so the addition is safe. + while buf.len() < size + 2 { let n = stream.read(&mut tmp).await?; if n == 0 { - return Ok(out); + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete chunk body", + )); } buf.extend_from_slice(&tmp[..n]); - }; - let size_line = String::from_utf8_lossy(&buf[pos..line_end]).into_owned(); - let size_hex = size_line.split(';').next().unwrap_or("").trim(); - let size = usize::from_str_radix(size_hex, 16).unwrap_or(0); - pos = line_end + 2; - if size == 0 { - return Ok(out); } - while buf.len() < pos + size + 2 { - let n = stream.read(&mut tmp).await?; - if n == 0 { - return Ok(out); + if &buf[size..size + 2] != b"\r\n" { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid chunk terminator", + )); + } + out.extend_from_slice(&buf[..size]); + framing_bytes += 2; + // Discard consumed framing as well as data, so many small chunks do + // not retain an unbounded second copy of the request on the heap. + buf.drain(..size + 2); + } +} + +async fn read_chunk_line( + stream: &mut (impl AsyncRead + Unpin), + buf: &mut Vec, + limit: usize, +) -> std::io::Result> { + let mut tmp = [0u8; 4096]; + loop { + if let Some(end) = find_subslice(buf, b"\r\n") { + if end + 2 > limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk headers too large", + )); } - buf.extend_from_slice(&tmp[..n]); + let line = buf[..end].to_vec(); + buf.drain(..end + 2); + return Ok(line); + } + if buf.len() >= limit { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "chunk headers too large", + )); } - out.extend_from_slice(&buf[pos..pos + size]); - if out.len() > MAX_BODY_BYTES { - return Ok(Vec::new()); + let n = stream.read(&mut tmp).await?; + if n == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete chunk headers", + )); } - pos += size + 2; + buf.extend_from_slice(&tmp[..n]); } } @@ -225,3 +328,146 @@ fn reason_phrase(status: u16) -> &'static str { fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } + +#[cfg(test)] +mod request_framing_tests { + use super::*; + + #[tokio::test] + async fn incomplete_fixed_length_body_is_rejected() { + // A complete JSON value is still an incomplete HTTP message. It must + // never reach a mutating handler after the client disconnects early. + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 9\r\n\r\n{}"[..]; + assert!(read_request(&mut wire).await.unwrap().is_none()); + } + + #[tokio::test] + async fn invalid_content_length_is_rejected() { + for value in ["bad", "-1", "+2", "", "18446744073709551616"] { + let wire = format!("POST / HTTP/1.1\r\nContent-Length: {value}\r\n\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{value:?}" + ); + } + } + + #[tokio::test] + async fn ambiguous_or_unsupported_body_framing_is_rejected() { + for headers in [ + "Transfer-Encoding: gzip\r\n", + "Transfer-Encoding: xchunked\r\n", + "Transfer-Encoding: chunked, gzip\r\n", + "Content-Length: 9\r\nContent-Length: 2\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ] { + let wire = format!("POST / HTTP/1.1\r\n{headers}\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{headers:?}" + ); + } + } + + #[tokio::test] + async fn complete_fixed_length_body_is_preserved() { + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn oversized_header_is_rejected_even_when_terminator_arrives() { + let wire = format!( + "POST / HTTP/1.1\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ); + assert!(read_request(&mut wire.as_bytes()).await.unwrap().is_none()); + } +} + +#[cfg(test)] +mod chunk_framing_tests { + use super::*; + + #[tokio::test] + async fn malformed_or_incomplete_chunks_never_return_a_partial_body() { + for wire in [ + &b"2\r\n{}\r\n"[..], + &b"2\r\n{}\r\nxyz\r\n"[..], + &b"5\r\n{}"[..], + &b"2\r\n{}XX0\r\n\r\n"[..], + &b"2\r\n{}\r\n0\r\n"[..], + &b"2\r\n{}\r\n0\r\nX-Trailer: unfinished"[..], + &b"+2\r\n{}\r\n0\r\n\r\n"[..], + &b"\r\n"[..], + ] { + assert!( + read_chunked_body(&mut &b""[..], wire.to_vec()) + .await + .is_err(), + "{wire:?}" + ); + } + } + + #[tokio::test] + async fn oversized_chunk_is_rejected_before_reading_its_payload() { + for size in [MAX_BODY_BYTES + 1, usize::MAX] { + let wire = format!("{size:x}\r\n"); + assert!(read_chunked_body(&mut &b""[..], wire.into_bytes()) + .await + .is_err()); + } + } + + #[tokio::test] + async fn chunk_lines_and_trailers_are_bounded() { + for wire in [ + format!("1;{}", "x".repeat(MAX_HEADER_BYTES)), + format!("0\r\nX-Trailer: {}", "x".repeat(MAX_HEADER_BYTES)), + ] { + assert!(read_chunked_body(&mut &b""[..], wire.into_bytes()) + .await + .is_err()); + } + } + + #[tokio::test] + async fn cumulative_chunk_framing_is_bounded() { + let chunk = format!("1;{}\r\nx\r\n", "x".repeat(MAX_HEADER_BYTES / 2)); + let wire = format!("{chunk}{chunk}{chunk}0\r\n\r\n"); + assert!(read_chunked_body(&mut wire.as_bytes(), Vec::new()) + .await + .is_err()); + } + + #[tokio::test] + async fn complete_chunked_request_is_preserved() { + let mut wire = + &b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}\r\n0\r\n\r\n"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn many_small_chunks_are_accepted() { + // The minimum legal data chunk carries five framing bytes per byte + // of content. Fragmentation alone must not exhaust the extension cap. + let wire = format!("{}0\r\n\r\n", "1\r\nx\r\n".repeat(20_000)); + let body = read_chunked_body(&mut wire.as_bytes(), Vec::new()) + .await + .unwrap(); + assert_eq!(body, vec![b'x'; 20_000]); + } + + #[tokio::test] + async fn complete_chunks_extensions_and_trailers_are_decoded() { + let wire = b"2;part=first\r\nab\r\n2\r\ncd\r\n0\r\nX-Trailer: ok\r\n\r\n"; + let body = read_chunked_body(&mut wire.as_slice(), Vec::new()) + .await + .unwrap(); + assert_eq!(body, b"abcd"); + } +} diff --git a/services/s3/Cargo.toml b/services/s3/Cargo.toml index 988d4b3..3522920 100644 --- a/services/s3/Cargo.toml +++ b/services/s3/Cargo.toml @@ -21,4 +21,4 @@ md-5 = "0.10" sha2 = "0.10" hmac = "0.12" hex = "0.4" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "sync"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "time", "sync"] } diff --git a/services/s3/src/model.rs b/services/s3/src/model.rs index 27b4e78..fc49390 100644 --- a/services/s3/src/model.rs +++ b/services/s3/src/model.rs @@ -139,6 +139,13 @@ pub struct Object { pub retention: ObjectRetention, #[serde(rename = "legalHold")] pub legal_hold: ObjectLegalHold, + /// Internal commit identity for GCS resumable uploads. This is persisted + /// with object metadata, never added to provider-visible user metadata. + #[serde( + rename = "_resumableUploadId", + skip_serializing_if = "String::is_empty" + )] + pub resumable_upload_id: String, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] diff --git a/services/s3/src/store.rs b/services/s3/src/store.rs index 63024fc..bc6b965 100644 --- a/services/s3/src/store.rs +++ b/services/s3/src/store.rs @@ -56,6 +56,21 @@ pub enum StoreError { Io(io::Error), } +/// Keeps provider-specific condition failures separate from storage failures. +/// The store only serializes the check and mutation; callers define the +/// condition and its error without adding provider rules to this crate. +#[derive(Debug)] +pub enum ConditionalError { + Store(StoreError), + Condition(E), +} + +impl From for ConditionalError { + fn from(error: StoreError) -> Self { + Self::Store(error) + } +} + impl From for StoreError { fn from(e: io::Error) -> Self { StoreError::Io(e) @@ -147,6 +162,13 @@ impl FileBucketStore { self.root.join(name) } + /// Small immutable completion results survive object/bucket deletion and + /// session cleanup until the workspace is reset. These are files, so bucket + /// listing ignores them; no uploaded body is retained in this history. + pub(crate) fn resumable_upload_receipt_path(&self, upload_id: &str) -> PathBuf { + self.root.join(format!(".gcs-upload-{upload_id}.json")) + } + pub fn objects_path(&self, bucket: &str) -> PathBuf { self.bucket_path(bucket).join("objects") } @@ -219,32 +241,23 @@ impl FileBucketStore { // --- bucket CRUD -------------------------------------------------------- - /// Creates a bucket. Returns `(bucket, created)`; `created` is false when the - /// bucket directory already existed (matching the legacy `CreateBucket`). + /// Creates a bucket. Returns `(bucket, created)`; `created` is false when + /// committed bucket metadata already exists. An empty directory left by an + /// interrupted creation is completed on retry. pub fn create_bucket(&self, name: &str) -> Result<(Bucket, bool)> { - if !valid_bucket_name(name) { - return Err(StoreError::InvalidBucketName); + let _guard = self.lock_object_writes(); + if let Some(existing) = self.get_bucket(name)? { + return Ok((existing, false)); } + let bucket = Bucket { + name: name.to_string(), + created_at: self.now(), + ..Default::default() + }; let path = self.bucket_path(name); - match fs::metadata(&path) { - Ok(_) => { - // Directory exists: mirror legacy `return bucket, !ok, err`. - let existing = self.get_bucket(name)?; - let created = existing.is_none(); - Ok((existing.unwrap_or_default(), created)) - } - Err(e) if e.kind() == io::ErrorKind::NotFound => { - let bucket = Bucket { - name: name.to_string(), - created_at: self.now(), - ..Default::default() - }; - fs::create_dir_all(&path)?; - Self::write_json(&path.join("bucket.json"), &bucket)?; - Ok((bucket, true)) - } - Err(e) => Err(StoreError::Io(e)), - } + fs::create_dir_all(&path)?; + Self::write_json(&path.join("bucket.json"), &bucket)?; + Ok((bucket, true)) } /// Reads a bucket's metadata; `None` if it does not exist. @@ -291,6 +304,7 @@ impl FileBucketStore { if !valid_bucket_name(name) { return Err(StoreError::InvalidBucketName); } + let _guard = self.lock_object_writes(); let path = self.bucket_path(name); match fs::metadata(&path) { Ok(_) => {} @@ -425,6 +439,106 @@ fn splitmix64(state: &mut u64) -> u64 { mod tests { use super::*; + #[test] + fn object_snapshot_waits_until_metadata_and_body_writes_finish() { + use std::sync::mpsc; + use std::time::Duration; + + let root = + std::env::temp_dir().join(format!("devcloud-s3-snapshot-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let store = FileBucketStore::new(&root); + store.create_bucket("data").unwrap(); + store + .put_object(crate::objops::PutObjectInput { + bucket: "data".to_string(), + key: "key".to_string(), + body: b"old".to_vec(), + ..Default::default() + }) + .unwrap(); + let reading_store = FileBucketStore::new(&root); + let guard = store.lock_object_writes(); + // Pause a write at the real intermediate state: a replaced body with + // the previous object metadata still present. + write_atomic(&store.object_path("data", "key").join("body"), b"new body").unwrap(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let reader = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + done_tx + .send(reading_store.get_object_snapshot("data", "key")) + .unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(matches!( + done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + let committed = store + .put_object_locked(crate::objops::PutObjectInput { + bucket: "data".to_string(), + key: "key".to_string(), + body: b"new body".to_vec(), + ..Default::default() + }) + .unwrap(); + drop(guard); + let snapshot = done_rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap() + .unwrap(); + reader.join().unwrap(); + assert_eq!(snapshot, (committed, b"new body".to_vec())); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bucket_deletion_waits_for_an_in_flight_object_commit() { + use std::sync::mpsc; + use std::time::Duration; + + let root = + std::env::temp_dir().join(format!("devcloud-s3-delete-lock-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let store = FileBucketStore::new(&root); + store.create_bucket("data").unwrap(); + let deleting_store = FileBucketStore::new(&root); + let guard = store.lock_object_writes(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + done_tx.send(deleting_store.delete_bucket("data")).unwrap(); + }); + started_rx.recv().unwrap(); + assert!(matches!( + done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + store + .put_object_locked(crate::objops::PutObjectInput { + bucket: "data".to_string(), + key: "key".to_string(), + body: b"committed".to_vec(), + ..Default::default() + }) + .unwrap(); + drop(guard); + assert!(matches!( + done_rx.recv().unwrap(), + Err(StoreError::BucketNotEmpty) + )); + worker.join().unwrap(); + assert!(store.get_bucket("data").unwrap().is_some()); + assert_eq!( + store.get_object("data", "key").unwrap().unwrap().1, + b"committed" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn instances_over_one_root_share_the_object_write_lock() { let base = std::env::temp_dir().join(format!("devcloud-s3-lock-{}", std::process::id())); diff --git a/services/s3/src/store_multipart.rs b/services/s3/src/store_multipart.rs index 4eba1f8..786483e 100644 --- a/services/s3/src/store_multipart.rs +++ b/services/s3/src/store_multipart.rs @@ -37,6 +37,7 @@ impl FileBucketStore { &self, input: CreateMultipartUploadInput, ) -> Result { + let _guard = self.lock_object_writes(); self.require_bucket_and_key(&input.bucket, &input.key)?; let mut upload = MultipartUpload { bucket: input.bucket.clone(), diff --git a/services/s3/src/store_objectlock.rs b/services/s3/src/store_objectlock.rs index cc19c12..689f1d6 100644 --- a/services/s3/src/store_objectlock.rs +++ b/services/s3/src/store_objectlock.rs @@ -29,10 +29,21 @@ impl FileBucketStore { acl: &str, ) -> Result { let _guard = self.lock_object_writes(); + self.require_bucket_and_key(bucket, key)?; + if !version_id.is_empty() { + Self::version_path(&self.object_path(bucket, key), version_id)?; + } + self.preserve_current_resumable_upload(bucket, key)?; let (mut object, mut body) = match self.get_object_version(bucket, key, version_id)? { Some(v) => v, None => return Ok(false), }; + if !version_id.is_empty() { + let path = Self::version_path(&self.object_path(bucket, key), version_id)?; + if path.join("object.json").is_file() { + self.preserve_resumable_upload_result(&object, &path.join("object.json"))?; + } + } object.acl = acl.to_string(); if object.delete_marker { body = Vec::new(); @@ -138,10 +149,21 @@ impl FileBucketStore { update: impl FnOnce(&mut Object), ) -> Result> { let _guard = self.lock_object_writes(); + self.require_bucket_and_key(bucket, key)?; + if !version_id.is_empty() { + Self::version_path(&self.object_path(bucket, key), version_id)?; + } + self.preserve_current_resumable_upload(bucket, key)?; let (mut object, mut body) = match self.get_object_version(bucket, key, version_id)? { Some(v) => v, None => return Ok(None), }; + if !version_id.is_empty() { + let path = Self::version_path(&self.object_path(bucket, key), version_id)?; + if path.join("object.json").is_file() { + self.preserve_resumable_upload_result(&object, &path.join("object.json"))?; + } + } update(&mut object); if object.delete_marker { body = Vec::new(); diff --git a/services/s3/src/store_objects.rs b/services/s3/src/store_objects.rs index 2d93e15..850fab3 100644 --- a/services/s3/src/store_objects.rs +++ b/services/s3/src/store_objects.rs @@ -10,11 +10,11 @@ use crate::objops::{ UpdateObjectMetadataInput, }; use crate::store::{ - remove_dir_all_ignoring_missing, remove_if_exists, write_atomic, FileBucketStore, Result, - StoreError, + remove_dir_all_ignoring_missing, remove_if_exists, write_atomic, ConditionalError, + FileBucketStore, Result, StoreError, }; use crate::time_fmt::{time_after, GO_ZERO_TIME}; -use crate::validation::{valid_bucket_name, valid_object_key}; +use crate::validation::{valid_bucket_name, valid_object_key, valid_upload_id}; use std::cmp::Ordering; use std::fs; use std::io; @@ -70,8 +70,42 @@ impl FileBucketStore { self.put_object_locked(input) } + /// Checks the current object and stores its replacement under one shared + /// root write lock. Missing objects and delete markers are passed as None. + /// The predicate must not call any locking API on a store over this root. + pub fn put_object_if( + &self, + input: PutObjectInput, + predicate: impl FnOnce(Option<&Object>) -> std::result::Result<(), E>, + ) -> std::result::Result> { + let _guard = self.lock_object_writes(); + self.check_current_object_locked(&input.bucket, &input.key, predicate)?; + Ok(self.put_object_locked(input)?) + } + + /// The caller must retain the root write lock through the mutation. + fn check_current_object_locked( + &self, + bucket: &str, + key: &str, + predicate: impl FnOnce(Option<&Object>) -> std::result::Result<(), E>, + ) -> std::result::Result<(), ConditionalError> { + self.require_bucket_and_key(bucket, key)?; + let current = self.read_current_object_metadata(bucket, key)?; + predicate(current.as_ref().filter(|object| !object.delete_marker)) + .map_err(ConditionalError::Condition) + } + /// `put_object` for a caller already holding `lock_object_writes`. pub(crate) fn put_object_locked(&self, input: PutObjectInput) -> Result { + self.put_object_with_resumable_id_locked(input, "") + } + + fn put_object_with_resumable_id_locked( + &self, + input: PutObjectInput, + upload_id: &str, + ) -> Result { if !valid_bucket_name(&input.bucket) { return Err(StoreError::InvalidBucketName); } @@ -106,6 +140,7 @@ impl FileBucketStore { encryption: clean_server_side_encryption(&input.encryption), retention: clean_object_retention(&input.retention), legal_hold: clean_object_legal_hold(&input.legal_hold), + resumable_upload_id: upload_id.to_string(), ..Default::default() }; if object.retention.mode.is_empty() { @@ -122,6 +157,7 @@ impl FileBucketStore { let path = self.object_path(&input.bucket, &input.key); fs::create_dir_all(&path)?; + self.preserve_current_resumable_upload(&input.bucket, &input.key)?; write_atomic(&path.join("body"), &input.body)?; Self::write_json(&path.join("object.json"), &object)?; if !object.version_id.is_empty() { @@ -130,6 +166,216 @@ impl FileBucketStore { Ok(object) } + /// Commits a GCS resumable upload exactly once. The identity is included in + /// the atomic object metadata write, so a crash before the completion + /// receipt is linked can be recovered without assigning a new generation. + pub fn put_resumable_object(&self, input: PutObjectInput, upload_id: &str) -> Result { + if !valid_upload_id(upload_id) { + return Err(StoreError::InvalidUploadId); + } + let _guard = self.lock_object_writes(); + if let Some(receipt) = self.resumable_result_for_input_locked(&input, upload_id)? { + return Ok(receipt); + } + self.commit_resumable_object_locked(input, upload_id) + } + + /// Like put_object_if, but an upload already committed returns its original + /// receipt before evaluating the predicate, including after bucket deletion. + /// The predicate must not call any locking API on a store over this root. + pub fn put_resumable_object_if( + &self, + input: PutObjectInput, + upload_id: &str, + predicate: impl FnOnce(Option<&Object>) -> std::result::Result<(), E>, + ) -> std::result::Result> { + if !valid_upload_id(upload_id) { + return Err(StoreError::InvalidUploadId.into()); + } + let _guard = self.lock_object_writes(); + if let Some(receipt) = self.resumable_result_for_input_locked(&input, upload_id)? { + return Ok(receipt); + } + self.check_current_object_locked(&input.bucket, &input.key, predicate)?; + Ok(self.commit_resumable_object_locked(input, upload_id)?) + } + + fn resumable_result_for_input_locked( + &self, + input: &PutObjectInput, + upload_id: &str, + ) -> Result> { + if let Some(receipt) = self.read_resumable_upload_receipt(upload_id)? { + if receipt.bucket != input.bucket || receipt.key != input.key { + return Err(StoreError::InvalidUploadId); + } + return Ok(Some(receipt)); + } + self.get_resumable_upload_result_locked(&input.bucket, Some(&input.key), upload_id) + } + + fn commit_resumable_object_locked( + &self, + input: PutObjectInput, + upload_id: &str, + ) -> Result { + let object = self.put_object_with_resumable_id_locked(input, upload_id)?; + // The version snapshot is already complete on this path. Recovery of + // an interrupted commit uses preserve_resumable_upload_result instead. + fs::hard_link( + self.object_path(&object.bucket, &object.key) + .join("object.json"), + self.resumable_upload_receipt_path(upload_id), + )?; + Ok(object) + } + + /// Claims the best-effort dashboard notification after a durable result is + /// available. Both an initial completion and a recovered retry use this + /// boundary, so an I/O failure can be retried without emitting twice. + pub fn claim_resumable_upload_event(&self, object: &Object) -> Result { + let _guard = self.lock_object_writes(); + let receipt = self.read_resumable_upload_receipt(&object.resumable_upload_id)?; + if receipt.as_ref() != Some(object) { + return Err(StoreError::InvalidUploadId); + } + let path = self + .resumable_upload_receipt_path(&object.resumable_upload_id) + .with_extension("notified"); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + if fs::metadata(path)?.is_file() { + Ok(false) + } else { + Err(StoreError::Io(error)) + } + } + Err(error) => Err(StoreError::Io(error)), + } + } + + /// Returns the original completion result even after session cleanup or a + /// later object overwrite/deletion. An outstanding session supplies `key` + /// so the atomic object marker can recover a missing receipt after restart. + pub fn get_resumable_upload_result( + &self, + bucket: &str, + key: Option<&str>, + upload_id: &str, + ) -> Result> { + let _guard = self.lock_object_writes(); + self.get_resumable_upload_result_locked(bucket, key, upload_id) + } + + fn get_resumable_upload_result_locked( + &self, + bucket: &str, + key: Option<&str>, + upload_id: &str, + ) -> Result> { + if !valid_upload_id(upload_id) + || !valid_bucket_name(bucket) + || key.is_some_and(|key| !valid_object_key(key)) + { + return Ok(None); + } + if let Some(receipt) = self.read_resumable_upload_receipt(upload_id)? { + return Ok( + (receipt.bucket == bucket && key.is_none_or(|key| receipt.key == key)) + .then_some(receipt), + ); + } + let Some(key) = key else { + return Ok(None); + }; + let path = self.object_path(bucket, key).join("object.json"); + if let Some(object) = read_object(&path)? { + if !object.delete_marker + && object.resumable_upload_id == upload_id + && object.bucket == bucket + && object.key == key + { + self.preserve_resumable_upload_result(&object, &path)?; + return self.read_resumable_upload_receipt(upload_id); + } + } + Ok(None) + } + + fn read_resumable_upload_receipt(&self, upload_id: &str) -> Result> { + if !valid_upload_id(upload_id) { + return Ok(None); + } + let receipt = read_object(&self.resumable_upload_receipt_path(upload_id))?; + if receipt.as_ref().is_some_and(|object| { + object.resumable_upload_id != upload_id + || object.delete_marker + || !valid_bucket_name(&object.bucket) + || !valid_object_key(&object.key) + }) { + return Err(StoreError::Io(io::Error::new( + io::ErrorKind::InvalidData, + "invalid resumable upload receipt", + ))); + } + Ok(receipt) + } + + pub(crate) fn preserve_current_resumable_upload(&self, bucket: &str, key: &str) -> Result<()> { + let path = self.object_path(bucket, key).join("object.json"); + if let Some(object) = read_object(&path)? { + self.preserve_resumable_upload_result(&object, &path)?; + } + Ok(()) + } + + /// Must run under the root-wide write lock before replacing any metadata + /// or deleting an object. Every metadata update uses rename, so a hard link + /// atomically preserves the exact original result in an immutable inode. + /// If the receipt cannot be preserved, the subsequent mutation must fail. + pub(crate) fn preserve_resumable_upload_result( + &self, + object: &Object, + source: &Path, + ) -> Result<()> { + let upload_id = &object.resumable_upload_id; + if upload_id.is_empty() { + return Ok(()); + } + if !valid_upload_id(upload_id) + || !valid_bucket_name(&object.bucket) + || !valid_object_key(&object.key) + || object.delete_marker + { + return Err(StoreError::InvalidUploadId); + } + if let Some(receipt) = self.read_resumable_upload_receipt(upload_id)? { + if receipt.bucket != object.bucket + || receipt.key != object.key + || receipt.last_modified != object.last_modified + || receipt.etag != object.etag + || receipt.size != object.size + { + return Err(StoreError::InvalidUploadId); + } + return Ok(()); + } + let path = self.object_path(&object.bucket, &object.key); + if !object.version_id.is_empty() && source == path.join("object.json") { + // Also recover a stop between the current metadata rename and its + // version snapshot, before any subsequent writer discards the body. + let body = fs::read(path.join("body"))?; + self.write_object_version(&path, object, &body)?; + } + fs::hard_link(source, self.resumable_upload_receipt_path(upload_id))?; + Ok(()) + } + /// `put_object` guarded by `If-None-Match: *`: fails with /// `PreconditionFailed` when a current object (not a delete marker) exists. pub fn put_object_if_absent(&self, input: PutObjectInput) -> Result { @@ -155,6 +401,26 @@ impl FileBucketStore { input: UpdateObjectMetadataInput, ) -> Result> { let _guard = self.lock_object_writes(); + self.update_object_metadata_locked(input) + } + + /// Checks current metadata and updates it without admitting another writer + /// between the check and update. Delete markers are passed as None. + /// The predicate must not call any locking API on a store over this root. + pub fn update_object_metadata_if( + &self, + input: UpdateObjectMetadataInput, + predicate: impl FnOnce(Option<&Object>) -> std::result::Result<(), E>, + ) -> std::result::Result, ConditionalError> { + let _guard = self.lock_object_writes(); + self.check_current_object_locked(&input.bucket, &input.key, predicate)?; + Ok(self.update_object_metadata_locked(input)?) + } + + fn update_object_metadata_locked( + &self, + input: UpdateObjectMetadataInput, + ) -> Result> { if !valid_bucket_name(&input.bucket) { return Err(StoreError::InvalidBucketName); } @@ -169,6 +435,7 @@ impl FileBucketStore { Some(o) => o, None => return Ok(None), }; + self.preserve_resumable_upload_result(&object, &path.join("object.json"))?; if !input.content_type.is_empty() { object.content_type = input.content_type; } @@ -220,6 +487,18 @@ impl FileBucketStore { Ok(Some((object, body))) } + /// Reads metadata and body without an intervening object write. Use this + /// boundary for provider conditions or copy/compose sources; get_object is + /// also used internally by callers that already hold the root write lock. + pub fn get_object_snapshot( + &self, + bucket: &str, + key: &str, + ) -> Result)>> { + let _guard = self.lock_object_writes(); + self.get_object(bucket, key) + } + /// Reads a specific object version; an empty `version_id` falls back to the /// current object. A delete-marker version returns `Some` with an empty body. pub fn get_object_version( @@ -255,6 +534,20 @@ impl FileBucketStore { Ok(self.delete_object_with_result(bucket, key, false)?.1) } + /// Checks the current object and deletes it under the same root write lock. + /// The predicate receives None for a missing object or a delete marker and + /// must not call any locking API on a store over this root. + pub fn delete_object_if( + &self, + bucket: &str, + key: &str, + predicate: impl FnOnce(Option<&Object>) -> std::result::Result<(), E>, + ) -> std::result::Result> { + let _guard = self.lock_object_writes(); + self.check_current_object_locked(bucket, key, predicate)?; + Ok(self.delete_object_with_result_locked(bucket, key, false)?.1) + } + /// Deletes the current object, returning the delete marker (when versioned) /// and whether anything existed. Honors object-lock retention/legal-hold. pub fn delete_object_with_result( @@ -264,6 +557,15 @@ impl FileBucketStore { bypass_governance: bool, ) -> Result<(Object, bool)> { let _guard = self.lock_object_writes(); + self.delete_object_with_result_locked(bucket, key, bypass_governance) + } + + fn delete_object_with_result_locked( + &self, + bucket: &str, + key: &str, + bypass_governance: bool, + ) -> Result<(Object, bool)> { if !valid_bucket_name(bucket) { return Err(StoreError::InvalidBucketName); } @@ -286,6 +588,7 @@ impl FileBucketStore { { return Err(StoreError::ObjectLocked); } + self.preserve_resumable_upload_result(¤t, &path.join("object.json"))?; } let now = self.now(); let version_id = if existing_bucket.versioning == "Suspended" { @@ -316,6 +619,7 @@ impl FileBucketStore { if object_lock_prevents_delete(¤t, &self.now(), bypass_governance) { return Err(StoreError::ObjectLocked); } + self.preserve_resumable_upload_result(¤t, &path.join("object.json"))?; } fs::remove_dir_all(&path)?; let _ = fs::remove_dir(&objects_path); @@ -335,6 +639,9 @@ impl FileBucketStore { return Err(StoreError::VersionIdRequired); } let _guard = self.lock_object_writes(); + self.require_bucket_and_key(bucket, key)?; + let version_dir = Self::version_path(&self.object_path(bucket, key), version_id)?; + self.preserve_current_resumable_upload(bucket, key)?; let object = match self.get_object_version(bucket, key, version_id)? { Some((o, _)) => o, None => return Ok((Object::default(), false)), @@ -344,7 +651,11 @@ impl FileBucketStore { { return Err(StoreError::ObjectLocked); } - let version_dir = Self::version_path(&self.object_path(bucket, key), version_id)?; + // A null-version read may use the current object when no version + // snapshot exists yet; the current preservation above covers it. + if version_dir.join("object.json").is_file() { + self.preserve_resumable_upload_result(&object, &version_dir.join("object.json"))?; + } remove_dir_all_ignoring_missing(&version_dir)?; self.rebuild_current_object(bucket, key)?; Ok((object, true)) @@ -518,6 +829,7 @@ impl FileBucketStore { } fn rebuild_current_object(&self, bucket: &str, key: &str) -> Result<()> { + self.preserve_current_resumable_upload(bucket, key)?; let path = self.object_path(bucket, key); let versions_dir = path.join("versions"); let entries = match fs::read_dir(&versions_dir) { diff --git a/services/s3/tests/objects_parity.rs b/services/s3/tests/objects_parity.rs index 9740e5b..c6fc47a 100644 --- a/services/s3/tests/objects_parity.rs +++ b/services/s3/tests/objects_parity.rs @@ -35,6 +35,251 @@ fn keys(objs: &[Object]) -> Vec { objs.iter().map(|o| o.key.clone()).collect() } +#[test] +fn resumable_receipt_failure_blocks_mutation_before_committed_data_is_lost() { + use devcloud_s3::model::{ObjectLegalHold, ObjectRetention}; + use devcloud_s3::objops::UpdateObjectMetadataInput; + + for operation in [ + "put", + "metadata", + "acl", + "retention", + "legal-hold", + "delete", + "delete-version", + ] { + let root = tempdir(); + let store = FileBucketStore::new(&root); + store.create_bucket("data").unwrap(); + store.put_bucket_versioning("data", "Enabled").unwrap(); + let id = "0123456789abcdef0123456789abcdef"; + let original = store + .put_resumable_object( + PutObjectInput { + bucket: "data".to_string(), + key: "key".to_string(), + body: b"committed".to_vec(), + ..Default::default() + }, + id, + ) + .unwrap(); + let receipt = root.join(format!(".gcs-upload-{id}.json")); + std::fs::remove_file(&receipt).unwrap(); + std::fs::create_dir(&receipt).unwrap(); + let result = match operation { + "put" => store + .put_object(PutObjectInput { + bucket: "data".to_string(), + key: "key".to_string(), + body: b"replacement".to_vec(), + ..Default::default() + }) + .map(|_| ()), + "metadata" => store + .update_object_metadata(UpdateObjectMetadataInput { + bucket: "data".to_string(), + key: "key".to_string(), + content_type: "text/plain".to_string(), + ..Default::default() + }) + .map(|_| ()), + "acl" => store + .put_object_acl("data", "key", "", "public-read") + .map(|_| ()), + "retention" => store + .put_object_retention( + "data", + "key", + "", + ObjectRetention { + mode: "GOVERNANCE".to_string(), + retain_until_date: "2099-01-01T00:00:00Z".to_string(), + }, + ) + .map(|_| ()), + "legal-hold" => store + .put_object_legal_hold( + "data", + "key", + "", + ObjectLegalHold { + status: "ON".to_string(), + }, + ) + .map(|_| ()), + "delete" => store.delete_object("data", "key").map(|_| ()), + "delete-version" => store + .delete_object_version("data", "key", &original.version_id, false) + .map(|_| ()), + _ => unreachable!(), + }; + assert!( + result.is_err(), + "{operation} must preserve the receipt before mutating" + ); + assert_eq!( + store.get_object("data", "key").unwrap().unwrap(), + (original.clone(), b"committed".to_vec()), + "{operation}" + ); + assert_eq!( + store + .get_object_version("data", "key", &original.version_id) + .unwrap() + .unwrap(), + (original.clone(), b"committed".to_vec()), + "{operation}" + ); + std::fs::remove_dir(&receipt).unwrap(); + assert_eq!( + store + .get_resumable_upload_result("data", Some("key"), id) + .unwrap(), + Some(original) + ); + std::fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn explicit_version_operations_recover_an_interrupted_resumable_snapshot() { + use devcloud_s3::model::{ObjectLegalHold, ObjectRetention}; + + for operation in ["replay", "acl", "retention", "legal-hold", "delete"] { + for missing in ["snapshot", "body"] { + let root = tempdir(); + let store = FileBucketStore::new(&root); + store.create_bucket("data").unwrap(); + store.put_bucket_versioning("data", "Enabled").unwrap(); + let id = "0123456789abcdef0123456789abcdef"; + let original = store + .put_resumable_object( + PutObjectInput { + bucket: "data".to_string(), + key: "key".to_string(), + body: b"committed".to_vec(), + ..Default::default() + }, + id, + ) + .unwrap(); + std::fs::remove_file(root.join(format!(".gcs-upload-{id}.json"))).unwrap(); + let version = store + .object_versions_path("data", "key") + .join(&original.version_id); + if missing == "snapshot" { + std::fs::remove_dir_all(&version).unwrap(); + } else { + std::fs::remove_file(version.join("body")).unwrap(); + } + match operation { + "replay" => assert_eq!( + store + .get_resumable_upload_result("data", Some("key"), id) + .unwrap(), + Some(original.clone()) + ), + "acl" => assert!(store + .put_object_acl("data", "key", &original.version_id, "public-read") + .unwrap()), + "retention" => assert!(store + .put_object_retention( + "data", + "key", + &original.version_id, + ObjectRetention { + mode: "GOVERNANCE".to_string(), + retain_until_date: "2099-01-01T00:00:00Z".to_string(), + } + ) + .unwrap() + .is_some()), + "legal-hold" => assert!(store + .put_object_legal_hold( + "data", + "key", + &original.version_id, + ObjectLegalHold { + status: "ON".to_string() + } + ) + .unwrap() + .is_some()), + "delete" => assert!( + store + .delete_object_version("data", "key", &original.version_id, false) + .unwrap() + .1 + ), + _ => unreachable!(), + } + assert_eq!( + store + .get_resumable_upload_result("data", Some("key"), id) + .unwrap(), + Some(original.clone()) + ); + let version_after = store + .get_object_version("data", "key", &original.version_id) + .unwrap(); + if operation == "delete" { + assert!(version_after.is_none()); + assert!(store.get_object("data", "key").unwrap().is_none()); + } else { + assert_eq!(version_after.unwrap().1, b"committed"); + } + std::fs::remove_dir_all(root).unwrap(); + } + } +} + +#[test] +fn resumable_receipts_are_bound_to_the_upload_bucket_and_key() { + let root = tempdir(); + let store = FileBucketStore::new(&root); + store.create_bucket("first").unwrap(); + store.create_bucket("second").unwrap(); + let id = "0123456789abcdef0123456789abcdef"; + store + .put_resumable_object( + PutObjectInput { + bucket: "first".to_string(), + key: "key".to_string(), + body: b"committed".to_vec(), + ..Default::default() + }, + id, + ) + .unwrap(); + assert!(store + .get_resumable_upload_result("second", None, id) + .unwrap() + .is_none()); + assert!(store + .get_resumable_upload_result("first", Some("other-key"), id) + .unwrap() + .is_none()); + assert!(store + .get_resumable_upload_result("first", None, "../key") + .unwrap() + .is_none()); + assert!(store + .put_resumable_object( + PutObjectInput { + bucket: "first".to_string(), + key: "other-key".to_string(), + body: b"replacement".to_vec(), + ..Default::default() + }, + id + ) + .is_err()); + assert!(store.get_object("first", "other-key").unwrap().is_none()); + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn delete_marker_json_matches_oracle() { // A delete marker leaves createdAt at the legacy zero time and always emits the diff --git a/services/s3/tests/shared_root_parity.rs b/services/s3/tests/shared_root_parity.rs index bd292ba..2310e61 100644 --- a/services/s3/tests/shared_root_parity.rs +++ b/services/s3/tests/shared_root_parity.rs @@ -2,8 +2,9 @@ //! one storage root, as S3, GCS, BigQuery, and Redshift do under the //! orchestrator. -use devcloud_s3::objops::{CreateMultipartUploadInput, PutObjectInput}; -use devcloud_s3::store::{FileBucketStore, StoreError}; +use devcloud_s3::model::Object; +use devcloud_s3::objops::{CreateMultipartUploadInput, PutObjectInput, UpdateObjectMetadataInput}; +use devcloud_s3::store::{ConditionalError, FileBucketStore, StoreError}; fn tempdir() -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; @@ -25,6 +26,240 @@ fn put_input(key: &str, body: &[u8]) -> PutObjectInput { } } +#[test] +fn provider_conditions_and_mutations_exclude_a_competing_writer() { + use std::sync::mpsc; + use std::time::Duration; + + for operation in ["put", "resumable", "metadata", "delete"] { + let root = tempdir(); + let provider = FileBucketStore::new(&root); + let s3 = FileBucketStore::new(root.join(".")); + provider.create_bucket("data").unwrap(); + let creates = operation == "put" || operation == "resumable"; + let original = if creates { + None + } else { + Some(provider.put_object(put_input("key", b"original")).unwrap()) + }; + let (checked_tx, checked_rx) = mpsc::channel(); + let (continue_tx, continue_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + + std::thread::scope(|scope| { + let predicate = move |current: Option<&Object>| -> Result<(), &'static str> { + assert_eq!(current, original.as_ref()); + checked_tx.send(()).unwrap(); + continue_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(()) + }; + let mutation = scope.spawn(|| match operation { + "put" => provider + .put_object_if(put_input("key", b"provider"), predicate) + .map(|_| ()), + "resumable" => provider + .put_resumable_object_if( + put_input("key", b"provider"), + "0123456789abcdef0123456789abcdef", + predicate, + ) + .map(|_| ()), + "metadata" => provider + .update_object_metadata_if( + UpdateObjectMetadataInput { + bucket: "data".to_string(), + key: "key".to_string(), + content_type: "text/plain".to_string(), + ..Default::default() + }, + predicate, + ) + .map(|object| assert!(object.is_some())), + "delete" => provider + .delete_object_if("data", "key", predicate) + .map(|deleted| assert!(deleted)), + _ => unreachable!(), + }); + checked_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let competitor = scope.spawn(|| { + started_tx.send(()).unwrap(); + let result = if creates { + s3.put_object_if_absent(put_input("key", b"competitor")) + } else { + s3.put_object(put_input("key", b"competitor")) + }; + done_tx.send(result).unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + // The competing S3 write cannot complete after the provider has + // inspected the current object but before its mutation commits. + assert!(matches!( + done_rx.recv_timeout(Duration::from_millis(100)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + continue_tx.send(()).unwrap(); + mutation.join().unwrap().unwrap(); + let competing_result = done_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + competitor.join().unwrap(); + if creates { + assert!(matches!( + competing_result, + Err(StoreError::PreconditionFailed) + )); + } else { + competing_result.unwrap(); + } + }); + let (object, body) = s3.get_object_snapshot("data", "key").unwrap().unwrap(); + if creates { + assert_eq!(body, b"provider", "{operation}"); + } else { + assert_eq!(body, b"competitor", "{operation}"); + assert_eq!(object.metageneration, 1, "{operation}"); + assert_eq!(object.content_type, "application/octet-stream"); + } + std::fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn rejected_provider_conditions_leave_data_and_versions_unchanged() { + let root = tempdir(); + let store = FileBucketStore::new(&root); + store.create_bucket("data").unwrap(); + store.put_bucket_versioning("data", "Enabled").unwrap(); + let original = store.put_object(put_input("key", b"original")).unwrap(); + for operation in ["put", "resumable", "metadata", "delete"] { + let predicate = |current: Option<&Object>| { + assert_eq!(current, Some(&original)); + Err::<(), _>("provider condition failed") + }; + let result = match operation { + "put" => store + .put_object_if(put_input("key", b"replacement"), predicate) + .map(|_| ()), + "resumable" => store + .put_resumable_object_if( + put_input("key", b"replacement"), + "0123456789abcdef0123456789abcdef", + predicate, + ) + .map(|_| ()), + "metadata" => store + .update_object_metadata_if( + UpdateObjectMetadataInput { + bucket: "data".to_string(), + key: "key".to_string(), + content_type: "text/plain".to_string(), + ..Default::default() + }, + predicate, + ) + .map(|_| ()), + "delete" => store.delete_object_if("data", "key", predicate).map(|_| ()), + _ => unreachable!(), + }; + assert!(matches!( + result, + Err(ConditionalError::Condition("provider condition failed")) + )); + assert_eq!( + store.get_object_snapshot("data", "key").unwrap().unwrap(), + (original.clone(), b"original".to_vec()), + "{operation}" + ); + assert_eq!( + store.list_object_versions("data", "key").unwrap().unwrap(), + vec![original.clone()], + "{operation}" + ); + } + assert!(!root + .join(".gcs-upload-0123456789abcdef0123456789abcdef.json") + .exists()); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn conditional_resumable_replay_returns_the_commit_before_checking_conditions() { + for later_state in ["receipt", "marker", "overwrite", "delete", "delete-bucket"] { + let root = tempdir(); + let store = FileBucketStore::new(&root); + store.create_bucket("data").unwrap(); + let id = "0123456789abcdef0123456789abcdef"; + let original = store + .put_resumable_object_if(put_input("key", b"original"), id, |current| { + assert!(current.is_none()); + Ok::<(), &'static str>(()) + }) + .unwrap(); + match later_state { + "receipt" => {} + "marker" => std::fs::remove_file(root.join(format!(".gcs-upload-{id}.json"))).unwrap(), + "overwrite" => { + store.put_object(put_input("key", b"newer")).unwrap(); + } + "delete" | "delete-bucket" => { + store.delete_object("data", "key").unwrap(); + if later_state == "delete-bucket" { + store.delete_bucket("data").unwrap(); + } + } + _ => unreachable!(), + } + let replayed = store + .put_resumable_object_if( + put_input("key", b"must not replace"), + id, + |_| -> Result<(), &'static str> { + panic!("an existing commit must precede provider conditions") + }, + ) + .unwrap(); + assert_eq!(replayed, original, "{later_state}"); + if later_state == "delete-bucket" { + assert!(store.get_bucket("data").unwrap().is_none()); + } else { + let current = store.get_object_snapshot("data", "key").unwrap(); + match later_state { + "delete" => assert!(current.is_none()), + "overwrite" => assert_eq!(current.unwrap().1, b"newer"), + _ => assert_eq!(current.unwrap(), (original, b"original".to_vec())), + } + } + std::fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn provider_condition_treats_delete_markers_as_absent_and_keeps_store_errors() { + let root = tempdir(); + let store = FileBucketStore::new(&root); + store.create_bucket("data").unwrap(); + store.put_bucket_versioning("data", "Enabled").unwrap(); + store.put_object(put_input("key", b"original")).unwrap(); + store.delete_object("data", "key").unwrap(); + store + .put_object_if(put_input("key", b"recreated"), |current| { + assert!(current.is_none()); + Ok::<(), &'static str>(()) + }) + .unwrap(); + let error = store.delete_object_if("missing", "key", |_| -> Result<(), &'static str> { + panic!("a missing bucket is a store error") + }); + assert!(matches!( + error, + Err(ConditionalError::Store(StoreError::BucketNotExist)) + )); + assert_eq!( + store.get_object_snapshot("data", "key").unwrap().unwrap().1, + b"recreated" + ); + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn conditional_puts_across_instances_admit_one_writer() { let root = tempdir(); diff --git a/services/s3/tests/store_parity.rs b/services/s3/tests/store_parity.rs index eb6315a..6c0d81c 100644 --- a/services/s3/tests/store_parity.rs +++ b/services/s3/tests/store_parity.rs @@ -97,6 +97,24 @@ fn bucket_crud_matches_oracle() { assert_eq!(rel_tree(&root), vec!["bravo/", "bravo/bucket.json"]); } +#[test] +fn create_bucket_recovers_directory_left_before_metadata_was_committed() { + let root = tempdir(); + let store = FileBucketStore::new(&root); + // A failed write (or process interruption) after mkdir leaves this exact + // state. Retrying must persist a real bucket instead of returning a blank + // default resource and a false success. + std::fs::create_dir_all(store.bucket_path("retry-bucket")).unwrap(); + let (bucket, created) = store.create_bucket("retry-bucket").unwrap(); + assert!(created); + assert_eq!(bucket.name, "retry-bucket"); + let persisted = store.get_bucket("retry-bucket").unwrap().unwrap(); + assert_eq!(persisted.name, bucket.name); + assert_eq!(persisted.created_at, bucket.created_at); + assert!(!store.create_bucket("retry-bucket").unwrap().1); + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn delete_rejects_non_empty_bucket() { let root = tempdir(); diff --git a/services/sqs/src/http.rs b/services/sqs/src/http.rs index 98294ec..d125863 100644 --- a/services/sqs/src/http.rs +++ b/services/sqs/src/http.rs @@ -15,7 +15,7 @@ use std::sync::Mutex; use std::time::Duration; use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use crate::http_json::{mapped_error, parse_receive_message, receive_message_success, JsonOutcome}; @@ -277,11 +277,14 @@ enum Outcome { Query(QueryOutcome), } -async fn read_request(stream: &mut TcpStream) -> std::io::Result> { +async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> std::io::Result> { let mut buf = Vec::new(); let mut tmp = [0u8; 4096]; let header_end = loop { if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + if pos + 4 > MAX_HEADER_BYTES { + return Ok(None); + } break pos; } if buf.len() > MAX_HEADER_BYTES { @@ -308,14 +311,31 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result let mut headers = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { - headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + let key = k.trim().to_ascii_lowercase(); + if matches!(key.as_str(), "content-length" | "transfer-encoding") + && headers.contains_key(&key) + { + return Ok(None); + } + headers.insert(key, v.trim().to_string()); } } - let content_length: usize = headers - .get("content-length") - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + // This transport supports fixed-length request bodies only. Never + // dispatch an unsupported transfer coding as an empty request body. + if headers.contains_key("transfer-encoding") { + return Ok(None); + } + let content_length: usize = match headers.get("content-length") { + None => 0, + Some(value) if !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) => { + match value.parse() { + Ok(length) => length, + Err(_) => return Ok(None), + } + } + Some(_) => return Ok(None), + }; if content_length > MAX_BODY_BYTES { return Ok(None); } @@ -324,7 +344,7 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result while body.len() < content_length { let n = stream.read(&mut tmp).await?; if n == 0 { - break; + return Ok(None); } body.extend_from_slice(&tmp[..n]); } @@ -466,3 +486,61 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { } haystack.windows(needle.len()).position(|w| w == needle) } + +#[cfg(test)] +mod request_framing_tests { + use super::*; + + #[tokio::test] + async fn incomplete_fixed_length_body_is_rejected() { + // A complete JSON value is still an incomplete HTTP message. It must + // never reach a mutating handler after the client disconnects early. + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 9\r\n\r\n{}"[..]; + assert!(read_request(&mut wire).await.unwrap().is_none()); + } + + #[tokio::test] + async fn invalid_content_length_is_rejected() { + for value in ["bad", "-1", "+2", "", "18446744073709551616"] { + let wire = format!("POST / HTTP/1.1\r\nContent-Length: {value}\r\n\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{value:?}" + ); + } + } + + #[tokio::test] + async fn ambiguous_or_unsupported_body_framing_is_rejected() { + for headers in [ + "Transfer-Encoding: gzip\r\n", + "Transfer-Encoding: xchunked\r\n", + "Transfer-Encoding: chunked, gzip\r\n", + "Content-Length: 9\r\nContent-Length: 2\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n", + ] { + let wire = format!("POST / HTTP/1.1\r\n{headers}\r\n{{}}"); + assert!( + read_request(&mut wire.as_bytes()).await.unwrap().is_none(), + "{headers:?}" + ); + } + } + + #[tokio::test] + async fn complete_fixed_length_body_is_preserved() { + let mut wire = &b"POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"[..]; + let request = read_request(&mut wire).await.unwrap().unwrap(); + assert_eq!(request.body, b"{}"); + } + + #[tokio::test] + async fn oversized_header_is_rejected_even_when_terminator_arrives() { + let wire = format!( + "POST / HTTP/1.1\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ); + assert!(read_request(&mut wire.as_bytes()).await.unwrap().is_none()); + } +} diff --git a/services/sqs/src/sigv4.rs b/services/sqs/src/sigv4.rs index f8e8e0a..66e0831 100644 --- a/services/sqs/src/sigv4.rs +++ b/services/sqs/src/sigv4.rs @@ -266,8 +266,11 @@ fn url_decode(s: &str) -> String { while i < bytes.len() { match bytes[i] { b'%' if i + 2 < bytes.len() => { - if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { - out.push(b); + if let (Some(hi), Some(lo)) = ( + (bytes[i + 1] as char).to_digit(16), + (bytes[i + 2] as char).to_digit(16), + ) { + out.push((hi * 16 + lo) as u8); i += 3; continue; } @@ -326,3 +329,20 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { } diff == 0 } + +#[cfg(test)] +mod percent_decode_tests { + use super::*; + + #[test] + fn malformed_percent_escapes_next_to_unicode_do_not_panic() { + for value in ["%あ", "%aあ", "猫%猫", "%é", "%💾", "%", "%2", "%zz"] { + assert_eq!(url_decode(value), value); + } + } + + #[test] + fn valid_percent_escapes_and_plus_are_preserved() { + assert_eq!(url_decode("%E7%8C%AB+%2B%2F"), "猫 +/"); + } +} diff --git a/web/dashboard/package-lock.json b/web/dashboard/package-lock.json index a40e54c..b93e03f 100644 --- a/web/dashboard/package-lock.json +++ b/web/dashboard/package-lock.json @@ -16,18 +16,20 @@ "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "@vitejs/plugin-react": "4.7.0", + "playwright": "1.64.0", + "react-test-renderer": "18.3.1", "typescript": "5.9.3", - "vite": "6.4.2" + "vite": "6.4.4" } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -36,9 +38,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -46,21 +48,21 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "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", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -77,14 +79,14 @@ } }, "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==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -94,14 +96,14 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -111,9 +113,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -121,29 +123,29 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -163,9 +165,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -173,9 +175,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -183,9 +185,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -193,27 +195,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "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==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -255,33 +257,33 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "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==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "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", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -289,14 +291,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -893,9 +895,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -910,9 +909,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -927,9 +923,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -944,9 +937,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -961,9 +951,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -978,9 +965,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -995,9 +979,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1012,9 +993,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1029,9 +1007,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1046,9 +1021,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1063,9 +1035,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1080,9 +1049,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1097,9 +1063,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1302,9 +1265,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.24", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", - "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1315,9 +1278,9 @@ } }, "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==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -1335,11 +1298,11 @@ ], "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" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -1349,9 +1312,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "dev": true, "funding": [ { @@ -1402,9 +1365,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.345", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz", - "integrity": "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, @@ -1565,9 +1528,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -1584,11 +1547,24 @@ } }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/picocolors": { "version": "1.1.1", @@ -1610,10 +1586,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -1631,9 +1636,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1664,6 +1669,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1674,6 +1686,35 @@ "node": ">=0.10.0" } }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.3.1", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -1739,9 +1780,9 @@ } }, "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==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1787,9 +1828,9 @@ "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==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -1818,9 +1859,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/dashboard/package.json b/web/dashboard/package.json index a27974c..b97b740 100644 --- a/web/dashboard/package.json +++ b/web/dashboard/package.json @@ -6,18 +6,22 @@ "scripts": { "build": "tsc -b && vite build", "dev": "vite", - "typecheck": "tsc -b" + "typecheck": "tsc -b", + "test": "node --test tests/*.test.mjs", + "test:browser": "node tests/browser-smoke.mjs" }, "dependencies": { "react": "18.3.1", "react-dom": "18.3.1" }, "devDependencies": { - "@vitejs/plugin-react": "4.7.0", - "typescript": "5.9.3", - "vite": "6.4.2", "@types/node": "22.10.7", "@types/react": "18.3.18", - "@types/react-dom": "18.3.5" + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.7.0", + "playwright": "1.64.0", + "react-test-renderer": "18.3.1", + "typescript": "5.9.3", + "vite": "6.4.4" } } diff --git a/web/dashboard/src/app/api/eventClient.ts b/web/dashboard/src/app/api/eventClient.ts index 2bf141c..bc30671 100644 --- a/web/dashboard/src/app/api/eventClient.ts +++ b/web/dashboard/src/app/api/eventClient.ts @@ -8,6 +8,7 @@ export type DashboardEvent = { type Listener = { topics: Set cb: (event: DashboardEvent) => void + onResync?: () => void } const BACKOFF_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000] @@ -28,10 +29,10 @@ class EventClient { private retryIndex = 0 private retryTimeout: number | null = null - subscribe(topics: string[], cb: (event: DashboardEvent) => void): () => void { + subscribe(topics: string[], cb: (event: DashboardEvent) => void, onResync?: () => void): () => void { const id = this.nextID this.nextID += 1 - this.listeners.set(id, { topics: new Set(topics), cb }) + this.listeners.set(id, { topics: new Set(topics), cb, onResync }) this.ensureOpen() let cancelled = false return () => { @@ -45,7 +46,7 @@ class EventClient { } private ensureOpen(): void { - if (this.ws) { + if (this.ws || this.retryTimeout !== null) { return } this.connect() @@ -65,16 +66,30 @@ class EventClient { this.ws = ws ws.onopen = () => { - this.retryIndex = 0 + if (this.ws === ws) { + this.retryIndex = 0 + } } ws.onmessage = (raw) => { + if (this.ws !== ws || typeof raw.data !== 'string') { + return + } try { - const event = JSON.parse(raw.data as string) as DashboardEvent - if (event.type === 'ready') { - return + // The dashboard forwards the relay's envelope verbatim. Only the + // nested event is a service event; hello/ping/resync are control frames. + const frame: unknown = JSON.parse(raw.data) + if (!isRecord(frame)) return + if (frame.type === 'event' && isDashboardEvent(frame.event)) { + this.dispatch(frame.event) + } else if (frame.type === 'hello') { + // The relay has no replay buffer. Refresh snapshots after reconnect + // and on first connect: a mutation can race the initial HTTP load + // or happen while the first WebSocket connection is still retrying. + this.resync() + } else if (frame.type === 'resync') { + this.resync() } - this.dispatch(event) } catch { /* malformed frame — ignore */ } @@ -86,6 +101,12 @@ class EventClient { return } this.ws = null + this.detach(ws) + try { + ws.close() + } catch { + /* a failed connection may already be closed */ + } if (this.listeners.size === 0) { return } @@ -122,14 +143,30 @@ class EventClient { } } + private resync(): void { + for (const listener of this.listeners.values()) { + try { + listener.onResync?.() + } catch { + /* one failed refresh must not prevent other subscribers refreshing */ + } + } + } + + private detach(ws: WebSocket): void { + ws.onopen = null + ws.onmessage = null + ws.onerror = null + ws.onclose = null + } + private close(): void { if (this.retryTimeout !== null) { window.clearTimeout(this.retryTimeout) this.retryTimeout = null } if (this.ws) { - this.ws.onerror = null - this.ws.onclose = null + this.detach(this.ws) try { this.ws.close(1000, 'no listeners') } catch { @@ -141,11 +178,24 @@ class EventClient { } } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isDashboardEvent(value: unknown): value is DashboardEvent { + return isRecord(value) + && typeof value.type === 'string' + && typeof value.service === 'string' + && typeof value.timestamp === 'string' + && (value.payload === undefined || isRecord(value.payload)) +} + const eventClient = new EventClient() export function subscribeDashboardEvents( topics: string[], cb: (event: DashboardEvent) => void, + onResync?: () => void, ): () => void { - return eventClient.subscribe(topics, cb) + return eventClient.subscribe(topics, cb, onResync) } diff --git a/web/dashboard/src/app/api/hooks/useDashboardEvents.ts b/web/dashboard/src/app/api/hooks/useDashboardEvents.ts index 25ee04a..c407f77 100644 --- a/web/dashboard/src/app/api/hooks/useDashboardEvents.ts +++ b/web/dashboard/src/app/api/hooks/useDashboardEvents.ts @@ -8,6 +8,8 @@ type UseDashboardEventsOptions = { topics: string[] /** Called whenever a matching event arrives. */ onEvent: (event: DashboardEvent) => void + /** Refresh snapshots after reconnect or a relay queue overflow. */ + onResync?: () => void /** Set to false to opt out of the shared WebSocket stream. */ enabled?: boolean } @@ -20,12 +22,15 @@ type UseDashboardEventsOptions = { export function useDashboardEvents({ topics, onEvent, + onResync, enabled = true, }: UseDashboardEventsOptions): void { const onEventRef = useRef(onEvent) + const onResyncRef = useRef(onResync) useEffect(() => { onEventRef.current = onEvent - }, [onEvent]) + onResyncRef.current = onResync + }, [onEvent, onResync]) const topicsKey = topics.slice().sort().join(',') @@ -34,9 +39,11 @@ export function useDashboardEvents({ return } const requestedTopics = topicsKey ? topicsKey.split(',') : [] - const unsubscribe = subscribeDashboardEvents(requestedTopics, (event) => { - onEventRef.current(event) - }) + const unsubscribe = subscribeDashboardEvents( + requestedTopics, + (event) => onEventRef.current(event), + () => onResyncRef.current?.(), + ) return unsubscribe }, [topicsKey, enabled]) } diff --git a/web/dashboard/src/app/api/hooks/useLatestRequest.ts b/web/dashboard/src/app/api/hooks/useLatestRequest.ts new file mode 100644 index 0000000..c90b035 --- /dev/null +++ b/web/dashboard/src/app/api/hooks/useLatestRequest.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useRef } from 'react' + +/** + * Start a new snapshot request and return a predicate for its callbacks. Only + * the latest request may publish a result or an error, including after an + * event refresh overtakes the initial load. Unmounting invalidates all work. + * Use a separate instance for independently refreshed state. + */ +export function useLatestRequest(): () => () => boolean { + const version = useRef(0) + const mounted = useRef(true) + + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + version.current += 1 + } + }, []) + + return useCallback(() => { + const requestVersion = ++version.current + return () => mounted.current && version.current === requestVersion + }, []) +} diff --git a/web/dashboard/src/app/services/applicationautoscaling/ApplicationAutoScalingDashboard.tsx b/web/dashboard/src/app/services/applicationautoscaling/ApplicationAutoScalingDashboard.tsx index 4e5f26b..43b41b7 100644 --- a/web/dashboard/src/app/services/applicationautoscaling/ApplicationAutoScalingDashboard.tsx +++ b/web/dashboard/src/app/services/applicationautoscaling/ApplicationAutoScalingDashboard.tsx @@ -3,6 +3,7 @@ import { Button } from '../../../ui/Button' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { getApplicationAutoScalingStatus, @@ -36,7 +37,9 @@ export function ApplicationAutoScalingDashboard({ service }: ApplicationAutoScal const [state, setState] = useState({ status: 'loading' }) const isDisabled = service?.status === 'disabled' + const startRefresh = useLatestRequest() const refresh = useCallback(() => { + const isCurrent = startRefresh() if (isDisabled) { setState({ status: 'success', @@ -56,6 +59,7 @@ export function ApplicationAutoScalingDashboard({ service }: ApplicationAutoScal listApplicationAutoScalingScheduledActions(), ]) .then(([statusPayload, targetsPayload, policiesPayload, actionsPayload]) => { + if (!isCurrent()) return setState({ status: 'success', statusPayload, @@ -65,15 +69,16 @@ export function ApplicationAutoScalingDashboard({ service }: ApplicationAutoScal }) }) .catch((error: Error) => { + if (!isCurrent()) return setState({ status: 'error', message: error.message }) }) - }, [isDisabled, service]) + }, [startRefresh, isDisabled, service]) useEffect(() => { refresh() }, [refresh]) - useDashboardEvents({ topics: ['applicationautoscaling'], onEvent: refresh, enabled: !isDisabled }) + useDashboardEvents({ topics: ['applicationautoscaling'], onEvent: refresh, onResync: refresh, enabled: !isDisabled }) if (isDisabled) { return ( diff --git a/web/dashboard/src/app/services/bigquery/BigQueryDashboard.tsx b/web/dashboard/src/app/services/bigquery/BigQueryDashboard.tsx index b96ad48..0344a6a 100644 --- a/web/dashboard/src/app/services/bigquery/BigQueryDashboard.tsx +++ b/web/dashboard/src/app/services/bigquery/BigQueryDashboard.tsx @@ -3,6 +3,7 @@ import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { Button } from '../../../ui/Button' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { getBigQueryStatus, @@ -34,7 +35,9 @@ export function BigQueryDashboard({ service }: BigQueryDashboardProps): JSX.Elem const [rowFilter, setRowFilter] = useState('') const isDisabled = service?.status === 'disabled' + const startRefreshCatalog = useLatestRequest() const refreshCatalog = useCallback(() => { + const isCurrent = startRefreshCatalog() if (isDisabled) { setCatalogState({ status: 'success', statusPayload: disabledStatus(service), datasets: [], jobs: [] }) setRowsState({ status: 'idle' }) @@ -49,6 +52,7 @@ export function BigQueryDashboard({ service }: BigQueryDashboardProps): JSX.Elem const project = projects.projects[0] const projectId = project?.projectId ?? statusPayload.project const [{ datasets }, { jobs }] = await Promise.all([listBigQueryDatasets(projectId), listBigQueryJobs(projectId)]) + if (!isCurrent()) return setCatalogState({ status: 'success', statusPayload, datasets, jobs }) setActiveDatasetId((current) => current && datasets.some((dataset) => dataset.datasetId === current) ? current : datasets[0]?.datasetId, @@ -62,22 +66,25 @@ export function BigQueryDashboard({ service }: BigQueryDashboardProps): JSX.Elem }) }) .catch((error: Error) => { + if (!isCurrent()) return setCatalogState({ status: 'error', message: error.message }) }) - }, [activeDatasetId, isDisabled, service]) + }, [startRefreshCatalog, activeDatasetId, isDisabled, service]) useEffect(() => { refreshCatalog() }, [refreshCatalog]) - useDashboardEvents({ topics: ['bigquery'], onEvent: refreshCatalog, enabled: !isDisabled }) + useDashboardEvents({ topics: ['bigquery'], onEvent: refreshCatalog, onResync: refreshCatalog, enabled: !isDisabled }) const datasets = catalogState.status === 'success' ? catalogState.datasets : [] const activeDataset = datasets.find((dataset) => dataset.datasetId === activeDatasetId) const activeTable = activeDataset?.tables.find((table) => table.tableId === activeTableId) const projectId = catalogState.status === 'success' ? catalogState.statusPayload.project : service?.endpoint + const startRefreshRows = useLatestRequest() const refreshRows = useCallback(() => { + const isCurrent = startRefreshRows() if (!activeDataset || !activeTable || catalogState.status !== 'success' || isDisabled) { setRowsState({ status: 'idle' }) return @@ -85,13 +92,15 @@ export function BigQueryDashboard({ service }: BigQueryDashboardProps): JSX.Elem setRowsState({ status: 'loading' }) listBigQueryRows(catalogState.statusPayload.project, activeDataset.datasetId, activeTable.tableId) .then(({ rows }) => { + if (!isCurrent()) return setActiveRowIndex(0) setRowsState({ status: 'success', rows }) }) .catch((error: Error) => { + if (!isCurrent()) return setRowsState({ status: 'error', message: error.message }) }) - }, [activeDataset, activeTable, catalogState, isDisabled]) + }, [startRefreshRows, activeDataset, activeTable, catalogState, isDisabled]) useEffect(() => { refreshRows() diff --git a/web/dashboard/src/app/services/cloudrun/CloudRunDashboard.tsx b/web/dashboard/src/app/services/cloudrun/CloudRunDashboard.tsx index 86a414b..0b86a0a 100644 --- a/web/dashboard/src/app/services/cloudrun/CloudRunDashboard.tsx +++ b/web/dashboard/src/app/services/cloudrun/CloudRunDashboard.tsx @@ -3,6 +3,7 @@ import { Button } from '../../../ui/Button' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { getCloudRunLogs, getCloudRunStatus, listCloudRunInstances, listCloudRunRevisions, listCloudRunServices } from './api' import type { CloudRunInstance, CloudRunRevision, CloudRunService, CloudRunStatus } from './types' @@ -30,12 +31,15 @@ export function CloudRunDashboard({ service }: CloudRunDashboardProps): JSX.Elem const [detail, setDetail] = useState() const isDisabled = service?.status === 'disabled' + const startRefresh = useLatestRequest() const refresh = useCallback(() => { + const isCurrent = startRefresh() if (isDisabled) { return } Promise.all([getCloudRunStatus(), listCloudRunServices(), listCloudRunInstances()]) .then(([statusPayload, servicesPayload, instancesPayload]) => { + if (!isCurrent()) return setState({ status: 'success', statusPayload, @@ -49,9 +53,10 @@ export function CloudRunDashboard({ service }: CloudRunDashboardProps): JSX.Elem ) }) .catch((error: Error) => { + if (!isCurrent()) return setState({ status: 'error', message: error.message }) }) - }, [isDisabled]) + }, [startRefresh, isDisabled]) useEffect(() => { refresh() @@ -79,7 +84,7 @@ export function CloudRunDashboard({ service }: CloudRunDashboardProps): JSX.Elem } }, [selected, isDisabled, state]) - useDashboardEvents({ topics: ['cloudrun'], onEvent: refresh, enabled: !isDisabled }) + useDashboardEvents({ topics: ['cloudrun'], onEvent: refresh, onResync: refresh, enabled: !isDisabled }) if (isDisabled) { return ( diff --git a/web/dashboard/src/app/services/dynamodb/DynamoDBDashboard.tsx b/web/dashboard/src/app/services/dynamodb/DynamoDBDashboard.tsx index 57e85c8..e946f9d 100644 --- a/web/dashboard/src/app/services/dynamodb/DynamoDBDashboard.tsx +++ b/web/dashboard/src/app/services/dynamodb/DynamoDBDashboard.tsx @@ -3,6 +3,7 @@ import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { Button } from '../../../ui/Button' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { createDynamoDBTable, @@ -104,7 +105,9 @@ export function DynamoDBDashboard({ service }: DynamoDBDashboardProps): JSX.Elem writeRecentDynamoDBOperations(recentOperations) }, [recentOperations]) + const startRefreshTables = useLatestRequest() const refreshTables = useCallback(() => { + const isCurrent = startRefreshTables() if (isDisabled) { setTablesState({ status: 'success', statusPayload: disabledStatus(service), tables: [] }) setItemsState({ status: 'idle' }) @@ -116,26 +119,30 @@ export function DynamoDBDashboard({ service }: DynamoDBDashboardProps): JSX.Elem setTablesState({ status: 'loading' }) Promise.all([getDynamoDBStatus(), listDynamoDBTables()]) .then(([statusPayload, { tables }]) => { + if (!isCurrent()) return setTablesState({ status: 'success', statusPayload, tables }) setActiveTableName((current) => current && tables.some((table) => table.tableName === current) ? current : tables[0]?.tableName, ) }) .catch((error: Error) => { + if (!isCurrent()) return setTablesState({ status: 'error', message: error.message }) }) - }, [isDisabled, service]) + }, [startRefreshTables, isDisabled, service]) useEffect(() => { refreshTables() }, [refreshTables]) - useDashboardEvents({ topics: ['dynamodb'], onEvent: refreshTables, enabled: !isDisabled }) + useDashboardEvents({ topics: ['dynamodb'], onEvent: refreshTables, onResync: refreshTables, enabled: !isDisabled }) const tables = tablesState.status === 'success' ? tablesState.tables : [] const activeTable = tables.find((table) => table.tableName === activeTableName) + const startRefreshItems = useLatestRequest() const refreshItems = useCallback(() => { + const isCurrent = startRefreshItems() if (!activeTableName || isDisabled) { setItemsState({ status: 'idle' }) return @@ -143,19 +150,23 @@ export function DynamoDBDashboard({ service }: DynamoDBDashboardProps): JSX.Elem setItemsState({ status: 'loading' }) listDynamoDBItems(activeTableName, normalizedItemLimit(itemLimit)) .then(({ items }) => { + if (!isCurrent()) return setActiveItemIndex(0) setItemsState({ status: 'success', items }) }) .catch((error: Error) => { + if (!isCurrent()) return setItemsState({ status: 'error', message: error.message }) }) - }, [activeTableName, isDisabled, itemLimit]) + }, [startRefreshItems, activeTableName, isDisabled, itemLimit]) useEffect(() => { refreshItems() }, [refreshItems]) + const startRefreshTableDetail = useLatestRequest() const refreshTableDetail = useCallback(() => { + const isCurrent = startRefreshTableDetail() if (!activeTableName || isDisabled) { setTableDetailState({ status: 'idle' }) return @@ -168,6 +179,7 @@ export function DynamoDBDashboard({ service }: DynamoDBDashboardProps): JSX.Elem getDynamoDBStreams(activeTableName), ]) .then(([table, indexes, ttl, streams]) => { + if (!isCurrent()) return setTableDetailState({ status: 'success', table: table.table, @@ -178,9 +190,10 @@ export function DynamoDBDashboard({ service }: DynamoDBDashboardProps): JSX.Elem }) }) .catch((error: Error) => { + if (!isCurrent()) return setTableDetailState({ status: 'error', message: error.message }) }) - }, [activeTableName, isDisabled]) + }, [startRefreshTableDetail, activeTableName, isDisabled]) useEffect(() => { refreshTableDetail() diff --git a/web/dashboard/src/app/services/gcs/GCSDashboard.tsx b/web/dashboard/src/app/services/gcs/GCSDashboard.tsx index dca3ecd..9735744 100644 --- a/web/dashboard/src/app/services/gcs/GCSDashboard.tsx +++ b/web/dashboard/src/app/services/gcs/GCSDashboard.tsx @@ -4,6 +4,7 @@ import { Panel } from '../../../ui/Panel' import { Button } from '../../../ui/Button' import { dangerConfirm, useConfirm } from '../../../ui/Confirm' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { createGCSBucket, @@ -38,24 +39,30 @@ export function GCSDashboard({ service }: GCSDashboardProps): JSX.Element { const [objectsState, setObjectsState] = useState({ status: 'idle' }) const [activeBucket, setActiveBucket] = useState() const [activeObject, setActiveObject] = useState() + const [inspectionTarget, setInspectionTarget] = useState<{ bucket: string; name: string }>() const [prefix, setPrefix] = useState('') const [bucketName, setBucketName] = useState('') const [message, setMessage] = useState() const [objectsRefreshNonce, setObjectsRefreshNonce] = useState(0) const isDisabled = service?.status === 'disabled' const confirm = useConfirm() + const inspectedObjectName = inspectionTarget?.bucket === activeBucket ? inspectionTarget?.name : undefined + const startRefresh = useLatestRequest() const refresh = useCallback(() => { + const isCurrent = startRefresh() if (isDisabled) { setDashboardState({ status: 'success', statusPayload: disabledStatus(service), buckets: [], sessions: [] }) setObjectsState({ status: 'idle' }) setActiveBucket(undefined) setActiveObject(undefined) + setInspectionTarget(undefined) return } setDashboardState({ status: 'loading' }) Promise.all([getGCSStatus(), listGCSBuckets(), listGCSUploadSessions()]) .then(([statusPayload, bucketsResponse, sessionsResponse]) => { + if (!isCurrent()) return setDashboardState({ status: 'success', statusPayload, @@ -69,9 +76,10 @@ export function GCSDashboard({ service }: GCSDashboardProps): JSX.Element { ) }) .catch((error: Error) => { + if (!isCurrent()) return setDashboardState({ status: 'error', message: error.message }) }) - }, [isDisabled, service]) + }, [startRefresh, isDisabled, service]) useEffect(() => { refresh() @@ -87,10 +95,19 @@ export function GCSDashboard({ service }: GCSDashboardProps): JSX.Element { [refresh], ) - useDashboardEvents({ topics: ['gcs'], onEvent: onGCSEvent, enabled: !isDisabled }) + useDashboardEvents({ + topics: ['gcs'], + onEvent: onGCSEvent, + onResync: () => { + refresh() + setObjectsRefreshNonce((n) => n + 1) + }, + enabled: !isDisabled, + }) useEffect(() => { setActiveObject(undefined) + setInspectionTarget(undefined) if (!activeBucket || isDisabled) { setObjectsState({ status: 'idle' }) return @@ -113,6 +130,24 @@ export function GCSDashboard({ service }: GCSDashboardProps): JSX.Element { } }, [activeBucket, isDisabled, prefix, objectsRefreshNonce]) + useEffect(() => { + setActiveObject(undefined) + if (!inspectionTarget || inspectionTarget.bucket !== activeBucket || isDisabled) { + return + } + let cancelled = false + getGCSObject(inspectionTarget.bucket, inspectionTarget.name) + .then((object) => { + if (!cancelled) setActiveObject(object) + }) + .catch((error: Error) => { + if (!cancelled) setMessage(error.message) + }) + return () => { + cancelled = true + } + }, [activeBucket, inspectionTarget, isDisabled]) + const buckets = dashboardState.status === 'success' ? dashboardState.buckets : [] const sessions = dashboardState.status === 'success' ? dashboardState.sessions : [] const statusPayload = dashboardState.status === 'success' ? dashboardState.statusPayload : undefined @@ -120,15 +155,14 @@ export function GCSDashboard({ service }: GCSDashboardProps): JSX.Element { function selectBucket(bucket: string): void { setActiveBucket(bucket) setActiveObject(undefined) + setInspectionTarget(undefined) } function inspectObject(object: GCSObjectSummary): void { if (!activeBucket) { return } - getGCSObject(activeBucket, object.name) - .then(setActiveObject) - .catch((error: Error) => setMessage(error.message)) + setInspectionTarget({ bucket: activeBucket, name: object.name }) } function createBucket(): void { @@ -184,9 +218,10 @@ export function GCSDashboard({ service }: GCSDashboardProps): JSX.Element { deleteGCSObject(activeBucket, object.name) .then(() => { setMessage(`Deleted object ${object.gcsUri}`) - setActiveObject(undefined) - setPrefix((current) => current) - refreshObjects(activeBucket, prefix, setObjectsState) + // A different bucket may be selected while deletion is in flight. + // Reload through the effect for the current selection, which also + // cancels obsolete list and inspector requests. + setObjectsRefreshNonce((n) => n + 1) }) .catch((error: Error) => setMessage(error.message)) } @@ -250,7 +285,7 @@ export function GCSDashboard({ service }: GCSDashboardProps): JSX.Element { void): void { - setObjectsState({ status: 'loading' }) - listGCSObjects(bucket, prefix.trim()) - .then(({ objects }) => setObjectsState({ status: 'success', objects })) - .catch((error: Error) => setObjectsState({ status: 'error', message: error.message })) -} - function disabledStatus(service?: DashboardService): GCSStatus { return { status: 'disabled', diff --git a/web/dashboard/src/app/services/lambda/LambdaDashboard.tsx b/web/dashboard/src/app/services/lambda/LambdaDashboard.tsx index 18d61cc..54f0b5b 100644 --- a/web/dashboard/src/app/services/lambda/LambdaDashboard.tsx +++ b/web/dashboard/src/app/services/lambda/LambdaDashboard.tsx @@ -4,6 +4,7 @@ import { Button } from '../../../ui/Button' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { getLambdaStatus, invokeLambdaFunction, listLambdaFunctions, listLambdaInvocations } from './api' import type { LambdaFunction, LambdaInvocation, LambdaInvokeResult, LambdaStatus } from './types' @@ -35,12 +36,15 @@ export function LambdaDashboard({ service }: LambdaDashboardProps): JSX.Element const invokeSeq = useRef(0) const isDisabled = service?.status === 'disabled' + const startRefresh = useLatestRequest() const refresh = useCallback(() => { + const isCurrent = startRefresh() if (isDisabled) { return } Promise.all([getLambdaStatus(), listLambdaFunctions(), listLambdaInvocations()]) .then(([statusPayload, functionsPayload, invocationsPayload]) => { + if (!isCurrent()) return setState({ status: 'success', statusPayload, @@ -54,15 +58,16 @@ export function LambdaDashboard({ service }: LambdaDashboardProps): JSX.Element ) }) .catch((error: Error) => { + if (!isCurrent()) return setState({ status: 'error', message: error.message }) }) - }, [isDisabled]) + }, [startRefresh, isDisabled]) useEffect(() => { refresh() }, [refresh]) - useDashboardEvents({ topics: ['lambda'], onEvent: refresh, enabled: !isDisabled }) + useDashboardEvents({ topics: ['lambda'], onEvent: refresh, onResync: refresh, enabled: !isDisabled }) if (isDisabled) { return ( diff --git a/web/dashboard/src/app/services/mail/MailDashboard.tsx b/web/dashboard/src/app/services/mail/MailDashboard.tsx index 2ee999c..05a45b5 100644 --- a/web/dashboard/src/app/services/mail/MailDashboard.tsx +++ b/web/dashboard/src/app/services/mail/MailDashboard.tsx @@ -4,6 +4,7 @@ import { dangerConfirm, useConfirm } from '../../../ui/Confirm' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { deleteAllMailMessages, getMailMessageRaw, listMailMessages } from './api' import { decodeMimeAddress, decodeMimeEncodedWord } from './mimeDecoder' @@ -25,7 +26,9 @@ export function MailDashboard({ service }: MailDashboardProps): JSX.Element { const [filter, setFilter] = useState('') const isDisabled = service?.status === 'disabled' + const startRefreshMessages = useLatestRequest() const refreshMessages = useCallback((): void => { + const isCurrent = startRefreshMessages() if (isDisabled) { setMessagesState({ status: 'success', messages: [] }) return @@ -33,15 +36,17 @@ export function MailDashboard({ service }: MailDashboardProps): JSX.Element { setMessagesState({ status: 'loading' }) listMailMessages() .then(({ messages }) => { + if (!isCurrent()) return setMessagesState({ status: 'success', messages }) setSelectedID((current) => current ?? messages[0]?.id) }) .catch((error: Error) => { + if (!isCurrent()) return setMessagesState({ status: 'error', message: error.message }) }) - }, [isDisabled]) + }, [startRefreshMessages, isDisabled]) - useDashboardEvents({ topics: ['mail'], onEvent: refreshMessages, enabled: !isDisabled }) + useDashboardEvents({ topics: ['mail'], onEvent: refreshMessages, onResync: refreshMessages, enabled: !isDisabled }) async function clearMessages(): Promise { if (isDisabled) { diff --git a/web/dashboard/src/app/services/pubsub/PubSubDashboard.tsx b/web/dashboard/src/app/services/pubsub/PubSubDashboard.tsx index 63aa9c3..166bf5e 100644 --- a/web/dashboard/src/app/services/pubsub/PubSubDashboard.tsx +++ b/web/dashboard/src/app/services/pubsub/PubSubDashboard.tsx @@ -3,6 +3,7 @@ import { Button } from '../../../ui/Button' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { ackPubSubMessages, @@ -51,7 +52,9 @@ export function PubSubDashboard({ service }: PubSubDashboardProps): JSX.Element const [busyAction, setBusyAction] = useState() const isDisabled = service?.status === 'disabled' + const startRefresh = useLatestRequest() const refresh = useCallback(() => { + const isCurrent = startRefresh() if (isDisabled) { setState({ status: 'success', statusPayload: disabledStatus(service), topics: [], subscriptions: [] }) setActiveSubscriptionName(undefined) @@ -61,6 +64,7 @@ export function PubSubDashboard({ service }: PubSubDashboardProps): JSX.Element setState({ status: 'loading' }) Promise.all([getPubSubStatus(), listPubSubTopics(), listPubSubSubscriptions()]) .then(([statusPayload, topics, subscriptions]) => { + if (!isCurrent()) return setState({ status: 'success', statusPayload, @@ -76,15 +80,16 @@ export function PubSubDashboard({ service }: PubSubDashboardProps): JSX.Element ) }) .catch((error: Error) => { + if (!isCurrent()) return setState({ status: 'error', message: error.message }) }) - }, [isDisabled, service]) + }, [startRefresh, isDisabled, service]) useEffect(() => { refresh() }, [refresh]) - useDashboardEvents({ topics: ['pubsub'], onEvent: refresh, enabled: !isDisabled }) + useDashboardEvents({ topics: ['pubsub'], onEvent: refresh, onResync: refresh, enabled: !isDisabled }) const topics = state.status === 'success' ? state.topics : [] const subscriptions = state.status === 'success' ? state.subscriptions : [] diff --git a/web/dashboard/src/app/services/redis/RedisDashboard.tsx b/web/dashboard/src/app/services/redis/RedisDashboard.tsx index 3a37155..abfa340 100644 --- a/web/dashboard/src/app/services/redis/RedisDashboard.tsx +++ b/web/dashboard/src/app/services/redis/RedisDashboard.tsx @@ -5,6 +5,7 @@ import { dangerConfirm, useConfirm } from '../../../ui/Confirm' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { deleteRedisKey, @@ -40,7 +41,9 @@ export function RedisDashboard({ service }: RedisDashboardProps): JSX.Element { const [message, setMessage] = useState() const isDisabled = service?.status === 'disabled' + const startRefresh = useLatestRequest() const refresh = useCallback(() => { + const isCurrent = startRefresh() if (isDisabled) { setState({ status: 'success', statusPayload: disabledStatus(service), keys: [], nextCursor: 0 }) setActiveKey(undefined) @@ -50,6 +53,7 @@ export function RedisDashboard({ service }: RedisDashboardProps): JSX.Element { setState({ status: 'loading' }) Promise.all([getRedisStatus(), listRedisKeys(0, match)]) .then(([statusPayload, keysPayload]) => { + if (!isCurrent()) return setState({ status: 'success', statusPayload, @@ -60,8 +64,11 @@ export function RedisDashboard({ service }: RedisDashboardProps): JSX.Element { current && keysPayload.keys.some((key) => key.key === current) ? current : keysPayload.keys[0]?.key, ) }) - .catch((error: Error) => setState({ status: 'error', message: error.message })) - }, [isDisabled, match, service]) + .catch((error: Error) => { + if (!isCurrent()) return + setState({ status: 'error', message: error.message }) + }) + }, [startRefresh, isDisabled, match, service]) const loadMore = useCallback(() => { if (state.status !== 'success' || state.nextCursor === 0 || isDisabled) { @@ -87,7 +94,7 @@ export function RedisDashboard({ service }: RedisDashboardProps): JSX.Element { refresh() }, [refresh]) - useDashboardEvents({ topics: ['redis'], onEvent: refresh, enabled: !isDisabled }) + useDashboardEvents({ topics: ['redis'], onEvent: refresh, onResync: refresh, enabled: !isDisabled }) useEffect(() => { if (!activeKey || isDisabled) { diff --git a/web/dashboard/src/app/services/redshift/RedshiftDashboard.tsx b/web/dashboard/src/app/services/redshift/RedshiftDashboard.tsx index 065c6ed..2f52948 100644 --- a/web/dashboard/src/app/services/redshift/RedshiftDashboard.tsx +++ b/web/dashboard/src/app/services/redshift/RedshiftDashboard.tsx @@ -4,6 +4,7 @@ import { Button } from '../../../ui/Button' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { getRedshiftCatalog, getRedshiftStatus, listRedshiftClusters, listRedshiftStatements, runRedshiftQuery } from './api' import type { RedshiftCatalog, RedshiftCluster, RedshiftQueryResult, RedshiftStatement, RedshiftStatus, RedshiftTable } from './types' @@ -29,7 +30,9 @@ export function RedshiftDashboard({ service }: RedshiftDashboardProps): JSX.Elem const [tableFilter, setTableFilter] = useState('') const isDisabled = service?.status === 'disabled' + const startRefresh = useLatestRequest() const refresh = useCallback(() => { + const isCurrent = startRefresh() if (isDisabled) { setState({ status: 'success', @@ -45,6 +48,7 @@ export function RedshiftDashboard({ service }: RedshiftDashboardProps): JSX.Elem setState({ status: 'loading' }) Promise.all([getRedshiftStatus(), listRedshiftClusters(), getRedshiftCatalog(), listRedshiftStatements()]) .then(([statusPayload, clustersPayload, catalogPayload, statementsPayload]) => { + if (!isCurrent()) return const tables = catalogPayload.catalog.tables setState({ status: 'success', @@ -56,15 +60,16 @@ export function RedshiftDashboard({ service }: RedshiftDashboardProps): JSX.Elem setActiveTableKey((current) => (current && tables.some((table) => tableKey(table) === current) ? current : tableKey(tables[0]))) }) .catch((error: Error) => { + if (!isCurrent()) return setState({ status: 'error', message: error.message }) }) - }, [isDisabled, service]) + }, [startRefresh, isDisabled, service]) useEffect(() => { refresh() }, [refresh]) - useDashboardEvents({ topics: ['redshift'], onEvent: refresh, enabled: !isDisabled }) + useDashboardEvents({ topics: ['redshift'], onEvent: refresh, onResync: refresh, enabled: !isDisabled }) const catalog = state.status === 'success' ? state.catalog : undefined const tables = catalog?.tables ?? [] diff --git a/web/dashboard/src/app/services/s3/S3Dashboard.tsx b/web/dashboard/src/app/services/s3/S3Dashboard.tsx index f93fc6a..2fca99c 100644 --- a/web/dashboard/src/app/services/s3/S3Dashboard.tsx +++ b/web/dashboard/src/app/services/s3/S3Dashboard.tsx @@ -4,6 +4,7 @@ import { Panel } from '../../../ui/Panel' import { Button } from '../../../ui/Button' import { dangerConfirm, useConfirm } from '../../../ui/Confirm' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { BucketSidebar } from './BucketSidebar' import { ObjectBrowser } from './ObjectBrowser' @@ -30,7 +31,9 @@ export function S3Dashboard({ service }: S3DashboardProps): JSX.Element { const isDisabled = service?.status === 'disabled' const confirm = useConfirm() + const startRefreshBuckets = useLatestRequest() const refreshBuckets = useCallback(() => { + const isCurrent = startRefreshBuckets() if (isDisabled) { setBucketsState({ status: 'success', buckets: [] }) setActiveBucket(undefined) @@ -40,15 +43,17 @@ export function S3Dashboard({ service }: S3DashboardProps): JSX.Element { setBucketsState({ status: 'loading' }) listS3Buckets() .then(({ buckets }) => { + if (!isCurrent()) return setBucketsState({ status: 'success', buckets }) setActiveBucket((current) => current && buckets.some((bucket) => bucket.name === current) ? current : buckets[0]?.name, ) }) .catch((error: Error) => { + if (!isCurrent()) return setBucketsState({ status: 'error', message: error.message }) }) - }, [isDisabled]) + }, [startRefreshBuckets, isDisabled]) useEffect(() => { refreshBuckets() @@ -66,7 +71,15 @@ export function S3Dashboard({ service }: S3DashboardProps): JSX.Element { [refreshBuckets], ) - useDashboardEvents({ topics: ['s3'], onEvent: onS3Event, enabled: !isDisabled }) + useDashboardEvents({ + topics: ['s3'], + onEvent: onS3Event, + onResync: () => { + refreshBuckets() + setObjectsRefreshNonce((n) => n + 1) + }, + enabled: !isDisabled, + }) const buckets = bucketsState.status === 'success' ? bucketsState.buckets : [] const selectedBucket = buckets.find((bucket) => bucket.name === activeBucket)?.name diff --git a/web/dashboard/src/app/services/sqs/SQSDashboard.tsx b/web/dashboard/src/app/services/sqs/SQSDashboard.tsx index ef93b07..d71d4ca 100644 --- a/web/dashboard/src/app/services/sqs/SQSDashboard.tsx +++ b/web/dashboard/src/app/services/sqs/SQSDashboard.tsx @@ -3,6 +3,7 @@ import { Button } from '../../../ui/Button' import { EmptyState } from '../../../ui/EmptyState' import { Panel } from '../../../ui/Panel' import { useDashboardEvents } from '../../api/hooks/useDashboardEvents' +import { useLatestRequest } from '../../api/hooks/useLatestRequest' import type { DashboardService } from '../dashboard/types' import { changeSQSMessageVisibility, @@ -78,7 +79,9 @@ export function SQSDashboard({ service }: SQSDashboardProps): JSX.Element { const [purgeError, setPurgeError] = useState() const isDisabled = service?.status === 'disabled' + const startRefreshQueues = useLatestRequest() const refreshQueues = useCallback(() => { + const isCurrent = startRefreshQueues() if (isDisabled) { setQueuesState({ status: 'success', statusPayload: disabledStatus(service), queues: [] }) setDetailState({ status: 'idle' }) @@ -89,26 +92,30 @@ export function SQSDashboard({ service }: SQSDashboardProps): JSX.Element { setQueuesState({ status: 'loading' }) Promise.all([getSQSStatus(), listSQSQueues()]) .then(([statusPayload, { queues }]) => { + if (!isCurrent()) return setQueuesState({ status: 'success', statusPayload, queues }) setActiveQueueName((current) => current && queues.some((queue) => queue.name === current) ? current : queues[0]?.name, ) }) .catch((error: Error) => { + if (!isCurrent()) return setQueuesState({ status: 'error', message: error.message }) }) - }, [isDisabled, service]) + }, [startRefreshQueues, isDisabled, service]) useEffect(() => { refreshQueues() }, [refreshQueues]) - useDashboardEvents({ topics: ['sqs'], onEvent: refreshQueues, enabled: !isDisabled }) + useDashboardEvents({ topics: ['sqs'], onEvent: refreshQueues, onResync: refreshQueues, enabled: !isDisabled }) const queues = queuesState.status === 'success' ? queuesState.queues : [] const activeQueue = queues.find((queue) => queue.name === activeQueueName) + const startRefreshDetail = useLatestRequest() const refreshDetail = useCallback(() => { + const isCurrent = startRefreshDetail() if (!activeQueueName || isDisabled) { setDetailState({ status: 'idle' }) return @@ -116,13 +123,15 @@ export function SQSDashboard({ service }: SQSDashboardProps): JSX.Element { setDetailState({ status: 'loading' }) Promise.all([listSQSMessages(activeQueueName), listSQSLeases(activeQueueName), getSQSDeadLetter(activeQueueName)]) .then(([messages, leases, dlq]) => { + if (!isCurrent()) return setActiveMessageIndex(0) setDetailState({ status: 'success', messages: messages.messages, leases: leases.leases, dlq }) }) .catch((error: Error) => { + if (!isCurrent()) return setDetailState({ status: 'error', message: error.message }) }) - }, [activeQueueName, isDisabled]) + }, [startRefreshDetail, activeQueueName, isDisabled]) useEffect(() => { refreshDetail() diff --git a/web/dashboard/tests/browser-smoke.mjs b/web/dashboard/tests/browser-smoke.mjs new file mode 100644 index 0000000..b84b3ff --- /dev/null +++ b/web/dashboard/tests/browser-smoke.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const repository = fileURLToPath(new URL('../../../', import.meta.url)) +const binary = resolve(process.env.DEVCLOUD_BINARY ?? join(repository, 'target/debug/devcloud')) +const output = resolve(process.env.PLAYWRIGHT_OUTPUT_DIR ?? join(repository, 'target/dashboard-browser')) +const workspace = await mkdtemp(join(tmpdir(), 'devcloud-dashboard-browser-')) +const reservations = [] +for (let index = 0; index < 3; index += 1) { + const server = createServer() + await new Promise((yes, no) => { server.once('error', no); server.listen(0, '127.0.0.1', yes) }) + reservations.push(server) +} +const [dashboardPort, relayPort, gcsPort] = reservations.map((server) => server.address().port) +await Promise.all(reservations.map((server) => new Promise((yes) => server.close(yes)))) +await mkdir(join(workspace, '.devcloud')) +await mkdir(output, { recursive: true }) +await writeFile(join(workspace, '.devcloud/config.yaml'), `project: dashboard-browser +server: + dashboardPort: ${dashboardPort} + eventRelayPort: ${relayPort} + gcsPort: ${gcsPort} +`) + +const processHandle = spawn(binary, ['up', 'gcs'], { cwd: workspace, stdio: ['ignore', 'pipe', 'pipe'] }) +let serverLog = '' +let spawnError +for (const stream of [processHandle.stdout, processHandle.stderr]) { + stream.on('data', (chunk) => { serverLog = (serverLog + chunk).slice(-16000) }) +} +const exited = new Promise((yes) => { + processHandle.once('exit', yes) + processHandle.once('error', (error) => { spawnError = error; yes() }) +}) +const dashboard = `http://127.0.0.1:${dashboardPort}` +const provider = `http://127.0.0.1:${gcsPort}` +let browser +let page + +async function waitUntil(condition, description) { + const deadline = Date.now() + 20000 + while (Date.now() < deadline) { + if (spawnError) throw spawnError + if (processHandle.exitCode !== null || processHandle.signalCode !== null) { + throw new Error(`devcloud exited while waiting for ${description}: ${serverLog}`) + } + if (await condition()) return + await delay(50) + } + throw new Error(`Timed out waiting for ${description}`) +} + +async function request(url, options) { + const response = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) }) + assert.ok(response.ok, `${options?.method ?? 'GET'} ${url}: ${response.status} ${await response.text()}`) +} + +async function upload(name) { + await request(`${provider}/upload/storage/v1/b/review-bucket/o?uploadType=media&name=${encodeURIComponent(name)}`, { + method: 'POST', headers: { 'content-type': 'text/plain' }, body: `Dashboard browser smoke: ${name}\n`, + }) +} + +try { + await waitUntil(async () => { + try { + const response = await fetch(`${provider}/storage/v1/b?project=dashboard-browser`, { signal: AbortSignal.timeout(1000) }) + return response.ok + } catch { return false } + }, 'GCS startup') + await request(`${provider}/storage/v1/b?project=dashboard-browser`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'review-bucket' }), + }) + await upload('before-connect.txt') + browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH }) + const context = await browser.newContext({ viewport: { width: 1440, height: 1080 }, deviceScaleFactor: 1 }) + await context.addInitScript(() => { + window.__smokeSockets = [] + const NativeWebSocket = window.WebSocket + window.WebSocket = class extends NativeWebSocket { + constructor(...args) { + super(...args) + window.__smokeSockets.push(this) + } + } + }) + page = await context.newPage() + page.setDefaultTimeout(20000) + const errors = [] + let helloCount = 0 + page.on('pageerror', (error) => errors.push(error.message)) + page.on('websocket', (socket) => { + socket.on('framereceived', ({ payload }) => { + try { if (JSON.parse(payload.toString()).type === 'hello') helloCount += 1 } catch { /* protocol control frame */ } + }) + }) + await page.goto(`${dashboard}/dashboard/gcs`) + await page.getByText('before-connect.txt', { exact: true }).waitFor() + await waitUntil(() => helloCount > 0, 'initial event subscription') + // Settle the hello-triggered snapshot before mutating; otherwise that initial + // request could include the new object and mask a broken event dispatcher. + await page.waitForLoadState('networkidle') + const registryResponse = await fetch(`${dashboard}/api/services`, { signal: AbortSignal.timeout(5000) }) + assert.equal(registryResponse.status, 200) + const registry = await registryResponse.json() + assert.equal(registry.services.length, 12) + assert.deepEqual(registry.services.filter((service) => service.status === 'running').map((service) => service.id), ['gcs'], + 'devcloud up gcs must report only GCS as running') + assert.ok(registry.services.every((service) => service.id === 'gcs' || service.status === 'disabled'), + 'unselected services must be disabled') + await page.getByText('1/12 running', { exact: true }).waitFor() + + // A provider mutation must reach the page through the real relay and proxy. + await upload('live-event.txt') + await page.getByText('live-event.txt', { exact: true }).waitFor() + + // Hold the browser offline so this mutation cannot be delivered as an event. + // The reconnect hello must refresh the snapshot without navigating the page. + const hellosBeforeDisconnect = helloCount + await context.setOffline(true) + await page.evaluate(() => { for (const socket of window.__smokeSockets) socket.close() }) + await page.waitForFunction(() => window.__smokeSockets.every((socket) => socket.readyState === WebSocket.CLOSED)) + await upload('after-reconnect.txt') + assert.equal(await page.getByText('after-reconnect.txt', { exact: true }).count(), 0) + await context.setOffline(false) + await waitUntil(() => helloCount > hellosBeforeDisconnect, 'event reconnect') + await page.getByText('after-reconnect.txt', { exact: true }).waitFor() + await page.getByText('after-reconnect.txt', { exact: true }).click() + await page.locator('.object-inspector').getByText('gs://review-bucket/after-reconnect.txt', { exact: true }).waitFor() + assert.deepEqual(errors, [], 'the dashboard must not throw in the browser') + await page.screenshot({ path: join(output, 'gcs-live-and-reconnect.png'), fullPage: true }) + await writeFile(join(output, 'result.json'), JSON.stringify({ + passed: ['selected service registry', 'initial snapshot', 'live provider event', 'snapshot after reconnect', 'object inspector'], + helloCount, pageErrors: errors, + }, null, 2) + '\n') + console.log('Dashboard browser smoke passed: selected service registry, initial snapshot, live event, reconnect snapshot, inspector.') +} catch (error) { + if (page) await page.screenshot({ path: join(output, 'failure.png'), fullPage: true }).catch(() => {}) + throw error +} finally { + await browser?.close().catch(() => {}) + await writeFile(join(output, 'devcloud.log'), serverLog) + if (processHandle.pid && processHandle.exitCode === null && processHandle.signalCode === null) { + processHandle.kill('SIGTERM') + await Promise.race([exited, delay(5000, undefined, { ref: false })]) + if (processHandle.exitCode === null && processHandle.signalCode === null) processHandle.kill('SIGKILL') + } + await exited + await rm(workspace, { recursive: true, force: true }) +} diff --git a/web/dashboard/tests/eventClient.test.mjs b/web/dashboard/tests/eventClient.test.mjs new file mode 100644 index 0000000..7f1e63e --- /dev/null +++ b/web/dashboard/tests/eventClient.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import ts from 'typescript' + +let moduleID = 0 + +async function setup(t) { + const timers = new Map() + let nextTimer = 0 + const sockets = [] + const subscriptions = [] + class WebSocketStub { + constructor(url) { + this.url = url + sockets.push(this) + } + close(...args) { this.closed = args } + open() { this.onopen?.() } + frame(value) { this.onmessage?.({ data: JSON.stringify(value) }) } + } + const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + const oldWebSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket') + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { protocol: 'https:', host: 'localhost:18025' }, + setTimeout(callback, delay) { + timers.set(++nextTimer, { callback, delay }) + return nextTimer + }, + clearTimeout(id) { timers.delete(id) }, + }, + }) + Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: WebSocketStub }) + t.after(() => { + for (const unsubscribe of subscriptions) unsubscribe() + if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow) + else delete globalThis.window + if (oldWebSocket) Object.defineProperty(globalThis, 'WebSocket', oldWebSocket) + else delete globalThis.WebSocket + }) + // Use the installed TypeScript compiler and Node's runner; no browser or + // additional test dependency is needed to exercise the public subscriber API. + const source = await readFile(new URL('../src/app/api/eventClient.ts', import.meta.url), 'utf8') + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }) + const { subscribeDashboardEvents } = await import( + `data:text/javascript;base64,${Buffer.from(outputText).toString('base64')}#${moduleID++}` + ) + return { + subscribe(...args) { + const unsubscribe = subscribeDashboardEvents(...args) + subscriptions.push(unsubscribe) + return unsubscribe + }, + sockets, + timers, + retry() { + assert.equal(timers.size, 1) + const [id, timer] = timers.entries().next().value + timers.delete(id) + timer.callback() + }, + } +} + +const mail = { + type: 'mail.received', service: 'mail', timestamp: '2026-10-12T00:00:00Z', + payload: { messageID: 'mail-1' }, +} +const s3 = { type: 's3.object.put', service: 's3', timestamp: mail.timestamp } + +test('unwraps actual relay frames and filters the nested service, without notifying on control frames', async (t) => { + const { subscribe, sockets } = await setup(t) + const mailEvents = [], s3Events = [], allEvents = [] + t.after(subscribe(['mail'], (event) => mailEvents.push(event))) + t.after(subscribe(['s3'], (event) => s3Events.push(event))) + t.after(subscribe([], (event) => allEvents.push(event))) + assert.equal(sockets.length, 1) + assert.equal(sockets[0].url, 'wss://localhost:18025/api/events') + sockets[0].open() + sockets[0].frame({ type: 'hello', seq: 0 }) + sockets[0].frame({ type: 'event', seq: 1, event: mail }) + sockets[0].frame({ type: 'event', seq: 2, event: s3 }) + sockets[0].frame({ type: 'ping', seq: 2 }) + sockets[0].frame({ type: 'resync', seq: 2, reason: 'slow-consumer' }) + assert.deepEqual(mailEvents, [mail]) + assert.deepEqual(s3Events, [s3]) + assert.deepEqual(allEvents, [mail, s3]) +}) + +test('refreshes each snapshot on resync and reconnect, without inventing service notifications', async (t) => { + const { subscribe, sockets, retry } = await setup(t) + let refreshes = 0 + const events = [] + t.after(subscribe(['s3'], () => {}, () => { throw new Error('subscriber failed') })) + t.after(subscribe(['mail'], (event) => events.push(event), () => refreshes++)) + sockets[0].open() + sockets[0].frame({ type: 'hello', seq: 0 }) + assert.equal(refreshes, 1) + sockets[0].frame({ type: 'resync', seq: 5, reason: 'slow-consumer' }) + assert.equal(refreshes, 2) + sockets[0].onclose() + retry() + sockets[1].open() + sockets[1].frame({ type: 'hello', seq: 10 }) + assert.equal(refreshes, 3) + assert.deepEqual(events, []) +}) + +test('refreshes after the first connection failed before any hello arrived', async (t) => { + const { subscribe, sockets, retry } = await setup(t) + let refreshes = 0 + subscribe(['mail'], () => {}, () => refreshes++) + // The initial HTTP snapshot may already have finished before this failure. + sockets[0].onerror() + retry() + sockets[1].open() + sockets[1].frame({ type: 'hello', seq: 12 }) + assert.equal(refreshes, 1) +}) + +test('does not deliver malformed frames or let a failing listener block other listeners', async (t) => { + const { subscribe, sockets } = await setup(t) + const received = [] + t.after(subscribe([], () => { throw new Error('subscriber failed') })) + t.after(subscribe([], (event) => received.push(event))) + for (const frame of [null, [], 42, {}, { type: 'event' }, + { type: 'event', event: { type: 'mail.received' } }, + { type: 'event', event: { ...mail, payload: [] } }]) { + sockets[0].frame(frame) + } + sockets[0].onmessage({ data: '{' }) + sockets[0].onmessage({ data: new Uint8Array([1, 2]) }) + sockets[0].frame({ type: 'event', seq: 1, event: mail }) + assert.deepEqual(received, [mail]) +}) + +test('closes failed sockets and preserves one pending reconnect when new listeners subscribe', async (t) => { + const { subscribe, sockets, timers, retry } = await setup(t) + t.after(subscribe(['mail'], () => {})) + const fail = sockets[0].onerror + fail() + fail() + assert.deepEqual(sockets[0].closed, []) + assert.equal(timers.values().next().value.delay, 1000) + t.after(subscribe(['s3'], () => {})) + assert.equal(sockets.length, 1) + retry() + assert.equal(sockets.length, 2) + sockets[1].onerror() + assert.equal(timers.values().next().value.delay, 2000) +}) + +test('last unsubscribe cancels reconnects and stale callbacks cannot reach new subscribers', async (t) => { + const { subscribe, sockets, timers } = await setup(t) + const unsubscribe = subscribe(['mail'], () => {}) + const oldMessage = sockets[0].onmessage + const oldClose = sockets[0].onclose + unsubscribe() + unsubscribe() + assert.deepEqual(sockets[0].closed, [1000, 'no listeners']) + assert.equal(sockets[0].onmessage, null) + const received = [] + const stop = subscribe(['mail'], (event) => received.push(event)) + oldMessage({ data: JSON.stringify({ type: 'event', seq: 1, event: mail }) }) + oldClose() + assert.deepEqual(received, []) + assert.equal(timers.size, 0) + sockets[1].onerror() + assert.equal(timers.size, 1) + stop() + assert.equal(timers.size, 0) +}) diff --git a/web/dashboard/tests/gcsDashboard.test.mjs b/web/dashboard/tests/gcsDashboard.test.mjs new file mode 100644 index 0000000..f80e4d9 --- /dev/null +++ b/web/dashboard/tests/gcsDashboard.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import * as React from 'react' +import * as jsxRuntime from 'react/jsx-runtime' +import TestRenderer from 'react-test-renderer' +import ts from 'typescript' + +const { act, create } = TestRenderer + +function deferred() { + let resolve, reject + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +function object(bucket, name) { + return { + name, size: 1, etag: 'etag', contentType: 'text/plain', storageClass: 'STANDARD', + updated: '2026-10-12T00:00:00Z', generation: '1', metageneration: '1', + gcsUri: `gs://${bucket}/${name}`, + downloadUrl: `/api/gcs/buckets/${bucket}/objects/${name}/download`, + } +} + +const objectA = object('bucket-a', 'shared-name') +const objectB = object('bucket-b', 'shared-name') + +function component(renderer, name) { + return renderer.root.find((node) => typeof node.type === 'function' && node.type.name === name) +} + +function loadSource(source, imports) { + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, + }, + }) + const module = { exports: {} } + new Function('require', 'module', 'exports', outputText)((name) => { + assert.ok(Object.hasOwn(imports, name), `unexpected component import: ${name}`) + return imports[name] + }, module, module.exports) + return module.exports +} + +const latestRequestHook = loadSource( + await readFile(new URL('../src/app/api/hooks/useLatestRequest.ts', import.meta.url), 'utf8'), + { react: React }, +) + +async function setup(t, overrides = {}) { + const listingRequests = [] + const events = {} + const api = { + getGCSStatus: async () => ({ status: 'running', running: true }), + listGCSBuckets: async () => ({ + buckets: ['bucket-a', 'bucket-b'].map((name) => ({ name, objectCount: 1, gcsUri: `gs://${name}` })), + }), + listGCSUploadSessions: async () => ({ sessions: [] }), + listGCSObjects: async (bucket) => { + listingRequests.push(bucket) + return { bucket, objects: [bucket === 'bucket-a' ? objectA : objectB] } + }, + getGCSObject: async (bucket) => bucket === 'bucket-a' ? objectA : objectB, + deleteGCSObject: async () => {}, + ...overrides, + } + const wrap = (tag) => ({ children, ...props }) => React.createElement(tag, props, children) + const imports = { + react: React, + 'react/jsx-runtime': jsxRuntime, + '../../../ui/EmptyState': { EmptyState: () => null }, + '../../../ui/Panel': { Panel: wrap('section') }, + '../../../ui/Button': { Button: wrap('button') }, + '../../../ui/Confirm': { dangerConfirm: (options) => options, useConfirm: () => async () => true }, + '../../api/hooks/useDashboardEvents': { useDashboardEvents: (options) => { events.current = options } }, + '../../api/hooks/useLatestRequest': latestRequestHook, + './api': api, + } + // Render the real component and its hooks. Only API I/O and surrounding UI + // widgets are substituted; deferred promises control actual response order. + const source = await readFile(new URL('../src/app/services/gcs/GCSDashboard.tsx', import.meta.url), 'utf8') + const { GCSDashboard } = loadSource(source, imports) + let renderer + await act(async () => { renderer = create(React.createElement(GCSDashboard)) }) + t.after(async () => { await act(async () => { renderer.unmount() }) }) + return { renderer, listingRequests, events } +} + +test('a completed deletion reloads the current bucket and cannot replace its rows with the old bucket', async (t) => { + const deletion = deferred() + const deletions = [] + const { renderer, listingRequests } = await setup(t, { + deleteGCSObject: (bucket, name) => { + deletions.push([bucket, name]) + return deletion.promise + }, + }) + await act(async () => { await component(renderer, 'ObjectBrowser').props.onDeleteObject(objectA) }) + assert.deepEqual(deletions, [['bucket-a', objectA.name]]) + await act(async () => { component(renderer, 'BucketList').props.onSelectBucket('bucket-b') }) + assert.equal(component(renderer, 'ObjectBrowser').props.objectsState.objects[0].gcsUri, objectB.gcsUri) + + await act(async () => { deletion.resolve() }) + const browser = component(renderer, 'ObjectBrowser').props + assert.equal(browser.bucketName, 'bucket-b') + assert.deepEqual(browser.objectsState.objects, [objectB]) + assert.deepEqual(listingRequests, ['bucket-a', 'bucket-b', 'bucket-b']) +}) + +test('an old inspector response cannot replace the selected object after a bucket change', async (t) => { + const first = deferred(), second = deferred() + const requests = [] + const { renderer } = await setup(t, { + getGCSObject: (bucket, name) => { + requests.push([bucket, name]) + return bucket === 'bucket-a' ? first.promise : second.promise + }, + }) + await act(async () => { component(renderer, 'ObjectBrowser').props.onInspectObject(objectA) }) + await act(async () => { component(renderer, 'BucketList').props.onSelectBucket('bucket-b') }) + await act(async () => { component(renderer, 'ObjectBrowser').props.onInspectObject(objectB) }) + await act(async () => { second.resolve(objectB) }) + assert.deepEqual(component(renderer, 'ObjectInspector').props.object, objectB) + + await act(async () => { first.resolve(objectA) }) + assert.deepEqual(component(renderer, 'ObjectInspector').props.object, objectB) + assert.deepEqual(requests, [['bucket-a', objectA.name], ['bucket-b', objectB.name]]) +}) + +test('a failed obsolete inspector request cannot publish an error in a different bucket', async (t) => { + const pending = deferred() + const { renderer } = await setup(t, { getGCSObject: () => pending.promise }) + await act(async () => { component(renderer, 'ObjectBrowser').props.onInspectObject(objectA) }) + await act(async () => { component(renderer, 'BucketList').props.onSelectBucket('bucket-b') }) + await act(async () => { pending.reject(new Error('obsolete inspector error')) }) + assert.equal(component(renderer, 'ObjectInspector').props.object, undefined) + assert.ok(!JSON.stringify(renderer.toJSON()).includes('obsolete inspector error')) +}) + +for (const olderResult of ['success', 'error']) { + test(`an older snapshot ${olderResult} cannot overwrite a newer resync snapshot or selection`, async (t) => { + const initial = deferred() + let requests = 0 + const newBuckets = [{ name: 'bucket-b', objectCount: 1, gcsUri: 'gs://bucket-b' }] + const { renderer, events } = await setup(t, { + listGCSBuckets: () => ++requests === 1 ? initial.promise : Promise.resolve({ buckets: newBuckets }), + }) + await act(async () => { events.current.onResync() }) + assert.equal(component(renderer, 'BucketList').props.activeBucket, 'bucket-b') + await act(async () => { + if (olderResult === 'success') { + initial.resolve({ buckets: [{ name: 'bucket-a', objectCount: 1, gcsUri: 'gs://bucket-a' }] }) + } else { + initial.reject(new Error('obsolete snapshot error')) + } + }) + const buckets = component(renderer, 'BucketList').props + assert.equal(buckets.activeBucket, 'bucket-b') + assert.deepEqual(buckets.buckets, newBuckets) + assert.deepEqual(component(renderer, 'ObjectBrowser').props.objectsState.objects, [objectB]) + assert.ok(!JSON.stringify(renderer.toJSON()).includes('obsolete snapshot error')) + }) +} + +test('unmounting invalidates pending and subsequently started snapshot callbacks', async () => { + let beginRequest + function Consumer() { + beginRequest = latestRequestHook.useLatestRequest() + return null + } + let renderer + await act(async () => { renderer = create(React.createElement(Consumer)) }) + const isCurrent = beginRequest() + assert.equal(isCurrent(), true) + await act(async () => { renderer.unmount() }) + assert.equal(isCurrent(), false) + assert.equal(beginRequest()(), false) +})