From 8a9f9359ff943b30eb7b95a9b56046e1bd9cde4a Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 11:09:06 +0900 Subject: [PATCH 1/8] fix(storage): reject malformed HTTP and recover interrupted writes Validate request framing and percent escapes, bound chunk metadata without rejecting valid small chunks, and recover GCS resumable checkpoints and S3 bucket creation after interrupted persistence. Add focused regressions and explicit Tokio features for independent service builds. --- services/applicationautoscaling/Cargo.toml | 2 +- services/applicationautoscaling/src/http.rs | 94 +++- services/applicationautoscaling/src/sigv4.rs | 24 +- services/bigquery/Cargo.toml | 2 +- services/bigquery/src/http.rs | 298 +++++++++++-- services/dynamodb/Cargo.toml | 2 +- services/dynamodb/src/http.rs | 94 +++- services/dynamodb/src/sigv4.rs | 24 +- .../dynamodb/tests/persist_cache_parity.rs | 25 +- services/gcs/Cargo.toml | 2 +- services/gcs/src/http.rs | 419 +++++++++++++++--- services/gcs/tests/http_parity.rs | 247 +++++++++++ services/pubsub/src/http.rs | 118 ++++- services/redshift/Cargo.toml | 2 +- services/redshift/src/http.rs | 300 +++++++++++-- services/s3/Cargo.toml | 2 +- services/s3/src/store.rs | 37 +- services/s3/tests/store_parity.rs | 18 + services/sqs/src/http.rs | 94 +++- services/sqs/src/sigv4.rs | 24 +- 20 files changed, 1634 insertions(+), 194 deletions(-) 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/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/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..c438d03 100644 --- a/services/gcs/src/http.rs +++ b/services/gcs/src/http.rs @@ -26,7 +26,7 @@ use devcloud_s3::store::{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; @@ -1306,12 +1306,11 @@ fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { 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 +1328,23 @@ 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 !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,7 +1354,10 @@ 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"); + } }; } } @@ -1379,15 +1388,11 @@ fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { } 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; + 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) } @@ -1659,10 +1664,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 +1691,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 +1730,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 +2200,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 +2230,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 +2284,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); } - 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", + )); + } + 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 +2423,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 +2670,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/http_parity.rs b/services/gcs/tests/http_parity.rs index 989e959..c1ec033 100644 --- a/services/gcs/tests/http_parity.rs +++ b/services/gcs/tests/http_parity.rs @@ -1414,6 +1414,253 @@ 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(); +} + +/// 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/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/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/store.rs b/services/s3/src/store.rs index 63024fc..9f12c77 100644 --- a/services/s3/src/store.rs +++ b/services/s3/src/store.rs @@ -219,32 +219,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. 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"), "猫 +/"); + } +} From 445d525ccb9f7719189aa3ffca8cc916621bc393 Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 11:09:27 +0900 Subject: [PATCH 2/8] fix(runtime): confine workspaces and stabilize service lifecycles Validate reset/init paths and symlinks before touching storage. Recheck Cloud Run invocation authorization after slot waits and reclaim cancelled resources. Honor external Redis targets and graceful child shutdown. Protect Mail/Redis control HTTP and forward strict dashboard credentials. Keep relay handshakes from blocking other clients or shutdown. --- orchestrator/src/config.rs | 48 ++- orchestrator/src/services/dashboard.rs | 23 +- orchestrator/src/services/redis.rs | 412 +++++++++++++++++++- orchestrator/src/supervisor.rs | 27 +- orchestrator/tests/redis_external.rs | 225 +++++++++++ orchestrator/tests/workspace_safety.rs | 168 ++++++++ services/cloudrun/src/body.rs | 42 +- services/cloudrun/src/http.rs | 37 +- services/cloudrun/src/instances.rs | 267 +++++++++++-- services/cloudrun/src/server.rs | 23 +- services/dashboard/src/config.rs | 10 + services/dashboard/src/forward.rs | 96 ++++- services/dashboard/src/http.rs | 74 +++- services/dashboard/src/mail.rs | 14 +- services/dashboard/src/redis.rs | 8 +- services/dashboard/tests/forwarding.rs | 63 +++ services/event-relay/src/lib.rs | 118 ++++-- services/lambda/src/body.rs | 42 +- services/mail/src/browser_guard.rs | 181 +++++++++ services/mail/src/http.rs | 112 +++++- services/mail/src/lib.rs | 1 + services/mailbox/src/main.rs | 12 +- services/redis-control/src/browser_guard.rs | 181 +++++++++ services/redis-control/src/http.rs | 125 +++++- services/redis-control/src/lib.rs | 29 +- services/redis/Cargo.toml | 2 +- services/redis/src/lib.rs | 26 ++ services/redis/src/main.rs | 7 +- 28 files changed, 2205 insertions(+), 168 deletions(-) create mode 100644 orchestrator/tests/redis_external.rs create mode 100644 orchestrator/tests/workspace_safety.rs create mode 100644 services/mail/src/browser_guard.rs create mode 100644 services/redis-control/src/browser_guard.rs 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..6712ffd 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 { @@ -62,6 +52,9 @@ fn build_config(cfg: &AppConfig) -> Config { 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_endpoint: http_ep(cfg.server.s3_port), @@ -88,6 +81,8 @@ fn build_config(cfg: &AppConfig) -> Config { 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_endpoint: http_ep(cfg.server.pubsub_rest_port), 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..be51828 --- /dev/null +++ b/orchestrator/tests/redis_external.rs @@ -0,0 +1,225 @@ +#![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) { + 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") => format!("*2\r\n{}{}", bulk("databases"), bulk("16")), + Some("INFO") => bulk( + "redis_version:7.0.0\r\nconnected_clients:2\r\nused_memory_human:1M\r\ndb3: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() { + 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))); + } + } + } + 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}/3\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"], 3); + 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!(["database-3"])); + 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/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/src/config.rs b/services/dashboard/src/config.rs index 1b820fc..6b04d8b 100644 --- a/services/dashboard/src/config.rs +++ b/services/dashboard/src/config.rs @@ -44,6 +44,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 +67,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, @@ -108,6 +113,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 +160,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/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/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/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/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..b485868 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_current_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/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); + } } } }); From ee7a5b20107a826814632d75953d4631741994d5 Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 11:09:55 +0900 Subject: [PATCH 3/8] fix(dashboard): restore live events and guard stale responses Decode the relay event envelope and refresh snapshots on every hello and resync. Cancel obsolete sockets and protect all service snapshots from out-of-order responses. Fix GCS bucket and inspector races, add React and real-browser regression coverage, and rebuild embedded dashboard assets. --- .../assets/react/assets/index-C7w4pPfk.js | 109 ++++++ .../assets/react/assets/index-DYqY_0vS.js | 109 ------ services/dashboard/assets/react/index.html | 2 +- web/dashboard/package-lock.json | 359 ++++++++++-------- web/dashboard/package.json | 14 +- web/dashboard/src/app/api/eventClient.ts | 72 +++- .../src/app/api/hooks/useDashboardEvents.ts | 15 +- .../src/app/api/hooks/useLatestRequest.ts | 25 ++ .../ApplicationAutoScalingDashboard.tsx | 9 +- .../services/bigquery/BigQueryDashboard.tsx | 15 +- .../services/cloudrun/CloudRunDashboard.tsx | 9 +- .../services/dynamodb/DynamoDBDashboard.tsx | 21 +- .../src/app/services/gcs/GCSDashboard.tsx | 60 ++- .../app/services/lambda/LambdaDashboard.tsx | 9 +- .../src/app/services/mail/MailDashboard.tsx | 9 +- .../app/services/pubsub/PubSubDashboard.tsx | 9 +- .../src/app/services/redis/RedisDashboard.tsx | 13 +- .../services/redshift/RedshiftDashboard.tsx | 9 +- .../src/app/services/s3/S3Dashboard.tsx | 17 +- .../src/app/services/sqs/SQSDashboard.tsx | 15 +- web/dashboard/tests/browser-smoke.mjs | 148 ++++++++ web/dashboard/tests/eventClient.test.mjs | 176 +++++++++ web/dashboard/tests/gcsDashboard.test.mjs | 180 +++++++++ 23 files changed, 1072 insertions(+), 332 deletions(-) create mode 100644 services/dashboard/assets/react/assets/index-C7w4pPfk.js delete mode 100644 services/dashboard/assets/react/assets/index-DYqY_0vS.js create mode 100644 web/dashboard/src/app/api/hooks/useLatestRequest.ts create mode 100644 web/dashboard/tests/browser-smoke.mjs create mode 100644 web/dashboard/tests/eventClient.test.mjs create mode 100644 web/dashboard/tests/gcsDashboard.test.mjs 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/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..4c1b011 --- /dev/null +++ b/web/dashboard/tests/browser-smoke.mjs @@ -0,0 +1,148 @@ +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') + + // 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: ['initial snapshot', 'live provider event', 'snapshot after reconnect', 'object inspector'], + helloCount, pageErrors: errors, + }, null, 2) + '\n') + console.log('Dashboard browser smoke passed: 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) +}) From 90c4844e7f6db3ca0042195a34c7b44fabbe3b84 Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 11:10:19 +0900 Subject: [PATCH 4/8] ci: verify workspace, browser behavior, and dependencies Run formatting, independent crate checks, workspace tests, Clippy, browser smoke verification, generated-asset checks, and Rust/npm audits. Update h2 and anyhow, document reproducible verification, and accept successful 204 responses in the Mail E2E helper. --- .github/workflows/ci.yml | 113 +++++++++++++++++++++++++++++++++++++++ Cargo.lock | 16 +++--- README.md | 45 ++++++++++++++-- scripts/mail-e2e.sh | 2 + 4 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci.yml 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..00a3097 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. | @@ -441,12 +443,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 +505,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 -cargo build --workspace -cargo test --workspace +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 --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/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")) From 78a57bd1a2fb97f244c9ec563d2d21ac3080d904 Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 11:48:21 +0900 Subject: [PATCH 5/8] fix(storage): replay completed resumable uploads safely --- README.md | 2 + services/gcs/src/http.rs | 89 ++++--- services/gcs/tests/http_parity.rs | 376 ++++++++++++++++++++++++++++ services/s3/src/model.rs | 7 + services/s3/src/store.rs | 53 ++++ services/s3/src/store_multipart.rs | 1 + services/s3/src/store_objectlock.rs | 22 ++ services/s3/src/store_objects.rs | 201 ++++++++++++++- services/s3/tests/objects_parity.rs | 245 ++++++++++++++++++ 9 files changed, 966 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 00a3097..c5151f4 100644 --- a/README.md +++ b/README.md @@ -349,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 | diff --git a/services/gcs/src/http.rs b/services/gcs/src/http.rs index c438d03..6ecc6f6 100644 --- a/services/gcs/src/http.rs +++ b/services/gcs/src/http.rs @@ -1224,7 +1224,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 +1279,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")) { @@ -1361,31 +1381,21 @@ fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { }; } } - 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)) - } + match server.store.put_resumable_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() + }, + &id, + ) { + Ok(object) => complete_resumable_upload(server, &id, &object), Err(e) => { let message = store_error_text(&e, &session.bucket, &session.name); if let Some(prior_received_bytes) = staged_chunk_rollback { @@ -1399,6 +1409,29 @@ fn put_resumable_upload(server: &mut Server, req: &Request) -> Response { } } +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())`). diff --git a/services/gcs/tests/http_parity.rs b/services/gcs/tests/http_parity.rs index c1ec033..7c26700 100644 --- a/services/gcs/tests/http_parity.rs +++ b/services/gcs/tests/http_parity.rs @@ -1549,6 +1549,382 @@ fn final_chunk_does_not_commit_session_offset_before_object() { 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] 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 9f12c77..3671e96 100644 --- a/services/s3/src/store.rs +++ b/services/s3/src/store.rs @@ -147,6 +147,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") } @@ -282,6 +289,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(_) => {} @@ -416,6 +424,51 @@ fn splitmix64(state: &mut u64) -> u64 { mod tests { use super::*; + #[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..46b3048 100644 --- a/services/s3/src/store_objects.rs +++ b/services/s3/src/store_objects.rs @@ -14,7 +14,7 @@ use crate::store::{ 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; @@ -72,6 +72,14 @@ impl FileBucketStore { /// `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 +114,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 +131,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 +140,182 @@ 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.read_resumable_upload_receipt(upload_id)? { + if receipt.bucket != input.bucket || receipt.key != input.key { + return Err(StoreError::InvalidUploadId); + } + return Ok(receipt); + } + if let Some(receipt) = + self.get_resumable_upload_result_locked(&input.bucket, Some(&input.key), upload_id)? + { + return Ok(receipt); + } + 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 { @@ -169,6 +355,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; } @@ -286,6 +473,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 +504,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 +524,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 +536,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 +714,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 From 8e36741fee131d732bc95c982165ea397c58edf8 Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 11:48:33 +0900 Subject: [PATCH 6/8] fix(redis): accept validated databases when CONFIG is restricted --- orchestrator/tests/redis_external.rs | 46 ++++++++++++++++++++++------ services/redis-control/src/lib.rs | 2 +- services/redis-control/src/server.rs | 20 +++++++++++- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/orchestrator/tests/redis_external.rs b/orchestrator/tests/redis_external.rs index be51828..6d1056a 100644 --- a/orchestrator/tests/redis_external.rs +++ b/orchestrator/tests/redis_external.rs @@ -49,7 +49,7 @@ fn bulk(value: &str) -> String { format!("${}\r\n{value}\r\n", value.len()) } -async fn fake_redis(stream: TcpStream, wrong_auth: Arc) { +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; @@ -70,10 +70,13 @@ async fn fake_redis(stream: TcpStream, wrong_auth: Arc) { db = args[1].parse().unwrap(); "+OK\r\n".into() } - Some("CONFIG") => format!("*2\r\n{}{}", bulk("databases"), bulk("16")), - Some("INFO") => bulk( - "redis_version:7.0.0\r\nconnected_clients:2\r\nused_memory_human:1M\r\ndb3:keys=1,expires=0\r\n", - ), + 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(), }; @@ -122,6 +125,15 @@ async fn http(port: u16, method: &str, path: &str, body: &str) -> Result<(u16, V #[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)); @@ -135,7 +147,7 @@ async fn external_redis_dashboard_uses_its_url_password_port_and_database() { _ = &mut stopped => break, connection = listener.accept() => { let (stream, _) = connection.unwrap(); - connections.spawn(fake_redis(stream, Arc::clone(&observed))); + connections.spawn(fake_redis(stream, Arc::clone(&observed), config_allowed)); } } } @@ -163,7 +175,7 @@ async fn external_redis_dashboard_uses_its_url_password_port_and_database() { 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}/3\n binaryPath: /redis-must-not-be-started\n" + "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(); @@ -194,7 +206,23 @@ async fn external_redis_dashboard_uses_its_url_password_port_and_database() { }; assert_eq!(status["mode"], "external"); assert_eq!(status["address"], format!("127.0.0.1:{upstream_port}")); - assert_eq!(status["currentDB"], 3); + 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", @@ -204,7 +232,7 @@ async fn external_redis_dashboard_uses_its_url_password_port_and_database() { .await .unwrap(); assert_eq!(status, 200); - assert_eq!(result["rows"], json!(["database-3"])); + 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(); diff --git a/services/redis-control/src/lib.rs b/services/redis-control/src/lib.rs index b485868..1098c93 100644 --- a/services/redis-control/src/lib.rs +++ b/services/redis-control/src/lib.rs @@ -121,7 +121,7 @@ pub async fn run( // as the legacy listener which always runs regardless of upstream liveness. server.refresh_database_count().await; if cfg.initial_db != 0 { - server.set_current_db(cfg.initial_db).await.map_err(|_| { + server.set_initial_db(cfg.initial_db).await.map_err(|_| { "redis control: failed to select the configured initial database".to_string() })?; } 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(()) } From 60c8ed2c10b082c94c9547ce7efece82971d4d15 Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 11:48:45 +0900 Subject: [PATCH 7/8] fix(dashboard): report only enabled services as running --- orchestrator/src/services/dashboard.rs | 129 ++++++++++++++++++++++--- services/dashboard/src/config.rs | 4 + services/dashboard/src/services.rs | 13 ++- services/dashboard/src/sqs.rs | 6 +- web/dashboard/tests/browser-smoke.mjs | 13 ++- 5 files changed, 142 insertions(+), 23 deletions(-) diff --git a/orchestrator/src/services/dashboard.rs b/orchestrator/src/services/dashboard.rs index 6712ffd..d738eae 100644 --- a/orchestrator/src/services/dashboard.rs +++ b/orchestrator/src/services/dashboard.rs @@ -43,12 +43,15 @@ 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, @@ -56,45 +59,46 @@ fn build_config(cfg: &AppConfig) -> Config { 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"), @@ -129,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/services/dashboard/src/config.rs b/services/dashboard/src/config.rs index 6b04d8b..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, @@ -105,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"), 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/web/dashboard/tests/browser-smoke.mjs b/web/dashboard/tests/browser-smoke.mjs index 4c1b011..b84b3ff 100644 --- a/web/dashboard/tests/browser-smoke.mjs +++ b/web/dashboard/tests/browser-smoke.mjs @@ -107,6 +107,15 @@ try { // 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') @@ -128,10 +137,10 @@ try { 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: ['initial snapshot', 'live provider event', 'snapshot after reconnect', 'object inspector'], + 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: initial snapshot, live event, reconnect snapshot, inspector.') + 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 From 9a04677f059efd764e2652056ab3295759896b87 Mon Sep 17 00:00:00 2001 From: shingo imota Date: Mon, 12 Oct 2026 12:17:32 +0900 Subject: [PATCH 8/8] fix(storage): make cross-service preconditions atomic --- services/gcs/src/http.rs | 232 +++++++++++----- services/gcs/tests/conditional_writes.rs | 338 +++++++++++++++++++++++ services/s3/src/store.rs | 70 +++++ services/s3/src/store_objects.rs | 131 ++++++++- services/s3/tests/shared_root_parity.rs | 239 +++++++++++++++- 5 files changed, 925 insertions(+), 85 deletions(-) create mode 100644 services/gcs/tests/conditional_writes.rs diff --git a/services/gcs/src/http.rs b/services/gcs/src/http.rs index 6ecc6f6..7198954 100644 --- a/services/gcs/src/http.rs +++ b/services/gcs/src/http.rs @@ -22,7 +22,7 @@ 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}; @@ -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,22 +1062,60 @@ 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( server: &mut Server, req: &Request, @@ -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, @@ -1317,14 +1374,6 @@ fn put_resumable_upload(server: &mut Server, req: &Request, bucket: &str) -> Res } 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(); // 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 @@ -1350,6 +1399,16 @@ fn put_resumable_upload(server: &mut Server, req: &Request, bucket: &str) -> Res session.received_bytes = upload_range.end + 1; 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, prior_received_bytes, &payload) @@ -1381,7 +1440,9 @@ fn put_resumable_upload(server: &mut Server, req: &Request, bucket: &str) -> Res }; } } - match server.store.put_resumable_object( + // 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(), @@ -1394,17 +1455,24 @@ fn put_resumable_upload(server: &mut Server, req: &Request, bucket: &str) -> Res ..Default::default() }, &id, + |current| check_preconditions(current, &session.preconditions), ) { Ok(object) => complete_resumable_upload(server, &id, &object), - Err(e) => { - let message = store_error_text(&e, &session.bucket, &session.name); + 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); } - 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), + ), + } } } } @@ -1435,9 +1503,11 @@ fn complete_resumable_upload(server: &mut Server, id: &str, object: &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", @@ -1450,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)) + } } } @@ -1565,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( @@ -1575,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", 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/s3/src/store.rs b/services/s3/src/store.rs index 3671e96..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) @@ -424,6 +439,61 @@ 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; diff --git a/services/s3/src/store_objects.rs b/services/s3/src/store_objects.rs index 46b3048..850fab3 100644 --- a/services/s3/src/store_objects.rs +++ b/services/s3/src/store_objects.rs @@ -10,8 +10,8 @@ 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, valid_upload_id}; @@ -70,6 +70,32 @@ 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, "") @@ -148,17 +174,51 @@ impl FileBucketStore { 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(receipt); - } - if let Some(receipt) = - self.get_resumable_upload_result_locked(&input.bucket, Some(&input.key), upload_id)? - { - return Ok(receipt); + 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. @@ -341,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); } @@ -407,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( @@ -442,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( @@ -451,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); } 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();