diff --git a/.github/workflows/build_frontends.yml b/.github/workflows/build_frontends.yml index a2e70b133..f10cebcf5 100644 --- a/.github/workflows/build_frontends.yml +++ b/.github/workflows/build_frontends.yml @@ -4,8 +4,16 @@ name: Build node packages on: pull_request: branches: ["*"] + paths: + - "api/**" + - "packages/client-library-otel/**" + - "studio/**" push: branches: ["main", "release-*"] + paths: + - "api/**" + - "packages/client-library-otel/**" + - "studio/**" env: CARGO_TERM_COLOR: always @@ -34,7 +42,7 @@ jobs: - name: Setup Biome uses: biomejs/setup-biome@v2 with: - version: latest + version: 1.8.3 - name: Install dependencies run: pnpm install @@ -57,10 +65,23 @@ jobs: - name: Typecheck all workspaces run: pnpm --recursive typecheck + # Testing + + - name: Test Studio API + run: pnpm --filter @fiberplane/studio test + + - name: Test Studio Frontend + run: pnpm --filter @fiberplane/studio-frontend test + # Building - - name: Build all workspaces - run: pnpm --recursive build + - name: Build api, frontend, and client library + run: | + pnpm \ + --filter=@fiberplane/studio-frontend \ + --filter=@fiberplane/studio \ + --filter=@fiberplane/hono-otel \ + build # Release a preview version diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..5e10581ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +See the [changelog on the website](https://fiberplane.com/changelog) for a detailed overview. diff --git a/Cargo.lock b/Cargo.lock index ccbd1215c..0f6df575c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,18 +179,18 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] name = "async-trait" -version = "0.1.81" +version = "0.1.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +checksum = "a27b8a3a6e1a44fa4c8baf1f653e4172e81486d4941f2237e20dc2d0cf4ddff1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -210,7 +210,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -226,7 +226,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 2.0.76", + "syn", ] [[package]] @@ -393,7 +393,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.76", + "syn", "which", ] @@ -569,7 +569,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -665,7 +665,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.76", + "syn", ] [[package]] @@ -676,7 +676,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -703,7 +703,7 @@ checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -818,7 +818,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry-proto", "opentelemetry_sdk", - "prost 0.13.1", + "prost 0.13.2", "rand", "reqwest", "schemars", @@ -857,7 +857,7 @@ dependencies = [ "opentelemetry", "opentelemetry-proto", "opentelemetry_sdk", - "prost 0.13.1", + "prost 0.13.2", "schemars", "serde", "serde_json", @@ -878,9 +878,9 @@ name = "fpx-macros" version = "0.1.0" dependencies = [ "attribute-derive", - "proc-macro-error", + "manyhow", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -959,7 +959,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -1654,7 +1654,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -1827,7 +1827,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost 0.13.1", + "prost 0.13.2", "reqwest", "serde_json", "thiserror", @@ -1844,7 +1844,7 @@ dependencies = [ "hex", "opentelemetry", "opentelemetry_sdk", - "prost 0.13.1", + "prost 0.13.2", "schemars", "serde", "tonic 0.12.2", @@ -1968,7 +1968,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2005,31 +2005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn 2.0.76", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", + "syn", ] [[package]] @@ -2075,12 +2051,12 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13db3d3fde688c61e2446b4d843bc27a7e8af269a69440c0308021dc92333cc" +checksum = "3b2ecbe40f08db5c006b5764a2645f7f3f141ce756412ac9e1dd6087e6d32995" dependencies = [ "bytes", - "prost-derive 0.13.1", + "prost-derive 0.13.2", ] [[package]] @@ -2093,20 +2069,20 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] name = "prost-derive" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18bec9b0adc4eba778b33684b7ba3e7137789434769ee3ce3930463ef904cfca" +checksum = "acf0c195eebb4af52c752bec4f52f645da98b6e92077a04110c7f349477ae5ac" dependencies = [ "anyhow", "itertools 0.13.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2129,9 +2105,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba92fb39ec7ad06ca2582c0ca834dfeadcaf06ddfc8e635c80aa7e1c05315fdd" +checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" dependencies = [ "bytes", "rand", @@ -2186,7 +2162,7 @@ dependencies = [ "proc-macro-utils 0.8.0", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2473,7 +2449,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.76", + "syn", ] [[package]] @@ -2544,7 +2520,7 @@ checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2555,7 +2531,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2628,7 +2604,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2728,7 +2704,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.76", + "syn", ] [[package]] @@ -2739,19 +2715,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.76" +version = "2.0.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" dependencies = [ "proc-macro2", "quote", @@ -2791,7 +2757,7 @@ checksum = "5999e24eaa32083191ba4e425deb75cdf25efefabe5aaccb7446dd0d4122a3f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2811,7 +2777,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2873,9 +2839,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.3" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", @@ -2907,7 +2873,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -3058,7 +3024,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost 0.13.1", + "prost 0.13.2", "socket2", "tokio", "tokio-stream", @@ -3180,7 +3146,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -3311,7 +3277,7 @@ dependencies = [ "lazy_format", "proc-macro2", "quote", - "syn 2.0.76", + "syn", "thiserror", ] @@ -3433,7 +3399,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.76", + "syn", "wasm-bindgen-shared", ] @@ -3467,7 +3433,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3731,7 +3697,7 @@ dependencies = [ "async-trait", "proc-macro2", "quote", - "syn 2.0.76", + "syn", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-macro-support", @@ -3784,7 +3750,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8a9a1c145..51c79ec76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,6 @@ clap = { version = "4.0", features = ["derive", "env"] } schemars = "0.8.21" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } -tokio = { version = "1.37", features = ["rt-multi-thread", "signal"] } +tokio = { version = "1.40", features = ["rt-multi-thread", "signal"] } tracing = { version = "0.1" } url = { version = "2.5" } diff --git a/api/drizzle/0012_deep_junta.sql b/api/drizzle/0012_deep_junta.sql new file mode 100644 index 000000000..3dd515165 --- /dev/null +++ b/api/drizzle/0012_deep_junta.sql @@ -0,0 +1 @@ +ALTER TABLE `app_routes` ADD `registration_order` integer DEFAULT -1; \ No newline at end of file diff --git a/api/drizzle/0013_futuristic_snowbird.sql b/api/drizzle/0013_futuristic_snowbird.sql new file mode 100644 index 000000000..49550d04e --- /dev/null +++ b/api/drizzle/0013_futuristic_snowbird.sql @@ -0,0 +1,28 @@ +/* +We want to delete PRIMARY KEY(handler_type,method,path,route_origin) from 'app_routes' table +SQLite does not supportprimary key deletion from existing table +We can do it in 3 steps with drizzle orm: + - create new mirror table table without pk, rename current table to old_table, generate SQL + - migrate old data from one table to another + - delete old_table in schema, generate sql +*/ +ALTER TABLE `app_routes` RENAME TO `old_app_routes`; +--> statement-breakpoint +CREATE TABLE `app_routes` ( + `id` integer PRIMARY KEY AUTOINCREMENT, + `path` TEXT, + `method` TEXT, + `handler` TEXT, + `handler_type` TEXT, + `currentlyRegistered` INTEGER DEFAULT false, + `registration_order` INTEGER DEFAULT -1, + `route_origin` TEXT DEFAULT 'discovered', + `openapi_spec` TEXT, + `request_type` TEXT DEFAULT 'http' +); +--> statement-breakpoint +INSERT INTO `app_routes` (`path`, `method`, `handler`, `handler_type`, `currentlyRegistered`, `registration_order`, `route_origin`, `openapi_spec`, `request_type`) +SELECT `path`, `method`, `handler`, `handler_type`, `currentlyRegistered`, `registration_order`, `route_origin`, `openapi_spec`, `request_type` +FROM `old_app_routes`; +--> statement-breakpoint +DROP TABLE `old_app_routes`; \ No newline at end of file diff --git a/api/drizzle/0014_worthless_mystique.sql b/api/drizzle/0014_worthless_mystique.sql new file mode 100644 index 000000000..61a2dd68f --- /dev/null +++ b/api/drizzle/0014_worthless_mystique.sql @@ -0,0 +1,13 @@ +CREATE TABLE `otel_spans_new` ( + `inner` text NOT NULL, + `span_id` text NOT NULL, + `trace_id` text NOT NULL +); +--> statement-breakpoint +INSERT INTO `otel_spans_new` (`inner`, `span_id`, `trace_id`) +SELECT `parsed_payload`, `span_id`, `trace_id` +FROM `otel_spans`; +--> statement-breakpoint +DROP TABLE `otel_spans`; +--> statement-breakpoint +ALTER TABLE `otel_spans_new` RENAME TO `otel_spans`; diff --git a/api/drizzle/meta/0012_snapshot.json b/api/drizzle/meta/0012_snapshot.json new file mode 100644 index 000000000..e7495f4f0 --- /dev/null +++ b/api/drizzle/meta/0012_snapshot.json @@ -0,0 +1,488 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cd5bd8ef-be05-4fd5-a1b5-f5b9769b882a", + "prevId": "cee1e0f6-1736-488e-8d3e-6243c83d5ae1", + "tables": { + "app_requests": { + "name": "app_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_url": { + "name": "request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_headers": { + "name": "request_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_query_params": { + "name": "request_query_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_path_params": { + "name": "request_path_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_route": { + "name": "request_route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_responses": { + "name": "app_responses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers": { + "name": "response_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_details": { + "name": "failure_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_failure": { + "name": "is_failure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_responses_request_id_app_requests_id_fk": { + "name": "app_responses_request_id_app_requests_id_fk", + "tableFrom": "app_responses", + "tableTo": "app_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_routes": { + "name": "app_routes", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler_type": { + "name": "handler_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentlyRegistered": { + "name": "currentlyRegistered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "registration_order": { + "name": "registration_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": -1 + }, + "route_origin": { + "name": "route_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovered'" + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "id": { + "columns": [ + "handler_type", + "method", + "path", + "route_origin" + ], + "name": "id" + } + }, + "uniqueConstraints": {} + }, + "mizu_logs": { + "name": "mizu_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignored": { + "name": "ignored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caller_location": { + "name": "caller_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "matching_issues": { + "name": "matching_issues", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "otel_spans": { + "name": "otel_spans", + "columns": { + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_payload": { + "name": "parsed_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "settings_key_unique": { + "name": "settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/api/drizzle/meta/0013_snapshot.json b/api/drizzle/meta/0013_snapshot.json new file mode 100644 index 000000000..beb81e96a --- /dev/null +++ b/api/drizzle/meta/0013_snapshot.json @@ -0,0 +1,485 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cf7990f9-0720-4897-b0c5-449511f37c63", + "prevId": "cd5bd8ef-be05-4fd5-a1b5-f5b9769b882a", + "tables": { + "app_requests": { + "name": "app_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_url": { + "name": "request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_headers": { + "name": "request_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_query_params": { + "name": "request_query_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_path_params": { + "name": "request_path_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_route": { + "name": "request_route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_responses": { + "name": "app_responses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers": { + "name": "response_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_details": { + "name": "failure_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_failure": { + "name": "is_failure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_responses_request_id_app_requests_id_fk": { + "name": "app_responses_request_id_app_requests_id_fk", + "tableFrom": "app_responses", + "tableTo": "app_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_routes": { + "name": "app_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler_type": { + "name": "handler_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentlyRegistered": { + "name": "currentlyRegistered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "registration_order": { + "name": "registration_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": -1 + }, + "route_origin": { + "name": "route_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovered'" + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "mizu_logs": { + "name": "mizu_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignored": { + "name": "ignored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caller_location": { + "name": "caller_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "matching_issues": { + "name": "matching_issues", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "otel_spans": { + "name": "otel_spans", + "columns": { + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_payload": { + "name": "parsed_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "settings_key_unique": { + "name": "settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/api/drizzle/meta/0014_snapshot.json b/api/drizzle/meta/0014_snapshot.json new file mode 100644 index 000000000..b27baa05a --- /dev/null +++ b/api/drizzle/meta/0014_snapshot.json @@ -0,0 +1,471 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5897188d-e0e3-45b6-9023-532b91137314", + "prevId": "cf7990f9-0720-4897-b0c5-449511f37c63", + "tables": { + "app_requests": { + "name": "app_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_url": { + "name": "request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_headers": { + "name": "request_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_query_params": { + "name": "request_query_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_path_params": { + "name": "request_path_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_route": { + "name": "request_route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_responses": { + "name": "app_responses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers": { + "name": "response_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_details": { + "name": "failure_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_failure": { + "name": "is_failure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_responses_request_id_app_requests_id_fk": { + "name": "app_responses_request_id_app_requests_id_fk", + "tableFrom": "app_responses", + "tableTo": "app_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_routes": { + "name": "app_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler_type": { + "name": "handler_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentlyRegistered": { + "name": "currentlyRegistered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "registration_order": { + "name": "registration_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": -1 + }, + "route_origin": { + "name": "route_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovered'" + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "mizu_logs": { + "name": "mizu_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignored": { + "name": "ignored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caller_location": { + "name": "caller_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "matching_issues": { + "name": "matching_issues", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "otel_spans": { + "name": "otel_spans", + "columns": { + "inner": { + "name": "inner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "settings_key_unique": { + "name": "settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"otel_spans\".\"parsed_payload\"": "\"otel_spans\".\"inner\"" + } + } +} \ No newline at end of file diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 5e955b604..564bf358a 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -85,6 +85,27 @@ "when": 1724163077731, "tag": "0011_aspiring_triathlon", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1725009049745, + "tag": "0012_deep_junta", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1725009596194, + "tag": "0013_futuristic_snowbird", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1725289614651, + "tag": "0014_worthless_mystique", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/api/package.json b/api/package.json index e87268cbe..02151c8a8 100644 --- a/api/package.json +++ b/api/package.json @@ -1,5 +1,5 @@ { - "version": "0.8.0-beta.3", + "version": "0.8.2-canary.0", "name": "@fiberplane/studio", "description": "Local development debugging interface for Hono apps", "author": "Fiberplane", @@ -38,21 +38,21 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.24.3", + "@fiberplane/fpx-types": "workspace:*", "@hono/node-server": "^1.11.1", "@hono/zod-validator": "^0.2.2", "@iarna/toml": "^2.2.5", "@langchain/core": "^0.2.15", "@libsql/client": "^0.6.2", - "@fiberplane/fpx-types": "workspace:*", "acorn": "^8.11.3", "acorn-walk": "^8.3.2", "chalk": "^5.3.0", "dotenv": "^16.4.5", - "drizzle-kit": "^0.21.2", - "drizzle-orm": "^0.30.10", + "drizzle-kit": "^0.24.2", + "drizzle-orm": "^0.33.0", "drizzle-zod": "^0.5.1", "figlet": "^1.7.0", - "hono": "^4.3.7", + "hono": "^4.6.2", "minimatch": "^10.0.1", "openai": "^4.47.1", "source-map": "^0.7.4", diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index ef53625c3..1d87298f9 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,52 +1,36 @@ +import type { OtelSpan } from "@fiberplane/fpx-types"; import { relations, sql } from "drizzle-orm"; -import { - integer, - primaryKey, - sqliteTable, - text, -} from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; -export const appRoutes = sqliteTable( - "app_routes", - { - path: text("path", { mode: "text" }), - method: text("method", { mode: "text" }), - // The text of the function serving the request - handler: text("handler", { mode: "text" }), - // In practice, handler_type is either "route" or "middleware" - I didn't feel like defining an enum - handlerType: text("handler_type", { mode: "text" }), - // A flag that indicates if this route is currently registered or the result of an old probe - currentlyRegistered: integer("currentlyRegistered", { - mode: "boolean", - }).default(false), - // A flag for route type that indicated if the route was added manually by user or by probe - routeOrigin: text("route_origin", { - mode: "text", - enum: ["discovered", "custom", "open_api"], - }).default("discovered"), - // serialized OpenAPI spec for AI prompting - openApiSpec: text("openapi_spec", { mode: "text" }), - requestType: text("request_type", { - mode: "text", - enum: ["http", "websocket"], - }).default("http"), - }, - (table) => { - return { - id: primaryKey({ - name: "id", - columns: [ - table.method, - table.path, - table.handlerType, - table.routeOrigin, - ], - }), - }; - }, -); +export const appRoutes = sqliteTable("app_routes", { + id: integer("id", { mode: "number" }).primaryKey(), + path: text("path", { mode: "text" }), + method: text("method", { mode: "text" }), + // The text of the function serving the request + handler: text("handler", { mode: "text" }), + // In practice, handler_type is either "route" or "middleware" - I didn't feel like defining an enum + handlerType: text("handler_type", { mode: "text" }), + // A flag that indicates if this route is currently registered or the result of an old probe + currentlyRegistered: integer("currentlyRegistered", { + mode: "boolean", + }).default(false), + registrationOrder: integer("registration_order", { + mode: "number", + }).default(-1), + // A flag for route type that indicated if the route was added manually by user or by probe + routeOrigin: text("route_origin", { + mode: "text", + enum: ["discovered", "custom", "open_api"], + }).default("discovered"), + // serialized OpenAPI spec for AI prompting + openApiSpec: text("openapi_spec", { mode: "text" }), + requestType: text("request_type", { + mode: "text", + enum: ["http", "websocket"], + }).default("http"), +}); export const appRoutesSelectSchema = createSelectSchema(appRoutes); export const appRoutesInsertSchema = createInsertSchema(appRoutes); @@ -172,15 +156,10 @@ const CallerLocationSchema = z.object({ column: z.string(), }); -// TODO: Make a better schema for this -// And add a separate schema for the raw spans from the exporter -// Inspo: https://github.com/wperron/sqliteexporter/blob/main/migrations/20240120195122_init.up.sql export const otelSpans = sqliteTable("otel_spans", { - spanId: text("span_id"), - traceId: text("trace_id"), - parsedPayload: text("parsed_payload", { mode: "json" }), - createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), - updatedAt: text("updated_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + inner: text("inner", { mode: "json" }).$type().notNull(), + spanId: text("span_id").notNull(), + traceId: text("trace_id").notNull(), }); export const newMizuLogSchema = createInsertSchema(mizuLogs); diff --git a/api/src/lib/ai/anthropic.ts b/api/src/lib/ai/anthropic.ts index 3c8b0bc71..c392034fe 100644 --- a/api/src/lib/ai/anthropic.ts +++ b/api/src/lib/ai/anthropic.ts @@ -20,6 +20,11 @@ type GenerateRequestOptions = { baseUrl?: string; history?: Array; openApiSpec?: string; + middleware?: { + handler: string; + method: string; + path: string; + }[]; }; /** @@ -39,6 +44,7 @@ export async function generateRequestWithAnthropic({ handler, history, openApiSpec, + middleware, }: GenerateRequestOptions) { logger.debug( "Generating request data with Anthropic", @@ -49,6 +55,7 @@ export async function generateRequestWithAnthropic({ `path: ${path}`, `handler: ${handler}`, `openApiSpec: ${openApiSpec}`, + `middleware: ${middleware}`, ); const anthropicClient = new Anthropic({ apiKey, baseURL: baseUrl }); const userPrompt = await invokeRequestGenerationPrompt({ @@ -58,6 +65,7 @@ export async function generateRequestWithAnthropic({ handler, history, openApiSpec, + middleware, }); const toolChoice: Anthropic.Messages.MessageCreateParams.ToolChoiceTool = { diff --git a/api/src/lib/ai/index.ts b/api/src/lib/ai/index.ts index fbdf3ad89..bb22b963b 100644 --- a/api/src/lib/ai/index.ts +++ b/api/src/lib/ai/index.ts @@ -10,6 +10,7 @@ export async function generateRequestWithAiProvider({ handler, history, openApiSpec, + middleware, }: { inferenceConfig: Settings; persona: string; @@ -18,6 +19,11 @@ export async function generateRequestWithAiProvider({ handler: string; history?: string[]; openApiSpec?: string; + middleware?: { + handler: string; + method: string; + path: string; + }[]; }) { const { openaiApiKey, @@ -39,6 +45,7 @@ export async function generateRequestWithAiProvider({ handler, history, openApiSpec, + middleware, }).then( (parsedArgs) => { return { data: parsedArgs, error: null }; @@ -62,6 +69,7 @@ export async function generateRequestWithAiProvider({ handler, history, openApiSpec, + middleware, }).then( (parsedArgs) => { return { data: parsedArgs, error: null }; diff --git a/api/src/lib/ai/openai.ts b/api/src/lib/ai/openai.ts index 3b8022554..007b84881 100644 --- a/api/src/lib/ai/openai.ts +++ b/api/src/lib/ai/openai.ts @@ -13,6 +13,11 @@ type GenerateRequestOptions = { handler: string; history?: Array; openApiSpec?: string; + middleware?: { + handler: string; + method: string; + path: string; + }[]; }; /** @@ -32,6 +37,7 @@ export async function generateRequestWithOpenAI({ handler, history, openApiSpec, + middleware, }: GenerateRequestOptions) { logger.debug( "Generating request data with OpenAI", @@ -42,6 +48,7 @@ export async function generateRequestWithOpenAI({ `path: ${path}`, `handler: ${handler}`, `openApiSpec: ${openApiSpec}`, + `middleware: ${middleware}`, ); const openaiClient = new OpenAI({ apiKey, baseURL: baseUrl }); const userPrompt = await invokeRequestGenerationPrompt({ @@ -51,6 +58,7 @@ export async function generateRequestWithOpenAI({ handler, history, openApiSpec, + middleware, }); const response = await openaiClient.chat.completions.create({ diff --git a/api/src/lib/ai/prompts.ts b/api/src/lib/ai/prompts.ts index f8cc9de29..e7de9caf0 100644 --- a/api/src/lib/ai/prompts.ts +++ b/api/src/lib/ai/prompts.ts @@ -6,6 +6,27 @@ export const getSystemPrompt = (persona: string) => { : FRIENDLY_PARAMETER_GENERATION_SYSTEM_PROMPT; }; +function formatMiddleware( + middleware?: { + handler: string; + method: string; + path: string; + }[], +) { + // HACK - Filter out react renderer middleware + const filteredMiddleware = middleware?.filter( + (m) => !/function reactRenderer/i.test(m?.handler), + ); + + if (!filteredMiddleware || filteredMiddleware.length === 0) { + return "NO MIDDLEWARE"; + } + + return filteredMiddleware + .map((m) => `${m.handler}`) + .join("\n"); +} + export const invokeRequestGenerationPrompt = async ({ persona, method, @@ -13,6 +34,7 @@ export const invokeRequestGenerationPrompt = async ({ handler, history, openApiSpec, + middleware, }: { persona: string; method: string; @@ -20,6 +42,11 @@ export const invokeRequestGenerationPrompt = async ({ handler: string; history?: Array; openApiSpec?: string; + middleware?: { + handler: string; + method: string; + path: string; + }[]; }) => { const promptTemplate = persona === "QA" ? qaTesterPrompt : friendlyTesterPrompt; @@ -29,6 +56,7 @@ export const invokeRequestGenerationPrompt = async ({ handler, history: history?.join("\n") ?? "NO HISTORY", openApiSpec: openApiSpec ?? "NO OPENAPI SPEC", + middleware: formatMiddleware(middleware), }); const userPrompt = userPromptInterface.value; return userPrompt; @@ -56,6 +84,9 @@ The request you make should be a {method} request to route: {path} Here is the OpenAPI spec for the handler: {openApiSpec} +Here is the middleware that will be applied to the request: +{middleware} + Here is the code for the handler: {handler} @@ -79,6 +110,9 @@ The request you make should be a {method} request to route: {path} Here is the OpenAPI spec for the handler: {openApiSpec} +Here is the middleware that will be applied to the request: +{middleware} + Here is the code for the handler: {handler} diff --git a/api/src/lib/app-routes.ts b/api/src/lib/app-routes.ts new file mode 100644 index 000000000..59caefb00 --- /dev/null +++ b/api/src/lib/app-routes.ts @@ -0,0 +1,84 @@ +import { and, eq } from "drizzle-orm"; +import type { LibSQLDatabase } from "drizzle-orm/libsql"; +import { z } from "zod"; +import * as schema from "../db/schema.js"; + +const { appRoutes } = schema; + +export const schemaProbedRoutes = z.object({ + routes: z.array( + z.object({ + method: z.string(), + path: z.string(), + handler: z.string(), + handlerType: z.string(), + }), + ), +}); + +/** + * Re-registers all app routes in a database transaction + * + * This is used to update the app routes when the client library reports new routes + * + * The logic is: + * - Unregister all routes (including middleware), by setting currentlyRegistered to false + * - Delete all old middleware, since we don't want stale middleware in the database + * - Insert all new routes and middleware + * - Update all routes that changed + * + * A route is considered "changed" if there's already a record in the database with + * the same path and method, with `handlerType === "route"` and `routeOrigin === "discovered"`, + * but the handler or other information is different + */ +export async function reregisterRoutes( + db: LibSQLDatabase, + { routes }: z.infer, +) { + return db.transaction(async (tx) => { + // Unregister all routes + await tx + .update(appRoutes) + .set({ currentlyRegistered: false, registrationOrder: -1 }); + + // Delete all old middleware + await tx.delete(appRoutes).where(eq(appRoutes.handlerType, "middleware")); + + const currentDiscoveredRoutes = await tx + .select() + .from(appRoutes) + .where( + and( + eq(appRoutes.handlerType, "route"), + eq(appRoutes.routeOrigin, "discovered"), + ), + ); + + // HACK - This is an N+1 query, but we should never have too many routes + // TODO - Investigate "update many" logic: https://orm.drizzle.team/learn/guides/update-many-with-different-value + // TODO - Could just delete all old routes we're going to update, then do one big insert + for (const [index, route] of routes.entries()) { + const routeToUpdate = + route.handlerType === "route" && + currentDiscoveredRoutes.find( + (r) => r.path === route.path && r.method === route.method, + ); + if (routeToUpdate) { + await tx + .update(appRoutes) + .set({ + handler: route.handler, + currentlyRegistered: true, + registrationOrder: index, + }) + .where(eq(appRoutes.id, routeToUpdate.id)); + } else { + await tx.insert(appRoutes).values({ + ...route, + currentlyRegistered: true, + registrationOrder: index, + }); + } + } + }); +} diff --git a/api/src/lib/otel/index.ts b/api/src/lib/otel/index.ts index 908221a73..34ea04ca8 100644 --- a/api/src/lib/otel/index.ts +++ b/api/src/lib/otel/index.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { type OtelSpan, OtelSpanSchema } from "@fiberplane/fpx-types"; import type { ESpanKind, EStatusCode, @@ -87,8 +88,8 @@ type MizuSpan = { */ export async function fromCollectorRequest( tracesData: IExportTraceServiceRequest, -) { - const result: Array = []; +): Promise> { + const result: Array = []; for (const resourceSpan of tracesData.resourceSpans ?? []) { const resourceAttributes = resourceSpan.resource @@ -133,7 +134,7 @@ export async function fromCollectorRequest( const name = span.name; const traceState = span.traceState; - const spanInstance = { + const spanInstance = OtelSpanSchema.parse({ trace_id: traceId, span_id: spanId, parent_span_id: parentSpanId, @@ -150,7 +151,7 @@ export async function fromCollectorRequest( status: span.status ? mapStatus(span.status) : undefined, events, links, - }; + } satisfies MizuSpan); result.push(spanInstance); } diff --git a/api/src/lib/proxy-request/index.ts b/api/src/lib/proxy-request/index.ts index c4cbc29f3..de0143d4b 100644 --- a/api/src/lib/proxy-request/index.ts +++ b/api/src/lib/proxy-request/index.ts @@ -140,7 +140,7 @@ export async function handleSuccessfulRequest( .extend({ headers: z.instanceof(Headers), status: z.number(), - body: z.instanceof(ReadableStream), + body: z.instanceof(ReadableStream).nullable(), traceId: z.string().optional(), }) .transform(async ({ headers, status }) => { diff --git a/api/src/routes/app-routes.ts b/api/src/routes/app-routes.ts index f11deb9a2..21041790e 100644 --- a/api/src/routes/app-routes.ts +++ b/api/src/routes/app-routes.ts @@ -10,6 +10,7 @@ import { appRoutes, appRoutesInsertSchema, } from "../db/schema.js"; +import { reregisterRoutes, schemaProbedRoutes } from "../lib/app-routes.js"; import { OTEL_TRACE_ID_REGEX, generateOtelTraceId, @@ -77,17 +78,6 @@ app.post( }, ); -const schemaProbedRoutes = z.object({ - routes: z.array( - z.object({ - method: z.string(), - path: z.string(), - handler: z.string(), - handlerType: z.string(), - }), - ), -}); - app.post( "/v0/probed-routes", zValidator("json", schemaProbedRoutes), @@ -97,26 +87,8 @@ app.post( try { if (routes.length > 0) { - // "Unregister" all app routes (including middleware) - await db.update(appRoutes).set({ currentlyRegistered: false }); - // "Re-register" all current app routes - for (const route of routes) { - await db - .insert(appRoutes) - .values({ - ...route, - currentlyRegistered: true, - }) - .onConflictDoUpdate({ - target: [ - appRoutes.path, - appRoutes.method, - appRoutes.handlerType, - appRoutes.routeOrigin, - ], - set: { handler: route.handler, currentlyRegistered: true }, - }); - } + // "Re-register" all current app routes in a database transaction + await reregisterRoutes(db, { routes }); // TODO - Detect if anything actually changed before invalidating the query on the frontend // This would be more of an optimization, but is friendlier to the frontend @@ -163,6 +135,13 @@ app.delete("/v0/app-routes/:method/:path", async (ctx) => { return ctx.json(createdRoute?.[0]); }); +app.delete("/v0/app-requests/", async (ctx) => { + const db = ctx.get("db"); + await db.delete(appResponses); + await db.delete(appRequests); + return ctx.text("OK"); +}); + app.get("/v0/all-requests", async (ctx) => { const db = ctx.get("db"); const requests = await db @@ -298,6 +277,8 @@ app.all( requestQueryParams, requestBody, requestRoute, + updatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), }; const insertResult = await db diff --git a/api/src/routes/inference.ts b/api/src/routes/inference.ts index b00f1a1b7..d52a54ad9 100644 --- a/api/src/routes/inference.ts +++ b/api/src/routes/inference.ts @@ -10,41 +10,65 @@ import type { Bindings, Variables } from "../lib/types.js"; const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); -app.post("/v0/generate-request", cors(), async (ctx) => { - const { handler, method, path, history, persona, openApiSpec } = - await ctx.req.json(); +const generateRequestSchema = z.object({ + handler: z.string(), + method: z.string(), + path: z.string(), + history: z.array(z.string()).nullish(), + persona: z.string(), + openApiSpec: z.string().nullish(), + middleware: z + .array( + z.object({ + handler: z.string(), + method: z.string(), + path: z.string(), + }), + ) + .nullish(), +}); - const db = ctx.get("db"); - const inferenceConfig = await getInferenceConfig(db); +app.post( + "/v0/generate-request", + cors(), + zValidator("json", generateRequestSchema), + async (ctx) => { + const { handler, method, path, history, persona, openApiSpec, middleware } = + ctx.req.valid("json"); - if (!inferenceConfig) { - return ctx.json( - { - message: "No inference configuration found", - }, - 403, - ); - } + const db = ctx.get("db"); + const inferenceConfig = await getInferenceConfig(db); - const { data: parsedArgs, error: generateError } = - await generateRequestWithAiProvider({ - inferenceConfig, - persona, - method, - path, - handler, - history, - openApiSpec, - }); + if (!inferenceConfig) { + return ctx.json( + { + message: "No inference configuration found", + }, + 403, + ); + } - if (generateError) { - return ctx.json({ message: generateError.message }, 500); - } + const { data: parsedArgs, error: generateError } = + await generateRequestWithAiProvider({ + inferenceConfig, + persona, + method, + path, + handler, + history: history ?? undefined, + openApiSpec: openApiSpec ?? undefined, + middleware: middleware ? middleware : undefined, + }); - return ctx.json({ - request: parsedArgs, - }); -}); + if (generateError) { + return ctx.json({ message: generateError.message }, 500); + } + + return ctx.json({ + request: parsedArgs, + }); + }, +); app.post( "/v0/analyze-error", diff --git a/api/src/routes/traces.ts b/api/src/routes/traces.ts index 3a2b8223c..6e92908d8 100644 --- a/api/src/routes/traces.ts +++ b/api/src/routes/traces.ts @@ -1,5 +1,10 @@ +import { + OtelSpanSchema, + type TraceDetailSpansResponse, + type TraceListResponse, +} from "@fiberplane/fpx-types"; import type { IExportTraceServiceRequest } from "@opentelemetry/otlp-transformer"; -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, sql } from "drizzle-orm"; import { Hono } from "hono"; import * as schema from "../db/schema.js"; import { fromCollectorRequest } from "../lib/otel/index.js"; @@ -22,21 +27,20 @@ app.get("/v1/traces", async (ctx) => { const fpxWorker = await getSetting(db, "fpxWorkerProxy"); if (fpxWorker?.enabled && fpxWorker.baseUrl) { - const response = await fetch(`${fpxWorker.baseUrl}/ts-compat/v1/traces`); + const response = await fetch(`${fpxWorker.baseUrl}/v1/traces`); const json = await response.json(); return ctx.json(json); } - const spans = await db - .select() - .from(otelSpans) - .where(and(sql`parsed_payload->>'scope_name' = 'fpx-tracer'`)) - .orderBy(desc(otelSpans.createdAt)); + const spans = await db.query.otelSpans.findMany({ + where: sql`inner->>'scope_name' = 'fpx-tracer'`, + orderBy: desc(sql`inner->>'end_time'`), + }); const traceMap = new Map>(); for (const span of spans) { - const traceId = span.traceId; + const traceId = span.inner.trace_id; if (!traceId) { continue; } @@ -51,7 +55,12 @@ app.get("/v1/traces", async (ctx) => { spans, })); - return ctx.json(traces); + const response: TraceListResponse = traces.map(({ traceId, spans }) => ({ + traceId, + spans: spans.map(({ inner }) => OtelSpanSchema.parse(inner)), + })); + + return ctx.json(response); }); /** @@ -67,7 +76,7 @@ app.get("/v1/traces/:traceId/spans", async (ctx) => { const fpxWorker = await getSetting(db, "fpxWorkerProxy"); if (fpxWorker?.enabled && fpxWorker.baseUrl) { const response = await fetch( - `${fpxWorker.baseUrl}/ts-compat/v1/traces/${traceId}/spans`, + `${fpxWorker.baseUrl}/v1/traces/${traceId}/spans`, ); const json = await response.json(); return ctx.json(json); @@ -78,11 +87,16 @@ app.get("/v1/traces/:traceId/spans", async (ctx) => { .from(otelSpans) .where( and( - sql`parsed_payload->>'scope_name' = 'fpx-tracer'`, - eq(otelSpans.traceId, traceId), + sql`inner->>'scope_name' = 'fpx-tracer'`, + sql`inner->>'trace_id' = ${traceId}`, ), ); - return ctx.json(traces); + + const response: TraceDetailSpansResponse = traces.map(({ inner }) => + OtelSpanSchema.parse(inner), + ); + + return ctx.json(response); }); app.post("/v1/traces/delete-all-hack", async (ctx) => { @@ -112,14 +126,15 @@ app.post("/v1/traces", async (ctx) => { } try { - const tracesPayload = (await fromCollectorRequest(body)).map((span) => ({ - rawPayload: body, - parsedPayload: span, - spanId: span.span_id, - traceId: span.trace_id, - })); - - // TODO - Find a way to use a type guard + const tracesPayload = (await fromCollectorRequest(body)).map( + (span) => + ({ + inner: OtelSpanSchema.parse(span), + spanId: span.span_id, + traceId: span.trace_id, + }) satisfies typeof otelSpans.$inferInsert, + ); + try { await db.insert(otelSpans).values(tracesPayload); } catch (error) { diff --git a/biome.jsonc b/biome.jsonc index 361172bf1..fb6057360 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -92,6 +92,7 @@ // Client library and website related "dist", + "www/", ".astro", // ignore all tsconfig.json files diff --git a/examples/goose-quotes/drizzle/0001_last_captain_america.sql b/examples/goose-quotes/drizzle/0001_last_captain_america.sql new file mode 100644 index 000000000..af0a66cd4 --- /dev/null +++ b/examples/goose-quotes/drizzle/0001_last_captain_america.sql @@ -0,0 +1 @@ +ALTER TABLE "geese" ADD COLUMN "honks" integer DEFAULT 0; \ No newline at end of file diff --git a/examples/goose-quotes/drizzle/meta/0001_snapshot.json b/examples/goose-quotes/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..26eb4b43a --- /dev/null +++ b/examples/goose-quotes/drizzle/meta/0001_snapshot.json @@ -0,0 +1,101 @@ +{ + "id": "d13ec396-5ca3-4ae5-8451-3442765c5abb", + "prevId": "301ac579-5843-4fa3-8b30-6012ab5546da", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.geese": { + "name": "geese", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_leader": { + "name": "is_leader", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "programming_language": { + "name": "programming_language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "motivations": { + "name": "motivations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "honks": { + "name": "honks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/examples/goose-quotes/drizzle/meta/_journal.json b/examples/goose-quotes/drizzle/meta/_journal.json index 23d1b2be6..1a74fcb56 100644 --- a/examples/goose-quotes/drizzle/meta/_journal.json +++ b/examples/goose-quotes/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1722995764012, "tag": "0000_talented_the_watchers", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1726591286939, + "tag": "0001_last_captain_america", + "breakpoints": true } ] } \ No newline at end of file diff --git a/examples/goose-quotes/src/db/client.ts b/examples/goose-quotes/src/db/client.ts new file mode 100644 index 000000000..ee5a9b202 --- /dev/null +++ b/examples/goose-quotes/src/db/client.ts @@ -0,0 +1,150 @@ +import { neon } from "@neondatabase/serverless"; +import { asc, eq, ilike } from "drizzle-orm"; +import type { drizzle } from "drizzle-orm/neon-http"; +import { geese } from "./schema"; + +export const getAllGeese = async (db: ReturnType) => { + console.log("Fetching all geese"); + return await db.select().from(geese); +}; + +export const searchGeese = async ( + db: ReturnType, + name: string, +) => { + console.log({ action: "searchGeese", name }); + return await db + .select() + .from(geese) + .where(ilike(geese.name, `%${name}%`)) + .orderBy(asc(geese.name)); +}; + +export const createGoose = async ( + db: ReturnType, + gooseData: Partial, +) => { + const { name, isFlockLeader, programmingLanguage, motivations, location } = + gooseData; + const description = `A person named ${name} who talks like a Goose`; + + console.log({ + action: "createGoose", + name, + isFlockLeader, + programmingLanguage, + }); + + return await db + .insert(geese) + .values({ + name, + description, + isFlockLeader, + programmingLanguage, + motivations, + location, + }) + .returning({ + id: geese.id, + name: geese.name, + description: geese.description, + isFlockLeader: geese.isFlockLeader, + programmingLanguage: geese.programmingLanguage, + motivations: geese.motivations, + location: geese.location, + }); +}; + +export const getGooseById = async ( + db: ReturnType, + id: number, +) => { + console.log(`Fetching goose with id: ${id}`); + return (await db.select().from(geese).where(eq(geese.id, id)))?.[0]; +}; + +export const getFlockLeaders = async (db: ReturnType) => { + console.log("Fetching flock leaders"); + return await db.select().from(geese).where(eq(geese.isFlockLeader, true)); +}; + +export const updateGooseName = async ( + db: ReturnType, + id: number, + name: string, +) => { + console.log({ action: "updateGooseName", id, name }); + return ( + await db.update(geese).set({ name }).where(eq(geese.id, id)).returning() + )?.[0]; +}; + +export const getGeeseByLanguage = async ( + db: ReturnType, + language: string, +) => { + console.log(`Fetching geese with programming language: ${language}`); + return await db + .select() + .from(geese) + .where(ilike(geese.programmingLanguage, `%${language}%`)); +}; + +export const updateGooseMotivations = async ( + db: ReturnType, + id: number, + motivations: string, +) => { + console.log({ action: "updateGooseMotivations", id, motivations }); + return ( + await db + .update(geese) + .set({ motivations }) + .where(eq(geese.id, id)) + .returning() + )?.[0]; +}; + +export const updateGooseAvatar = async ( + db: ReturnType, + id: number, + avatarKey: string, +) => { + console.log(`Updating avatar for goose with id: ${id}`); + return ( + await db + .update(geese) + .set({ avatar: avatarKey }) + .where(eq(geese.id, id)) + .returning() + )[0]; +}; + +export const updateGoose = async ( + db: ReturnType, + id: number, + updateData: Partial, +) => { + console.log({ action: "updateGoose", id, updateData }); + + // Simulate a race condition by splitting the update into two parts + const updatePromises = Object.entries(updateData).map( + async ([key, value]) => { + // Introduce a random delay to increase the chance of interleaved updates + await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); + + return db + .update(geese) + .set({ [key]: value }) + .where(eq(geese.id, id)) + .returning(); + }, + ); + + // Wait for all updates to complete + const results = await Promise.all(updatePromises); + + // Return the last result, which may not contain all updates + return results[results.length - 1][0]; +}; diff --git a/examples/goose-quotes/src/db/schema.ts b/examples/goose-quotes/src/db/schema.ts index b85784f21..c364dd17f 100644 --- a/examples/goose-quotes/src/db/schema.ts +++ b/examples/goose-quotes/src/db/schema.ts @@ -1,5 +1,6 @@ import { boolean, + integer, jsonb, pgTable, serial, @@ -17,6 +18,7 @@ export const geese = pgTable("geese", { location: text("location"), bio: text("bio"), avatar: text("avatar"), + honks: integer("honks").default(0), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); diff --git a/examples/goose-quotes/src/index.ts b/examples/goose-quotes/src/index.ts index e0d8725f3..e5384f859 100644 --- a/examples/goose-quotes/src/index.ts +++ b/examples/goose-quotes/src/index.ts @@ -1,10 +1,17 @@ import { Hono } from "hono"; -import { instrument } from "@fiberplane/hono-otel"; +import { instrument, measure } from "@fiberplane/hono-otel"; import { neon } from "@neondatabase/serverless"; import { asc, eq, ilike } from "drizzle-orm"; import { drizzle } from "drizzle-orm/neon-http"; +import { + createGoose, + getAllGeese, + getGeeseByLanguage, + getGooseById, + updateGoose, +} from "./db/client"; import { geese } from "./db/schema"; import { upgradeWebSocket } from "hono/cloudflare-workers"; @@ -26,6 +33,7 @@ const app = new Hono<{ Bindings: Bindings }>(); app.get("/", (c) => { const { shouldHonk } = c.req.query(); const honk = typeof shouldHonk !== "undefined" ? "Honk honk!" : ""; + console.log(`Home page accessed. Honk: ${honk}`); return c.text(`Hello Goose Quotes! ${honk}`.trim()); }); @@ -39,22 +47,27 @@ app.get("/api/geese", async (c) => { const db = drizzle(sql); const name = c.req.query("name"); - - console.log("not searching"); + console.log({ action: "search_geese", name }); if (!name) { - return c.json(await db.select().from(geese)); + const allGeese = await measure("getAllGeese", () => getAllGeese(db))(); + console.log({ action: "get_all_geese", count: allGeese.length }); + return c.json(allGeese); } - console.log("searching for", name); - - const searchResults = await db - .select() - .from(geese) - .where(ilike(geese.name, `%${name}%`)) - .orderBy(asc(geese.name)); - - console.log("found", searchResults.length, "results"); + const searchResults = await measure("searchGeese", () => + db + .select() + .from(geese) + .where(ilike(geese.name, `%${name}%`)) + .orderBy(asc(geese.name)), + )(); + + console.log({ + action: "search_geese_results", + count: searchResults.length, + name, + }); return c.json(searchResults); }); @@ -72,26 +85,20 @@ app.post("/api/geese", async (c) => { await c.req.json(); const description = `A person named ${name} who talks like a Goose`; - const created = await db - .insert(geese) - .values({ + console.log(`Creating new goose: ${name}`); + + const created = await measure("createGoose", () => + createGoose(db, { name, description, isFlockLeader, programmingLanguage, motivations, location, - }) - .returning({ - id: geese.id, - name: geese.name, - description: geese.description, - isFlockLeader: geese.isFlockLeader, - programmingLanguage: geese.programmingLanguage, - motivations: geese.motivations, - location: geese.location, - }); - return c.json(created?.[0]); + }), + )(); + console.log({ action: "create_goose", id: created[0].id, name }); + return c.json(created); }); /** @@ -103,9 +110,10 @@ app.post("/api/geese/:id/generate", async (c) => { const id = c.req.param("id"); - const goose = (await db.select().from(geese).where(eq(geese.id, +id)))?.[0]; + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } @@ -117,35 +125,44 @@ app.post("/api/geese/:id/generate", async (c) => { fetch: globalThis.fetch, }); - const response = await openaiClient.chat.completions.create({ - model: "gpt-4o-mini", - messages: [ - { - role: "system", - content: trimPrompt(` - You are a goose. You are a very smart goose. You are part goose, part AI. You are a GooseAI. - You are also influenced heavily by the work of ${gooseName}. - - Always respond without preamble. If I ask for a list, give me a newline-separated list. That's it. - Don't number it. Don't bullet it. Just newline it. - - Never forget to Honk. A lot. - `), - }, - { - role: "user", - content: trimPrompt(` - Reimagine five famous quotes by ${gooseName}, except with significant goose influence. - `), - }, - ], - temperature: 0.7, - max_tokens: 2048, - }); + console.log(`Generating quotes for goose: ${gooseName}`); + + const response = await measure("generateQuotes", () => + openaiClient.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: trimPrompt(` + You are a goose. You are a very smart goose. You are part goose, part AI. You are a GooseAI. + You are also influenced heavily by the work of ${gooseName}. + + Always respond without preamble. If I ask for a list, give me a newline-separated list. That's it. + Don't number it. Don't bullet it. Just newline it. + + Never forget to Honk. A lot. + `), + }, + { + role: "user", + content: trimPrompt(` + Reimagine five famous quotes by ${gooseName}, except with significant goose influence. + `), + }, + ], + temperature: 0.7, + max_tokens: 2048, + }), + )(); const quotes = response.choices[0].message.content ?.split("\n") .filter((quote) => quote.length > 0); + console.log({ + action: "generate_quotes", + gooseName, + quoteCount: quotes?.length, + }); return c.json({ name: goose.name, quotes }); }); @@ -157,10 +174,13 @@ app.get("/api/geese/flock-leaders", async (c) => { const sql = neon(c.env.DATABASE_URL); const db = drizzle(sql); - const flockLeaders = await db - .select() - .from(geese) - .where(eq(geese.isFlockLeader, true)); + console.log("Fetching flock leaders"); + + const flockLeaders = await measure("getFlockLeaders", () => + db.select().from(geese).where(eq(geese.isFlockLeader, true)), + )(); + + console.log(`Found ${flockLeaders.length} flock leaders`); return c.json(flockLeaders); }); @@ -174,12 +194,16 @@ app.get("/api/geese/:id", async (c) => { const id = c.req.param("id"); - const goose = (await db.select().from(geese).where(eq(geese.id, +id)))?.[0]; + console.log(`Fetching goose with id: ${id}`); + + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } + console.log(`Found goose: ${goose.name}`); return c.json(goose); }); @@ -192,9 +216,10 @@ app.post("/api/geese/:id/bio", async (c) => { const id = c.req.param("id"); - const goose = (await db.select().from(geese).where(eq(geese.id, +id)))?.[0]; + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } @@ -206,45 +231,48 @@ app.post("/api/geese/:id/bio", async (c) => { location, } = goose; + console.log(`Generating bio for goose: ${gooseName}`); + const openaiClient = new OpenAI({ apiKey: c.env.OPENAI_API_KEY, fetch: globalThis.fetch, }); - const response = await openaiClient.chat.completions.create({ - model: "gpt-4o", - messages: [ - { - role: "system", - content: trimPrompt(` - You are a professional bio writer. Your task is to generate a compelling and engaging bio for a goose. - `), - }, - { - role: "user", - content: trimPrompt(` - Generate a bio for a goose named ${gooseName} with the following details: - Description: ${description} - Programming Language: ${programmingLanguage} - Motivations: ${motivations} - Location: ${location} - `), - }, - ], - temperature: 0.7, - max_tokens: 2048, - }); + const response = await measure("generateBio", () => + openaiClient.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "system", + content: trimPrompt(` + You are a professional bio writer. Your task is to generate a compelling and engaging bio for a goose. + `), + }, + { + role: "user", + content: trimPrompt(` + Generate a bio for a goose named ${gooseName} with the following details: + Description: ${description} + Programming Language: ${programmingLanguage} + Motivations: ${motivations} + Location: ${location} + `), + }, + ], + temperature: 0.7, + max_tokens: 2048, + }), + )(); const bio = response.choices[0].message.content; // Update the goose with the generated bio - const updatedGoose = await db - .update(geese) - .set({ bio }) - .where(eq(geese.id, +id)) - .returning(); + const updatedGoose = await measure("updateGoose", () => + updateGoose(db, +id, { bio }), + )(); - return c.json(updatedGoose[0]); + console.log(`Bio generated and updated for goose: ${gooseName}`); + return c.json(updatedGoose); }); /** @@ -255,13 +283,26 @@ app.post("/api/geese/:id/honk", async (c) => { const db = drizzle(sql); const id = c.req.param("id"); - const goose = (await db.select().from(geese).where(eq(geese.id, +id)))?.[0]; + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } - return c.json({ message: `Honk honk! ${goose.name} honks back at you!` }); + const currentHonks = goose.honks || 0; + + const updatedGoose = await measure("updateGoose", () => + updateGoose(db, +id, { honks: currentHonks + 1 }), + )(); + + console.log( + `Honk received for goose: ${goose.name}. New honk count: ${updatedGoose.honks}`, + ); + return c.json({ + message: `Honk honk! ${goose.name} honks back at you!`, + honks: updatedGoose.honks, + }); }); /** @@ -272,17 +313,35 @@ app.patch("/api/geese/:id", async (c) => { const db = drizzle(sql); const id = c.req.param("id"); - const { name } = await c.req.json(); + const updateData = await c.req.json(); - const goose = ( - await db.update(geese).set({ name }).where(eq(geese.id, +id)).returning() - )?.[0]; + console.log(`Updating goose ${id} with data:`, updateData); + + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } - return c.json(goose); + // Simulate a race condition by splitting the update into multiple parts + const updatePromises = Object.entries(updateData).map( + async ([key, value]) => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000)); + return measure("updateGoose", () => + updateGoose(db, +id, { [key]: value }), + )(); + }, + ); + + await Promise.all(updatePromises); + + const updatedGoose = await measure("getGooseById", () => + getGooseById(db, +id), + )(); + + console.log(`Goose ${id} updated successfully`); + return c.json(updatedGoose); }); /** @@ -294,11 +353,15 @@ app.get("/api/geese/language/:language", async (c) => { const language = c.req.param("language"); - const geeseByLanguage = await db - .select() - .from(geese) - .where(ilike(geese.programmingLanguage, `%${language}%`)); + console.log(`Fetching geese with programming language: ${language}`); + const geeseByLanguage = await measure("getGeeseByLanguage", () => + getGeeseByLanguage(db, language), + )(); + + console.log( + `Found ${geeseByLanguage.length} geese for language: ${language}`, + ); return c.json(geeseByLanguage); }); @@ -312,18 +375,18 @@ app.patch("/api/geese/:id/motivations", async (c) => { const id = c.req.param("id"); const { motivations } = await c.req.json(); - const updatedGoose = ( - await db - .update(geese) - .set({ motivations }) - .where(eq(geese.id, +id)) - .returning() - )?.[0]; + console.log(`Updating motivations for goose ${id}`); + + const updatedGoose = await measure("updateGoose", () => + updateGoose(db, +id, { motivations }), + )(); if (!updatedGoose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } + console.log(`Motivations updated for goose ${id}`); return c.json(updatedGoose); }); @@ -332,9 +395,10 @@ app.post("/api/geese/:id/change-name-url-form", async (c) => { const db = drizzle(sql); const id = c.req.param("id"); - const [goose] = await db.select().from(geese).where(eq(geese.id, +id)); + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } @@ -342,15 +406,16 @@ app.post("/api/geese/:id/change-name-url-form", async (c) => { const name = form.get("name"); if (!name) { + console.error("Name is required for changing goose name"); return c.json({ message: "Name is required" }, 400); } - const [updatedGoose] = await db - .update(geese) - .set({ name }) - .where(eq(geese.id, +id)) - .returning(); + console.log(`Changing name of goose ${id} to ${name}`); + const updatedGoose = await measure("updateGoose", () => + updateGoose(db, +id, { name }), + )(); + console.log(`Name changed for goose ${id}`); return c.json(updatedGoose, 200); }); @@ -363,16 +428,18 @@ app.post("/api/geese/:id/avatar", async (c) => { const id = c.req.param("id"); - const [goose] = await db.select().from(geese).where(eq(geese.id, +id)); + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } const { avatar, avatarName } = await c.req.parseBody(); - console.log({ avatarName }, "is the avatar name"); + console.log({ action: "update_avatar", gooseId: id, avatarName }); // Validate the avatar is a file if (!(avatar instanceof File)) { + console.error(`Invalid avatar type for goose ${id}: ${typeof avatar}`); return c.json( { message: "Avatar must be a file", actualType: typeof avatar }, 422, @@ -382,6 +449,7 @@ app.post("/api/geese/:id/avatar", async (c) => { // Validate the avatar is a JPEG, PNG, or GIF const allowedTypes = ["image/jpeg", "image/png", "image/gif"]; if (!allowedTypes.includes(avatar.type)) { + console.error(`Invalid avatar file type for goose ${id}: ${avatar.type}`); return c.json({ message: "Avatar must be a JPEG, PNG, or GIF image" }, 422); } @@ -390,16 +458,19 @@ app.post("/api/geese/:id/avatar", async (c) => { // Save the avatar to the bucket const bucketKey = `goose-${id}-avatar-${Date.now()}.${fileExtension}`; - await c.env.GOOSE_AVATARS.put(bucketKey, avatar.stream(), { - httpMetadata: { contentType: avatar.type }, - }); + await measure("uploadAvatar", () => + c.env.GOOSE_AVATARS.put(bucketKey, avatar.stream(), { + httpMetadata: { contentType: avatar.type }, + }), + )(); + + console.log(`Avatar uploaded for goose ${id}: ${bucketKey}`); - const [updatedGoose] = await db - .update(geese) - .set({ avatar: bucketKey }) - .where(eq(geese.id, +id)) - .returning(); + const updatedGoose = await measure("updateGoose", () => + updateGoose(db, +id, { avatar: bucketKey }), + )(); + console.log(`Avatar updated for goose ${id}`); return c.json(updatedGoose); }); @@ -412,24 +483,32 @@ app.get("/api/geese/:id/avatar", async (c) => { const id = c.req.param("id"); - const [goose] = await db.select().from(geese).where(eq(geese.id, +id)); + const goose = await measure("getGooseById", () => getGooseById(db, +id))(); if (!goose) { + console.warn(`Goose not found: ${id}`); return c.json({ message: "Goose not found" }, 404); } const avatarKey = goose.avatar; if (!avatarKey) { + console.warn(`Goose ${id} has no avatar`); return c.json({ message: "Goose has no avatar" }, 404); } - const avatar = await c.env.GOOSE_AVATARS.get(avatarKey); + console.log(`Fetching avatar for goose ${id}: ${avatarKey}`); + + const avatar = await measure("getAvatar", () => + c.env.GOOSE_AVATARS.get(avatarKey), + )(); if (!avatar) { + console.error(`Avatar not found for goose ${id}: ${avatarKey}`); return c.json({ message: "Goose avatar not found" }, 404); } + console.log(`Avatar retrieved for goose ${id}`); const responseHeaders = mapR2HttpMetadataToHeaders(avatar.httpMetadata); return new Response(avatar.body, { headers: responseHeaders, @@ -442,7 +521,9 @@ app.get("/api/geese/:id/avatar", async (c) => { * For all methods, print "Honk honk!" */ app.all("/always-honk/:echo?", (c) => { - return c.text(`Honk honk! ${c.req.param("echo") ?? ""}`); + const echo = c.req.param("echo"); + console.log(`Always honk endpoint called with echo: ${echo}`); + return c.text(`Honk honk! ${echo ?? ""}`); }); app.get( @@ -454,13 +535,14 @@ app.get( const sql = neon(c.env.DATABASE_URL); const db = drizzle(sql); + console.log(`WebSocket message received: ${type}`); + switch (type) { case "GET_GEESE": - db.select() - .from(geese) - .then((geese) => { - ws.send(JSON.stringify({ type: "GEESE", payload: geese })); - }); + measure("getAllGeese", () => getAllGeese(db))().then((geese) => { + console.log(`Sending ${geese.length} geese over WebSocket`); + ws.send(JSON.stringify({ type: "GEESE", payload: geese })); + }); break; case "CREATE_GOOSE": { const { @@ -472,38 +554,30 @@ app.get( } = payload; const description = `A person named ${name} who talks like a Goose`; - db.insert(geese) - .values({ + console.log(`Creating new goose via WebSocket: ${name}`); + measure("createGoose", () => + createGoose(db, { name, description, isFlockLeader, programmingLanguage, motivations, location, - }) - .returning({ - id: geese.id, - name: geese.name, - description: geese.description, - isFlockLeader: geese.isFlockLeader, - programmingLanguage: geese.programmingLanguage, - motivations: geese.motivations, - location: geese.location, - }) - .then((newGoose) => { - ws.send( - JSON.stringify({ type: "NEW_GOOSE", payload: newGoose[0] }), - ); - }); + }), + )().then((newGoose) => { + console.log(`New goose created via WebSocket: ${newGoose[0].id}`); + ws.send(JSON.stringify({ type: "NEW_GOOSE", payload: newGoose })); + }); break; } // ... (handle other message types) default: + console.warn(`Unknown WebSocket message type: ${type}`); break; } }, onClose: () => { - console.log("Connection closed"); + console.log("WebSocket connection closed"); }, }; }), diff --git a/examples/node-api/.env.example b/examples/node-api/.env.example new file mode 100644 index 000000000..56568de93 --- /dev/null +++ b/examples/node-api/.env.example @@ -0,0 +1 @@ +FPX_ENDPOINT=http://localhost:8788/v1/traces \ No newline at end of file diff --git a/examples/node-api/.gitignore b/examples/node-api/.gitignore new file mode 100644 index 000000000..36fabb6cb --- /dev/null +++ b/examples/node-api/.gitignore @@ -0,0 +1,28 @@ +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ + +# env +.env +.env.production + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/examples/node-api/README.md b/examples/node-api/README.md new file mode 100644 index 000000000..131a55131 --- /dev/null +++ b/examples/node-api/README.md @@ -0,0 +1,9 @@ +```sh +pnpm install +cp .env.example .env +pnpm dev +``` + +```sh +open http://localhost:8787 +``` diff --git a/examples/node-api/package.json b/examples/node-api/package.json new file mode 100644 index 000000000..da4f2955d --- /dev/null +++ b/examples/node-api/package.json @@ -0,0 +1,16 @@ +{ + "name": "node-api", + "scripts": { + "dev": "tsx watch src/index.ts", + "debug": "tsx --inspect-brk src/index.ts" + }, + "dependencies": { + "@fiberplane/hono-otel": "workspace:*", + "@hono/node-server": "^1.12.2", + "hono": "^4.5.9" + }, + "devDependencies": { + "@types/node": "^20.11.17", + "tsx": "^4.7.1" + } +} diff --git a/examples/node-api/src/index.ts b/examples/node-api/src/index.ts new file mode 100644 index 000000000..579df5c36 --- /dev/null +++ b/examples/node-api/src/index.ts @@ -0,0 +1,38 @@ +import { instrument } from "@fiberplane/hono-otel"; +import { serve } from "@hono/node-server"; +import { config } from "dotenv"; +import { Hono } from "hono"; + +// Load environment variables from .env file +config(); + +const app = new Hono(); + +app.get("/", (c) => { + console.log("Hello Hono!"); + return c.text("Hello Hono!"); +}); + +app.get("/function", (c) => { + helloFunction(); + console.log(helloFunction, "that was a function"); + return c.text("Hello function!"); +}); + +function helloFunction() { + console.log("Hello function!"); +} + +app.post("/json", async (c) => { + const body = await c.req.json(); + console.log("json body", body); + return c.json({ message: "Hello Json!", body }); +}); + +const port = 8787; +console.log(`Server is running on port http://localhost:${port}`); + +serve({ + fetch: instrument(app).fetch, + port, +}); diff --git a/examples/node-api/tsconfig.json b/examples/node-api/tsconfig.json new file mode 100644 index 000000000..667b7e7e6 --- /dev/null +++ b/examples/node-api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": [ + "node" + ], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + } +} \ No newline at end of file diff --git a/fpx-lib/Cargo.toml b/fpx-lib/Cargo.toml index 9c927c54e..7bdb0a5db 100644 --- a/fpx-lib/Cargo.toml +++ b/fpx-lib/Cargo.toml @@ -38,7 +38,7 @@ thiserror = { version = "1.0", default-features = false } time = { version = "0.3", default-features = false, features = [ "serde-human-readable", ] } -tokio = { version = "1.39", default-features = false } +tokio = { version = "1.40", default-features = false } tower-http = { version = "0.5", default-features = false, features = [ "compression-br", "compression-gzip", @@ -55,7 +55,7 @@ wasm-bindgen = { version = "0.2", default-features = false, optional = true } [dev-dependencies] http-body-util = { version = "0.1", default-features = false } -tokio = { version = "1.39", default-features = false, features = [ +tokio = { version = "1.40", default-features = false, features = [ "macros", "test-util", ] } diff --git a/fpx-lib/src/api.rs b/fpx-lib/src/api.rs index 9a635b3fd..f17930ba5 100644 --- a/fpx-lib/src/api.rs +++ b/fpx-lib/src/api.rs @@ -54,28 +54,20 @@ impl Builder { let router = axum::Router::new() .route("/v1/traces", post(handlers::otel::trace_collector_handler)) - .route("/api/traces", get(handlers::traces::traces_list_handler)) + .route("/v1/traces", get(handlers::traces::traces_list_handler)) .route( - "/api/traces/:trace_id", + "/v1/traces/:trace_id", get(handlers::traces::traces_get_handler) .delete(handlers::traces::traces_delete_handler), ) .route( - "/api/traces/:trace_id/spans", + "/v1/traces/:trace_id/spans", get(handlers::spans::span_list_handler), ) .route( - "/api/traces/:trace_id/spans/:span_id", + "/v1/traces/:trace_id/spans/:span_id", get(handlers::spans::span_get_handler).delete(handlers::spans::span_delete_handler), ) - .route( - "/ts-compat/v1/traces/:trace_id/spans", - get(handlers::spans::ts_compat_span_list_handler), - ) - .route( - "/ts-compat/v1/traces", - get(handlers::traces::ts_compat_traces_list_handler), - ) .with_state(api_state) .fallback(StatusCode::NOT_FOUND) .layer(OtelTraceLayer::default()) diff --git a/fpx-lib/src/api/handlers/spans.rs b/fpx-lib/src/api/handlers/spans.rs index 572d4b338..130844e27 100644 --- a/fpx-lib/src/api/handlers/spans.rs +++ b/fpx-lib/src/api/handlers/spans.rs @@ -1,5 +1,6 @@ use crate::api::errors::{ApiServerError, CommonError}; -use crate::api::models::{ts_compat::TypeScriptCompatSpan, Span}; +use crate::api::models::Span; +use crate::data::models::HexEncodedId; use crate::data::{BoxedStore, DbError}; use axum::extract::{Path, State}; use axum::Json; @@ -12,14 +13,10 @@ use tracing::error; #[tracing::instrument(skip_all)] pub async fn span_get_handler( State(store): State, - Path((trace_id, span_id)): Path<(String, String)>, + Path((trace_id, span_id)): Path<(HexEncodedId, HexEncodedId)>, ) -> Result, ApiServerError> { let tx = store.start_readonly_transaction().await?; - hex::decode(&trace_id) - .map_err(|_| ApiServerError::ServiceError(SpanGetError::InvalidTraceId))?; - hex::decode(&span_id).map_err(|_| ApiServerError::ServiceError(SpanGetError::InvalidSpanId))?; - let span = store.span_get(&tx, &trace_id, &span_id).await?; Ok(Json(span.into())) @@ -32,14 +29,6 @@ pub enum SpanGetError { #[api_error(status_code = StatusCode::NOT_FOUND)] #[error("Span not found")] SpanNotFound, - - #[api_error(status_code = StatusCode::BAD_REQUEST)] - #[error("Trace ID is invalid")] - InvalidTraceId, - - #[api_error(status_code = StatusCode::BAD_REQUEST)] - #[error("Span ID is invalid")] - InvalidSpanId, } impl From for ApiServerError { @@ -54,32 +43,13 @@ impl From for ApiServerError { } } -#[tracing::instrument(skip_all)] -pub async fn ts_compat_span_list_handler( - State(store): State, - Path(trace_id): Path, -) -> Result>, ApiServerError> { - let tx = store.start_readonly_transaction().await?; - - hex::decode(&trace_id) - .map_err(|_| ApiServerError::ServiceError(SpanListError::InvalidTraceId))?; - - let spans = store.span_list_by_trace(&tx, &trace_id).await?; - let spans: Vec<_> = spans.into_iter().map(Into::into).collect(); - - Ok(Json(spans)) -} - #[tracing::instrument(skip_all)] pub async fn span_list_handler( State(store): State, - Path(trace_id): Path, + Path(trace_id): Path, ) -> Result>, ApiServerError> { let tx = store.start_readonly_transaction().await?; - hex::decode(&trace_id) - .map_err(|_| ApiServerError::ServiceError(SpanListError::InvalidTraceId))?; - let spans = store.span_list_by_trace(&tx, &trace_id).await?; let spans: Vec<_> = spans.into_iter().map(Into::into).collect(); @@ -89,11 +59,7 @@ pub async fn span_list_handler( #[derive(Debug, Serialize, Deserialize, Error, ApiError)] #[serde(tag = "error", content = "details", rename_all = "camelCase")] #[non_exhaustive] -pub enum SpanListError { - #[api_error(status_code = StatusCode::BAD_REQUEST)] - #[error("Trace ID is invalid")] - InvalidTraceId, -} +pub enum SpanListError {} impl From for ApiServerError { fn from(err: DbError) -> Self { @@ -105,15 +71,10 @@ impl From for ApiServerError { #[tracing::instrument(skip_all)] pub async fn span_delete_handler( State(store): State, - Path((trace_id, span_id)): Path<(String, String)>, + Path((trace_id, span_id)): Path<(HexEncodedId, HexEncodedId)>, ) -> Result> { let tx = store.start_readonly_transaction().await?; - hex::decode(&trace_id) - .map_err(|_| ApiServerError::ServiceError(SpanDeleteError::InvalidTraceId))?; - hex::decode(&span_id) - .map_err(|_| ApiServerError::ServiceError(SpanDeleteError::InvalidSpanId))?; - store.span_delete(&tx, &trace_id, &span_id).await?; Ok(StatusCode::NO_CONTENT) @@ -122,15 +83,7 @@ pub async fn span_delete_handler( #[derive(Debug, Serialize, Deserialize, Error, ApiError)] #[serde(tag = "error", content = "details", rename_all = "camelCase")] #[non_exhaustive] -pub enum SpanDeleteError { - #[api_error(status_code = StatusCode::BAD_REQUEST)] - #[error("Trace ID is invalid")] - InvalidTraceId, - - #[api_error(status_code = StatusCode::BAD_REQUEST)] - #[error("Trace ID is invalid")] - InvalidSpanId, -} +pub enum SpanDeleteError {} impl From for ApiServerError { fn from(err: DbError) -> Self { diff --git a/fpx-lib/src/api/handlers/traces.rs b/fpx-lib/src/api/handlers/traces.rs index d98e6db26..30bc8b48f 100644 --- a/fpx-lib/src/api/handlers/traces.rs +++ b/fpx-lib/src/api/handlers/traces.rs @@ -1,5 +1,6 @@ use crate::api::errors::{ApiServerError, CommonError}; -use crate::api::models::{ts_compat::TypeScriptCompatTrace, TraceSummary}; +use crate::api::models::TraceSummary; +use crate::data::models::HexEncodedId; use crate::data::{BoxedStore, DbError}; use axum::extract::{Path, State}; use axum::Json; @@ -9,28 +10,6 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::error; -#[tracing::instrument(skip_all)] -pub async fn ts_compat_traces_list_handler( - State(store): State, -) -> Result>, ApiServerError> { - let tx = store.start_readonly_transaction().await?; - - let traces = store.traces_list(&tx).await?; - - let mut result: Vec = Vec::new(); - - for trace in traces.into_iter() { - let spans = store.span_list_by_trace(&tx, &trace.trace_id).await?; - - result.push(TypeScriptCompatTrace { - trace_id: trace.trace_id.clone(), - spans: spans.iter().map(|span| span.clone().into()).collect(), - }); - } - - Ok(Json(result)) -} - #[tracing::instrument(skip_all)] pub async fn traces_list_handler( State(store): State, @@ -66,17 +45,14 @@ impl From for ApiServerError { #[tracing::instrument(skip_all)] pub async fn traces_get_handler( State(store): State, - Path(trace_id): Path, + Path(trace_id): Path, ) -> Result, ApiServerError> { let tx = store.start_readonly_transaction().await?; - hex::decode(&trace_id) - .map_err(|_| ApiServerError::ServiceError(TraceGetError::InvalidTraceId))?; - // Retrieve all the spans that are associated with the trace let spans = store.span_list_by_trace(&tx, &trace_id).await?; - let trace = TraceSummary::from_spans(trace_id, spans).ok_or(TraceGetError::NotFound)?; + let trace = TraceSummary::from_spans(trace_id.into(), spans).ok_or(TraceGetError::NotFound)?; Ok(Json(trace)) } @@ -85,10 +61,6 @@ pub async fn traces_get_handler( #[serde(tag = "error", content = "details", rename_all = "camelCase")] #[non_exhaustive] pub enum TraceGetError { - #[api_error(status_code = StatusCode::BAD_REQUEST)] - #[error("Trace ID is invalid")] - InvalidTraceId, - #[api_error(status_code = StatusCode::NOT_FOUND)] #[error("Trace was not found")] NotFound, @@ -104,13 +76,10 @@ impl From for ApiServerError { #[tracing::instrument(skip_all)] pub async fn traces_delete_handler( State(store): State, - Path(trace_id): Path, + Path(trace_id): Path, ) -> Result> { let tx = store.start_readonly_transaction().await?; - hex::decode(&trace_id) - .map_err(|_| ApiServerError::ServiceError(TraceDeleteError::InvalidTraceId))?; - // Retrieve all the spans that are associated with the trace store.span_delete_by_trace(&tx, &trace_id).await?; @@ -120,11 +89,7 @@ pub async fn traces_delete_handler( #[derive(Debug, Serialize, Deserialize, Error, ApiError)] #[serde(tag = "error", content = "details", rename_all = "camelCase")] #[non_exhaustive] -pub enum TraceDeleteError { - #[api_error(status_code = StatusCode::BAD_REQUEST)] - #[error("Trace ID is invalid")] - InvalidTraceId, -} +pub enum TraceDeleteError {} impl From for ApiServerError { fn from(err: DbError) -> Self { diff --git a/fpx-lib/src/api/models.rs b/fpx-lib/src/api/models.rs index 4b4f7cafa..b3469025c 100644 --- a/fpx-lib/src/api/models.rs +++ b/fpx-lib/src/api/models.rs @@ -2,7 +2,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; mod otel; -pub mod ts_compat; pub use otel::*; diff --git a/fpx-lib/src/api/models/otel.rs b/fpx-lib/src/api/models/otel.rs index 477f7fe0c..c9c794347 100644 --- a/fpx-lib/src/api/models/otel.rs +++ b/fpx-lib/src/api/models/otel.rs @@ -1,3 +1,4 @@ +use crate::data::models::HexEncodedId; use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; use opentelemetry_proto::tonic::common::v1::{any_value, KeyValue, KeyValueList}; use opentelemetry_proto::tonic::trace::v1::span::{Event, Link}; @@ -17,14 +18,14 @@ fn parse_time_nanos(nanos: u64) -> time::OffsetDateTime { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Span { - pub trace_id: String, - pub span_id: String, - pub parent_span_id: Option, + pub trace_id: HexEncodedId, + pub span_id: HexEncodedId, + pub parent_span_id: Option, pub name: String, - pub trace_state: String, - pub flags: u32, - pub kind: SpanKind, + pub trace_state: Option, + pub flags: Option, + pub kind: Option, pub scope_name: Option, pub scope_version: Option, @@ -65,7 +66,7 @@ impl Span { let scope_attributes = scope_span.scope.map(|scope| scope.attributes.into()); for span in scope_span.spans { - let kind = span.kind().into(); + let kind = Some(span.kind().into()); let attributes = span.attributes.into(); let start_time = parse_time_nanos(span.start_time_unix_nano); @@ -74,18 +75,18 @@ impl Span { let parent_span_id = if span.parent_span_id.is_empty() { None } else { - Some(hex::encode(span.parent_span_id)) + Some(span.parent_span_id.into()) }; let events: Vec<_> = span.events.into_iter().map(Into::into).collect(); let links: Vec<_> = span.links.into_iter().map(Into::into).collect(); - let trace_id = hex::encode(span.trace_id); - let span_id = hex::encode(span.span_id); + let trace_id = span.trace_id.into(); + let span_id = span.span_id.into(); let name = span.name; - let trace_state = span.trace_state; - let flags = span.flags; + let trace_state = Some(span.trace_state); + let flags = Some(span.flags); let span = Self { trace_id, @@ -254,7 +255,7 @@ impl From> for AttributeMap { } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] +#[serde(untagged)] pub enum AttributeValue { StringValue(String), BoolValue(bool), @@ -292,79 +293,27 @@ impl From for AttributeValue /// A trace contains a summary of its traces. #[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct TraceSummary { - /// The trace id. - pub trace_id: String, - - /// Start of the first span - #[serde(with = "time::serde::rfc3339")] - pub start_time: time::OffsetDateTime, - - /// End of the last span - #[serde(with = "time::serde::rfc3339")] - pub end_time: time::OffsetDateTime, - - /// The numbers of spans that we have for this trace. - pub num_spans: u32, + /// The trace id + pub trace_id: HexEncodedId, - /// A summary of the root span associated with this trace. - /// - /// A root span is a span that has no parent span. This can be empty if the - /// root span was never collected. - pub root_span: Option, + /// The spans that are part of this trace + pub spans: Vec, } impl TraceSummary { - pub fn from_spans(trace_id: String, spans: Vec) -> Option { + pub fn from_spans( + trace_id: HexEncodedId, + spans: Vec, + ) -> Option { if spans.is_empty() { return None; } - let num_spans = spans.len() as u32; + let spans = spans.into_iter().map(Into::into).collect(); - // Find the first start and the last end time. Note: unwrap is safe here - // since we check that there is at least 1 span present. - let start_time = spans.iter().map(|span| span.start_time).min().unwrap(); - let end_time = spans.iter().map(|span| span.end_time).max().unwrap(); - - let root_span = spans - .into_iter() - .find(|span| span.parent_span_id.is_none()) - .map(|span| span.inner.into_inner().into()); - - Some(Self { - trace_id, - start_time: start_time.into(), - end_time: end_time.into(), - root_span, - num_spans, - }) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SpanSummary { - /// The span id. - pub span_id: String, - - /// The name of the span. - pub name: String, - - /// The kind of span. - pub span_kind: SpanKind, - - /// Optional status of the span. - pub result: Option, -} - -impl From for SpanSummary { - fn from(span: Span) -> Self { - Self { - span_id: span.span_id, - name: span.name, - span_kind: span.kind, - result: span.status, - } + Some(Self { trace_id, spans }) } } @@ -389,43 +338,47 @@ mod tests { let tests = vec![ Test { input: AttributeValue::IntValue(1234), - expected: "{\"intValue\":1234}", + expected: "1234", }, Test { input: AttributeValue::DoubleValue(1234.1234), - expected: "{\"doubleValue\":1234.1234}", + expected: "1234.1234", }, Test { input: AttributeValue::StringValue("hello".to_string()), - expected: "{\"stringValue\":\"hello\"}", + expected: "\"hello\"", }, Test { input: AttributeValue::BoolValue(true), - expected: "{\"boolValue\":true}", - }, - Test { - input: AttributeValue::BytesValue(vec![1, 2, 3, 4]), - expected: "{\"bytesValue\":[1,2,3,4]}", + expected: "true", }, + // Test { + // input: AttributeValue::BytesValue(vec![1, 2, 3, 4]), + // expected: "[1,2,3,4]", + // }, Test { input: AttributeValue::ArrayValue(vec![ AttributeValue::IntValue(1234), AttributeValue::DoubleValue(1234.1234), ]), - expected: "{\"arrayValue\":[{\"intValue\":1234},{\"doubleValue\":1234.1234}]}", + expected: "[1234,1234.1234]", }, Test { input: AttributeValue::KvlistValue(kv_list), - expected: "{\"kvlistValue\":{\"key1\":{\"intValue\":1234},\"key2\":{\"doubleValue\":1234.1234}}}", + expected: "{\"key1\":1234,\"key2\":1234.1234}", }, ]; - for test in tests { + for (i, test) in tests.into_iter().enumerate() { let actual = serde_json::to_string(&test.input).unwrap(); - assert_eq!(actual, test.expected); + assert_eq!(actual, test.expected, "serializing failed for {:?}", i); let converted_back: AttributeValue = serde_json::from_str(&actual).unwrap(); - assert_eq!(converted_back, test.input); + assert_eq!( + converted_back, test.input, + "deserializing failed for {:?}", + i + ); } } } diff --git a/fpx-lib/src/api/models/ts_compat.rs b/fpx-lib/src/api/models/ts_compat.rs deleted file mode 100644 index 7fa1204c7..000000000 --- a/fpx-lib/src/api/models/ts_compat.rs +++ /dev/null @@ -1,131 +0,0 @@ -use super::{AttributeMap, AttributeValue, SpanEvent, SpanKind}; -use opentelemetry_proto::tonic::trace::v1::span::Link; -use opentelemetry_proto::tonic::trace::v1::Status; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TypeScriptCompatSpan { - pub span_id: Option, - pub trace_id: Option, - pub created_at: time::OffsetDateTime, - pub updated_at: time::OffsetDateTime, - pub parsed_payload: TypeScriptCompatOtelSpan, -} - -impl From for TypeScriptCompatSpan { - fn from(span: crate::data::models::Span) -> Self { - Self { - span_id: Some(span.span_id), - trace_id: Some(span.trace_id), - created_at: span.end_time.into(), - updated_at: span.end_time.into(), - parsed_payload: span.inner.0.into(), - } - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TypeScriptCompatTrace { - pub trace_id: String, - pub spans: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -pub struct TypeScriptCompatOtelSpan { - pub trace_id: String, - pub span_id: String, - pub parent_span_id: Option, - - pub name: String, - pub trace_state: String, - pub flags: u32, - pub kind: SpanKind, - - pub scope_name: Option, - pub scope_version: Option, - - #[serde(with = "time::serde::rfc3339")] - pub start_time: time::OffsetDateTime, - - #[serde(with = "time::serde::rfc3339")] - pub end_time: time::OffsetDateTime, - - pub attributes: TypeScriptCompatAttributeMap, - pub scope_attributes: Option, - pub resource_attributes: Option, - - pub status: Option, - pub events: Vec, - pub links: Vec, -} - -impl From for TypeScriptCompatOtelSpan { - fn from(span: crate::api::models::otel::Span) -> Self { - Self { - trace_id: span.trace_id, - span_id: span.span_id, - parent_span_id: span.parent_span_id, - name: span.name, - trace_state: span.trace_state, - flags: span.flags, - kind: span.kind, - scope_name: span.scope_name, - scope_version: span.scope_version, - start_time: span.start_time, - end_time: span.end_time, - attributes: span.attributes.into(), - scope_attributes: span.scope_attributes.map(Into::into), - resource_attributes: span.resource_attributes.map(Into::into), - status: span.status, - events: span.events, - links: span.links, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] -pub struct TypeScriptCompatAttributeMap(BTreeMap>); - -impl From for TypeScriptCompatAttributeMap { - fn from(attr_map: AttributeMap) -> Self { - let result = attr_map - .0 - .into_iter() - .map(|(key, value)| (key, value.map(Into::into))) - .collect(); - - TypeScriptCompatAttributeMap(result) - } -} - -impl From for serde_json::Map { - fn from(attr_map: crate::api::models::otel::AttributeMap) -> Self { - attr_map - .0 - .into_iter() - .map(|(key, value)| { - ( - key, - value.map(Into::into).unwrap_or(serde_json::Value::Null), - ) - }) - .collect() - } -} - -impl From for serde_json::Value { - fn from(value: AttributeValue) -> Self { - match value { - AttributeValue::StringValue(value) => value.into(), - AttributeValue::BoolValue(value) => value.into(), - AttributeValue::IntValue(value) => value.into(), - AttributeValue::DoubleValue(value) => value.into(), - AttributeValue::ArrayValue(values) => values.into(), - AttributeValue::KvlistValue(value) => serde_json::Value::Object(value.into()), - AttributeValue::BytesValue(value) => value.into(), - } - } -} diff --git a/fpx-lib/src/data.rs b/fpx-lib/src/data.rs index ff64e16e5..a399ade7e 100644 --- a/fpx-lib/src/data.rs +++ b/fpx-lib/src/data.rs @@ -1,3 +1,4 @@ +use crate::data::models::HexEncodedId; use crate::events::ServerEvents; use async_trait::async_trait; use std::sync::Arc; @@ -48,14 +49,14 @@ pub trait Store: Send + Sync { async fn span_get( &self, tx: &Transaction, - trace_id: &str, - span_id: &str, + trace_id: &HexEncodedId, + span_id: &HexEncodedId, ) -> Result; async fn span_list_by_trace( &self, tx: &Transaction, - trace_id: &str, + trace_id: &HexEncodedId, ) -> Result>; async fn span_create(&self, tx: &Transaction, span: models::Span) -> Result; @@ -71,13 +72,17 @@ pub trait Store: Send + Sync { ) -> Result>; /// Delete all spans with a specific trace_id. - async fn span_delete_by_trace(&self, tx: &Transaction, trace_id: &str) -> Result>; + async fn span_delete_by_trace( + &self, + tx: &Transaction, + trace_id: &HexEncodedId, + ) -> Result>; /// Delete a single span. async fn span_delete( &self, tx: &Transaction, - trace_id: &str, - span_id: &str, + trace_id: &HexEncodedId, + span_id: &HexEncodedId, ) -> Result>; } diff --git a/fpx-lib/src/data/models.rs b/fpx-lib/src/data/models.rs index 84b9cd61a..2d1545c42 100644 --- a/fpx-lib/src/data/models.rs +++ b/fpx-lib/src/data/models.rs @@ -1,19 +1,23 @@ use crate::api; use crate::api::models::SpanKind; use crate::data::util::{Json, Timestamp}; -use serde::Deserialize; +use serde::de::{Error, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt::Formatter; +use std::ops::Deref; +use std::str::FromStr; /// A computed value based on the span objects that are present. #[derive(Clone, Debug, Deserialize)] pub struct Trace { - pub trace_id: String, + pub trace_id: HexEncodedId, } #[derive(Clone, Debug, Deserialize, PartialEq)] pub struct Span { - pub trace_id: String, - pub span_id: String, - pub parent_span_id: Option, + pub trace_id: HexEncodedId, + pub span_id: HexEncodedId, + pub parent_span_id: Option, pub name: String, pub kind: SpanKind, @@ -46,15 +50,17 @@ impl From for Span { let span_id = span.span_id.clone(); let parent_span_id = span.parent_span_id.clone(); let name = span.name.clone(); - let kind = span.kind.clone(); + let kind = span.kind.clone().unwrap_or(SpanKind::Unspecified); let start_time = span.start_time.into(); let end_time = span.end_time.into(); let inner = Json(span); + // these .unwrap are safe as these are guaranteed to be valid as they come from the api `Span` Self { - trace_id, - span_id, - parent_span_id, + trace_id: HexEncodedId::new(trace_id).unwrap(), + span_id: HexEncodedId::new(span_id).unwrap(), + parent_span_id: parent_span_id + .map(|parent_span_id| HexEncodedId::new(parent_span_id).unwrap()), name, kind, start_time, @@ -63,3 +69,128 @@ impl From for Span { } } } + +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct HexEncodedId(String); + +impl HexEncodedId { + pub fn new(input: impl Into) -> Result { + let id = HexEncodedId(input.into()); + id.validate()?; + + Ok(id) + } + + pub fn validate(&self) -> Result<(), hex::FromHexError> { + hex::decode(&self.0).map(|_| ()) + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn as_inner(&self) -> &str { + &self.0 + } + + pub fn as_mut(&mut self) -> &mut str { + &mut self.0 + } +} + +impl From for String { + fn from(value: HexEncodedId) -> Self { + value.0 + } +} + +impl FromStr for HexEncodedId { + type Err = hex::FromHexError; + + fn from_str(s: &str) -> Result { + HexEncodedId::new(s) + } +} + +impl Deref for HexEncodedId { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From> for HexEncodedId { + fn from(value: Vec) -> Self { + // .unwrap() is safe because we literally encode it to hex in the exact same line + Self::new(hex::encode(value)).unwrap() + } +} + +#[cfg(feature = "libsql")] +impl From for libsql::Value { + fn from(value: HexEncodedId) -> Self { + value.into_inner().into() + } +} + +#[cfg(feature = "libsql")] +impl From<&HexEncodedId> for libsql::Value { + fn from(value: &HexEncodedId) -> Self { + value.as_inner().into() + } +} + +#[cfg(feature = "wasm-bindgen")] +impl From for wasm_bindgen::JsValue { + fn from(value: HexEncodedId) -> Self { + (&value).into() + } +} + +#[cfg(feature = "wasm-bindgen")] +impl From<&HexEncodedId> for wasm_bindgen::JsValue { + fn from(value: &HexEncodedId) -> Self { + wasm_bindgen::JsValue::from_str(value.as_inner()) + } +} + +struct HexEncodedIdVisitor; + +impl<'de> Visitor<'de> for HexEncodedIdVisitor { + type Value = HexEncodedId; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a hex-represented string") + } + + fn visit_str(self, v: &str) -> Result + where + E: Error, + { + HexEncodedId::new(v).map_err(Error::custom) + } + + fn visit_borrowed_str(self, v: &'de str) -> Result + where + E: Error, + { + HexEncodedId::new(v).map_err(Error::custom) + } + + fn visit_string(self, v: String) -> Result + where + E: Error, + { + HexEncodedId::new(v).map_err(Error::custom) + } +} + +impl<'de> Deserialize<'de> for HexEncodedId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_string(HexEncodedIdVisitor) + } +} diff --git a/fpx-macros/Cargo.toml b/fpx-macros/Cargo.toml index 7a59405cb..447065a4c 100644 --- a/fpx-macros/Cargo.toml +++ b/fpx-macros/Cargo.toml @@ -13,6 +13,6 @@ proc-macro = true [dependencies] attribute-derive = "0.10.1" -syn = { version = "2.0.76", features = ["full"] } -proc-macro-error = "1.0.4" +manyhow = "0.11.4" quote = "1.0.37" +syn = { version = "2.0.77", features = ["full"] } diff --git a/fpx-macros/src/lib.rs b/fpx-macros/src/lib.rs index 6fec741a3..e774a7f93 100644 --- a/fpx-macros/src/lib.rs +++ b/fpx-macros/src/lib.rs @@ -1,8 +1,8 @@ use attribute_derive::FromAttr; +use manyhow::{manyhow, Emitter, ErrorMessage}; use proc_macro::TokenStream; -use proc_macro_error::{abort_call_site, proc_macro_error}; use quote::quote; -use syn::{parse_macro_input, Data, DeriveInput, Expr}; +use syn::{Data, DeriveInput, Expr}; #[derive(FromAttr)] #[attribute(ident = api_error)] @@ -11,27 +11,31 @@ struct ApiErrorAttribute { status_code: Expr, } +#[manyhow] #[proc_macro_derive(ApiError, attributes(api_error))] -#[proc_macro_error] -pub fn derive_api_error(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); +pub fn derive_api_error(input: TokenStream, emitter: &mut Emitter) -> manyhow::Result { + let input: DeriveInput = syn::parse(input)?; let Data::Enum(data) = &input.data else { - abort_call_site!("`ApiError` derive is only supported on enums"); + emitter.emit(ErrorMessage::call_site( + "`ApiError` derive is only supported on enums", + )); + return Err(emitter.into_result().unwrap_err()); }; let struct_ident = &input.ident; let mut variants = vec![]; if data.variants.is_empty() { - return (quote! { + return Ok((quote! { + #[automatically_derived] impl crate::api::errors::ApiError for #struct_ident { fn status_code(&self) -> http::StatusCode { http::StatusCode::INTERNAL_SERVER_ERROR } } }) - .into(); + .into()); } for variant in &data.variants { @@ -45,7 +49,8 @@ pub fn derive_api_error(input: TokenStream) -> TokenStream { }); } - (quote! { + Ok((quote! { + #[automatically_derived] impl crate::api::errors::ApiError for #struct_ident { fn status_code(&self) -> http::StatusCode { match self { @@ -54,5 +59,5 @@ pub fn derive_api_error(input: TokenStream) -> TokenStream { } } }) - .into() + .into()) } diff --git a/fpx-workers/src/data.rs b/fpx-workers/src/data.rs index 52dfa289a..33949d6c4 100644 --- a/fpx-workers/src/data.rs +++ b/fpx-workers/src/data.rs @@ -1,4 +1,5 @@ use axum::async_trait; +use fpx_lib::data::models::HexEncodedId; use fpx_lib::data::sql::SqlBuilder; use fpx_lib::data::{models, DbError, Result, Store, Transaction}; use serde::Deserialize; @@ -83,8 +84,8 @@ impl Store for D1Store { async fn span_get( &self, _tx: &Transaction, - trace_id: &str, - span_id: &str, + trace_id: &HexEncodedId, + span_id: &HexEncodedId, ) -> Result { SendFuture::new(async { self.fetch_one( @@ -99,7 +100,7 @@ impl Store for D1Store { async fn span_list_by_trace( &self, _tx: &Transaction, - trace_id: &str, + trace_id: &HexEncodedId, ) -> Result> { SendFuture::new(async { self.fetch_all(self.sql_builder.span_list_by_trace(), &[trace_id.into()]) @@ -115,7 +116,7 @@ impl Store for D1Store { ) -> Result { SendFuture::new(async { let parent_span = match span.parent_span_id { - Some(val) => val.into(), + Some(val) => val.into_inner().into(), None => JsValue::null(), }; @@ -146,7 +147,7 @@ impl Store for D1Store { &self, _tx: &Transaction, // Future improvement could hold sort fields, limits, etc - ) -> Result> { + ) -> Result> { SendFuture::new(async { let traces = self .fetch_all(self.sql_builder.traces_list(None), &[]) @@ -158,7 +159,11 @@ impl Store for D1Store { } /// Delete all spans with a specific trace_id. - async fn span_delete_by_trace(&self, _tx: &Transaction, trace_id: &str) -> Result> { + async fn span_delete_by_trace( + &self, + _tx: &Transaction, + trace_id: &HexEncodedId, + ) -> Result> { SendFuture::new(async { let prepared_statement = self .database @@ -188,8 +193,8 @@ impl Store for D1Store { async fn span_delete( &self, _tx: &Transaction, - trace_id: &str, - span_id: &str, + trace_id: &HexEncodedId, + span_id: &HexEncodedId, ) -> Result> { SendFuture::new(async { let prepared_statement = self diff --git a/fpx-workers/wrangler.toml b/fpx-workers/wrangler.toml index c92fd8bdb..5c32d2224 100644 --- a/fpx-workers/wrangler.toml +++ b/fpx-workers/wrangler.toml @@ -9,7 +9,7 @@ class_name = "WebSocketHibernationServer" [[d1_databases]] binding = "DB" database_name = "DB" -database_id = "c6a49b5e-b1c9-4cee-8c91-3440c5bd3233" +database_id = "34c863ae-42ac-4cbe-9446-d5b6b274eb2c" [build] command = "cargo install -q worker-build && worker-build --release" diff --git a/fpx/Cargo.toml b/fpx/Cargo.toml index ccfb1c1ec..527751ced 100644 --- a/fpx/Cargo.toml +++ b/fpx/Cargo.toml @@ -56,7 +56,7 @@ strum = { version = "0.26", features = ["derive"] } serde_with = { version = "3.8.1" } thiserror = { version = "1.0" } time = { version = "0.3.17", features = ["serde-human-readable"] } -tokio = { version = "1.37", features = ["rt-multi-thread", "signal", "fs"] } +tokio = { version = "1.40", features = ["rt-multi-thread", "signal", "fs"] } tokio-tungstenite = { version = "0.21", features = [ "rustls-tls-webpki-roots", ] } # This should be kept the same as whatever Axum has diff --git a/fpx/src/api/client.rs b/fpx/src/api/client.rs index f91f2d98a..842993c78 100644 --- a/fpx/src/api/client.rs +++ b/fpx/src/api/client.rs @@ -116,11 +116,7 @@ impl ApiClient { trace_id: impl AsRef, span_id: impl AsRef, ) -> Result> { - let path = format!( - "api/traces/{}/spans/{}", - trace_id.as_ref(), - span_id.as_ref() - ); + let path = format!("v1/traces/{}/spans/{}", trace_id.as_ref(), span_id.as_ref()); self.do_req(Method::GET, path, api_result).await } @@ -130,7 +126,7 @@ impl ApiClient { &self, trace_id: impl AsRef, ) -> Result, ApiClientError> { - let path = format!("api/traces/{}/spans", trace_id.as_ref()); + let path = format!("v1/traces/{}/spans", trace_id.as_ref()); self.do_req(Method::GET, path, api_result).await } @@ -140,7 +136,7 @@ impl ApiClient { &self, trace_id: impl AsRef, ) -> Result> { - let path = format!("api/traces/{}", trace_id.as_ref()); + let path = format!("v1/traces/{}", trace_id.as_ref()); self.do_req(Method::GET, path, api_result).await } @@ -149,7 +145,7 @@ impl ApiClient { pub async fn trace_list( &self, ) -> Result, ApiClientError> { - let path = "api/traces"; + let path = "v1/traces"; self.do_req(Method::GET, path, api_result).await } @@ -159,7 +155,7 @@ impl ApiClient { &self, trace_id: impl AsRef, ) -> Result<(), ApiClientError> { - let path = format!("api/traces/{}", trace_id.as_ref()); + let path = format!("v1/traces/{}", trace_id.as_ref()); self.do_req(Method::DELETE, path, no_body).await } @@ -169,11 +165,7 @@ impl ApiClient { trace_id: impl AsRef, span_id: impl AsRef, ) -> Result<(), ApiClientError> { - let path = format!( - "api/traces/{}/spans/{}", - trace_id.as_ref(), - span_id.as_ref() - ); + let path = format!("v1/traces/{}/spans/{}", trace_id.as_ref(), span_id.as_ref()); self.do_req(Method::DELETE, path, no_body).await } diff --git a/fpx/src/data.rs b/fpx/src/data.rs index d114abc69..b70da418e 100644 --- a/fpx/src/data.rs +++ b/fpx/src/data.rs @@ -1,6 +1,6 @@ use anyhow::Context; use async_trait::async_trait; -use fpx_lib::data::models::Span; +use fpx_lib::data::models::{HexEncodedId, Span}; use fpx_lib::data::sql::SqlBuilder; use fpx_lib::data::{DbError, Result, Store, Transaction}; use libsql::{params, Builder, Connection}; @@ -116,7 +116,12 @@ impl Store for LibsqlStore { Ok(()) } - async fn span_get(&self, _tx: &Transaction, trace_id: &str, span_id: &str) -> Result { + async fn span_get( + &self, + _tx: &Transaction, + trace_id: &HexEncodedId, + span_id: &HexEncodedId, + ) -> Result { let span = self .connection .query(&self.sql_builder.span_get(), (trace_id, span_id)) @@ -127,7 +132,11 @@ impl Store for LibsqlStore { Ok(span) } - async fn span_list_by_trace(&self, _tx: &Transaction, trace_id: &str) -> Result> { + async fn span_list_by_trace( + &self, + _tx: &Transaction, + trace_id: &HexEncodedId, + ) -> Result> { let spans = self .connection .query(&self.sql_builder.span_list_by_trace(), params!(trace_id)) @@ -182,7 +191,11 @@ impl Store for LibsqlStore { } /// Delete all spans with a specific trace_id. - async fn span_delete_by_trace(&self, _tx: &Transaction, trace_id: &str) -> Result> { + async fn span_delete_by_trace( + &self, + _tx: &Transaction, + trace_id: &HexEncodedId, + ) -> Result> { let rows_affected = self .connection .execute(&self.sql_builder.span_delete_by_trace(), params!(trace_id)) @@ -195,8 +208,8 @@ impl Store for LibsqlStore { async fn span_delete( &self, _tx: &Transaction, - trace_id: &str, - span_id: &str, + trace_id: &HexEncodedId, + span_id: &HexEncodedId, ) -> Result> { let rows_affected = self .connection diff --git a/fpx/src/data/tests.rs b/fpx/src/data/tests.rs index 283e34407..32f98a4e4 100644 --- a/fpx/src/data/tests.rs +++ b/fpx/src/data/tests.rs @@ -1,6 +1,6 @@ use crate::data::LibsqlStore; use fpx_lib::api::models::{AttributeMap, SpanKind}; -use fpx_lib::data::models::Span; +use fpx_lib::data::models::{HexEncodedId, Span}; use fpx_lib::data::Store; use test_log::test; @@ -21,8 +21,8 @@ async fn span_successful() { .await .expect("unable to create transaction"); - let trace_id = String::from("2b76e003e3cff12e054bcd0ca6879ee4"); - let span_id = String::from("a6c0ed7c2f81e7c8"); + let trace_id = HexEncodedId::new("2b76e003e3cff12e054bcd0ca6879ee4").unwrap(); + let span_id = HexEncodedId::new("a6c0ed7c2f81e7c8").unwrap(); let now = time::OffsetDateTime::now_utc(); let inner_span: fpx_lib::api::models::Span = fpx_lib::api::models::Span { @@ -30,11 +30,11 @@ async fn span_successful() { span_id: span_id.clone(), parent_span_id: None, name: String::from("Test span"), - kind: SpanKind::Internal, + kind: Some(SpanKind::Internal), start_time: now, end_time: now, - trace_state: String::from(""), - flags: 0, + trace_state: Some(String::new()), + flags: Some(0), scope_name: None, scope_version: None, attributes: AttributeMap::default(), diff --git a/package.json b/package.json index a95a39bf4..fe2d9ab17 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "clean:api": "rimraf api/dist", "clean:frontend": "rimraf studio/dist", "format": "biome check . --write", - "lint": "pnpm --recursive lint" + "lint": "pnpm --recursive lint", + "typecheck": "pnpm --recursive typecheck" }, "repository": { "type": "git", diff --git a/packages/client-library-otel/package.json b/packages/client-library-otel/package.json index 2ca81072e..93eafe016 100644 --- a/packages/client-library-otel/package.json +++ b/packages/client-library-otel/package.json @@ -4,7 +4,7 @@ "author": "Fiberplane", "type": "module", "main": "dist/index.js", - "version": "0.1.0-beta.12", + "version": "0.3.0-beta.1", "dependencies": { "@opentelemetry/api": "~1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.52.1", @@ -22,7 +22,7 @@ "@swc/core": "^1.5.22", "@swc/plugin-transform-imports": "^2.0.4", "hono": "^4.3.9", - "nodemon": "^3.1.4", + "nodemon": "^3.1.5", "rimraf": "^6.0.1", "tsc-alias": "^1.8.10", "typescript": "^5.4.5" diff --git a/packages/client-library-otel/sample/index.ts b/packages/client-library-otel/sample/index.ts index e0d6f25fa..d08adbd6a 100644 --- a/packages/client-library-otel/sample/index.ts +++ b/packages/client-library-otel/sample/index.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { stream } from "hono/streaming"; import { instrument, measure } from "../src"; const app = new Hono(); @@ -48,4 +49,57 @@ app.get("/error", async () => { await delayedError(); }); +async function* rawRelaxedWelcome() { + await sleep(500); + yield "hello! "; + await sleep(500); + yield "Hono "; + await sleep(500); + yield "is "; + await sleep(500); + yield "awesome"; +} + +// This is an async generator function (and so returns an async iterator) +const generateRelaxedWelcome = measure("relaxedWelcome", rawRelaxedWelcome); + +app.get("/stream", async (c) => { + c.header("Content-Type", "text/plain"); + return stream(c, async (stream) => { + const result = generateRelaxedWelcome(); + + for await (const content of result) { + await stream.write(content); + } + }); +}); + +const fibonacci = measure( + "fibonacci", + function* (arg: number): Generator { + let a = 1; + let b = 1; + for (let i = 0; i < arg; i++) { + yield a; + [a, b] = [b, a + b]; + } + }, +); +// Example usage: +app.get("/fibonacci/:count", (c) => { + const count = Number.parseInt(c.req.param("count"), 10); + + const result = fibonacci(count); + const values = Array.from(result); + + return c.text(`Fibonacci sequence (${count} numbers): ${values.join(", ")}`); +}); + +app.get("/quick", async (c) => { + c.header("Content-Type", "text/plain"); + return stream(c, async (stream) => { + stream.write("ok"); + }); +}); + export default instrument(app); diff --git a/packages/client-library-otel/src/constants.ts b/packages/client-library-otel/src/constants.ts index a3edd3959..a5351e89e 100644 --- a/packages/client-library-otel/src/constants.ts +++ b/packages/client-library-otel/src/constants.ts @@ -1,3 +1,10 @@ +/** + * Constants for the environment variables we use to configure the library. + */ +export const ENV_FPX_ENDPOINT = "FPX_ENDPOINT"; +export const ENV_FPX_LOG_LEVEL = "FPX_LOG_LEVEL"; +export const ENV_FPX_SERVICE_NAME = "FPX_SERVICE_NAME"; + /** * SEMATTRS_* are constants that should actually be exposed by the Samantic Conventions package * but are not. diff --git a/packages/client-library-otel/src/instrumentation.ts b/packages/client-library-otel/src/instrumentation.ts index 208cc332d..23a3e2973 100644 --- a/packages/client-library-otel/src/instrumentation.ts +++ b/packages/client-library-otel/src/instrumentation.ts @@ -9,6 +9,11 @@ import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { ExecutionContext } from "hono"; // TODO figure out we can use something else import { AsyncLocalStorageContextManager } from "./async-hooks"; +import { + ENV_FPX_ENDPOINT, + ENV_FPX_LOG_LEVEL, + ENV_FPX_SERVICE_NAME, +} from "./constants"; import { getLogger } from "./logger"; import { measure } from "./measure"; import { @@ -17,10 +22,12 @@ import { patchFetch, patchWaitUntil, } from "./patch"; +import { PromiseStore } from "./promiseStore"; import { propagateFpxTraceId } from "./propagation"; import { isRouteInspectorRequest, respondWithRoutes } from "./routes"; import type { HonoLikeApp, HonoLikeEnv, HonoLikeFetch } from "./types"; import { + getFromEnv, getRequestAttributes, getResponseAttributes, getRootRequestAttributes, @@ -62,8 +69,7 @@ const defaultConfig = { monitor: { fetch: true, logging: true, - // NOTE - We don't proxy Cloudflare bindings by default yet because it's still experimental, and we don't have fancy UI for it yet in the Studio - cfBindings: false, + cfBindings: true, }, }; @@ -81,7 +87,7 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { request: Request, // Name this "rawEnv" because we coerce it below into something that's easier to work with rawEnv: HonoLikeEnv, - executionContext: ExecutionContext | undefined, + executionContext?: ExecutionContext, ) { // Merge the default config with the user's config const { @@ -101,13 +107,14 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { // NOTE - We do *not* want to have a default for the FPX_ENDPOINT, // so that people won't accidentally deploy to production with our middleware and // start sending data to the default url. - const endpoint = - typeof env === "object" && env !== null ? env.FPX_ENDPOINT : null; + const endpoint = getFromEnv(env, ENV_FPX_ENDPOINT); const isEnabled = !!endpoint && typeof endpoint === "string"; - const FPX_LOG_LEVEL = libraryDebugMode ? "debug" : env?.FPX_LOG_LEVEL; + const FPX_LOG_LEVEL = libraryDebugMode + ? "debug" + : getFromEnv(env, ENV_FPX_LOG_LEVEL); const logger = getLogger(FPX_LOG_LEVEL); - // NOTE - This should only log if the FPX_LOG_LEVEL is debug + // NOTE - This should only log if the FPX_LOG_LEVEL is "debug" logger.debug("Library debug mode is enabled"); if (!isEnabled) { @@ -123,7 +130,8 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { return respondWithRoutes(webStandardFetch, endpoint, app); } - const serviceName = env?.FPX_SERVICE_NAME ?? "unknown"; + const serviceName = + getFromEnv(env, ENV_FPX_SERVICE_NAME) ?? "unknown"; // Patch all functions we want to monitor in the runtime if (monitorCfBindings) { @@ -141,10 +149,10 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { endpoint, }); + const promiseStore = new PromiseStore(); // Enable tracing for waitUntil - const patched = executionContext && patchWaitUntil(executionContext); - const promises = patched?.promises ?? []; - const proxyExecutionCtx = patched?.proxyContext ?? executionContext; + const proxyExecutionCtx = + executionContext && patchWaitUntil(executionContext, promiseStore); const activeContext = propagateFpxTraceId(request); @@ -199,11 +207,19 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { }; span.setAttributes(requestAttributes); }, + endSpanManually: true, onSuccess: async (span, response) => { - const attributes = await getResponseAttributes( - (await response).clone(), - ); - span.setAttributes(attributes); + span.addEvent("first-response"); + + const attributesResponse = response.clone(); + + const updateSpan = async (response: Response) => { + const attributes = await getResponseAttributes(response); + span.setAttributes(attributes); + span.end(); + }; + + promiseStore.add(updateSpan(attributesResponse)); }, checkResult: async (result) => { const r = await result; @@ -217,18 +233,14 @@ export function instrument(app: HonoLikeApp, config?: FpxConfigOptions) { ); try { - return await context.with(activeContext, async () => { - return await measuredFetch( - newRequest, - env as HonoLikeEnv, - proxyExecutionCtx, - ); - }); + return await context.with(activeContext, () => + measuredFetch(newRequest, rawEnv, proxyExecutionCtx), + ); } finally { // Make sure all promises are resolved before sending data to the server if (proxyExecutionCtx) { proxyExecutionCtx.waitUntil( - Promise.allSettled(promises).finally(() => { + promiseStore.allSettled().finally(() => { return provider.forceFlush(); }), ); diff --git a/packages/client-library-otel/src/measure.ts b/packages/client-library-otel/src/measure.ts index 6217c5d3a..6bd7a022a 100644 --- a/packages/client-library-otel/src/measure.ts +++ b/packages/client-library-otel/src/measure.ts @@ -4,9 +4,11 @@ import { type Span, type SpanKind, SpanStatusCode, + context, trace, } from "@opentelemetry/api"; import type { FpxLogger } from "./logger"; +import { isPromise } from "./utils"; export type MeasureOptions< /** @@ -14,15 +16,9 @@ export type MeasureOptions< */ ARGS, /** - * The return type of the function being measured - * (awaited result if the return value is a promise) + * The return type of the function being measured. This could be a value including a promise or a(n) (async) generator */ RESULT, - /** - * The raw return type of the function being measured - * (it is used to determine if the onSuccess function can be async) - */ - RAW_RESULT, > = { name: string; /** @@ -43,8 +39,24 @@ export type MeasureOptions< */ onSuccess?: ( span: Span, - result: RESULT, - ) => RAW_RESULT extends Promise ? Promise | void : void; + result: ExtractInnerResult, + ) => RESULT extends Promise ? Promise | void : void; + + /** + * This is an advanced feature in cases where you don't want the open telemetry spans + * to be ended automatically. + * + * Some disclaimers: this can only be used in combination with promises and with an onSuccess + * handler. This handler should call span.end() at some point. If you want the on success + * handler to trigger another async function you may want to use waitUntil to prevent the + * worker from terminating before the traces/spans are finished & send to the server + * + * How this is currently used;: + * We're using it to show the duration of a request in case it's being streamed back to + * the client. In those cases the response is returned early while work is still being done. + * + */ + endSpanManually?: boolean; /** * Allows you to specify a function that will be called when the span ends @@ -60,8 +72,12 @@ export type MeasureOptions< * in the span and will not be thrown. */ checkResult?: ( - result: RESULT, - ) => RAW_RESULT extends Promise ? Promise | void : void; + result: ExtractInnerResult, + ) => RESULT extends Promise + ? Promise | void + : RESULT extends AsyncGenerator + ? Promise | void + : void; /** * Optional logger module to use for logging on errors, etc. @@ -70,6 +86,18 @@ export type MeasureOptions< logger?: FpxLogger; }; +type ExtractInnerResult = TYPE extends Generator< + infer T, + infer TReturn, + unknown +> + ? T | TReturn + : TYPE extends AsyncGenerator + ? T | TReturn + : TYPE extends Promise + ? R + : TYPE; + /** * Wraps a function in a span, measuring the time it takes to execute * the function. @@ -90,42 +118,39 @@ export function measure( * @param options param name and spanKind * @param fn */ -export function measure( - options: MeasureOptions, - fn: (...args: A) => R, -): (...args: A) => R; - -export function measure( - nameOrOptions: string | MeasureOptions, - fn: (...args: A) => R, -): (...args: A) => R { +export function measure( + options: MeasureOptions, + fn: (...args: ARGS) => RESULT, +): (...args: ARGS) => RESULT; + +export function measure( + nameOrOptions: string | MeasureOptions, + fn: (...args: ARGS) => RESULT, +): (...args: ARGS) => RESULT { const isOptions = typeof nameOrOptions === "object"; const name: string = isOptions ? nameOrOptions.name : nameOrOptions; - const spanKind: SpanKind | undefined = isOptions - ? nameOrOptions.spanKind - : undefined; - const attributes: Attributes | undefined = isOptions - ? nameOrOptions.attributes - : undefined; - const onStart = isOptions ? nameOrOptions.onStart : undefined; - const onSuccess = isOptions ? nameOrOptions.onSuccess : undefined; - const onError = isOptions ? nameOrOptions.onError : undefined; - const checkResult = isOptions ? nameOrOptions.checkResult : undefined; - const logger = isOptions ? nameOrOptions.logger : undefined; - - return (...args: A): R => { - function handleActiveSpan(span: Span): R { - let shouldEndSpan = true; + const { + onStart, + onSuccess, + onError, + checkResult, + logger, + endSpanManually, + attributes, + spanKind, + } = isOptions ? nameOrOptions : ({} as MeasureOptions); - let pendingPromiseChain: Promise | undefined; + return (...args: ARGS): RESULT => { + function handleActiveSpan(span: Span): RESULT { + let shouldEndSpan = true; if (onStart) { try { onStart(span, args); } catch (error) { if (logger) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; + const errorMessage = formatException(convertToException(error)); + logger.warn( `Error in onStart while measuring ${name}:`, errorMessage, @@ -136,48 +161,81 @@ export function measure( try { const returnValue = fn(...args); - if (isPromise(returnValue)) { + + if (isGeneratorValue(returnValue)) { shouldEndSpan = false; - return handlePromise(span, returnValue, { + const handlerOptions = { + endSpanManually, onSuccess, + checkResult, onError, + }; + + return handleGenerator( + span, + returnValue as Generator< + ExtractInnerResult, + ExtractInnerResult, + unknown + >, + handlerOptions, + ) as RESULT; + } + + if (isAsyncGeneratorValue(returnValue)) { + shouldEndSpan = false; + const handlerOptions = { + endSpanManually, + onSuccess, checkResult, - }) as R; + onError, + }; + + return handleAsyncGenerator( + span, + returnValue as AsyncGenerator< + ExtractInnerResult, + ExtractInnerResult, + unknown + >, + handlerOptions, + ) as RESULT; + } + + if (isPromise>(returnValue)) { + shouldEndSpan = false; + return handlePromise( + span, + returnValue as Promise>, + { + onSuccess, + onError, + checkResult, + endSpanManually, + }, + ) as RESULT; } span.setStatus({ code: SpanStatusCode.OK }); - // HACK - `onSuccess` can be async, so we need to wait for it to finish before ending the span (in the finally clause) if (onSuccess) { - pendingPromiseChain = new Promise((resolve) => { - try { - const onSuccessResult = onSuccess(span, returnValue); - if (onSuccessResult instanceof Promise) { - onSuccessResult.then(() => { - resolve(returnValue); - }); - } else { - resolve(returnValue); - } - } catch (error) { - if (logger) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - logger.warn( - `Error in onSuccess while measuring ${name}:`, - errorMessage, - ); - } - resolve(returnValue); + try { + onSuccess(span, returnValue as ExtractInnerResult); + } catch (error) { + if (logger) { + const errorMessage = formatException(convertToException(error)); + logger.warn( + `Error in onSuccess while measuring ${name}:`, + errorMessage, + ); } - }); + } } return returnValue; } catch (error) { - const sendError: Exception = - error instanceof Error ? error : "Unknown error occurred"; - span.recordException(sendError); + const exception: Exception = convertToException(error); + span.recordException(exception); if (onError) { try { @@ -189,13 +247,7 @@ export function measure( throw error; } finally { - if (pendingPromiseChain) { - pendingPromiseChain.then(() => { - if (shouldEndSpan) { - span.end(); - } - }); - } else if (shouldEndSpan) { + if (shouldEndSpan) { span.end(); } } @@ -215,30 +267,29 @@ export function measure( * * @returns the promise */ -async function handlePromise( +async function handlePromise>( span: Span, - promise: Promise, + resultPromise: T, options: Pick< - MeasureOptions>, - "onSuccess" | "onError" | "checkResult" + MeasureOptions, + "onSuccess" | "onError" | "checkResult" | "endSpanManually" >, -): Promise { - const { onSuccess, onError, checkResult } = options; +) { + const { onSuccess, onError, checkResult, endSpanManually = false } = options; try { - const result = await Promise.resolve(promise); + const result = (await resultPromise) as ExtractInnerResult; if (checkResult) { try { await checkResult(result); } catch (error) { // recordException only accepts Error objects or strings - const sendError: Exception = - error instanceof Error ? error : "Unknown error occured"; - span.recordException(sendError); + const exception = convertToException(error); + span.recordException(exception); if (onError) { try { - await onError(span, sendError); + await onError(span, exception); } catch { // swallow error } @@ -249,10 +300,14 @@ async function handlePromise( await onSuccess(span, result); } catch { // swallow error + } finally { + if (!endSpanManually) { + span.end(); + } } } - return result; + return result as ExtractInnerResult; } } @@ -268,15 +323,9 @@ async function handlePromise( return result; } catch (error) { try { - // recordException only accepts Error objects or strings - const sendError: Exception = - error instanceof Error ? error : "Unknown error occured"; - span.recordException(sendError); - - const message = - typeof sendError === "string" - ? sendError - : sendError.message || "Unknown error occured"; + const exception = convertToException(error); + span.recordException(exception); + const message = formatException(exception); span.setStatus({ code: SpanStatusCode.ERROR, message, @@ -296,10 +345,263 @@ async function handlePromise( // Rethrow the error throw error; } finally { + if (!endSpanManually || !onSuccess) { + span.end(); + } + } +} + +/** + * Handles synchronous iterators (generators). + * Measures the time until the generator is fully consumed. + */ +function handleGenerator( + span: Span, + iterable: Generator, + options: Pick< + MeasureOptions>, + "onSuccess" | "onError" | "checkResult" | "endSpanManually" + >, +): Generator { + const { checkResult, endSpanManually, onError, onSuccess } = options; + function handleError(error: unknown) { + const exception = convertToException(error); + span.recordException(exception); + const message = formatException(exception); + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + + if (onError) { + try { + onError(span, error); + } catch { + // swallow error + } + } + + span.end(); + } + + const active = context.active(); + return { + next: context.bind( + active, + measure("iterator.next", function nextFunction(...args: [] | [TNext]) { + try { + const result = iterable.next(...args); + if (result.done) { + try { + if (checkResult) { + checkResult(result.value); + } + + if (!endSpanManually) { + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + } + + if (onSuccess) { + onSuccess(span, result.value); + } + } catch (error) { + handleError(error); + } + } + + return result; + } catch (error) { + handleError(error); + throw error; + } + }), + ), + return: context.bind(active, function returnFunction(value: TReturn) { + try { + const result = iterable.return(value); + if (result.done) { + try { + if (checkResult) { + checkResult(result.value); + } + + if (!endSpanManually) { + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + } + + if (onSuccess) { + onSuccess(span, result.value); + } + } catch (error) { + handleError(error); + } + } + + return result; + } catch (error) { + handleError(error); + throw error; + } + }), + throw: context.bind(active, function throwFunction(error: unknown) { + try { + return iterable.throw(error); + } finally { + handleError(error); + } + }), + [Symbol.iterator]() { + return this; + }, + }; +} + +/** + * Handles asynchronous iterators (async generators). + * Measures the time until the async generator is fully consumed. + */ +function handleAsyncGenerator( + span: Span, + iterable: AsyncGenerator, + options: Pick< + MeasureOptions>, + "onSuccess" | "onError" | "checkResult" | "endSpanManually" + >, +): AsyncGenerator { + const { checkResult, endSpanManually, onError, onSuccess } = options; + + const active = context.active(); + function handleError(error: unknown) { + const exception = convertToException(error); + span.recordException(exception); + const message = formatException(exception); + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + + if (onError) { + try { + onError(span, error); + } catch { + // swallow error + } + } + span.end(); } + + return { + next: context.bind( + active, + measure( + "iterator.next", + async function nextFunction(...args: [] | [TNext]) { + try { + const result = await iterable.next(...args); + if (result.done) { + try { + if (checkResult) { + await checkResult(result.value); + } + + if (!endSpanManually) { + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + } + + if (onSuccess) { + await onSuccess(span, result.value); + } + } catch (error) { + handleError(error); + } + } + + return result; + } catch (error) { + handleError(error); + throw error; + } + }, + ), + ), + return: context.bind(active, async function returnFunction(value: TReturn) { + try { + const result = await iterable.return(value); + if (result.done) { + try { + if (checkResult) { + checkResult(result.value); + } + + if (!endSpanManually) { + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + } + + if (onSuccess) { + onSuccess(span, result.value); + } + } catch (error) { + handleError(error); + } + } + + return result; + } catch (error) { + handleError(error); + throw error; + } + }), + throw: context.bind(active, async function throwFunction(error: unknown) { + try { + return await iterable.throw(error); + } finally { + handleError(error); + } + }), + [Symbol.asyncIterator]() { + return this; + }, + }; } -function isPromise(value: unknown): value is Promise { - return value instanceof Promise; +function convertToException(error: unknown) { + return error instanceof Error ? error : "Unknown error occurred"; +} + +function formatException(exception: Exception) { + return typeof exception === "string" + ? exception + : exception.message || "Unknown error occurred"; +} + +// const GeneratorFunction = Object.getPrototypeOf(function* () {}).constructor; +export function isGeneratorValue< + T = unknown, + TReturn = unknown, + TNext = unknown, +>(value: unknown): value is Generator { + return ( + value !== null && typeof value === "object" && Symbol.iterator in value + ); +} + +/** + * Type guard to check if a function is an async generator. + * + * @param fn - The function to be checked + * @returns true if the function is an async generator, otherwise false + */ +export function isAsyncGeneratorValue< + T = unknown, + TReturn = unknown, + TNext = unknown, +>(value: unknown): value is AsyncGenerator { + return ( + value !== null && typeof value === "object" && Symbol.asyncIterator in value + ); } diff --git a/packages/client-library-otel/src/patch/fetch.ts b/packages/client-library-otel/src/patch/fetch.ts index acbf75115..145b45e9a 100644 --- a/packages/client-library-otel/src/patch/fetch.ts +++ b/packages/client-library-otel/src/patch/fetch.ts @@ -24,10 +24,10 @@ export function patchFetch() { onStart: (span, [input, init]) => { span.setAttributes(getRequestAttributes(input, init)); }, - onSuccess: async (span, response) => { - const attributes = await getResponseAttributes( - (await response).clone(), - ); + onSuccess: async (span, responsePromise) => { + const response = await responsePromise; + const attributeResponse = response.clone(); + const attributes = await getResponseAttributes(attributeResponse); span.setAttributes(attributes); }, }, diff --git a/packages/client-library-otel/src/patch/log.ts b/packages/client-library-otel/src/patch/log.ts index a09d3a6df..9b53fc56f 100644 --- a/packages/client-library-otel/src/patch/log.ts +++ b/packages/client-library-otel/src/patch/log.ts @@ -95,5 +95,11 @@ function transformLogMessage(message: unknown) { return errorToJson(message); } + // NOTE - Functions are not serializable, so we stringify them. + // Otherwise, we could end up with a `null` value in the attributes! + if (typeof message === "function") { + return message?.toString() ?? ""; + } + return message; } diff --git a/packages/client-library-otel/src/patch/waitUntil.ts b/packages/client-library-otel/src/patch/waitUntil.ts index 010a81c3c..6001109df 100644 --- a/packages/client-library-otel/src/patch/waitUntil.ts +++ b/packages/client-library-otel/src/patch/waitUntil.ts @@ -1,10 +1,13 @@ +import { PromiseStore } from "../promiseStore"; + /** * This returns a proxy-ed ExecutionContext which has a waitUntil method that * collects promises passed to it. It also returns an array of promises that */ -export function patchWaitUntil(context: ExecutionContext) { - const promises: Promise[] = []; - +export function patchWaitUntil( + context: ExecutionContext, + store: PromiseStore = new PromiseStore(), +) { const proxyContext = new Proxy(context, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); @@ -12,7 +15,7 @@ export function patchWaitUntil(context: ExecutionContext) { const original: ExecutionContext["waitUntil"] = value; return function waitUntil(this: unknown, promise: Promise) { const scope = this === receiver ? target : this; - promises.push(promise); + store.add(promise); return original.apply(scope, [promise]); }; } @@ -21,5 +24,5 @@ export function patchWaitUntil(context: ExecutionContext) { }, }); - return { proxyContext, promises }; + return proxyContext; } diff --git a/packages/client-library-otel/src/promiseStore.ts b/packages/client-library-otel/src/promiseStore.ts new file mode 100644 index 000000000..7df399cea --- /dev/null +++ b/packages/client-library-otel/src/promiseStore.ts @@ -0,0 +1,16 @@ +const noop = () => {}; + +export class PromiseStore { + private promises: Array> = []; + + add(promise: Promise) { + this.promises.push(promise); + promise.finally(() => { + this.promises = this.promises.filter((p) => p === promise); + }); + } + + allSettled(): Promise { + return Promise.allSettled(this.promises).then(noop); + } +} diff --git a/packages/client-library-otel/src/types.ts b/packages/client-library-otel/src/types.ts index e592359af..20cb66fc8 100644 --- a/packages/client-library-otel/src/types.ts +++ b/packages/client-library-otel/src/types.ts @@ -40,7 +40,7 @@ export type HonoResponse = Awaited; export type HonoLikeFetch = ( request: Request, env: HonoLikeEnv, - executionContext: ExecutionContext | undefined, + executionContext?: ExecutionContext, ) => HonoFetchResult; // type HonoLikeFetch = Hono["fetch"]; diff --git a/packages/client-library-otel/src/utils/env.ts b/packages/client-library-otel/src/utils/env.ts new file mode 100644 index 000000000..b24bc1d27 --- /dev/null +++ b/packages/client-library-otel/src/utils/env.ts @@ -0,0 +1,51 @@ +/** + * In Hono-node environments, env vars are not available on the `env` object that's passed to `app.fetch`. + * This helper will also check process.env and fallback to that if the env var is not present on the `env` object. + */ +export function getFromEnv(honoEnv: unknown, key: string) { + const env = getNodeSafeEnv(honoEnv); + + return typeof env === "object" && env !== null + ? (env as Record)?.[key] + : null; +} + +/** + * Return `process.env` if we're in Node.js, otherwise `honoEnv` + * + * Used to get the env object for accessing and recording env vars. + * This eixsts because in Node.js, the `env` object passed to `app.fetch` is different from the env object in other runtimes. + * + * @param honoEnv - The env object from the `app.fetch` method. + * @returns - `process.env` if we're in Node.js, otherwise `honoEnv`. + */ +export function getNodeSafeEnv(honoEnv: unknown) { + const hasProcessEnv = runtimeHasProcessEnv(); + const isRunningInHonoNode = isHonoNodeEnv(honoEnv); + return hasProcessEnv && isRunningInHonoNode ? process.env : honoEnv; +} + +function runtimeHasProcessEnv() { + if (typeof process !== "undefined" && typeof process.env !== "undefined") { + return true; + } + return false; +} + +/** + * Helper to determine if the env is coming from a Hono node environment. + * + * In Node.js, the `env` passed to `app.fetch` is an object with keys "incoming" and "outgoing", + * one of which has circular references. We don't want to serialize this. + */ +function isHonoNodeEnv(env: unknown) { + if (typeof env !== "object" || env === null) { + return false; + } + const envKeys = Object.keys(env).map((key) => key.toLowerCase()); + return ( + envKeys.length === 2 && + envKeys.includes("incoming") && + envKeys.includes("outgoing") + ); +} diff --git a/packages/client-library-otel/src/utils/index.ts b/packages/client-library-otel/src/utils/index.ts index c0cafb4e3..23bfc055a 100644 --- a/packages/client-library-otel/src/utils/index.ts +++ b/packages/client-library-otel/src/utils/index.ts @@ -1,4 +1,10 @@ +export { getFromEnv } from "./env"; export * from "./errors"; export * from "./json"; export * from "./request"; export * from "./wrapper"; +export { cloneResponse } from "./response"; + +export function isPromise(value: unknown): value is Promise { + return value instanceof Promise; +} diff --git a/packages/client-library-otel/src/utils/request.ts b/packages/client-library-otel/src/utils/request.ts index 79d214ad4..4fca2fb01 100644 --- a/packages/client-library-otel/src/utils/request.ts +++ b/packages/client-library-otel/src/utils/request.ts @@ -21,6 +21,8 @@ import type { InitParam, InputParam, } from "../types"; +import { getNodeSafeEnv } from "./env"; +import { safelySerializeJSON } from "./json"; // There are so many different types of headers // and we want to support all of them so we can @@ -40,13 +42,24 @@ export function headersToObject(headers: PossibleHeaders) { } /** - * HELPER + * Helper to get the request attributes for the root request. + * + * Requires that we have a cloned request, so we can get the body and headers + * without consuming the original request. */ -export async function getRootRequestAttributes(request: Request, env: unknown) { - let attributes: Attributes = { - // NOTE - We should not do this in production - [FPX_REQUEST_ENV]: JSON.stringify(env), - }; +export async function getRootRequestAttributes( + request: Request, + honoEnv: unknown, +) { + let attributes: Attributes = {}; + + // HACK - We need to account for the fact that the Hono `env` is different across runtimes + // If process.env is available, we use that, otherwise we use the `env` object from the Hono runtime + const env = getNodeSafeEnv(honoEnv); + if (env) { + // NOTE - We should not *ever* do this in production + attributes[FPX_REQUEST_ENV] = safelySerializeJSON(env); + } if (request.body) { const bodyAttr = await formatRootRequestBody(request); @@ -227,10 +240,31 @@ async function tryGetResponseBodyAsText( } try { - return await response.text(); + if (response.body) { + return await streamToString(response.body as ReadableStream); + } } catch { - return null; + // swallow error + } + + return null; +} + +// Helper function to convert a ReadableStream to a string +async function streamToString(stream: ReadableStream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let result = ""; + + while (true) { + const { done, value } = await reader.read(); + result += decoder.decode(value, { stream: true }); + if (done) { + break; + } } + + return result; } export async function getResponseAttributes( @@ -242,6 +276,7 @@ export async function getResponseAttributes( }; const responseText = await tryGetResponseBodyAsText(response); + if (responseText) { attributes[FPX_RESPONSE_BODY] = responseText; } diff --git a/packages/client-library-otel/src/utils/response.ts b/packages/client-library-otel/src/utils/response.ts new file mode 100644 index 000000000..539cc1112 --- /dev/null +++ b/packages/client-library-otel/src/utils/response.ts @@ -0,0 +1,19 @@ +export function cloneResponse(response: Response) { + const [a = null, b = null] = response.body ? response.body.tee() : []; + + return [ + createResponseWithBody(response, a), + createResponseWithBody(response, b), + ] as const; +} + +export function createResponseWithBody( + response: Response, + newBody: Response["body"], +) { + return new Response(newBody, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); +} diff --git a/packages/types/package.json b/packages/types/package.json index 22c1ce09b..e811dc113 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,7 +1,7 @@ { "name": "@fiberplane/fpx-types", "description": "Shared types and schemas for fpx", - "version": "0.0.5", + "version": "0.0.6", "type": "module", "exports": { ".": "./dist/index.js" diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts new file mode 100644 index 000000000..fbb6e02a7 --- /dev/null +++ b/packages/types/src/api.ts @@ -0,0 +1,10 @@ +import type { OtelSpan } from "./otel.js"; + +export type TraceSummary = { + traceId: string; + spans: Array; +}; + +export type TraceListResponse = Array; + +export type TraceDetailSpansResponse = Array; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3fda96971..5110b7fb8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,5 @@ +export * from "./api.js"; +export * from "./otel.js"; export * from "./schemas.js"; export * from "./settings.js"; export * from "./ws.js"; diff --git a/packages/types/src/otel.ts b/packages/types/src/otel.ts new file mode 100644 index 000000000..3041995ad --- /dev/null +++ b/packages/types/src/otel.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +const OtelStatusSchema = z.object({ + code: z.number(), + message: z.string().nullish(), +}); + +export type OtelStatus = z.infer; + +const OtelAttributesSchema = z.record( + z.string(), + z.union([ + z.object({ + String: z.string(), + }), + z.string(), + z.object({ + Int: z.number(), + }), + z.number(), + + // NOTE - It's possible the middleware is setting null for some attributes + // We need this null case here defensively + // FP-4019 + z.null(), + + // z.boolean(), + // z.undefined(), + // z.record( + // z.string(), + // z.union([ + // z.string(), + // z.number(), + // z.null() + // ]) + // ), + ]), +); + +export type OtelAttributes = z.infer; + +const OtelEventSchema = z.object({ + name: z.string(), + timestamp: z.coerce.date(), // ISO 8601 format + attributes: OtelAttributesSchema, +}); + +export type OtelEvent = z.infer; + +export const OtelSpanSchema = z + .object({ + trace_id: z.string(), + span_id: z.string(), + parent_span_id: z.union([z.string(), z.null()]), + name: z.string(), + trace_state: z.string().nullish(), + flags: z.number().optional(), // This determines whether or not the trace will be sampled + kind: z.string(), + start_time: z.coerce.date(), // ISO 8601 format + end_time: z.coerce.date(), // ISO 8601 format + attributes: OtelAttributesSchema, + status: OtelStatusSchema.optional(), + + // This is where we will store logs that happened along the way + events: z.array(OtelEventSchema).optional(), + + // Links to related traces, etc + links: z.array( + z.object({ + trace_id: z.string(), + span_id: z.string(), + trace_state: z.string(), + attributes: OtelAttributesSchema, + flags: z.number().optional(), + }), + ), + }) + .passthrough(); // HACK - Passthrough to vendorify traces + +export type OtelSpan = z.infer; + +const OtelTraceSchema = z.object({ + traceId: z.string(), + spans: z.array(OtelSpanSchema), +}); + +export type OtelTrace = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3493a77a7..b471778b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 1.12.0 '@hono/zod-validator': specifier: ^0.2.2 - version: 0.2.2(hono@4.5.5)(zod@3.23.8) + version: 0.2.2(hono@4.6.2)(zod@3.23.8) '@iarna/toml': specifier: ^2.2.5 version: 2.2.5 @@ -60,20 +60,20 @@ importers: specifier: ^16.4.5 version: 16.4.5 drizzle-kit: - specifier: ^0.21.2 - version: 0.21.4 + specifier: ^0.24.2 + version: 0.24.2 drizzle-orm: - specifier: ^0.30.10 - version: 0.30.10(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + specifier: ^0.33.0 + version: 0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) drizzle-zod: specifier: ^0.5.1 - version: 0.5.1(drizzle-orm@0.30.10(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8) + version: 0.5.1(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8) figlet: specifier: ^1.7.0 version: 1.7.0 hono: - specifier: ^4.3.7 - version: 4.5.5 + specifier: ^4.6.2 + version: 4.6.2 minimatch: specifier: ^10.0.1 version: 10.0.1 @@ -184,6 +184,25 @@ importers: specifier: ^3.73.0 version: 3.73.0(@cloudflare/workers-types@4.20240821.1) + examples/node-api: + dependencies: + '@fiberplane/hono-otel': + specifier: workspace:* + version: link:../../packages/client-library-otel + '@hono/node-server': + specifier: ^1.12.2 + version: 1.12.2(hono@4.5.9) + hono: + specifier: ^4.5.9 + version: 4.5.9 + devDependencies: + '@types/node': + specifier: ^20.11.17 + version: 20.14.15 + tsx: + specifier: ^4.7.1 + version: 4.17.0 + packages/client-library-otel: dependencies: '@opentelemetry/api': @@ -230,8 +249,8 @@ importers: specifier: ^4.3.9 version: 4.5.5 nodemon: - specifier: ^3.1.4 - version: 3.1.4 + specifier: ^3.1.5 + version: 3.1.5 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -298,6 +317,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menubar': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -361,6 +383,12 @@ importers: hono: specifier: ^4.4.8 version: 4.5.5 + immer: + specifier: ^10.1.1 + version: 10.1.1 + proxy-memoize: + specifier: ^3.0.1 + version: 3.0.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -403,10 +431,16 @@ importers: zod: specifier: ^3.23.8 version: 3.23.8 + zustand: + specifier: ^4.5.5 + version: 4.5.5(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1) devDependencies: '@darkobits/vite-plugin-favicons': specifier: ^0.3.2 version: 0.3.2(typescript@5.5.4)(vite@5.4.0(@types/node@20.14.15)) + '@iconify/react': + specifier: ^5.0.2 + version: 5.0.2(react@18.3.1) '@types/node': specifier: ^20.12.12 version: 20.14.15 @@ -480,15 +514,21 @@ importers: '@astrojs/markdown-remark': specifier: ^5.2.0 version: 5.2.0 + '@astrojs/partytown': + specifier: ^2.1.2 + version: 2.1.2 + '@astrojs/sitemap': + specifier: ^3.1.6 + version: 3.1.6 '@astrojs/starlight': - specifier: ^0.26.1 - version: 0.26.1(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) + specifier: ^0.27.1 + version: 0.27.1(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) '@iconify-json/lucide': specifier: ^1.1.208 version: 1.1.208 astro: - specifier: ^4.14.5 - version: 4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) + specifier: ^4.15.6 + version: 4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) astro-icon: specifier: ^1.1.1 version: 1.1.1 @@ -503,7 +543,7 @@ importers: version: 0.32.6 starlight-package-managers: specifier: ^0.6.0 - version: 0.6.0(@astrojs/starlight@0.26.1(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)))(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) + version: 0.6.0(@astrojs/starlight@0.27.1(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)))(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) tailwindcss: specifier: ^3.4.4 version: 3.4.9 @@ -579,6 +619,9 @@ packages: peerDependencies: astro: ^4.8.0 + '@astrojs/partytown@2.1.2': + resolution: {integrity: sha512-1a9T5lqxtnrw0qLPo1KwliUvaaUzPNPtWucD8VxdwT7zqcpODFk1RzGgAgqVo+YhutFrTu/qclbtnOfXBuskjw==} + '@astrojs/prism@3.1.0': resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==} engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} @@ -586,8 +629,8 @@ packages: '@astrojs/sitemap@3.1.6': resolution: {integrity: sha512-1Qp2NvAzVImqA6y+LubKi1DVhve/hXXgFvB0szxiipzh7BvtuKe4oJJ9dXSqaubaTkt4nMa6dv6RCCAYeB6xaQ==} - '@astrojs/starlight@0.26.1': - resolution: {integrity: sha512-0qNYWZJ+ZOdSfM7du6fGuwUhyTHtAeRIl0zYe+dF0TxDvcakplO1SYLbGGX6lEVYE3PdBne7dcJww85bXZJIIQ==} + '@astrojs/starlight@0.27.1': + resolution: {integrity: sha512-L2hEgN/Tk7tfBDeaqUOgOpey5NcUL78FuQa06iNxyZ6RjyYyuXSniOoFxZYIo5PpY9O1dLdK22PkZyCDpO729g==} peerDependencies: astro: ^4.8.6 @@ -665,6 +708,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.25.6': + resolution: {integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.24.7': resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==} engines: {node: '>=6.9.0'} @@ -693,6 +741,10 @@ packages: resolution: {integrity: sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.25.6': + resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} + engines: {node: '>=6.9.0'} + '@biomejs/biome@1.8.3': resolution: {integrity: sha512-/uUV3MV+vyAczO+vKrPdOW0Iaet7UnJMU4bNMinggGJTAnBPjCoLEYcyYtYHNnUNYlv4xZMH6hVIQCAozq8d5w==} engines: {node: '>=14.21.3'} @@ -746,6 +798,11 @@ packages: cpu: [x64] os: [win32] + '@builder.io/partytown@0.10.2': + resolution: {integrity: sha512-A9U+4PREWcS+CCYzKGIPovtGB/PBgnH/8oQyCE6Nr9drDJk6cMPpLQIEajpGPmG9tYF7N3FkRvhXm/AS9+0iKg==} + engines: {node: '>=18.0.0'} + hasBin: true + '@cloudflare/kv-asset-handler@0.3.4': resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} engines: {node: '>=16.13'} @@ -895,6 +952,9 @@ packages: '@devexpress/error-stack-parser@2.0.6': resolution: {integrity: sha512-fneVypElGUH6Be39mlRZeAu00pccTlf4oVuzf9xPJD1cdEqI8NyAiQua/EW7lZdrbMUbgyXcJmfKPefhYius3A==} + '@drizzle-team/brocli@0.10.1': + resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==} + '@drizzle-team/brocli@0.8.2': resolution: {integrity: sha512-zTrFENsqGvOkBOuHDC1pXCkDXNd2UhP4lI3gYGhQ1R1SPeAAfqzPsV1dcpMy4uNU6kB5VpU5NGhvwxVNETR02A==} @@ -1660,6 +1720,12 @@ packages: resolution: {integrity: sha512-e6oHjNiErRxsZRZBmc2KucuvY3btlO/XPncIpP2X75bRdTilF9GLjm3NHvKKunpJbbJJj31/FoPTksTf8djAVw==} engines: {node: '>=18.14.1'} + '@hono/node-server@1.12.2': + resolution: {integrity: sha512-xjzhqhSWUE/OhN0g3KCNVzNsQMlFUAL+/8GgPUr3TKcU7cvgZVBGswFofJ8WwGEHTqobzze1lDpGJl9ZNckDhA==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/zod-validator@0.2.2': resolution: {integrity: sha512-dSDxaPV70Py8wuIU2QNpoVEIOSzSXZ/6/B/h4xA7eOMz7+AarKTSGV8E6QwrdcCbBLkpqfJ4Q2TmBO0eP1tCBQ==} peerDependencies: @@ -1677,6 +1743,11 @@ packages: '@iconify-json/lucide@1.1.208': resolution: {integrity: sha512-wdlljQKeoqmIw3YcYMCObPvuB3Gc0vbb0tocULrb0isl7wqINlHmcCAbL2mtLbHmwbldtW25AsplB6H56njPoA==} + '@iconify/react@5.0.2': + resolution: {integrity: sha512-wtmstbYlEbo4NDxFxBJkhkf9gJBDqMGr7FaqLrAUMneRV3Z+fVHLJjOhWbkAF8xDQNFC/wcTYdrWo1lnRhmagQ==} + peerDependencies: + react: '>=16' + '@iconify/tools@4.0.5': resolution: {integrity: sha512-l8KoA1lxlN/FFjlMd3vjfD7BtcX/QnFWtlBapILMlJSBgM5zhDYak/ldw/LkKG3258q/0YmXa48sO/QpxX7ptg==} @@ -2421,6 +2492,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menubar@1.1.1': + resolution: {integrity: sha512-V05Hryq/BE2m+rs8d5eLfrS0jmSWSDHEbG7jEyLA5D5J9jTvWj/o3v3xDN9YsOlH6QIkJgiaNDaP+S4T1rdykw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.1': resolution: {integrity: sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==} peerDependencies: @@ -2852,6 +2936,21 @@ packages: '@shikijs/core@1.14.1': resolution: {integrity: sha512-KyHIIpKNaT20FtFPFjCQB5WVSTpLR/n+jQXhWHWVUMm9MaOaG9BGOG0MSyt7yA4+Lm+4c9rTc03tt3nYzeYSfw==} + '@shikijs/core@1.17.7': + resolution: {integrity: sha512-ZnIDxFu/yvje3Q8owSHaEHd+bu/jdWhHAaJ17ggjXofHx5rc4bhpCSW+OjC6smUBi5s5dd023jWtZ1gzMu/yrw==} + + '@shikijs/engine-javascript@1.17.7': + resolution: {integrity: sha512-wwSf7lKPsm+hiYQdX+1WfOXujtnUG6fnN4rCmExxa4vo+OTmvZ9B1eKauilvol/LHUPrQgW12G3gzem7pY5ckw==} + + '@shikijs/engine-oniguruma@1.17.7': + resolution: {integrity: sha512-pvSYGnVeEIconU28NEzBXqSQC/GILbuNbAHwMoSfdTBrobKAsV1vq2K4cAgiaW1TJceLV9QMGGh18hi7cCzbVQ==} + + '@shikijs/types@1.17.7': + resolution: {integrity: sha512-+qA4UyhWLH2q4EFd+0z4K7GpERDU+c+CN2XYD3sC+zjvAr5iuwD1nToXZMt1YODshjkEGEDV86G7j66bKjqDdg==} + + '@shikijs/vscode-textmate@9.2.2': + resolution: {integrity: sha512-TMp15K+GGYrWlZM8+Lnj9EaHEFmOen0WJBrfa17hF7taDOYthuPPV0GWzfd/9iMij0akS/8Yw2ikquH7uVi/fg==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3393,8 +3492,8 @@ packages: astro-icon@1.1.1: resolution: {integrity: sha512-HKBesWk2Faw/0+klLX+epQVqdTfSzZz/9+5vxXUjTJaN/HnpDf608gRPgHh7ZtwBPNJMEFoU5GLegxoDcT56OQ==} - astro@4.14.5: - resolution: {integrity: sha512-sv47kPE6FnvyxxHHcCePNwTKpOMKBq0r1m6WZYg6ag9j3yF9m72ov64NFB7c+hAMDUKgsHfVdLKjOOqDC/c+fA==} + astro@4.15.6: + resolution: {integrity: sha512-SWcUNwm8CiVRaIbh4w5byh62BNihpsovlCd4ElvC7cL/53D24HcI7AaGFsPrromCamQklwQmIan/QS7x/3lLuQ==} engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true @@ -3639,17 +3738,13 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} - cli-color@2.0.4: - resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==} - engines: {node: '>=0.10'} - cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} @@ -3820,10 +3915,6 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - d@1.0.2: - resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} - engines: {node: '>=0.12'} - data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} @@ -3847,6 +3938,15 @@ packages: supports-color: optional: true + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -3931,9 +4031,6 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} - difflib@0.2.4: - resolution: {integrity: sha512-9YVwmMb0wQHQNr5J9m6BSj6fk4pfGITGQOOs+D9Fl+INODWFOfvhIU1hNv6GgR1RBoC/9NJcwu77zShxV0kT7w==} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3972,20 +4069,16 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} - dreamopt@0.8.0: - resolution: {integrity: sha512-vyJTp8+mC+G+5dfgsY+r3ckxlz+QMX40VjPQsZc5gxVAxLmi64TBoVkP54A/pRAXMXsbu2GMMBrZPxNv23waMg==} - engines: {node: '>=0.4.0'} - - drizzle-kit@0.21.4: - resolution: {integrity: sha512-Nxcc1ONJLRgbhmR+azxjNF9Ly9privNLEIgW53c92whb4xp8jZLH1kMCh/54ci1mTMuYxPdOukqLwJ8wRudNwA==} - hasBin: true - drizzle-kit@0.23.2: resolution: {integrity: sha512-NWkQ7GD2OTbQ7HzcjsaCOf3n0tlFPSEAF38fvDpwDj8jRbGWGFtN2cD8I8wp4lU+5Os/oyP2xycTKGLHdPipUw==} hasBin: true - drizzle-orm@0.30.10: - resolution: {integrity: sha512-IRy/QmMWw9lAQHpwbUh1b8fcn27S/a9zMIzqea1WNOxK9/4EB8gIo+FZWLiPXzl2n9ixGSv8BhsLZiOppWEwBw==} + drizzle-kit@0.24.2: + resolution: {integrity: sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ==} + hasBin: true + + drizzle-orm@0.32.2: + resolution: {integrity: sha512-3fXKzPzrgZIcnWCSLiERKN5Opf9Iagrag75snfFlKeKSYB1nlgPBshzW3Zn6dQymkyiib+xc4nIz0t8U+Xdpuw==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=3' @@ -3995,6 +4088,8 @@ packages: '@op-engineering/op-sqlite': '>=2' '@opentelemetry/api': ^1.4.1 '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' '@types/better-sqlite3': '*' '@types/pg': '*' '@types/react': '>=18' @@ -4009,6 +4104,7 @@ packages: mysql2: '>=2' pg: '>=8' postgres: '>=3' + prisma: '*' react: '>=18' sql.js: '>=1' sqlite3: '>=5' @@ -4029,6 +4125,10 @@ packages: optional: true '@planetscale/database': optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true '@types/better-sqlite3': optional: true '@types/pg': @@ -4057,6 +4157,8 @@ packages: optional: true postgres: optional: true + prisma: + optional: true react: optional: true sql.js: @@ -4064,8 +4166,8 @@ packages: sqlite3: optional: true - drizzle-orm@0.32.2: - resolution: {integrity: sha512-3fXKzPzrgZIcnWCSLiERKN5Opf9Iagrag75snfFlKeKSYB1nlgPBshzW3Zn6dQymkyiib+xc4nIz0t8U+Xdpuw==} + drizzle-orm@0.33.0: + resolution: {integrity: sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=3' @@ -4199,30 +4301,12 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} es-module-lexer@1.5.4: resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} - es5-ext@0.10.64: - resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} - engines: {node: '>=0.10'} - - es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} - - es6-symbol@3.1.4: - resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} - engines: {node: '>=0.12'} - - es6-weak-map@2.0.3: - resolution: {integrity: sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==} - esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -4276,10 +4360,6 @@ packages: resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} engines: {node: '>=6'} - esniff@2.0.1: - resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} - engines: {node: '>=0.10'} - esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -4309,9 +4389,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - event-emitter@0.3.5: - resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} - event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -4357,9 +4434,6 @@ packages: resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} engines: {node: '>=4'} - ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -4515,9 +4589,6 @@ packages: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4601,11 +4672,6 @@ packages: engines: {node: 20 || >=22} hasBin: true - glob@8.1.0: - resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} - engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported - globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -4628,9 +4694,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - hanji@0.0.5: - resolution: {integrity: sha512-Abxw1Lq+TnYiL4BueXqMau222fPSPMFtya8HdpWsz/xVAhifXou71mPh/kY2+08RgFcVccjG3uZHs6K5HAe3zw==} - has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -4682,6 +4745,9 @@ packages: hast-util-to-html@9.0.1: resolution: {integrity: sha512-hZOofyZANbyWo+9RP75xIDV/gq+OUKx+T46IlwERnKmfpwp81XBFbT9mi26ws+SJchA4RVUQwIBJpqEOBhMzEQ==} + hast-util-to-html@9.0.2: + resolution: {integrity: sha512-RP5wNpj5nm1Z8cloDv4Sl4RS8jH5HYa0v93YB6Wb4poEzgMo/dAAL0KcT4974dCjcNG5pkLqTImeFHHCwwfY3g==} + hast-util-to-jsx-runtime@2.3.0: resolution: {integrity: sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==} @@ -4703,9 +4769,6 @@ packages: hastscript@9.0.0: resolution: {integrity: sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==} - heap@0.2.7: - resolution: {integrity: sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==} - highlight-es@1.0.3: resolution: {integrity: sha512-s/SIX6yp/5S1p8aC/NRDC1fwEb+myGIfp8/TzZz0rtAv8fzsdX7vGl3Q1TrXCsczFq8DI3CBFBCySPClfBSdbg==} @@ -4720,6 +4783,10 @@ packages: resolution: {integrity: sha512-zz8ktqMDRrZETjxBrv8C5PQRFbrTRCLNVAjD1SNQyOzv4VjmX68Uxw83xQ6oxdAB60HiWnGEatiKA8V3SZLDkQ==} engines: {node: '>=16.0.0'} + hono@4.6.2: + resolution: {integrity: sha512-v+39817TgAhetmHUEli8O0uHDmxp2Up3DnhS4oUZXOl5IQ9np9tYtldd42e5zgdLVS0wsOoXQNZ6mx+BGmEvCA==} + engines: {node: '>=16.9.0'} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -4768,6 +4835,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immer@10.1.1: + resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -4783,10 +4853,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4902,9 +4968,6 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} - is-promise@2.2.2: - resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} - is-reference@3.0.2: resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} @@ -4994,10 +5057,6 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-diff@0.9.0: - resolution: {integrity: sha512-cVnggDrVkAAA3OvFfHpFEhOnmcsUpleEKq4d4O8sQWWSH40MBrWstKigVB1kGrgLWzuom+7rRdaCsnBD6VyObQ==} - hasBin: true - json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -5096,9 +5155,6 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.throttle@4.1.1: - resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -5143,15 +5199,15 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-queue@0.1.0: - resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} - magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -5222,10 +5278,6 @@ packages: memoize-one@4.0.3: resolution: {integrity: sha512-QmpUu4KqDmX0plH4u+tf0riMc1KHE1+lw95cMrLlXQAFOx/xnBtwhZ52XJxd9X2O6kwKBqX32kmhbhlobD0cuw==} - memoizee@0.4.17: - resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==} - engines: {node: '>=0.12'} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -5345,6 +5397,10 @@ packages: resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} engines: {node: '>=8.6'} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -5366,6 +5422,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -5498,9 +5558,6 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} - next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - nice-napi@1.0.2: resolution: {integrity: sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==} os: ['!win32'] @@ -5552,8 +5609,8 @@ packages: node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} - nodemon@3.1.4: - resolution: {integrity: sha512-wjPBbFhtpJwmIeY2yP7QF+UKzPfltVGtfce1g/bB15/8vCGZj8uxD62b/b9M9/WVgme0NZudpownKN+c0plXlQ==} + nodemon@3.1.5: + resolution: {integrity: sha512-V5UtfYc7hjFD4SI3EzD5TR8ChAHEZ+Ns7Z5fBk8fAbTVAj+q3G+w7sHJrHxXBkVn6ApLVTljau8wfHwqmGUjMw==} engines: {node: '>=10'} hasBin: true @@ -5615,6 +5672,13 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oniguruma-to-js@0.4.3: + resolution: {integrity: sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==} + openai@4.55.4: resolution: {integrity: sha512-TEC75Y6U/OKIJp9fHao3zkTYfKLYGqXdD2TI+xN2Zd5W8KNKvv6E4/OBTOW7jg7fySfrBrhy5fYzBbyBcdHEtQ==} hasBin: true @@ -5628,8 +5692,8 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - ora@8.0.1: - resolution: {integrity: sha512-ANIvzobt1rls2BDny5fWZ3ZVKyD6nscLvfFRpQgfWsythlcsVUC9kL0zq6j2Z5z9wwp1kd7wpsD/T9qNPVLCaQ==} + ora@8.1.0: + resolution: {integrity: sha512-GQEkNkH/GHOhPFXcqZs3IDahXEQcQxsSjEkK4KvEEST4t7eNzoMjxTzef+EZ+JluDEV+Raoi3WQ2CflnRdSVnQ==} engines: {node: '>=18'} os-filter-obj@2.0.0: @@ -5823,6 +5887,9 @@ packages: picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -5910,6 +5977,10 @@ packages: resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@3.0.2: resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} engines: {node: '>=12'} @@ -5986,9 +6057,15 @@ packages: resolution: {integrity: sha512-RXyHaACeqXeqAKGLDl68rQKbmObRsTIn4TYVUUug1KfS47YWCo5MacGITEryugIgZqORCvJWEk4l449POg5Txg==} engines: {node: '>=12.0.0'} + proxy-compare@3.0.0: + resolution: {integrity: sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-memoize@3.0.1: + resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==} + pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} @@ -6153,6 +6230,9 @@ packages: regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regex@4.3.2: + resolution: {integrity: sha512-kK/AA3A9K6q2js89+VMymcboLOlF5lZRCYJv3gzszXFHBr6kO6qLGzbm+UIugBEV8SMMKCTR59txoY6ctRHYVw==} + rehype-autolink-headings@7.1.0: resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} @@ -6238,9 +6318,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} ret@0.1.15: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} @@ -6373,6 +6453,9 @@ packages: shiki@1.14.1: resolution: {integrity: sha512-FujAN40NEejeXdzPt+3sZ3F2dx1U24BY2XTY01+MG8mbxCiA2XukXdcbyMyLAHJ/1AUUnQd1tZlvIjefWWEJeA==} + shiki@1.17.7: + resolution: {integrity: sha512-Zf6hNtWhFyF4XP5OOsXkBTEx9JFPiN0TQx4wSe+Vqeuczewgk2vT4IZhF4gka55uelm052BD5BaHavNqUNZd+A==} + shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} @@ -6426,6 +6509,10 @@ packages: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -6654,10 +6741,6 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - timers-ext@0.1.8: - resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} - engines: {node: '>=0.12'} - tiny-worker@2.3.0: resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==} @@ -6737,6 +6820,16 @@ packages: typescript: optional: true + tsconfck@3.1.3: + resolution: {integrity: sha512-ulNZP1SVpRDesxeMLON/LtWM8HIgAJEIVpVVhBM6gsmvQ8+Rh+ZG7FWGvHh7Ah3pRABwVJWklWCr/BTZSv0xnQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -6773,9 +6866,6 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - type@2.7.3: - resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} - typesafe-path@0.2.2: resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} @@ -6888,6 +6978,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.2.2: + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6922,6 +7017,9 @@ packages: vfile@6.0.2: resolution: {integrity: sha512-zND7NlS8rJYb/sPqkb13ZvbbUoExdbi4w3SfRrMq6R3FvnLQmmfpajJNITuuYm6AZ5uao9vy4BAos3EXBPf2rg==} + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@1.6.0: resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -6971,8 +7069,8 @@ packages: terser: optional: true - vite@5.4.2: - resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==} + vite@5.4.5: + resolution: {integrity: sha512-pXqR0qtb2bTwLkev4SE3r4abCNioP3GkjvIDLlzziPpXtHgiJIjuKl+1GN6ESOT3wMjG3JTeARopj2SwYaHTOA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -7002,8 +7100,8 @@ packages: terser: optional: true - vitefu@0.2.5: - resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + vitefu@1.0.2: + resolution: {integrity: sha512-0/iAvbXyM3RiPPJ4lyD4w6Mjgtf4ejTK6TPvTNG3H32PLwuT0N/ZjJLiXug7ETE/LWtTeHw9WRv7uX/tIKYyKg==} peerDependencies: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -7338,6 +7436,21 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zustand@4.5.5: + resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7438,12 +7551,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@3.1.3(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4))': + '@astrojs/mdx@3.1.3(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4))': dependencies: '@astrojs/markdown-remark': 5.2.0 '@mdx-js/mdx': 3.0.1 acorn: 8.12.1 - astro: 4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) + astro: 4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) es-module-lexer: 1.5.4 estree-util-visit: 2.0.0 github-slugger: 2.0.0 @@ -7459,6 +7572,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@astrojs/partytown@2.1.2': + dependencies: + '@builder.io/partytown': 0.10.2 + mrmime: 2.0.0 + '@astrojs/prism@3.1.0': dependencies: prismjs: 1.29.0 @@ -7469,15 +7587,15 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.23.8 - '@astrojs/starlight@0.26.1(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4))': + '@astrojs/starlight@0.27.1(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4))': dependencies: - '@astrojs/mdx': 3.1.3(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) + '@astrojs/mdx': 3.1.3(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) '@astrojs/sitemap': 3.1.6 '@pagefind/default-ui': 1.1.0 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - astro: 4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) - astro-expressive-code: 0.35.6(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) + astro: 4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) + astro-expressive-code: 0.35.6(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) bcp-47: 2.1.0 hast-util-from-html: 2.0.1 hast-util-select: 6.0.2 @@ -7499,7 +7617,7 @@ snapshots: '@astrojs/telemetry@3.1.0': dependencies: ci-info: 4.0.0 - debug: 4.3.6 + debug: 4.3.7 dlv: 1.1.3 dset: 3.1.3 is-docker: 3.0.0 @@ -7530,9 +7648,9 @@ snapshots: '@babel/parser': 7.25.3 '@babel/template': 7.25.0 '@babel/traverse': 7.25.3 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 convert-source-map: 2.0.0 - debug: 4.3.6 + debug: 4.3.7 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -7541,14 +7659,14 @@ snapshots: '@babel/generator@7.25.0': dependencies: - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 '@babel/helper-annotate-as-pure@7.24.7': dependencies: - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@babel/helper-compilation-targets@7.25.2': dependencies: @@ -7561,7 +7679,7 @@ snapshots: '@babel/helper-module-imports@7.24.7': dependencies: '@babel/traverse': 7.25.3 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color @@ -7580,7 +7698,7 @@ snapshots: '@babel/helper-simple-access@7.24.7': dependencies: '@babel/traverse': 7.25.3 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color @@ -7593,7 +7711,7 @@ snapshots: '@babel/helpers@7.25.0': dependencies: '@babel/template': 7.25.0 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@babel/highlight@7.24.7': dependencies: @@ -7604,7 +7722,11 @@ snapshots: '@babel/parser@7.25.3': dependencies: - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 + + '@babel/parser@7.25.6': + dependencies: + '@babel/types': 7.25.6 '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.25.2)': dependencies: @@ -7618,7 +7740,7 @@ snapshots: '@babel/helper-module-imports': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color @@ -7630,7 +7752,7 @@ snapshots: dependencies: '@babel/code-frame': 7.24.7 '@babel/parser': 7.25.3 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@babel/traverse@7.25.3': dependencies: @@ -7638,8 +7760,8 @@ snapshots: '@babel/generator': 7.25.0 '@babel/parser': 7.25.3 '@babel/template': 7.25.0 - '@babel/types': 7.25.2 - debug: 4.3.6 + '@babel/types': 7.25.6 + debug: 4.3.7 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -7650,6 +7772,12 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 + '@babel/types@7.25.6': + dependencies: + '@babel/helper-string-parser': 7.24.8 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + '@biomejs/biome@1.8.3': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.8.3 @@ -7685,6 +7813,8 @@ snapshots: '@biomejs/cli-win32-x64@1.8.3': optional: true + '@builder.io/partytown@0.10.2': {} + '@cloudflare/kv-asset-handler@0.3.4': dependencies: mime: 3.0.0 @@ -7869,6 +7999,8 @@ snapshots: dependencies: stackframe: 1.3.4 + '@drizzle-team/brocli@0.10.1': {} + '@drizzle-team/brocli@0.8.2': {} '@emmetio/abbreviation@2.3.3': @@ -8280,7 +8412,7 @@ snapshots: '@expressive-code/plugin-shiki@0.35.6': dependencies: '@expressive-code/core': 0.35.6 - shiki: 1.12.1 + shiki: 1.14.1 '@expressive-code/plugin-text-markers@0.35.6': dependencies: @@ -8309,11 +8441,20 @@ snapshots: '@hono/node-server@1.12.0': {} + '@hono/node-server@1.12.2(hono@4.5.9)': + dependencies: + hono: 4.5.9 + '@hono/zod-validator@0.2.2(hono@4.5.5)(zod@3.23.8)': dependencies: hono: 4.5.5 zod: 3.23.8 + '@hono/zod-validator@0.2.2(hono@4.6.2)(zod@3.23.8)': + dependencies: + hono: 4.6.2 + zod: 3.23.8 + '@hookform/resolvers@3.9.0(react-hook-form@7.53.0(react@18.3.1))': dependencies: react-hook-form: 7.53.0(react@18.3.1) @@ -8324,6 +8465,11 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify/react@5.0.2(react@18.3.1)': + dependencies: + '@iconify/types': 2.0.0 + react: 18.3.1 + '@iconify/tools@4.0.5': dependencies: '@iconify/types': 2.0.0 @@ -9153,6 +9299,24 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-menubar@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-menu': 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-popover@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -9546,6 +9710,33 @@ snapshots: dependencies: '@types/hast': 3.0.4 + '@shikijs/core@1.17.7': + dependencies: + '@shikijs/engine-javascript': 1.17.7 + '@shikijs/engine-oniguruma': 1.17.7 + '@shikijs/types': 1.17.7 + '@shikijs/vscode-textmate': 9.2.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.2 + + '@shikijs/engine-javascript@1.17.7': + dependencies: + '@shikijs/types': 1.17.7 + '@shikijs/vscode-textmate': 9.2.2 + oniguruma-to-js: 0.4.3 + + '@shikijs/engine-oniguruma@1.17.7': + dependencies: + '@shikijs/types': 1.17.7 + '@shikijs/vscode-textmate': 9.2.2 + + '@shikijs/types@1.17.7': + dependencies: + '@shikijs/vscode-textmate': 9.2.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@9.2.2': {} + '@sinclair/typebox@0.27.8': {} '@sindresorhus/is@4.6.0': {} @@ -9721,23 +9912,23 @@ snapshots: '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.25.3 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.25.3 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@types/cacheable-request@6.0.3': dependencies: @@ -10103,9 +10294,9 @@ snapshots: astring@1.8.6: {} - astro-expressive-code@0.35.6(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)): + astro-expressive-code@0.35.6(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)): dependencies: - astro: 4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) + astro: 4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) rehype-expressive-code: 0.35.6 astro-icon@1.1.1: @@ -10117,18 +10308,15 @@ snapshots: - debug - supports-color - astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4): + astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4): dependencies: '@astrojs/compiler': 2.10.3 '@astrojs/internal-helpers': 0.4.1 '@astrojs/markdown-remark': 5.2.0 '@astrojs/telemetry': 3.1.0 '@babel/core': 7.25.2 - '@babel/generator': 7.25.0 - '@babel/parser': 7.25.3 '@babel/plugin-transform-react-jsx': 7.25.2(@babel/core@7.25.2) - '@babel/traverse': 7.25.3 - '@babel/types': 7.25.2 + '@babel/types': 7.25.6 '@oslojs/encoding': 0.4.1 '@rollup/pluginutils': 5.1.0(rollup@4.20.0) '@types/babel__core': 7.20.5 @@ -10142,7 +10330,7 @@ snapshots: common-ancestor-path: 1.0.1 cookie: 0.6.0 cssesc: 3.0.0 - debug: 4.3.6 + debug: 4.3.7 deterministic-object-hash: 2.0.2 devalue: 5.0.0 diff: 5.2.0 @@ -10151,8 +10339,8 @@ snapshots: es-module-lexer: 1.5.4 esbuild: 0.21.5 estree-walker: 3.0.3 - execa: 8.0.1 fast-glob: 3.3.2 + fastq: 1.17.1 flattie: 1.1.1 github-slugger: 2.0.0 gray-matter: 4.0.3 @@ -10161,10 +10349,11 @@ snapshots: js-yaml: 4.1.0 kleur: 4.1.5 magic-string: 0.30.11 - micromatch: 4.0.7 + magicast: 0.3.5 + micromatch: 4.0.8 mrmime: 2.0.0 neotraverse: 0.6.18 - ora: 8.0.1 + ora: 8.1.0 p-limit: 6.1.0 p-queue: 8.0.1 path-to-regexp: 6.2.2 @@ -10172,14 +10361,15 @@ snapshots: prompts: 2.4.2 rehype: 13.0.1 semver: 7.6.3 - shiki: 1.14.1 + shiki: 1.17.7 string-width: 7.2.0 strip-ansi: 7.1.0 - tsconfck: 3.1.1(typescript@5.5.4) + tinyexec: 0.3.0 + tsconfck: 3.1.3(typescript@5.5.4) unist-util-visit: 5.0.0 - vfile: 6.0.2 - vite: 5.4.2(@types/node@20.14.15) - vitefu: 0.2.5(vite@5.4.2(@types/node@20.14.15)) + vfile: 6.0.3 + vite: 5.4.5(@types/node@20.14.15) + vitefu: 1.0.2(vite@5.4.5(@types/node@20.14.15)) which-pm: 3.0.0 xxhash-wasm: 1.0.2 yargs-parser: 21.1.1 @@ -10492,21 +10682,13 @@ snapshots: cli-boxes@3.0.0: {} - cli-color@2.0.4: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-iterator: 2.0.3 - memoizee: 0.4.17 - timers-ext: 0.1.8 - cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 - cli-cursor@4.0.0: + cli-cursor@5.0.0: dependencies: - restore-cursor: 4.0.0 + restore-cursor: 5.1.0 cli-spinners@2.9.2: {} @@ -10665,11 +10847,6 @@ snapshots: csstype@3.1.3: {} - d@1.0.2: - dependencies: - es5-ext: 0.10.64 - type: 2.7.3 - data-uri-to-buffer@2.0.2: {} data-uri-to-buffer@4.0.1: {} @@ -10696,6 +10873,10 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + debug@4.3.7: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} decode-named-character-reference@1.0.2: @@ -10756,10 +10937,6 @@ snapshots: diff@5.2.0: {} - difflib@0.2.4: - dependencies: - heap: 0.2.7 - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -10799,36 +10976,27 @@ snapshots: dotenv@16.4.5: {} - dreamopt@0.8.0: - dependencies: - wordwrap: 1.0.0 - - drizzle-kit@0.21.4: + drizzle-kit@0.23.2: dependencies: + '@drizzle-team/brocli': 0.8.2 '@esbuild-kit/esm-loader': 2.6.5 - commander: 9.5.0 - env-paths: 3.0.0 esbuild: 0.19.12 esbuild-register: 3.6.0(esbuild@0.19.12) - glob: 8.1.0 - hanji: 0.0.5 - json-diff: 0.9.0 - zod: 3.23.8 transitivePeerDependencies: - supports-color - drizzle-kit@0.23.2: + drizzle-kit@0.24.2: dependencies: - '@drizzle-team/brocli': 0.8.2 + '@drizzle-team/brocli': 0.10.1 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.19.12 esbuild-register: 3.6.0(esbuild@0.19.12) transitivePeerDependencies: - supports-color - drizzle-orm@0.30.10(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): + drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): optionalDependencies: - '@cloudflare/workers-types': 4.20240806.0 + '@cloudflare/workers-types': 4.20240821.1 '@libsql/client': 0.6.2 '@neondatabase/serverless': 0.9.4 '@opentelemetry/api': 1.9.0 @@ -10836,9 +11004,9 @@ snapshots: '@types/react': 18.3.3 react: 18.3.1 - drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): + drizzle-orm@0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): optionalDependencies: - '@cloudflare/workers-types': 4.20240821.1 + '@cloudflare/workers-types': 4.20240806.0 '@libsql/client': 0.6.2 '@neondatabase/serverless': 0.9.4 '@opentelemetry/api': 1.9.0 @@ -10846,9 +11014,9 @@ snapshots: '@types/react': 18.3.3 react: 18.3.1 - drizzle-zod@0.5.1(drizzle-orm@0.30.10(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8): + drizzle-zod@0.5.1(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8): dependencies: - drizzle-orm: 0.30.10(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + drizzle-orm: 0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) zod: 3.23.8 dset@3.1.3: {} @@ -10888,39 +11056,12 @@ snapshots: entities@4.5.0: {} - env-paths@3.0.0: {} - error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 es-module-lexer@1.5.4: {} - es5-ext@0.10.64: - dependencies: - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esniff: 2.0.1 - next-tick: 1.1.0 - - es6-iterator@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-symbol: 3.1.4 - - es6-symbol@3.1.4: - dependencies: - d: 1.0.2 - ext: 1.7.0 - - es6-weak-map@2.0.3: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-iterator: 2.0.3 - es6-symbol: 3.1.4 - esbuild-register@3.6.0(esbuild@0.19.12): dependencies: debug: 4.3.6 @@ -11070,13 +11211,6 @@ snapshots: esm@3.2.25: optional: true - esniff@2.0.1: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-emitter: 0.3.5 - type: 2.7.3 - esprima@4.0.1: {} estree-util-attach-comments@3.0.0: @@ -11111,11 +11245,6 @@ snapshots: dependencies: '@types/estree': 1.0.5 - event-emitter@0.3.5: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} @@ -11180,10 +11309,6 @@ snapshots: ext-list: 2.2.2 sort-keys-length: 1.0.1 - ext@1.7.0: - dependencies: - type: 2.7.3 - extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -11216,7 +11341,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fast-json-stable-stringify@2.1.0: {} @@ -11296,7 +11421,7 @@ snapshots: find-yarn-workspace-root2@1.2.16: dependencies: - micromatch: 4.0.7 + micromatch: 4.0.8 pkg-dir: 4.2.0 flattie@1.1.1: {} @@ -11343,8 +11468,6 @@ snapshots: dependencies: minipass: 7.1.2 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -11415,14 +11538,6 @@ snapshots: package-json-from-dist: 1.0.0 path-scurry: 2.0.0 - glob@8.1.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 5.1.6 - once: 1.4.0 - globals@11.12.0: {} globby@11.1.0: @@ -11459,11 +11574,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - hanji@0.0.5: - dependencies: - lodash.throttle: 4.1.1 - sisteransi: 1.0.5 - has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -11596,6 +11706,20 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-html@9.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.0: dependencies: '@types/estree': 1.0.5 @@ -11657,8 +11781,6 @@ snapshots: property-information: 6.5.0 space-separated-tokens: 2.0.2 - heap@0.2.7: {} - highlight-es@1.0.3: dependencies: chalk: 2.4.2 @@ -11671,6 +11793,8 @@ snapshots: hono@4.5.9: {} + hono@4.6.2: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -11713,6 +11837,8 @@ snapshots: ignore@5.3.2: {} + immer@10.1.1: {} + import-fresh@3.3.0: dependencies: parent-module: 1.0.1 @@ -11724,11 +11850,6 @@ snapshots: indent-string@4.0.0: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ini@1.3.8: {} @@ -11822,8 +11943,6 @@ snapshots: is-plain-object@5.0.0: {} - is-promise@2.2.2: {} - is-reference@3.0.2: dependencies: '@types/estree': 1.0.5 @@ -11898,12 +12017,6 @@ snapshots: json-buffer@3.0.1: {} - json-diff@0.9.0: - dependencies: - cli-color: 2.0.4 - difflib: 0.2.4 - dreamopt: 0.8.0 - json-parse-even-better-errors@2.3.1: {} json-schema-traverse@1.0.0: {} @@ -11991,8 +12104,6 @@ snapshots: lodash.merge@4.6.2: {} - lodash.throttle@4.1.1: {} - lodash@4.17.21: {} log-symbols@4.1.0: @@ -12036,10 +12147,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru-queue@0.1.0: - dependencies: - es5-ext: 0.10.64 - magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -12048,6 +12155,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.25.6 + '@babel/types': 7.25.6 + source-map-js: 1.2.0 + markdown-extensions@2.0.0: {} markdown-table@3.0.3: {} @@ -12240,17 +12353,6 @@ snapshots: memoize-one@4.0.3: {} - memoizee@0.4.17: - dependencies: - d: 1.0.2 - es5-ext: 0.10.64 - es6-weak-map: 2.0.3 - event-emitter: 0.3.5 - is-promise: 2.2.2 - lru-queue: 0.1.0 - next-tick: 1.1.0 - timers-ext: 0.1.8 - merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -12534,6 +12636,11 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -12546,6 +12653,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -12683,8 +12792,6 @@ snapshots: neotraverse@0.6.18: {} - next-tick@1.1.0: {} - nice-napi@1.0.2: dependencies: node-addon-api: 3.2.1 @@ -12732,7 +12839,7 @@ snapshots: node-releases@2.0.18: {} - nodemon@3.1.4: + nodemon@3.1.5: dependencies: chokidar: 3.6.0 debug: 4.3.6(supports-color@5.5.0) @@ -12791,6 +12898,14 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oniguruma-to-js@0.4.3: + dependencies: + regex: 4.3.2 + openai@4.55.4(encoding@0.1.13)(zod@3.23.8): dependencies: '@types/node': 18.19.44 @@ -12817,10 +12932,10 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - ora@8.0.1: + ora@8.1.0: dependencies: chalk: 5.3.0 - cli-cursor: 4.0.0 + cli-cursor: 5.0.0 cli-spinners: 2.9.2 is-interactive: 2.0.0 is-unicode-supported: 2.0.0 @@ -13028,6 +13143,8 @@ snapshots: picocolors@1.0.1: {} + picocolors@1.1.0: {} + picomatch@2.3.1: {} pify@2.3.0: {} @@ -13112,6 +13229,12 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 + postcss@8.4.47: + dependencies: + nanoid: 3.3.7 + picocolors: 1.1.0 + source-map-js: 1.2.1 + postgres-array@3.0.2: {} postgres-bytea@3.0.0: @@ -13200,8 +13323,14 @@ snapshots: '@types/node': 20.14.15 long: 5.2.3 + proxy-compare@3.0.0: {} + proxy-from-env@1.1.0: {} + proxy-memoize@3.0.1: + dependencies: + proxy-compare: 3.0.0 + pseudomap@1.0.2: {} pstree.remy@1.1.8: {} @@ -13363,6 +13492,8 @@ snapshots: regenerator-runtime@0.14.1: {} + regex@4.3.2: {} + rehype-autolink-headings@7.1.0: dependencies: '@types/hast': 3.0.4 @@ -13508,10 +13639,10 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - restore-cursor@4.0.0: + restore-cursor@5.1.0: dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 + onetime: 7.0.0 + signal-exit: 4.1.0 ret@0.1.15: {} @@ -13694,6 +13825,15 @@ snapshots: '@shikijs/core': 1.14.1 '@types/hast': 3.0.4 + shiki@1.17.7: + dependencies: + '@shikijs/core': 1.17.7 + '@shikijs/engine-javascript': 1.17.7 + '@shikijs/engine-oniguruma': 1.17.7 + '@shikijs/types': 1.17.7 + '@shikijs/vscode-textmate': 9.2.2 + '@types/hast': 3.0.4 + shimmer@1.2.1: {} siginfo@2.0.0: {} @@ -13744,6 +13884,8 @@ snapshots: source-map-js@1.2.0: {} + source-map-js@1.2.1: {} + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -13780,10 +13922,10 @@ snapshots: as-table: 1.0.55 get-source: 2.0.12 - starlight-package-managers@0.6.0(@astrojs/starlight@0.26.1(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)))(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)): + starlight-package-managers@0.6.0(@astrojs/starlight@0.27.1(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)))(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)): dependencies: - '@astrojs/starlight': 0.26.1(astro@4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) - astro: 4.14.5(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) + '@astrojs/starlight': 0.27.1(astro@4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4)) + astro: 4.15.6(@types/node@20.14.15)(rollup@4.20.0)(typescript@5.5.4) state-local@1.0.7: {} @@ -14015,11 +14157,6 @@ snapshots: through@2.3.8: {} - timers-ext@0.1.8: - dependencies: - es5-ext: 0.10.64 - next-tick: 1.1.0 - tiny-worker@2.3.0: dependencies: esm: 3.2.25 @@ -14100,6 +14237,10 @@ snapshots: optionalDependencies: typescript: 5.5.4 + tsconfck@3.1.3(typescript@5.5.4): + optionalDependencies: + typescript: 5.5.4 + tslib@1.14.1: {} tslib@2.6.3: {} @@ -14128,8 +14269,6 @@ snapshots: type-fest@2.19.0: {} - type@2.7.3: {} - typesafe-path@0.2.2: {} typescript-auto-import-cache@0.3.3: @@ -14257,6 +14396,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + use-sync-external-store@1.2.2(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} uuid@10.0.0: {} @@ -14292,6 +14435,11 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.2 + vfile-message: 4.0.2 + vite-node@1.6.0(@types/node@20.14.15): dependencies: cac: 6.7.14 @@ -14341,18 +14489,18 @@ snapshots: '@types/node': 20.14.15 fsevents: 2.3.3 - vite@5.4.2(@types/node@20.14.15): + vite@5.4.5(@types/node@20.14.15): dependencies: esbuild: 0.21.5 - postcss: 8.4.41 + postcss: 8.4.47 rollup: 4.20.0 optionalDependencies: '@types/node': 20.14.15 fsevents: 2.3.3 - vitefu@0.2.5(vite@5.4.2(@types/node@20.14.15)): + vitefu@1.0.2(vite@5.4.5(@types/node@20.14.15)): optionalDependencies: - vite: 5.4.2(@types/node@20.14.15) + vite: 5.4.5(@types/node@20.14.15) vitest@1.6.0(@types/node@20.14.15): dependencies: @@ -14724,4 +14872,12 @@ snapshots: zod@3.23.8: {} + zustand@4.5.5(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1): + dependencies: + use-sync-external-store: 1.2.2(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + immer: 10.1.1 + react: 18.3.1 + zwitch@2.0.4: {} diff --git a/studio/package.json b/studio/package.json index 4b01f6959..44f3c98b0 100644 --- a/studio/package.json +++ b/studio/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-menubar": "^1.1.1", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-separator": "^1.0.3", @@ -51,6 +52,8 @@ "date-fns": "^3.6.0", "highlight-words-core": "^1.2.2", "hono": "^4.4.8", + "immer": "^10.1.1", + "proxy-memoize": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-highlight-words": "^0.20.0", @@ -64,10 +67,12 @@ "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zustand": "^4.5.5" }, "devDependencies": { "@darkobits/vite-plugin-favicons": "^0.3.2", + "@iconify/react": "^5.0.2", "@types/node": "^20.12.12", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", diff --git a/studio/src/App.tsx b/studio/src/App.tsx index 14c903af3..e0e2539d8 100644 --- a/studio/src/App.tsx +++ b/studio/src/App.tsx @@ -1,59 +1,37 @@ import { QueryClientProvider, queryClient } from "@/queries"; import { TooltipProvider } from "@radix-ui/react-tooltip"; -import { type ReactNode, useEffect } from "react"; -import { - Route, - BrowserRouter as Router, - Routes, - useNavigate, -} from "react-router-dom"; -import Layout from "./Layout"; +import { Route, BrowserRouter as Router, Routes } from "react-router-dom"; +import { Layout } from "./Layout"; import { Toaster } from "./components/ui/toaster"; import { RequestDetailsPage } from "./pages/RequestDetailsPage/RequestDetailsPage"; -import { - RequestorPage, - RequestorSessionHistoryProvider, -} from "./pages/RequestorPage"; +import { RequestorPage } from "./pages/RequestorPage"; import { RequestsPage } from "./pages/RequestsPage/RequestsPage"; -import { SettingsPage } from "./pages/SettingsPage"; export function App() { return ( - - - - - - } /> - } /> - } - /> - } - /> - } /> - } /> - - - - - - + + + + + } /> + } + /> + } + /> + } /> + } /> + + + + + ); } export default App; - -function Redirect({ to = "/requestor" }: { to?: string }): ReactNode { - const navigate = useNavigate(); - useEffect(() => { - navigate(to); - }, [to, navigate]); - - return null; -} diff --git a/studio/src/Layout.tsx b/studio/src/Layout.tsx deleted file mode 100644 index 0b708174e..000000000 --- a/studio/src/Layout.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; -import type React from "react"; -import type { ComponentProps } from "react"; -import { NavLink } from "react-router-dom"; -import FpxIcon from "./assets/fpx.svg"; -import { WebhoncBadge } from "./components/WebhoncBadge"; -import { Button } from "./components/ui/button"; -import { useWebsocketQueryInvalidation } from "./hooks"; -import { useProxyRequestsEnabled } from "./hooks/useProxyRequestsEnabled"; -import { cn } from "./utils"; - -const Branding = () => { - return ( -
- -
- ); -}; - -export const Layout: React.FC<{ children?: React.ReactNode }> = ({ - children, -}) => { - // Will add new fpx-requests as they come in by refetching - // In the future, we'll want to build a better ux around this (not auto refresh the table) - // - // This should be used only at the top level of the app to avoid unnecessary re-renders - useWebsocketQueryInvalidation(); - - const shouldShowProxyRequests = useProxyRequestsEnabled(); - - return ( -
- -
- {children} -
-
- ); -}; - -const HeaderNavLink = (props: ComponentProps) => { - return ( - - `rounded ${isActive ? "bg-muted" : ""} inline-block py-2 px-4 hover:underline` - } - /> - ); -}; - -export default Layout; diff --git a/studio/src/Layout/BottomBar.tsx b/studio/src/Layout/BottomBar.tsx new file mode 100644 index 000000000..e1b907eb7 --- /dev/null +++ b/studio/src/Layout/BottomBar.tsx @@ -0,0 +1,172 @@ +import IconWithNotification from "@/components/IconWithNotification"; +import { KeyboardShortcutKey } from "@/components/KeyboardShortcut"; +import { WebhoncBadge } from "@/components/WebhoncBadge"; +import { Button } from "@/components/ui/button"; +import { useProxyRequestsEnabled } from "@/hooks/useProxyRequestsEnabled"; +import { useOrphanLogs } from "@/pages/RequestDetailsPage/RequestDetailsPageV2/useOrphanLogs"; +import { useRequestorStore } from "@/pages/RequestorPage/store"; +import { useOtelTrace } from "@/queries"; +import { cn } from "@/utils"; +import { Icon } from "@iconify/react/dist/iconify.js"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@radix-ui/react-tooltip"; +import { useEffect, useState } from "react"; +import { Branding } from "./Branding"; +import { SettingsMenu, SettingsScreen } from "./Settings"; +import { FloatingSidePanel } from "./SidePanel"; +import { SidePanelTrigger } from "./SidePanel"; + +export function BottomBar() { + const shouldShowProxyRequests = useProxyRequestsEnabled(); + + const [settingsOpen, setSettingsOpen] = useState(false); + + const { + logsPanel, + timelinePanel, + aiPanel, + togglePanel, + activeHistoryResponseTraceId, + } = useRequestorStore( + "togglePanel", + "logsPanel", + "timelinePanel", + "aiPanel", + "activeHistoryResponseTraceId", + ); + + const traceId = activeHistoryResponseTraceId ?? ""; + const { data: spans } = useOtelTrace(traceId); + const logs = useOrphanLogs(traceId, spans ?? []); + + const hasErrorLogs = logs.some((log) => log.level === "error"); + + useEffect(() => { + if (hasErrorLogs && logsPanel !== "open") { + togglePanel("logsPanel"); + } + }, [hasErrorLogs, logsPanel, togglePanel]); + + return ( + + ); +} diff --git a/studio/src/Layout/Branding.tsx b/studio/src/Layout/Branding.tsx new file mode 100644 index 000000000..170113079 --- /dev/null +++ b/studio/src/Layout/Branding.tsx @@ -0,0 +1,10 @@ +import FpLogo from "@/assets/fp-logo.svg"; + +export function Branding() { + return ( +
+ + Fiberplane +
+ ); +} diff --git a/studio/src/Layout/Layout.tsx b/studio/src/Layout/Layout.tsx new file mode 100644 index 000000000..d2b64dc70 --- /dev/null +++ b/studio/src/Layout/Layout.tsx @@ -0,0 +1,21 @@ +import type React from "react"; +import { useWebsocketQueryInvalidation } from "../hooks"; +import { cn } from "../utils"; +import { BottomBar } from "./BottomBar"; + +export function Layout({ children }: { children?: React.ReactNode }) { + useWebsocketQueryInvalidation(); + + return ( +
+
+ {children} +
+ +
+ ); +} + +export default Layout; diff --git a/studio/src/Layout/Settings/SettingsMenu.tsx b/studio/src/Layout/Settings/SettingsMenu.tsx new file mode 100644 index 000000000..919887fd5 --- /dev/null +++ b/studio/src/Layout/Settings/SettingsMenu.tsx @@ -0,0 +1,94 @@ +import { Icon } from "@iconify/react/dist/iconify.js"; +import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; +import { + Menubar, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarTrigger, +} from "@radix-ui/react-menubar"; +import { useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +export function SettingsMenu({ + setSettingsOpen, +}: { setSettingsOpen: (open: boolean) => void }) { + const menuBarTriggerRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(undefined); + + useHotkeys("shift+?", () => { + setMenuOpen(true); + if (menuBarTriggerRef.current) { + menuBarTriggerRef.current.click(); + } + }); + + return ( + + + + + + setMenuOpen(undefined)} + onInteractOutside={() => setMenuOpen(undefined)} + forceMount={menuOpen} + className="z-50 min-w-[200px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md grid gap-1 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2" + > + } + > + Docs + + } + > + GitHub + + } + > + Discord + + + setSettingsOpen(true)} + > +
+ + Settings +
+
+
+
+
+ ); +} + +function MenuItemLink({ + href, + icon, + children, +}: { href: string; icon: React.ReactNode; children: React.ReactNode }) { + return ( + + + {icon} + {children} + + + ); +} diff --git a/studio/src/Layout/Settings/SettingsScreen.tsx b/studio/src/Layout/Settings/SettingsScreen.tsx new file mode 100644 index 000000000..2bfb4e147 --- /dev/null +++ b/studio/src/Layout/Settings/SettingsScreen.tsx @@ -0,0 +1,44 @@ +import { Button } from "@/components/ui/button"; +import { SettingsPage } from "@/pages/SettingsPage"; +import { Icon } from "@iconify/react/dist/iconify.js"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogPortal, + DialogTitle, +} from "@radix-ui/react-dialog"; + +export function SettingsScreen({ + settingsOpen, + setSettingsOpen, +}: { settingsOpen: boolean; setSettingsOpen: (open: boolean) => void }) { + return ( + + + +
+
+
+ Settings + + + +
+
+ + Manage your settings and preferences. + + + +
+
+
+
+
+
+ ); +} diff --git a/studio/src/Layout/Settings/index.tsx b/studio/src/Layout/Settings/index.tsx new file mode 100644 index 000000000..fd7252c22 --- /dev/null +++ b/studio/src/Layout/Settings/index.tsx @@ -0,0 +1,2 @@ +export { SettingsMenu } from "./SettingsMenu"; +export { SettingsScreen } from "./SettingsScreen"; diff --git a/studio/src/Layout/SidePanel/FloatingSidePanel.tsx b/studio/src/Layout/SidePanel/FloatingSidePanel.tsx new file mode 100644 index 000000000..95b92b873 --- /dev/null +++ b/studio/src/Layout/SidePanel/FloatingSidePanel.tsx @@ -0,0 +1,69 @@ +import { Button } from "@/components/ui/button"; +import { useIsLgScreen } from "@/hooks"; +import { + NavigationFrame, + NavigationPanel, +} from "@/pages/RequestorPage/NavigationPanel"; +import { useRequestorStore } from "@/pages/RequestorPage/store"; +import { cn } from "@/utils"; +import { Icon } from "@iconify/react"; +import { + DialogClose, + DialogContent, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, + Root, +} from "@radix-ui/react-dialog"; + +export function FloatingSidePanel() { + const { sidePanel, togglePanel } = useRequestorStore( + "sidePanel", + "togglePanel", + "path", + ); + + const isLgScreen = useIsLgScreen(); + + return ( + + ); +} diff --git a/studio/src/Layout/SidePanel/SidePanelTrigger.tsx b/studio/src/Layout/SidePanel/SidePanelTrigger.tsx new file mode 100644 index 000000000..7df5126bc --- /dev/null +++ b/studio/src/Layout/SidePanel/SidePanelTrigger.tsx @@ -0,0 +1,28 @@ +import { Button } from "@/components/ui/button"; +import { useRequestorStore } from "@/pages/RequestorPage/store"; +import { Icon } from "@iconify/react/dist/iconify.js"; +import { useHotkeys } from "react-hotkeys-hook"; + +export function SidePanelTrigger() { + const { sidePanel, togglePanel } = useRequestorStore( + "sidePanel", + "togglePanel", + ); + + useHotkeys("mod+b", () => { + togglePanel("sidePanel"); + }); + + return ( + + ); +} diff --git a/studio/src/Layout/SidePanel/index.tsx b/studio/src/Layout/SidePanel/index.tsx new file mode 100644 index 000000000..8b022676b --- /dev/null +++ b/studio/src/Layout/SidePanel/index.tsx @@ -0,0 +1,2 @@ +export { SidePanelTrigger } from "./SidePanelTrigger"; +export { FloatingSidePanel } from "./FloatingSidePanel"; diff --git a/studio/src/Layout/index.tsx b/studio/src/Layout/index.tsx new file mode 100644 index 000000000..d312c94fb --- /dev/null +++ b/studio/src/Layout/index.tsx @@ -0,0 +1 @@ +export { Layout } from "./Layout"; diff --git a/studio/src/assets/fp-logo.svg b/studio/src/assets/fp-logo.svg new file mode 100644 index 000000000..6610889f0 --- /dev/null +++ b/studio/src/assets/fp-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/studio/src/components/IconWithNotification.tsx b/studio/src/components/IconWithNotification.tsx new file mode 100644 index 000000000..21d2dc571 --- /dev/null +++ b/studio/src/components/IconWithNotification.tsx @@ -0,0 +1,51 @@ +import { cn } from "@/utils"; +import { Icon, type IconProps } from "@iconify/react"; +import type React from "react"; + +interface IconWithNotificationProps extends IconProps { + notificationColor?: string; + notificationSize?: number; + notificationPosition?: + | "top-right" + | "top-left" + | "bottom-right" + | "bottom-left"; + notificationContent?: string | number; + showNotification?: boolean; +} + +const IconWithNotification: React.FC = ({ + notificationColor = "bg-red-500", + notificationSize = 10, + notificationPosition = "top-right", + notificationContent, + showNotification = true, + ...iconProps +}) => { + return ( +
+ + {showNotification && ( + + )} +
+ ); +}; + +export default IconWithNotification; diff --git a/studio/src/components/KeyboardShortcut.tsx b/studio/src/components/KeyboardShortcut.tsx index 407e1d3b2..87429ab5c 100644 --- a/studio/src/components/KeyboardShortcut.tsx +++ b/studio/src/components/KeyboardShortcut.tsx @@ -2,8 +2,8 @@ export const KeyboardShortcutKey = ({ children, }: { children: React.ReactNode }) => { return ( - + {children} - + ); }; diff --git a/studio/src/components/Timeline/DetailsList/CodeMirrorEditor.tsx b/studio/src/components/Timeline/DetailsList/CodeMirrorEditor.tsx index 332436a9b..f2cf44e13 100644 --- a/studio/src/components/Timeline/DetailsList/CodeMirrorEditor.tsx +++ b/studio/src/components/Timeline/DetailsList/CodeMirrorEditor.tsx @@ -41,10 +41,16 @@ export function CodeMirrorJsonEditor(props: CodeMirrorEditorProps) { height={height} maxHeight={maxHeight} minHeight={minHeight} - extensions={[json()]} + extensions={[EditorView.lineWrapping, json()]} onChange={onChange} theme={[duotoneDark, customTheme]} readOnly={readOnly} + basicSetup={{ + // Turn off searching the input via cmd+g and cmd+f + searchKeymap: false, + // Investigate: Remap the default keymap: https://codemirror.net/docs/ref/#commands.defaultKeymap + // This will allow us to do things like cmd+enter to submit a payload + }} /> ); } @@ -83,7 +89,7 @@ export function CodeMirrorSqlEditor(props: CodeMirrorSqlEditorProps) { minHeight={minHeight} maxHeight={maxHeight} readOnly={readOnly} - extensions={[sql()]} + extensions={[EditorView.lineWrapping, sql()]} onChange={onChange} theme={[duotoneDark, customTheme]} /> diff --git a/studio/src/components/Timeline/DetailsList/KeyValueTableV2/index.ts b/studio/src/components/Timeline/DetailsList/KeyValueTableV2/index.ts index 0dd508022..e7be03241 100644 --- a/studio/src/components/Timeline/DetailsList/KeyValueTableV2/index.ts +++ b/studio/src/components/Timeline/DetailsList/KeyValueTableV2/index.ts @@ -1 +1,2 @@ -export { KeyValueTable, CollapsibleKeyValueTableV2 } from "./KeyValueTable"; +export { KeyValueTable } from "./KeyValueTable"; +export { CollapsibleKeyValueTableV2 } from "./CollapsibleKeyValueTable"; diff --git a/studio/src/components/Timeline/DetailsList/OrphanLog.tsx b/studio/src/components/Timeline/DetailsList/OrphanLog.tsx index e229869c9..558bcaaaf 100644 --- a/studio/src/components/Timeline/DetailsList/OrphanLog.tsx +++ b/studio/src/components/Timeline/DetailsList/OrphanLog.tsx @@ -5,9 +5,9 @@ import { objectHasStack, renderFullLogMessage, } from "@/utils"; -import { useTimelineIcon } from "../hooks"; +import { Icon } from "@iconify/react"; import { SubSectionHeading } from "../shared"; -import { getColorForLevel } from "../utils"; +import { getBgColorForLevel, getTextColorForLevel } from "../utils"; import { StackTrace } from "./StackTrace"; export function OrphanLog({ log }: { log: MizuOrphanLog }) { @@ -27,9 +27,11 @@ export function OrphanLog({ log }: { log: MizuOrphanLog }) { const description = getDescription(message ?? "", log.args); // TODO - Get stack from the span! const stack = objectHasStack(message) ? message.stack : null; - const icon = useTimelineIcon(log, { - colorOverride: getColorForLevel(log.level), - }); + const textColorLevel = getTextColorForLevel(level); + const bgColorLevel = getBgColorForLevel(level); + // const icon = useTimelineIcon(log, { + // colorOverride: getColorForLevel(log.level), + // }); const hasDescription = !!description; @@ -50,16 +52,19 @@ export function OrphanLog({ log }: { log: MizuOrphanLog }) { return (
-
- {icon} +
+ {topContent} {heading} @@ -67,15 +72,15 @@ export function OrphanLog({ log }: { log: MizuOrphanLog }) {
{hasDescription && contentsType === "multi-arg-log" && ( - + )} {hasDescription && contentsType === "json" && ( - + )} {stack && ( -
+
)} diff --git a/studio/src/components/Timeline/DetailsList/TextJsonViewer.tsx b/studio/src/components/Timeline/DetailsList/TextJsonViewer.tsx index 67e804d83..8425422e6 100644 --- a/studio/src/components/Timeline/DetailsList/TextJsonViewer.tsx +++ b/studio/src/components/Timeline/DetailsList/TextJsonViewer.tsx @@ -4,7 +4,7 @@ import { nordTheme } from "@uiw/react-json-view/nord"; export function TextOrJsonViewer({ text, - collapsed, + collapsed = true, textMaxPreviewLength, textMaxPreviewLines, }: { diff --git a/studio/src/components/Timeline/DetailsList/TimelineDetailsList.tsx b/studio/src/components/Timeline/DetailsList/TimelineDetailsList.tsx index 4913b6c80..a80969069 100644 --- a/studio/src/components/Timeline/DetailsList/TimelineDetailsList.tsx +++ b/studio/src/components/Timeline/DetailsList/TimelineDetailsList.tsx @@ -25,11 +25,12 @@ function TimelineListDetailsComponent({ onMouseEnter={() => setHighlightedSpanId(getId(item))} onMouseLeave={() => setHighlightedSpanId(null)} className={cn( - "p-2 max-w-full overflow-hidden", + "max-w-full overflow-hidden", "border-l-2 border-transparent rounded-sm transition-all bg-transparent", "hover:bg-primary/10 hover:border-blue-500 hover:rounded-l-none", "data-[highlighted=true]:bg-primary/10", "relative after:absolute after:bottom-[-4px] after:bg-muted-foreground/30 after:w-full after:h-px last:after:h-0", + "h-full", )} data-highlighted={highlightedSpanId === getId(item)} > diff --git a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareAISpan.tsx b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareAISpan.tsx index 7437fe488..4662c286d 100644 --- a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareAISpan.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareAISpan.tsx @@ -1,11 +1,11 @@ import { Badge } from "@/components/ui/badge"; -import { CF_BINDING_RESULT } from "@/constants"; -import type { OtelSpan } from "@/queries"; +import { CF_BINDING_ERROR, CF_BINDING_RESULT } from "@/constants"; import { getString } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; import { CollapsibleSubSection } from "../../../shared"; import { TextOrJsonViewer } from "../../TextJsonViewer"; -import { CfBindingOverview } from "./shared"; +import { CfBindingOverview, CfResultAndError } from "./shared"; /** * The AI binding, as of writing, only has a `run` method. @@ -16,6 +16,7 @@ export function CloudflareAISpan({ span }: { span: OtelSpan }) { const args = getString(span.attributes.args); const runAiArgs = useCloudflareAiArgs(args); const result = getString(span.attributes[CF_BINDING_RESULT]); + const error = getString(span.attributes[CF_BINDING_ERROR]); return (
@@ -39,9 +40,7 @@ export function CloudflareAISpan({ span }: { span: OtelSpan }) { )} - - - +
); diff --git a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareD1Span.tsx b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareD1Span.tsx index 40d59cda7..e9cdd8026 100644 --- a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareD1Span.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareD1Span.tsx @@ -1,11 +1,11 @@ -import { CF_BINDING_RESULT } from "@/constants"; -import type { OtelSpan } from "@/queries"; +import { CF_BINDING_ERROR, CF_BINDING_RESULT } from "@/constants"; import { type CloudflareD1VendorInfo, getString, noop } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; import { format } from "sql-formatter"; import { CollapsibleSubSection } from "../../../shared"; import { CodeMirrorSqlEditor } from "../../CodeMirrorEditor"; -import { TextOrJsonViewer } from "../../TextJsonViewer"; +import { CfResultAndError } from "./shared"; /** * The D1 span is rendered a little differently. @@ -29,6 +29,7 @@ export function CloudflareD1Span({ }, [vendorInfo]); const result = getString(span.attributes[CF_BINDING_RESULT]); + const error = getString(span.attributes[CF_BINDING_ERROR]); return (
@@ -39,9 +40,7 @@ export function CloudflareD1Span({ readOnly={true} /> - - - +
); } diff --git a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareKVSpan.tsx b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareKVSpan.tsx index 66dddb8d1..db58725df 100644 --- a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareKVSpan.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareKVSpan.tsx @@ -1,6 +1,10 @@ -import { CF_BINDING_METHOD, CF_BINDING_RESULT } from "@/constants"; -import type { OtelSpan } from "@/queries"; +import { + CF_BINDING_ERROR, + CF_BINDING_METHOD, + CF_BINDING_RESULT, +} from "@/constants"; import { getString } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; import { CollapsibleSubSection } from "../../../shared"; import { KeyValueTable } from "../../KeyValueTableV2"; @@ -20,6 +24,7 @@ export function CloudflareKVSpan({ span }: { span: OtelSpan }) { const args = getString(span.attributes.args); const kvArgs = useCloudflareKVArgs(args, method); const result = getString(span.attributes[CF_BINDING_RESULT]); + const error = getString(span.attributes[CF_BINDING_ERROR]); return (
@@ -33,11 +38,22 @@ export function CloudflareKVSpan({ span }: { span: OtelSpan }) {
) : null} + {/* + * NOTE - The result looks bad if we don't add some additional padding. + * That's why we're not using the CfResultAndError component here. + */}
- +
+ {error && ( + +
+ +
+
+ )}
); diff --git a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareR2Span.tsx b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareR2Span.tsx index 8e9432b0b..cc3dd1488 100644 --- a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareR2Span.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareR2Span.tsx @@ -1,10 +1,14 @@ -import { CF_BINDING_METHOD, CF_BINDING_RESULT } from "@/constants"; -import type { OtelSpan } from "@/queries"; +import { + CF_BINDING_ERROR, + CF_BINDING_METHOD, + CF_BINDING_RESULT, +} from "@/constants"; import { getString } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; import { CollapsibleSubSection } from "../../../shared"; import { TextOrJsonViewer } from "../../TextJsonViewer"; -import { CfBindingOverview, KeyBadge } from "./shared"; +import { CfBindingOverview, CfResultAndError, KeyBadge } from "./shared"; /** * Cloudflare R2 has the following methods: @@ -24,6 +28,7 @@ export function CloudflareR2Span({ span }: { span: OtelSpan }) { const args = getString(span.attributes.args); const r2Args = useCloudflareR2Args(args, method); const result = getString(span.attributes[CF_BINDING_RESULT]); + const error = getString(span.attributes[CF_BINDING_ERROR]); return (
@@ -35,9 +40,7 @@ export function CloudflareR2Span({ span }: { span: OtelSpan }) { )} - - - +
); diff --git a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareSpan.tsx b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareSpan.tsx index e8cc08957..1b830871b 100644 --- a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareSpan.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/CloudflareSpan.tsx @@ -1,5 +1,5 @@ -import type { OtelSpan } from "@/queries"; import { type CloudflareVendorInfo, isCloudflareD1VendorInfo } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { CloudflareAISpan } from "./CloudflareAISpan"; import { CloudflareD1Span } from "./CloudflareD1Span"; import { CloudflareKVSpan } from "./CloudflareKVSpan"; diff --git a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/shared.tsx b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/shared.tsx index 73f5b6b55..84a98e41e 100644 --- a/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/shared.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/CloudflareSpan/shared.tsx @@ -1,7 +1,9 @@ +import { CollapsibleSubSection } from "@/components/Timeline/shared"; import { Badge } from "@/components/ui/badge"; import { CF_BINDING_METHOD, CF_BINDING_NAME } from "@/constants"; -import type { OtelSpan } from "@/queries"; import { cn, getString } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; +import { TextOrJsonViewer } from "../../TextJsonViewer"; export function CfBindingOverview({ span, @@ -21,6 +23,29 @@ export function CfBindingOverview({ ); } +export function CfResultAndError({ + result, + error, +}: { + result: string; + error: string; +}) { + return ( + <> + {result && ( + + + + )} + {error && ( + + + + )} + + ); +} + export function KeyBadge({ keyName }: { keyName: string }) { return ( diff --git a/studio/src/components/Timeline/DetailsList/spans/FetchSpan.tsx b/studio/src/components/Timeline/DetailsList/spans/FetchSpan.tsx index c47892671..4c160fc90 100644 --- a/studio/src/components/Timeline/DetailsList/spans/FetchSpan.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/FetchSpan.tsx @@ -1,5 +1,4 @@ import { Status } from "@/components/ui/status"; -import type { OtelSpan } from "@/queries"; import { SENSITIVE_HEADERS, cn, getHttpMethodTextColor, noop } from "@/utils"; import { getRequestBody, @@ -17,6 +16,7 @@ import { isNeonVendorInfo, isOpenAIVendorInfo, } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { ClockIcon } from "@radix-ui/react-icons"; import { useMemo } from "react"; import { format } from "sql-formatter"; @@ -64,7 +64,7 @@ export function FetchSpan({ const url = getRequestUrl(span); const { component, title } = useVendorSpecificSection(vendorInfo) ?? {}; - const icon = useTimelineIcon(span); + const icon = useTimelineIcon(span, { vendorInfo }); return ( )}
- {formatDuration(span.start_time, span.end_time)} + {formatDuration( + span.start_time.toString(), + span.end_time.toString(), + )}
diff --git a/studio/src/components/Timeline/DetailsList/spans/IncomingRequest.tsx b/studio/src/components/Timeline/DetailsList/spans/IncomingRequest.tsx index 512532b40..8ce4e5fd9 100644 --- a/studio/src/components/Timeline/DetailsList/spans/IncomingRequest.tsx +++ b/studio/src/components/Timeline/DetailsList/spans/IncomingRequest.tsx @@ -1,4 +1,3 @@ -import type { OtelSpan } from "@/queries"; import { SENSITIVE_HEADERS, cn, @@ -11,6 +10,7 @@ import { isSensitiveEnvVar, } from "@/utils"; import { getMatchedRoute, getRequestMethod, getRequestUrl } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; import { useTimelineIcon } from "../../hooks"; import { diff --git a/studio/src/components/Timeline/graph/TimelineGraphLog.tsx b/studio/src/components/Timeline/graph/TimelineGraphLog.tsx index 5e36bf574..7bd2bc246 100644 --- a/studio/src/components/Timeline/graph/TimelineGraphLog.tsx +++ b/studio/src/components/Timeline/graph/TimelineGraphLog.tsx @@ -1,8 +1,9 @@ import type { MizuOrphanLog } from "@/queries"; import { cn } from "@/utils"; +import { Icon } from "@iconify/react"; import { useTimelineContext } from "../context"; -import { useTimelineIcon, useTimelineTitle } from "../hooks"; -import { getColorForLevel } from "../utils"; +import { useTimelineTitle } from "../hooks"; +import { getBgColorForLevel, getTextColorForLevel } from "../utils"; export const TimelineGraphLog: React.FC<{ log: MizuOrphanLog; @@ -13,9 +14,8 @@ export const TimelineGraphLog: React.FC<{ const left = ((new Date(log.timestamp).getTime() - startTime) / duration) * 100; const lineOffset = `calc(${left.toFixed(4)}% - ${3 * 0.0625}rem)`; - const icon = useTimelineIcon(log, { - colorOverride: getColorForLevel(log.level), - }); + const colorTextLevel = getTextColorForLevel(log.level); + const colorBgLevel = getBgColorForLevel(log.level); const title = useTimelineTitle(log); const { setHighlightedSpanId, highlightedSpanId } = useTimelineContext(); @@ -29,6 +29,7 @@ export const TimelineGraphLog: React.FC<{ "transition-all", "cursor-pointer", "data-[highlighted=true]:bg-primary/10", + colorBgLevel, )} data-highlighted={highlightedSpanId === `${log.id}`} href={`#${log.id}`} @@ -39,7 +40,9 @@ export const TimelineGraphLog: React.FC<{ : undefined } > -
{icon}
+
+ +
{title} @@ -49,12 +52,11 @@ export const TimelineGraphLog: React.FC<{
-
); }; diff --git a/studio/src/components/Timeline/graph/TimelineGraphSpan.tsx b/studio/src/components/Timeline/graph/TimelineGraphSpan.tsx index 5fa049720..07c1ea71d 100644 --- a/studio/src/components/Timeline/graph/TimelineGraphSpan.tsx +++ b/studio/src/components/Timeline/graph/TimelineGraphSpan.tsx @@ -1,5 +1,5 @@ -import type { OtelSpan } from "@/queries"; import { type VendorInfo, cn } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useTimelineContext } from "../context"; import { useTimelineIcon, useTimelineTitle } from "../hooks"; import { formatDuration } from "../utils"; @@ -56,14 +56,11 @@ export const TimelineGraphSpan: React.FC<{ "h-2.5 border-l-2 border-r-2 border-blue-500 flex items-center min-w-0 absolute", )} style={{ width: lineWidth, marginLeft: lineOffset }} - title={`${span.start_time} - ${span.end_time}`} + title={`duration: ${formatDuration(span.start_time.toString(), span.end_time.toString())}`} >
-
- {formatDuration(span.start_time, span.end_time)} -
); }; diff --git a/studio/src/components/Timeline/hooks/useAsWaterfall.ts b/studio/src/components/Timeline/hooks/useAsWaterfall.ts index 14860206f..d76885fd4 100644 --- a/studio/src/components/Timeline/hooks/useAsWaterfall.ts +++ b/studio/src/components/Timeline/hooks/useAsWaterfall.ts @@ -1,9 +1,10 @@ -import type { MizuOrphanLog, OtelSpan } from "@/queries"; +import type { MizuOrphanLog } from "@/queries"; import { type SpanWithVendorInfo, type Waterfall, getVendorInfo, } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; export function useAsWaterfall( @@ -30,7 +31,7 @@ export function useAsWaterfall( return [...spansWithVendorInfo, ...orphanLogs].sort((a, b) => { const timeA = "span" in a ? a.span.start_time : a.timestamp; const timeB = "span" in b ? b.span.start_time : b.timestamp; - if (timeA === timeB) { + if (timeA.getTime() === timeB.getTime()) { // If the times are the same, we need to sort giving the priority to the root span if ("span" in a && a?.span?.name === "request") { return -1; @@ -38,6 +39,17 @@ export function useAsWaterfall( if ("span" in b && b?.span?.name === "request") { return 1; } + + // If the time stamp is the same, sort on span_id/parent_span_id + // TODO: improve further sorting. + if ("span" in a && "span" in b) { + if (a.span.span_id === b.span.parent_span_id) { + return -1; + } + if (b.span.span_id === a.span.parent_span_id) { + return 1; + } + } } return new Date(timeA).getTime() - new Date(timeB).getTime(); }); diff --git a/studio/src/components/Timeline/hooks/useTimelineIcon.ts b/studio/src/components/Timeline/hooks/useTimelineIcon.ts index 2e665e1e0..f0d61a187 100644 --- a/studio/src/components/Timeline/hooks/useTimelineIcon.ts +++ b/studio/src/components/Timeline/hooks/useTimelineIcon.ts @@ -1,5 +1,6 @@ -import { type MizuOrphanLog, type OtelSpan, isMizuOrphanLog } from "@/queries"; +import { type MizuOrphanLog, isMizuOrphanLog } from "@/queries"; import { type VendorInfo, isCloudflareVendorInfo } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; import { getTypeIcon } from "../utils"; diff --git a/studio/src/components/Timeline/hooks/useTimelineTitle.tsx b/studio/src/components/Timeline/hooks/useTimelineTitle.tsx index a38787bd3..832567e25 100644 --- a/studio/src/components/Timeline/hooks/useTimelineTitle.tsx +++ b/studio/src/components/Timeline/hooks/useTimelineTitle.tsx @@ -115,10 +115,10 @@ export const useTimelineTitle = (waterfallItem: Waterfall[0]) => { const message = waterfallItem.message ? safeParseJson(waterfallItem.message) - : "log"; + : `log.${waterfallItem.level}`; return (
- {typeof message === "string" ? message : "log"} + {typeof message === "string" ? message : `log.${waterfallItem.level}`}
); }, [waterfallItem]); diff --git a/studio/src/components/Timeline/utils.tsx b/studio/src/components/Timeline/utils.tsx index 127f6b62e..3504e040a 100644 --- a/studio/src/components/Timeline/utils.tsx +++ b/studio/src/components/Timeline/utils.tsx @@ -8,8 +8,8 @@ import HonoLogo from "@/assets/HonoLogo.svg"; import NeonLogo from "@/assets/NeonLogo.svg"; import OpenAiLogo from "@/assets/OpenAILogo.svg"; import { CF_BINDING_METHOD, SpanKind } from "@/constants"; -import type { OtelSpan } from "@/queries"; import { type CloudflareVendorInfo, type Waterfall, getString } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { CommitIcon, PaperPlaneIcon, TimerIcon } from "@radix-ui/react-icons"; import { formatDistanceStrict } from "date-fns"; @@ -93,7 +93,22 @@ export const getTypeIcon = (type: string, colorOverride = "") => { } }; -export function getColorForLevel(level: string) { +// NOTE: can't use a generic *-yellow-500 because transparency modifiers are +// resolved before the color is applied and tailwind borks +export function getBgColorForLevel(level: string) { + switch (level) { + case "info": + return "bg-muted/50"; + case "warn": + return "bg-yellow-500/10"; + case "error": + return "bg-red-500/10"; + default: + return "bg-gray-500/10"; + } +} + +export function getTextColorForLevel(level: string) { switch (level) { case "info": return "text-muted-foreground"; diff --git a/studio/src/components/WebhoncBadge/WebhoncBadge.tsx b/studio/src/components/WebhoncBadge/WebhoncBadge.tsx index a36c02158..a2e018dfb 100644 --- a/studio/src/components/WebhoncBadge/WebhoncBadge.tsx +++ b/studio/src/components/WebhoncBadge/WebhoncBadge.tsx @@ -14,13 +14,13 @@ export function WebhoncBadge() { return ( - + diff --git a/studio/src/components/ui/command.tsx b/studio/src/components/ui/command.tsx index dfbd1b6ef..e4cf4f885 100644 --- a/studio/src/components/ui/command.tsx +++ b/studio/src/components/ui/command.tsx @@ -26,7 +26,7 @@ interface CommandDialogProps extends DialogProps {} const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( - + {children} diff --git a/studio/src/components/ui/dialog.tsx b/studio/src/components/ui/dialog.tsx index 9d37060cc..aa2a1b2ca 100644 --- a/studio/src/components/ui/dialog.tsx +++ b/studio/src/components/ui/dialog.tsx @@ -36,7 +36,7 @@ const DialogContent = React.forwardRef< >(({ className, ...props }, ref) => ( -
- - +
)); Table.displayName = "Table"; diff --git a/studio/src/components/ui/tabs.tsx b/studio/src/components/ui/tabs.tsx index 8077b631d..adb00236e 100644 --- a/studio/src/components/ui/tabs.tsx +++ b/studio/src/components/ui/tabs.tsx @@ -46,6 +46,9 @@ const TabsContent = React.forwardRef< className, )} {...props} + // NOTE: this is to avoid the focus ring on the tab content as we often want to + // more explicitly control the focus + tabIndex={-1} /> )); TabsContent.displayName = TabsPrimitive.Content.displayName; diff --git a/studio/src/hooks/index.ts b/studio/src/hooks/index.ts index 513cda55c..adae5a27a 100644 --- a/studio/src/hooks/index.ts +++ b/studio/src/hooks/index.ts @@ -6,3 +6,6 @@ export { useAiEnabled } from "./useAiEnabled.ts"; export { useRealtimeService } from "./useRealtimeService.ts"; export { useWebsocketQueryInvalidation } from "./useWebsocketQueryInvalidation.ts"; export { useCopyToClipboard } from "./useCopyToClipboard.ts"; +export { useKeySequence } from "./useKeySequence.ts"; +export { useInputFocusDetection } from "./useInputFocusDetection.ts"; +export { useLatest } from "./useLatest.ts"; diff --git a/studio/src/hooks/useInputFocusDetection.ts b/studio/src/hooks/useInputFocusDetection.ts new file mode 100644 index 000000000..fca3e968b --- /dev/null +++ b/studio/src/hooks/useInputFocusDetection.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +export function useInputFocusDetection() { + const [isInputFocused, setIsInputFocused] = useState(false); + + useEffect(() => { + const abortController = new AbortController(); + const signal = abortController.signal; + + const handleFocus = (event: FocusEvent) => { + if (event.target instanceof HTMLInputElement) { + setIsInputFocused(true); + } + }; + + const handleBlur = (event: FocusEvent) => { + if (event.target instanceof HTMLInputElement) { + setIsInputFocused(false); + } + }; + + document.addEventListener("focus", handleFocus, { capture: true, signal }); + document.addEventListener("blur", handleBlur, { capture: true, signal }); + + return () => { + abortController.abort(); + }; + }, []); + + const blurActiveInput = () => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLInputElement) { + activeElement.blur(); + } + }; + + return { isInputFocused, blurActiveInput }; +} diff --git a/studio/src/hooks/useKeySequence.ts b/studio/src/hooks/useKeySequence.ts new file mode 100644 index 000000000..3d6e0df00 --- /dev/null +++ b/studio/src/hooks/useKeySequence.ts @@ -0,0 +1,90 @@ +import { useInputFocusDetection } from "@/hooks"; +import { useHandler } from "@fiberplane/hooks"; +import { useCallback, useEffect, useRef } from "react"; +import { useLatest } from "./useLatest"; + +type KeySequenceOptions = { + isEnabled?: boolean; + description?: string; + timeoutMs?: number; +}; + +/** + * Hook that allows you to define a key sequence and execute a callback when it is matched. + * + * @param targetKeySequence - The sequence of keys to match. + * @param onSequenceMatched - The callback to execute when the sequence is matched. + * @param options - Optional configuration options. + * @returns A ref setter that can be used to scope the listener to a specific + * element (default: the key sequence is scoped to the document). + */ +export function useKeySequence( + targetKeySequence: string[], + onSequenceMatched: () => void, + options?: KeySequenceOptions, +) { + const { isEnabled = true, timeoutMs = 2000 } = options ?? {}; + + const { isInputFocused } = useInputFocusDetection(); + + const listenerElementRef = useRef(null); + const currentKeySequenceRef = useRef([]); + const timeoutIdRef = useRef(); + + const onSequenceMatchedRef = useLatest(onSequenceMatched); + + const resetKeySequence = useHandler(() => { + currentKeySequenceRef.current = []; + }); + + const handleKeyPress = useCallback( + (event: Event) => { + if (!(event instanceof KeyboardEvent)) { + return; + } + const domNode = listenerElementRef.current ?? document; + + if (event.target && !domNode.contains(event.target as Node)) { + return; + } + + currentKeySequenceRef.current = [ + ...currentKeySequenceRef.current, + event.key, + ].slice(-targetKeySequence.length); + + if ( + currentKeySequenceRef.current.join("") === targetKeySequence.join("") + ) { + onSequenceMatchedRef.current(); + currentKeySequenceRef.current = []; + } + + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + timeoutIdRef.current = setTimeout(resetKeySequence, timeoutMs); + }, + [targetKeySequence, timeoutMs, resetKeySequence, onSequenceMatchedRef], + ); + + useEffect(() => { + if (!isEnabled || isInputFocused) { + return; + } + + document.addEventListener("keydown", handleKeyPress); + return () => { + document.removeEventListener("keydown", handleKeyPress); + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + }; + }, [isEnabled, isInputFocused, handleKeyPress]); + + const setListenerElement = useHandler((element: HTMLElement | null) => { + listenerElementRef.current = element; + }); + + return setListenerElement; +} diff --git a/studio/src/hooks/useLatest.ts b/studio/src/hooks/useLatest.ts new file mode 100644 index 000000000..2b47f0277 --- /dev/null +++ b/studio/src/hooks/useLatest.ts @@ -0,0 +1,13 @@ +import { useRef } from "react"; + +/** + * Hook that returns the latest value of a variable. + * + * @param value - The value to track. + * @returns An object with a `current` property that holds the latest value. + */ +export function useLatest(value: T): { readonly current: T } { + const ref = useRef(value); + ref.current = value; + return ref; +} diff --git a/studio/src/index.css b/studio/src/index.css index c2e633736..9b95c8115 100644 --- a/studio/src/index.css +++ b/studio/src/index.css @@ -52,10 +52,36 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; overflow: hidden; height: 100%; width: 100%; } + + * { + scrollbar-width: thin; + scrollbar-color: var(--bg-gray-600) transparent; + } + + *::-webkit-scrollbar { + @apply h-3.5 w-3.5; + } + + *::-webkit-scrollbar-track { + @apply rounded-lg; + } + + *::-webkit-scrollbar-thumb { + @apply h-14 rounded-lg border-4 border-solid border-transparent bg-clip-content bg-gray-600; + } + + *::-webkit-scrollbar-thumb:hover { + @apply bg-gray-600; + } + + *::-webkit-scrollbar-corner { + @apply hidden; + } } diff --git a/studio/src/pages/RequestDetailsPage/RequestDetailsPage.tsx b/studio/src/pages/RequestDetailsPage/RequestDetailsPage.tsx index 56fd40b1d..2b38f715b 100644 --- a/studio/src/pages/RequestDetailsPage/RequestDetailsPage.tsx +++ b/studio/src/pages/RequestDetailsPage/RequestDetailsPage.tsx @@ -1,3 +1,4 @@ +import { useHandler } from "@fiberplane/hooks"; import { useParams } from "react-router-dom"; import { EmptyState } from "./EmptyState"; import { RequestDetailsPageV2 } from "./RequestDetailsPageV2"; @@ -12,7 +13,16 @@ export function RequestDetailsPage() { return ; } - return ; + const generateLinkToTrace = useHandler((traceId: string) => { + return `/requests/otel/${traceId}`; + }); + + return ( + + ); } export type TocItem = { diff --git a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2.tsx b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2.tsx index 143d9002e..ed67feb9c 100644 --- a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2.tsx +++ b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2.tsx @@ -8,8 +8,12 @@ import { useOrphanLogs } from "./useOrphanLogs"; export function RequestDetailsPageV2({ traceId, + paginationHidden = false, + generateLinkToTrace, }: { traceId: string; + paginationHidden?: boolean; + generateLinkToTrace: (traceId: string) => string; }) { const { data: traces } = useOtelTraces(); const { data: spans, isPending, error } = useOtelTrace(traceId); @@ -29,7 +33,7 @@ export function RequestDetailsPageV2({ return ""; } - return `/requests/otel/${traces[index].traceId}`; + return generateLinkToTrace(traces[index].traceId); }, }); @@ -53,7 +57,8 @@ export function RequestDetailsPageV2({ traceId={traceId} spans={spans} orphanLogs={orphanLogs} - pagination={pagination} + pagination={paginationHidden ? undefined : pagination} + generateLinkToTrace={generateLinkToTrace} /> ); } diff --git a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2Content.tsx b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2Content.tsx index c03a1978f..bf31dddfd 100644 --- a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2Content.tsx +++ b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/RequestDetailsPageV2Content.tsx @@ -19,8 +19,9 @@ import { ResizablePanelGroup, } from "@/components/ui/resizable"; import type { MizuOrphanLog } from "@/queries"; -import { type OtelSpan, useOtelTraces } from "@/queries/traces-otel"; +import { useOtelTraces } from "@/queries/traces-otel"; import { cn, isMac } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Link } from "react-router-dom"; @@ -40,6 +41,7 @@ export function RequestDetailsPageContentV2({ pagination, spans, orphanLogs = EMPTY_LIST, + generateLinkToTrace, }: { traceId: string; spans: Array; @@ -50,6 +52,7 @@ export function RequestDetailsPageContentV2({ handlePrevTrace: () => void; handleNextTrace: () => void; }; + generateLinkToTrace: (traceId: string) => string; }) { const currentTrace = { traceId, @@ -108,7 +111,7 @@ export function RequestDetailsPageContentV2({ {!isMostRecentTrace && ( Jump to latest diff --git a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useOrphanLogs.ts b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useOrphanLogs.ts index 5ed1ce545..5021ff610 100644 --- a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useOrphanLogs.ts +++ b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useOrphanLogs.ts @@ -1,7 +1,7 @@ -import { type MizuOrphanLog, type OtelSpan, isMizuOrphanLog } from "@/queries"; -import type { OtelEvent } from "@/queries/traces-otel"; +import { type MizuOrphanLog, isMizuOrphanLog } from "@/queries"; import { safeParseJson } from "@/utils"; import { getString } from "@/utils"; +import type { OtelEvent, OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; export function useOrphanLogs(traceId: string, spans: Array) { diff --git a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useRequestWaterfall.ts b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useRequestWaterfall.ts index da04d386a..4342d1edd 100644 --- a/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useRequestWaterfall.ts +++ b/studio/src/pages/RequestDetailsPage/RequestDetailsPageV2/useRequestWaterfall.ts @@ -1,9 +1,10 @@ -import type { MizuOrphanLog, OtelSpan } from "@/queries"; +import type { MizuOrphanLog } from "@/queries"; import { type SpanWithVendorInfo, type Waterfall, getVendorInfo, } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; export function useRequestWaterfall( diff --git a/studio/src/pages/RequestDetailsPage/hooks/useMostRecentRequest.ts b/studio/src/pages/RequestDetailsPage/hooks/useMostRecentRequest.ts index f59034518..30f6b48c6 100644 --- a/studio/src/pages/RequestDetailsPage/hooks/useMostRecentRequest.ts +++ b/studio/src/pages/RequestDetailsPage/hooks/useMostRecentRequest.ts @@ -1,5 +1,5 @@ -import type { OtelTrace } from "@/queries"; import { getRequestPath, isFpxRequestSpan } from "@/utils"; +import type { OtelTrace } from "@fiberplane/fpx-types"; export function useMostRecentRequest( currentTrace: OtelTrace, diff --git a/studio/src/pages/RequestDetailsPage/hooks/useNavigateToList.ts b/studio/src/pages/RequestDetailsPage/hooks/useNavigateToList.ts index 6857a9297..d8fa6e1e8 100644 --- a/studio/src/pages/RequestDetailsPage/hooks/useNavigateToList.ts +++ b/studio/src/pages/RequestDetailsPage/hooks/useNavigateToList.ts @@ -1,45 +1,19 @@ -import { useEffect, useState } from "react"; +import { useInputFocusDetection } from "@/hooks"; import { useHotkeys } from "react-hotkeys-hook"; import { useNavigate } from "react-router-dom"; export function useEscapeToList() { const navigate = useNavigate(); - - const [isInputFocused, setIsInputFocused] = useState(false); + const { isInputFocused, blurActiveInput } = useInputFocusDetection(); useHotkeys(["Escape"], () => { // catch all the cases where the user is in the input field // and we don't want to exit the page if (isInputFocused) { - const activeElement = document.activeElement; - if (activeElement instanceof HTMLInputElement) { - activeElement.blur(); - } + blurActiveInput(); return; } navigate("/requests"); }); - - useEffect(() => { - const handleFocus = (event: FocusEvent) => { - if (event.target instanceof HTMLInputElement) { - setIsInputFocused(true); - } - }; - const handleBlur = (event: FocusEvent) => { - if (event.target instanceof HTMLInputElement) { - setIsInputFocused(false); - } - }; - - // We can use AbortController to remove both event listeners a bit more cleanly - // https://frontendmasters.com/blog/patterns-for-memory-efficient-dom-manipulation/#use-abortcontroller-to-unbind-groups-of-events - document.addEventListener("focus", handleFocus, true); - document.addEventListener("blur", handleBlur, true); - return () => { - document.removeEventListener("focus", handleFocus, true); - document.removeEventListener("blur", handleBlur, true); - }; - }, []); } diff --git a/studio/src/pages/RequestDetailsPage/hooks/useReplayRequest.ts b/studio/src/pages/RequestDetailsPage/hooks/useReplayRequest.ts index 6d32d7dc9..be2969477 100644 --- a/studio/src/pages/RequestDetailsPage/hooks/useReplayRequest.ts +++ b/studio/src/pages/RequestDetailsPage/hooks/useReplayRequest.ts @@ -1,6 +1,4 @@ import { useMakeProxiedRequest } from "@/pages/RequestorPage/queries"; -import { useRequestor } from "@/pages/RequestorPage/reducer"; -import type { OtelSpan } from "@/queries"; import { getRequestBody, getRequestHeaders, @@ -8,9 +6,13 @@ import { getRequestQueryParams, getRequestUrl, } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; +import { useHandler } from "@fiberplane/hooks"; import { useCallback, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; export function useReplayRequest({ span }: { span?: OtelSpan }) { + const navigate = useNavigate(); const method = span ? getRequestMethod(span) : "GET"; const pathWithSearch = useMemo(() => { @@ -73,7 +75,6 @@ export function useReplayRequest({ span }: { span?: OtelSpan }) { return filterReplayHeaders(headers); }, [requestHeaders, filterReplayHeaders]); - const replayBody = useMemo(() => { const body = span ? getRequestBody(span) : undefined; try { @@ -99,52 +100,41 @@ export function useReplayRequest({ span }: { span?: OtelSpan }) { return span ? getRequestQueryParams(span) : null; }, [span]); - const { clearResponseBodyFromHistory, setActiveResponse } = useRequestor(); - - const { mutate: makeRequest, isPending: isReplaying } = useMakeProxiedRequest( - { - clearResponseBodyFromHistory, - setActiveResponse, - }, - ); - - const replay = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - return makeRequest( - { - addServiceUrlIfBarePath: (replayPath) => replayBaseUrl + replayPath, - body: canHaveRequestBody ? replayBody : { type: "text" }, - headers: replayHeaders, - method, - path: replayPath, - queryParams: Object.entries(requestQueryParams ?? {}).map( - ([key, value]) => ({ - id: key, - key, - value, - enabled: true, - }), - ), + const { mutate: makeRequest, isPending: isReplaying } = + useMakeProxiedRequest(); + + const replay = useHandler((e: React.FormEvent) => { + e.preventDefault(); + + return makeRequest( + { + addServiceUrlIfBarePath: (replayPath) => replayBaseUrl + replayPath, + body: canHaveRequestBody ? replayBody : { type: "text" }, + headers: replayHeaders, + method, + path: replayPath, + queryParams: Object.entries(requestQueryParams ?? {}).map( + ([key, value]) => ({ + id: key, + key, + value, + enabled: true, + }), + ), + }, + { + onSuccess(response) { + navigate({ + pathname: `/requests/${response.traceId}`, + search: "?filter-tab=requests", + }); }, - { - onError(error) { - console.error("Error replaying request", error); - }, + onError(error) { + console.error("Error replaying request", error); }, - ); - }, - [ - canHaveRequestBody, - makeRequest, - method, - replayBaseUrl, - replayPath, - replayBody, - replayHeaders, - requestQueryParams, - ], - ); + }, + ); + }); if (!span) { return { diff --git a/studio/src/pages/RequestDetailsPage/hooks/useShouldReplay.ts b/studio/src/pages/RequestDetailsPage/hooks/useShouldReplay.ts index fc45dcfeb..552a11287 100644 --- a/studio/src/pages/RequestDetailsPage/hooks/useShouldReplay.ts +++ b/studio/src/pages/RequestDetailsPage/hooks/useShouldReplay.ts @@ -1,10 +1,10 @@ -import type { OtelTrace } from "@/queries"; import { getRequestHeaders, getRequestMethod, getString, isFpxRequestSpan, } from "@/utils"; +import type { OtelTrace } from "@fiberplane/fpx-types"; export function useShouldReplay(trace: OtelTrace | null): boolean { if (!trace || trace?.spans?.length === 0) { diff --git a/studio/src/pages/RequestDetailsPage/v2/SummaryV2.tsx b/studio/src/pages/RequestDetailsPage/v2/SummaryV2.tsx index 9e0437f4f..04c6d4117 100644 --- a/studio/src/pages/RequestDetailsPage/v2/SummaryV2.tsx +++ b/studio/src/pages/RequestDetailsPage/v2/SummaryV2.tsx @@ -9,7 +9,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { OtelSpan } from "@/queries/traces-otel"; import { cn, getPathWithSearch, @@ -19,6 +18,7 @@ import { getStatusCode, getString, } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { SEMATTRS_EXCEPTION_MESSAGE, SEMATTRS_EXCEPTION_TYPE, @@ -29,11 +29,11 @@ export function SummaryV2({ requestSpan }: { requestSpan: OtelSpan }) { const errors = useMemo( () => requestSpan.events - .filter((event) => event.name === "exception") + ?.filter((event) => event.name === "exception") .map((event) => ({ name: getString(event.attributes[SEMATTRS_EXCEPTION_TYPE]), message: getString(event.attributes[SEMATTRS_EXCEPTION_MESSAGE]), - })), + })) ?? [], [requestSpan], ); const hasErrors = errors.length > 0; diff --git a/studio/src/pages/RequestDetailsPage/v2/TraceDetailsTimeline.tsx b/studio/src/pages/RequestDetailsPage/v2/TraceDetailsTimeline.tsx index 632615ec0..58d4e956f 100644 --- a/studio/src/pages/RequestDetailsPage/v2/TraceDetailsTimeline.tsx +++ b/studio/src/pages/RequestDetailsPage/v2/TraceDetailsTimeline.tsx @@ -3,6 +3,7 @@ import { extractWaterfallTimeStats, } from "@/components/Timeline"; import { type Waterfall, cn } from "@/utils"; +import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; diff --git a/studio/src/pages/RequestorPage/EventsTable.tsx b/studio/src/pages/RequestorPage/EventsTable.tsx index 526148ef2..784bfc880 100644 --- a/studio/src/pages/RequestorPage/EventsTable.tsx +++ b/studio/src/pages/RequestorPage/EventsTable.tsx @@ -1,6 +1,6 @@ import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; -import type { OtelEvent } from "@/queries/traces-otel"; import { getString, truncateWithEllipsis } from "@/utils"; +import type { OtelEvent } from "@fiberplane/fpx-types"; import { useMemo } from "react"; export function EventsTable({ events }: { events?: OtelEvent[] }) { diff --git a/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx b/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx index 2815a153c..9a50362e3 100644 --- a/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx +++ b/studio/src/pages/RequestorPage/FormDataForm/FormDataForm.tsx @@ -73,7 +73,7 @@ const FormDataFormRow = (props: FormDataRowProps) => { placeholder="name" readOnly={!onChangeKey} onChange={(e) => onChangeKey?.(e.target.value)} - className="w-24 h-8 bg-transparent shadow-none px-2 py-0 text-sm border-none" + className="w-28 h-8 bg-transparent shadow-none px-2 py-0 text-sm border-none" /> {value.type === "text" && ( { placeholder="name" readOnly={!onChangeKey} onChange={(e) => onChangeKey?.(e.target.value)} - className="w-24 h-8 bg-transparent shadow-none px-2 py-0 text-sm border-none" + className="w-28 h-8 bg-transparent shadow-none px-2 py-0 text-sm border-none" /> onChangeValue(e.target.value)} - className="h-8 flex-grow bg-transparent shadow-none px-2 py-0 text-sm border-none" + className="h-8 w-full bg-transparent shadow-none px-2 py-0 text-sm border-none" />
+
+
+ +
+

No logs found

+

+ There are currently no logs to display. This could be because no + events have been logged yet, or the traces are not available. +

+
+
+ ); +} diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTable.tsx b/studio/src/pages/RequestorPage/LogsTable/LogsTable.tsx new file mode 100644 index 000000000..40a71132e --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/LogsTable.tsx @@ -0,0 +1,177 @@ +import { + getBgColorForLevel, + getTextColorForLevel, +} from "@/components/Timeline/utils"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useCopyToClipboard } from "@/hooks"; +import type { MizuOrphanLog } from "@/queries"; +import { useOtelTrace } from "@/queries"; +import { cn, safeParseJson } from "@/utils"; +import { CopyIcon, Cross1Icon } from "@radix-ui/react-icons"; +import { Tabs } from "@radix-ui/react-tabs"; +import { useState } from "react"; +import { useOrphanLogs } from "../../RequestDetailsPage/RequestDetailsPageV2/useOrphanLogs"; +import { CustomTabTrigger, CustomTabsContent, CustomTabsList } from "../Tabs"; +import { useRequestorStore } from "../store"; +import { LogsEmptyState } from "./Empty"; + +type Props = { + traceId?: string; +}; + +export function LogsTable({ traceId = "" }: Props) { + const { data: spans } = useOtelTrace(traceId); + const { togglePanel } = useRequestorStore("togglePanel"); + const logs = useOrphanLogs(traceId, spans ?? []); + + return ( + + + Logs ({logs.length}) +
+ +
+
+ + {logs.length === 0 ? ( + + ) : ( +
+ {logs.map((log) => ( + + ))} +
+ )} +
+
+ ); +} + +type LogRowProps = { + log: MizuOrphanLog; +}; + +function LogRow({ log }: LogRowProps) { + const bgColor = getBgColorForLevel(log.level); + const textColor = getTextColorForLevel(log.level); + const [isExpanded, setIsExpanded] = useState(false); + // we don't want the focus ring to be visible when the user is selecting the row with the mouse + const [isMouseSelected, setIsMouseSelected] = useState(false); + const { isCopied, copyToClipboard } = useCopyToClipboard(); + + return ( +
setIsExpanded(e.currentTarget.open)} + onMouseDown={() => setIsMouseSelected(true)} + onBlur={() => setIsMouseSelected(false)} + > + +
+
+ {log.message} +
+
+ {formatTimestamp(log.timestamp)} +
+
+
+ {/* +
+ */} +
+

+ Level: {log.level.toUpperCase()} +

+ {log.service &&

Service: {log.service}

} + {log.callerLocation && ( +

+ Location: {log.callerLocation.file}:{log.callerLocation.line}: + {log.callerLocation.column} +

+ )} + {log.message && ( +
+

Message:

+

+ {safeParseJson(log.message) ? ( +

+                    {JSON.stringify(JSON.parse(log.message), null, 2)}
+                  
+ ) : ( + log.message + )} +

+
+ )} +
+
+ + + + + + +

Message copied

+
+
+
+
+
+
+ ); +} + +function getIconColor(level: MizuOrphanLog["level"]) { + switch (level) { + case "error": + return "bg-red-500"; + case "warn": + return "bg-yellow-500"; + case "info": + return "bg-blue-500"; + case "debug": + return "bg-green-500"; + default: + return "bg-gray-500"; + } +} + +function formatTimestamp(timestamp: Date) { + return timestamp.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/studio/src/pages/RequestorPage/LogsTable/index.tsx b/studio/src/pages/RequestorPage/LogsTable/index.tsx new file mode 100644 index 000000000..a519030c0 --- /dev/null +++ b/studio/src/pages/RequestorPage/LogsTable/index.tsx @@ -0,0 +1 @@ +export { LogsTable } from "./LogsTable"; diff --git a/studio/src/pages/RequestorPage/NavigationPanel/NavigationFrame.tsx b/studio/src/pages/RequestorPage/NavigationPanel/NavigationFrame.tsx new file mode 100644 index 000000000..bb7754cb0 --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/NavigationFrame.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/utils"; +import { BACKGROUND_LAYER } from "../styles"; + +export function NavigationFrame({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/studio/src/pages/RequestorPage/NavigationPanel/NavigationPanel.tsx b/studio/src/pages/RequestorPage/NavigationPanel/NavigationPanel.tsx new file mode 100644 index 000000000..563ab7e13 --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/NavigationPanel.tsx @@ -0,0 +1,57 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useKeySequence } from "@/hooks/useKeySequence"; +import { useHandler } from "@fiberplane/hooks"; +import { useSearchParams } from "react-router-dom"; +import { RequestsPanel } from "./RequestsPanel"; +import { RoutesPanel } from "./RoutesPanel"; + +const FILTER_TAB_KEY = "filter-tab"; +const TAB_KEYS = ["routes", "requests"] as const; +type NavigationTab = (typeof TAB_KEYS)[number]; + +function getTab(searchParams: URLSearchParams): NavigationTab { + const tab = searchParams.get(FILTER_TAB_KEY); + if (tab && TAB_KEYS.includes(tab as NavigationTab)) { + return tab as NavigationTab; + } + + return "routes"; +} + +export function NavigationPanel() { + const [params, setParams] = useSearchParams(); + const tab = getTab(params); + + const setTab = useHandler((newTab: NavigationTab) => { + setParams({ [FILTER_TAB_KEY]: newTab }, { replace: true }); + }); + + useKeySequence(["g", "r"], () => { + setTab("routes"); + }); + useKeySequence(["g", "a"], () => { + setTab("requests"); + }); + + return ( + setTab(tabValue as NavigationTab)} + > + + {TAB_KEYS.map((tabKey) => ( + + {tabKey.charAt(0).toUpperCase() + tabKey.slice(1)} + + ))} + + + + + + + + + ); +} diff --git a/studio/src/pages/RequestorPage/NavigationPanel/RequestsPanel/RequestsPanel.tsx b/studio/src/pages/RequestorPage/NavigationPanel/RequestsPanel/RequestsPanel.tsx new file mode 100644 index 000000000..885a66f4a --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/RequestsPanel/RequestsPanel.tsx @@ -0,0 +1,394 @@ +import { RequestMethod } from "@/components/Timeline"; +import { Status } from "@/components/ui/status"; +import { useInputFocusDetection } from "@/hooks"; +import { useOtelTraces } from "@/queries"; +import { + cn, + getRequestMethod, + getRequestPath, + getRequestUrl, + getStatusCode, +} from "@/utils"; +import type { OtelTrace } from "@fiberplane/fpx-types"; +import { Icon } from "@iconify/react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { + Link, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; +import type { Requestornator } from "../../queries"; +import { useRequestorStore, useServiceBaseUrl } from "../../store"; +import { useRequestorHistory } from "../../useRequestorHistory"; +import { Search } from "../Search"; + +export function RequestsPanel() { + const { history } = useRequestorHistory(); + const { data: traces = [] } = useOtelTraces(); + const items = useMemo(() => mergeLists(history, traces), [history, traces]); + + const [filterValue, setFilterValue] = useState(""); + const filteredItems = useMemo(() => { + return items.filter((item) => { + if (item.type === "request") { + return item.data.app_requests.requestUrl.includes(filterValue); + } + return item.data.spans.some((span) => + getRequestPath(span).includes(filterValue), + ); + }); + }, [items, filterValue]); + + const { activeHistoryResponseTraceId } = useRequestorStore( + "activeHistoryResponseTraceId", + ); + const { id = activeHistoryResponseTraceId } = useParams(); + + const activeIndex = useMemo(() => { + return filteredItems.findIndex((item) => getId(item) === id); + }, [filteredItems, id]); + + const [selectedItemId, setSelectedItemId] = useState(() => { + return activeIndex !== -1 ? getId(filteredItems[activeIndex]) : null; + }); + + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const searchRef = useRef(null); + + const handleItemSelect = useCallback( + (item: MergedListItem) => { + navigate({ + pathname: `/${item.type}/${getId(item)}`, + search: searchParams.toString(), + }); + }, + [navigate, searchParams], + ); + + const { isInputFocused, blurActiveInput } = useInputFocusDetection(); + + const getNextItemIndex = (currentIndex: number, direction: 1 | -1) => { + let nextIndex = currentIndex + direction; + if (nextIndex < 0) { + nextIndex = filteredItems.length - 1; + } else if (nextIndex >= filteredItems.length) { + nextIndex = 0; + } + return nextIndex; + }; + + useHotkeys(["j", "k", "/"], (event) => { + event.preventDefault(); + switch (event.key) { + case "j": + setSelectedItemId((prevId) => { + const currentIndex = filteredItems.findIndex( + (item) => getId(item) === prevId, + ); + const nextIndex = getNextItemIndex(currentIndex, 1); + return getId(filteredItems[nextIndex]); + }); + break; + case "k": + setSelectedItemId((prevId) => { + const currentIndex = filteredItems.findIndex( + (item) => getId(item) === prevId, + ); + const nextIndex = getNextItemIndex(currentIndex, -1); + return getId(filteredItems[nextIndex]); + }); + break; + case "/": { + if (searchRef.current) { + searchRef.current.focus(); + setSelectedItemId(null); + } + break; + } + } + }); + + useHotkeys( + ["Escape", "Enter"], + (event) => { + switch (event.key) { + case "Enter": { + if (isInputFocused && filteredItems.length > 0) { + setSelectedItemId(getId(filteredItems[0])); + const firstItemElement = document.getElementById( + `item-${getId(filteredItems[0])}`, + ); + if (firstItemElement) { + firstItemElement.focus(); + } + break; + } + + if (selectedItemId !== null) { + const selectedItem = filteredItems.find( + (item) => getId(item) === selectedItemId, + ); + if (selectedItem) { + handleItemSelect(selectedItem); + } + } + break; + } + + case "Escape": { + if (isInputFocused) { + blurActiveInput(); + break; + } + if (filterValue) { + setFilterValue(""); + break; + } + + setSelectedItemId(id); + break; + } + } + }, + { enableOnFormTags: ["input"] }, + ); + + return ( +
+
+
+ { + setSelectedItemId(null); + }} + placeholder="requests" + onItemSelect={() => {}} + itemCount={filteredItems.length} + /> +
+
+
+ {filteredItems.length === 0 && } + {filteredItems.map((item) => ( + handleItemSelect(item)} + searchParams={searchParams} + /> + ))} +
+
+ ); +} + +function EmptyState() { + return ( +
+
+
+ +
+

No requests recorded

+
+
    +
  1. + 1. Make sure your app is running and connected to the Fiberplane + Studio using the client library +
  2. +
  3. + 2. Send an API request to one your app's endpoints +
  4. +
  5. 3. Requests will appear here automatically
  6. +
+

+ If requests are still not appearing: +

+ +
+
+
+ ); +} + +type NavItemProps = { + item: MergedListItem; + isSelected: boolean; + onSelect: () => void; + searchParams: URLSearchParams; +}; + +const NavItem = memo( + ({ item, isSelected, onSelect, searchParams }: NavItemProps) => { + const { activeHistoryResponseTraceId } = useRequestorStore( + "activeHistoryResponseTraceId", + ); + const { id = activeHistoryResponseTraceId } = useParams(); + const itemRef = useRef(null); + + useEffect(() => { + if (isSelected && itemRef.current) { + itemRef.current.focus(); + } + }, [isSelected]); + + return ( + { + e.preventDefault(); + onSelect(); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + onSelect(); + } + }} + data-state-active={id === getId(item)} + data-state-selected={isSelected} + id={`item-${getId(item)}`} + > +
+ +
+
+ +
+
+ +
+ + ); + }, +); + +const getId = (item: MergedListItem) => { + return item.type === "request" + ? item.data.app_responses.traceId + : item.data.traceId; +}; + +function getSpan(trace: OtelTrace) { + return ( + trace.spans.find( + // (item) => item.span.parent_span_id === null, + (item) => item.name === "request", + ) || trace.spans[0] + ); +} + +const PathCell = ({ item }: { item: MergedListItem }) => { + const { removeServiceUrlFromPath } = useServiceBaseUrl(); + const path = + item.type === "request" + ? removeServiceUrlFromPath(item.data.app_requests.requestUrl) + : removeServiceUrlFromPath(getRequestUrl(getSpan(item.data))); + + return ( +
+ {path} +
+ ); +}; + +const StatusCell = ({ item }: { item: MergedListItem }) => { + const code = + item.type === "request" + ? Number.parseInt(item.data.app_responses.responseStatusCode) + : getStatusCode(getSpan(item.data)); + return ; +}; + +const MethodCell = ({ item }: { item: MergedListItem }) => { + const method = + item.type === "request" + ? item.data.app_requests.requestMethod + : getRequestMethod(getSpan(item.data)); + return ; +}; + +type MergedListItem = + | { + type: "request"; + data: Requestornator; + } + | { + type: "history"; + data: OtelTrace; + }; + +// Combine the history with traces by creating a new list that contains the history as well +// as traces that are not in the history. The new list should be sorted by the timestamp of the request. +// and contain a type property to distinguish between history and traces +function mergeLists( + history: Requestornator[], + traces: OtelTrace[], +): Array { + const mergedList: MergedListItem[] = [ + ...history.map((item): MergedListItem => ({ type: "request", data: item })), + ...traces + .filter( + (trace) => + !history.some((h) => h.app_responses.traceId === trace.traceId), + ) + .map((item): MergedListItem => { + return { type: "history", data: item }; + }), + ]; + + return mergedList.sort((a, b) => { + const aTime = + a.type === "request" + ? new Date(a.data.app_requests.updatedAt).getTime() + : new Date(a.data.spans[0].start_time).getTime(); + const bTime = + b.type === "request" + ? new Date(b.data.app_requests.updatedAt).getTime() + : new Date(b.data.spans[0].start_time).getTime(); + return bTime - aTime; // Sort in descending order (most recent first) + }); +} diff --git a/studio/src/pages/RequestorPage/NavigationPanel/RequestsPanel/index.ts b/studio/src/pages/RequestorPage/NavigationPanel/RequestsPanel/index.ts new file mode 100644 index 000000000..9ea7788b9 --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/RequestsPanel/index.ts @@ -0,0 +1 @@ +export { RequestsPanel } from "./RequestsPanel"; diff --git a/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx new file mode 100644 index 000000000..3542fea15 --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesItem.tsx @@ -0,0 +1,86 @@ +import { cn, getHttpMethodTextColor } from "@/utils"; +import { TrashIcon } from "@radix-ui/react-icons"; +import { memo, useEffect, useRef } from "react"; +import { useDeleteRoute } from "../../queries"; +import { type ProbedRoute, isWsRequest } from "../../types"; + +type RoutesItemProps = { + index: number; + route: ProbedRoute; + activeRoute: ProbedRoute | null; + selectedRoute: ProbedRoute | null; + handleRouteClick: (route: ProbedRoute) => void; + setSelectedRouteIndex: (index: number | null) => void; +}; + +export const RoutesItem = memo(function RoutesItem(props: RoutesItemProps) { + const { + index, + route, + activeRoute, + selectedRoute, + handleRouteClick, + setSelectedRouteIndex, + } = props; + const { mutate: deleteRoute } = useDeleteRoute(); + const canDeleteRoute = + route.routeOrigin === "custom" || + !route.currentlyRegistered || + route.routeOrigin === "open_api"; + + const method = isWsRequest(route.requestType) ? "WS" : route.method; + const isSelected = selectedRoute === route; + const isActive = activeRoute === route; + const buttonRef = useRef(null); + + useEffect(() => { + if (isSelected && buttonRef.current) { + buttonRef.current.focus(); + } + }, [isSelected]); + + return ( + + ); +}); diff --git a/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesPanel.tsx b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesPanel.tsx new file mode 100644 index 000000000..6ff3bfede --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/RoutesPanel.tsx @@ -0,0 +1,293 @@ +import { cn } from "@/utils"; +import { useHandler } from "@fiberplane/hooks"; +import { Icon } from "@iconify/react"; +import { useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useNavigate } from "react-router-dom"; +import { AddRouteButton } from "../../routes"; +import { useRequestorStore } from "../../store"; +import type { ProbedRoute } from "../../types"; +import { Search } from "../Search"; +import { RoutesItem } from "./RoutesItem"; + +export function RoutesPanel() { + const { routes, activeRoute, setActiveRoute } = useRequestorStore( + "routes", + "activeRoute", + "setActiveRoute", + ); + + const navigate = useNavigate(); + + const handleRouteClick = useHandler((route: ProbedRoute) => { + navigate( + { + pathname: "/", + }, + { replace: true }, + ); + setActiveRoute(route); + }); + + const hasAnyUserAddedRoutes = useMemo(() => { + return routes.some((r) => r.routeOrigin === "custom" && !r.isDraft); + }, [routes]); + + const hasAnyOpenApiRoutes = useMemo(() => { + return routes.some((r) => r.routeOrigin === "open_api"); + }, [routes]); + + const [filterValue, setFilterValue] = useState(""); + const filteredRoutes = useMemo(() => { + const cleanFilter = filterValue.trim().toLowerCase(); + if (cleanFilter.length < 3) { + return routes; + } + return routes.filter((r) => r.path.toLowerCase().includes(cleanFilter)); + }, [filterValue, routes]); + + const detectedRoutes = useMemo(() => { + const detected = filteredRoutes.filter( + (r) => r.routeOrigin === "discovered" && r.currentlyRegistered, + ); + // NOTE - This preserves the order the routes were registered in the Hono api + detected.sort((a, b) => a.registrationOrder - b.registrationOrder); + return detected; + }, [filteredRoutes]); + + const openApiRoutes = useMemo(() => { + return filteredRoutes.filter((r) => r.routeOrigin === "open_api"); + }, [filteredRoutes]); + + const userAddedRoutes = useMemo(() => { + return filteredRoutes.filter( + (r) => r.routeOrigin === "custom" && !r.isDraft, + ); + }, [filteredRoutes]); + + const allRoutes = useMemo(() => { + return [...userAddedRoutes, ...detectedRoutes, ...openApiRoutes]; + }, [userAddedRoutes, detectedRoutes, openApiRoutes]); + + const activeRouteIndex = useMemo(() => { + return allRoutes.findIndex( + (r) => r.path === activeRoute?.path && r.method === activeRoute.method, + ); + }, [allRoutes, activeRoute]); + + const [selectedRouteIndex, setSelectedRouteIndex] = useState( + null, + ); + + const getNextRouteIndex = (currentIndex: number, direction: 1 | -1) => { + let nextIndex = currentIndex + direction; + if (nextIndex < 0) { + nextIndex = allRoutes.length - 1; + } else if (nextIndex >= allRoutes.length) { + nextIndex = 0; + } + return nextIndex; + }; + + const searchRef = useRef(null); + + useHotkeys(["j", "k", "/"], (event) => { + event.preventDefault(); + switch (event.key) { + case "j": + setSelectedRouteIndex((prevIndex) => + getNextRouteIndex(prevIndex ?? activeRouteIndex, 1), + ); + break; + case "k": + setSelectedRouteIndex((prevIndex) => + getNextRouteIndex(prevIndex ?? activeRouteIndex, -1), + ); + break; + + case "/": { + if (searchRef.current) { + searchRef.current.focus(); + setSelectedRouteIndex(null); + } + break; + } + } + }); + + const handleItemSelect = (index: number) => { + if (allRoutes[index]) { + handleRouteClick(allRoutes[index]); + } + }; + + return ( +
+
+
+ { + setSelectedRouteIndex(null); + }} + placeholder="routes" + onItemSelect={handleItemSelect} + itemCount={allRoutes.length} + /> + +
+
+
+ {hasAnyUserAddedRoutes && ( + + {userAddedRoutes.map((route, index) => ( + + ))} + + )} + + + {detectedRoutes.map((route, index) => ( + + ))} + + + {hasAnyOpenApiRoutes && ( + + {openApiRoutes.map((route, index) => ( + + ))} + + )} + {allRoutes.length === 0 && } +
+
+ ); +} + +function EmptyState() { + return ( +
+
+
+ +
+

No routes detected

+
+

+ To enable route auto-detection: +

+
    +
  1. + 1. Install and add the client library: + + npm i @fiberplane/hono-otel + + Read more about using the client library on the{" "} + + docs + +
  2. +
  3. + 2. Set FPX_ENDPOINT environment variable to: + + http://localhost:8788/v1/traces + + in the .dev.vars file in your project +
  4. +
  5. + 3. Restart your application and Fiberplane Studio +
  6. +
+

+ If routes are still not detected: +

+ +

+ Or you can simply add a route manually by clicking the + button +

+
+
+
+ ); +} + +type RoutesSectionProps = { + title: string; + children: React.ReactNode; +}; + +export function RoutesSection(props: RoutesSectionProps) { + const { title, children } = props; + + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} diff --git a/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/index.tsx b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/index.tsx new file mode 100644 index 000000000..55a5ab222 --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/RoutesPanel/index.tsx @@ -0,0 +1,2 @@ +export { RoutesPanel } from "./RoutesPanel"; +export { RoutesItem } from "./RoutesItem"; diff --git a/studio/src/pages/RequestorPage/NavigationPanel/Search.tsx b/studio/src/pages/RequestorPage/NavigationPanel/Search.tsx new file mode 100644 index 000000000..ad70bc9e3 --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/Search.tsx @@ -0,0 +1,85 @@ +import { KeyboardShortcutKey } from "@/components/KeyboardShortcut"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/utils"; +import { forwardRef, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +type SearchProps = { + value: string; + onChange: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + onItemSelect: (index: number) => void; + itemCount: number; +}; + +export const Search = forwardRef( + ( + { value, onChange, onFocus, onBlur, placeholder, onItemSelect, itemCount }, + ref, + ) => { + const [selectedIndex, setSelectedIndex] = useState(null); + const inputRef = useRef(null); + + const isSearchInputFocused = document.activeElement === inputRef.current; + + useHotkeys( + ["Enter", "Escape"], + (event) => { + switch (event.key) { + case "Enter": + if (isSearchInputFocused && itemCount > 0) { + setSelectedIndex(0); + onItemSelect(0); + } else if (selectedIndex !== null) { + onItemSelect(selectedIndex); + } + break; + case "Escape": + if (isSearchInputFocused) { + inputRef.current?.blur(); + } else if (value) { + onChange(""); + } else { + setSelectedIndex(null); + } + break; + } + }, + { enableOnFormTags: ["input"] }, + ); + + return ( +
+ { + inputRef.current = node; + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + className={cn("peer", "text-sm", "pl-24 focus:pl-2", value && "pl-2")} + value={value} + onChange={(e) => onChange(e.target.value)} + onFocus={onFocus} + onBlur={onBlur} + /> + {!value && ( +
+ Type + / + + to search {placeholder} + +
+ )} +
+ ); + }, +); + +Search.displayName = "Search"; diff --git a/studio/src/pages/RequestorPage/NavigationPanel/index.ts b/studio/src/pages/RequestorPage/NavigationPanel/index.ts new file mode 100644 index 000000000..aaa27f759 --- /dev/null +++ b/studio/src/pages/RequestorPage/NavigationPanel/index.ts @@ -0,0 +1,4 @@ +// In the future we'll have a navigation panel (which will render the RoutesPanel) +export { RoutesItem } from "./RoutesPanel"; +export { NavigationPanel } from "./NavigationPanel"; +export { NavigationFrame } from "./NavigationFrame"; diff --git a/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/BottomToolbar.tsx b/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/BottomToolbar.tsx index df0fdf520..5531b5051 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/BottomToolbar.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/BottomToolbar.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/utils"; import { CopyAsCurl, type CopyAsCurlProps } from "./CopyAsCurl"; import { RequestBodyTypeDropdown, @@ -18,7 +19,12 @@ export function BottomToolbar({ const isDropdownDisabled = method === "GET" || method === "HEAD"; return ( -
+
{isCopied ? ( - + ) : ( - + )} diff --git a/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/RequestBodyCombobox.tsx b/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/RequestBodyCombobox.tsx index 16ab4ae0d..f7447f399 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/RequestBodyCombobox.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/RequestBodyCombobox.tsx @@ -19,7 +19,7 @@ import { import { cn } from "@/utils"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { useMemo, useState } from "react"; -import type { RequestBodyType, RequestorBody } from "../../reducer"; +import type { RequestBodyType, RequestorBody } from "../../store"; type RequestBodyTypeOption = { value: RequestBodyType; @@ -64,10 +64,10 @@ export function RequestBodyTypeDropdown({ variant="secondary" role="combobox" aria-expanded={open} - className="pl-3 disabled:pointer-events-auto" + className="pl-3 disabled:pointer-events-auto text-xs h-6" disabled={isDisabled} > - + {bodyTypeLabel} @@ -101,6 +101,7 @@ export function RequestBodyTypeDropdown({ { handleRequestBodyTypeChange( currentValue as RequestBodyType, diff --git a/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/utils.ts b/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/utils.ts index 493582a02..507b7ca18 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/utils.ts +++ b/studio/src/pages/RequestorPage/RequestPanel/BottomToolbar/utils.ts @@ -1,4 +1,4 @@ -import type { RequestorState } from "../../reducer"; +import type { RequestorState } from "../../store"; /** * Get the value of the request body, if any. It doesn't support multi-part diff --git a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx index d75fc9eaa..dce0feb91 100644 --- a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx +++ b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx @@ -3,17 +3,12 @@ import { Tabs } from "@/components/ui/tabs"; import { useToast } from "@/components/ui/use-toast"; import { cn } from "@/utils"; import { EraserIcon, InfoCircledIcon } from "@radix-ui/react-icons"; -import type { Dispatch, SetStateAction } from "react"; +import { type Dispatch, type SetStateAction, memo } from "react"; import { FormDataForm } from "../FormDataForm"; -import { KeyValueForm, type KeyValueParameter } from "../KeyValueForm"; +import { KeyValueForm } from "../KeyValueForm"; import { CustomTabTrigger, CustomTabsContent, CustomTabsList } from "../Tabs"; import type { AiTestingPersona } from "../ai"; -import type { - RequestBodyType, - RequestorBody, - RequestsPanelTab, -} from "../reducer"; -import type { RequestMethod } from "../types"; +import type { RequestorBody, RequestsPanelTab } from "../store"; import type { WebSocketState } from "../useMakeWebsocketRequest"; import { AiDropDownMenu } from "./AiDropDownMenu"; import { AIGeneratedInputsBanner } from "./AiGeneratedInputsBanner"; @@ -22,29 +17,9 @@ import { FileUploadForm } from "./FileUploadForm"; import { PathParamForm } from "./PathParamForm"; import "./styles.css"; import { CodeMirrorJsonEditor } from "@/components/Timeline"; +import { useRequestorStore } from "../store"; type RequestPanelProps = { - activeRequestsPanelTab: RequestsPanelTab; - setActiveRequestsPanelTab: (tab: string) => void; - method: RequestMethod; - path: string; - shouldShowRequestTab: (tab: RequestsPanelTab) => boolean; - body: RequestorBody; - // FIXME - setBody: (body: undefined | string | RequestorBody) => void; - handleRequestBodyTypeChange: ( - contentType: RequestBodyType, - isMultipart?: boolean, - ) => void; - pathParams: KeyValueParameter[]; - queryParams: KeyValueParameter[]; - setPathParams: (params: KeyValueParameter[]) => void; - clearPathParams: () => void; - setQueryParams: (params: KeyValueParameter[]) => void; - setRequestHeaders: (headers: KeyValueParameter[]) => void; - requestHeaders: KeyValueParameter[]; - websocketMessage: string; - setWebsocketMessage: (message: string | undefined) => void; aiEnabled: boolean; isLoadingParameters: boolean; fillInRequest: () => void; @@ -57,25 +32,10 @@ type RequestPanelProps = { sendWebsocketMessage: (message: string) => void; }; -export function RequestPanel(props: RequestPanelProps) { +export const RequestPanel = memo(function RequestPanel( + props: RequestPanelProps, +) { const { - handleRequestBodyTypeChange, - activeRequestsPanelTab, - setActiveRequestsPanelTab, - shouldShowRequestTab, - body, - path, - method, - setBody, - pathParams, - queryParams, - requestHeaders, - setPathParams, - clearPathParams, - setQueryParams, - setRequestHeaders, - websocketMessage, - setWebsocketMessage, aiEnabled, isLoadingParameters, fillInRequest, @@ -88,8 +48,49 @@ export function RequestPanel(props: RequestPanelProps) { sendWebsocketMessage, } = props; + const { + path, + body, + method, + setBody, + pathParams, + queryParams, + requestHeaders, + setRequestHeaders, + setQueryParams, + setPathParams, + clearPathParams, + handleRequestBodyTypeChange, + activeRequestsPanelTab, + setActiveRequestsPanelTab, + websocketMessage, + setWebsocketMessage, + visibleRequestsPanelTabs, + } = useRequestorStore( + "path", + "body", + "method", + "setBody", + "pathParams", + "queryParams", + "requestHeaders", + "setRequestHeaders", + "setQueryParams", + "setPathParams", + "clearPathParams", + "handleRequestBodyTypeChange", + "activeRequestsPanelTab", + "setActiveRequestsPanelTab", + "websocketMessage", + "setWebsocketMessage", + "visibleRequestsPanelTabs", + ); const { toast } = useToast(); + const shouldShowRequestTab = (tab: RequestsPanelTab): boolean => { + return visibleRequestsPanelTabs.includes(tab); + }; + const shouldShowBody = shouldShowRequestTab("body"); const shouldShowMessages = shouldShowRequestTab("messages"); @@ -161,7 +162,7 @@ export function RequestPanel(props: RequestPanelProps) { setIgnoreAiInputsBanner={setIgnoreAiInputsBanner} /> { setQueryParams([]); }} @@ -175,7 +176,7 @@ export function RequestPanel(props: RequestPanelProps) { {pathParams.length > 0 ? ( <> @@ -309,7 +310,7 @@ export function RequestPanel(props: RequestPanelProps) { /> ); -} +}); type PanelSectionHeaderProps = { title: string; @@ -327,7 +328,7 @@ export function PanelSectionHeader({ return (
@@ -336,15 +337,17 @@ export function PanelSectionHeader({ {children} {handleClearData && ( - + )}
); diff --git a/studio/src/pages/RequestorPage/RequestorHistory.tsx b/studio/src/pages/RequestorPage/RequestorHistory.tsx index 1dd82aee4..5e3893068 100644 --- a/studio/src/pages/RequestorPage/RequestorHistory.tsx +++ b/studio/src/pages/RequestorPage/RequestorHistory.tsx @@ -5,18 +5,18 @@ import { truncatePathWithEllipsis, } from "@/utils"; import type { Requestornator } from "./queries"; +import { useServiceBaseUrl } from "./store"; type RequestorHistoryProps = { history: Array; loadHistoricalRequest: (traceId: string) => void; - removeServiceUrlFromPath: (path: string) => string; }; export function RequestorHistory({ history, loadHistoricalRequest, - removeServiceUrlFromPath, }: RequestorHistoryProps) { + const { removeServiceUrlFromPath } = useServiceBaseUrl(); return ( <> {!history.length && ( @@ -149,6 +149,7 @@ export function StatusCode({ "rounded-md", "px-2", "py-1", + "text-xs", "bg-opacity-30", "font-sans", isGreen && ["text-green-400", "bg-green-800"], diff --git a/studio/src/pages/RequestorPage/RequestorInput.tsx b/studio/src/pages/RequestorPage/RequestorInput.tsx index 2d710ac0d..8a3cb0d4d 100644 --- a/studio/src/pages/RequestorPage/RequestorInput.tsx +++ b/studio/src/pages/RequestorPage/RequestorInput.tsx @@ -8,61 +8,59 @@ import { } from "@/components/ui/tooltip"; import { useToast } from "@/components/ui/use-toast"; import { cn, isMac } from "@/utils"; -import { - FilePlusIcon, - MixerHorizontalIcon, - TriangleRightIcon, -} from "@radix-ui/react-icons"; -import { useCallback, useMemo } from "react"; +import { useHandler } from "@fiberplane/hooks"; +import { Icon } from "@iconify/react"; +import { FilePlusIcon, MixerHorizontalIcon } from "@radix-ui/react-icons"; import { useHotkeys } from "react-hotkeys-hook"; +import { useShallow } from "zustand/react/shallow"; import { RequestMethodCombobox } from "./RequestMethodCombobox"; import { useAddRoutes } from "./queries"; import { - type RequestMethod, - type RequestMethodInputValue, - type RequestType, - isWsRequest, -} from "./types"; + useActiveRoute, + useRequestorStore, + useRequestorStoreRaw, +} from "./store"; +import { isWsRequest } from "./types"; import type { WebSocketState } from "./useMakeWebsocketRequest"; type RequestInputProps = { - method: RequestMethod; - handleMethodChange: (method: RequestMethodInputValue) => void; - path?: string; - handlePathInputChange: (newPath: string) => void; onSubmit: (e: React.FormEvent) => void; isRequestorRequesting?: boolean; formRef: React.RefObject; - requestType: RequestType; websocketState: WebSocketState; disconnectWebsocket: () => void; - getIsInDraftMode: () => boolean; }; export function RequestorInput({ - getIsInDraftMode, - method, - handleMethodChange, - path, - handlePathInputChange, onSubmit, isRequestorRequesting, - requestType, formRef, websocketState, disconnectWebsocket, }: RequestInputProps) { const { toast } = useToast(); + const { requestType } = useActiveRoute(); + + const { + method, + path, + updatePath: handlePathInputChange, + updateMethod: handleMethodChange, + } = useRequestorStore("method", "path", "updatePath", "updateMethod"); + + // Use the low level store hook to get whether we are in draft mode + const isInDraftMode = useRequestorStoreRaw( + useShallow(({ activeRoute }) => !activeRoute), + ); + const isWsConnected = websocketState.isConnected; const { mutate: addRoutes } = useAddRoutes(); - const canSaveDraftRoute = useMemo(() => { - return !!path && getIsInDraftMode(); - }, [path, getIsInDraftMode]); + const canSaveDraftRoute = !!path && isInDraftMode; - const handleAddRoute = useCallback(() => { + const handleAddRoute = useHandler(() => { if (canSaveDraftRoute) { addRoutes({ method: requestType === "websocket" ? "GET" : method, @@ -76,11 +74,11 @@ export function RequestorInput({ description: "Added new route", }); } - }, [addRoutes, canSaveDraftRoute, method, path, requestType, toast]); + }); useHotkeys("mod+s", handleAddRoute, { enableOnFormTags: ["INPUT"], - preventDefault: getIsInDraftMode(), + preventDefault: isInDraftMode, }); return ( @@ -101,10 +99,10 @@ export function RequestorInput({ onChange={(e) => { handlePathInputChange(e.target.value); }} - className="flex-grow w-full bg-transparent font-mono border-none shadow-none focus:ring-0 ml-0" + className="flex-grow text-xs w-full bg-transparent font-mono border-none shadow-none focus:ring-0 ml-0" />
-
+
{canSaveDraftRoute && ( @@ -154,7 +152,7 @@ export function RequestorInput({ }} disabled={isRequestorRequesting} variant={isWsConnected ? "destructive" : "default"} - className={cn("p-2 md:p-2.5")} + className={cn("p-2 md:px-2.5 py-1 h-auto")} > {isWsRequest(requestType) @@ -166,7 +164,10 @@ export function RequestorInput({ {isWsRequest(requestType) ? ( ) : ( - + )} diff --git a/studio/src/pages/RequestorPage/RequestorPage.tsx b/studio/src/pages/RequestorPage/RequestorPage.tsx index 0b401ba1f..fb63fe0d5 100644 --- a/studio/src/pages/RequestorPage/RequestorPage.tsx +++ b/studio/src/pages/RequestorPage/RequestorPage.tsx @@ -7,25 +7,16 @@ import { ResizablePanelGroup, usePanelConstraints, } from "@/components/ui/resizable"; -import { useToast } from "@/components/ui/use-toast"; -import { useIsLgScreen, useIsSmScreen } from "@/hooks"; +import { useIsLgScreen } from "@/hooks"; import { cn } from "@/utils"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { RequestPanel } from "./RequestPanel"; -import { RequestorInput } from "./RequestorInput"; -import { ResponsePanel } from "./ResponsePanel"; -import { RoutesCombobox } from "./RoutesCombobox"; -import { RoutesPanel } from "./RoutesPanel"; -import { AiTestGenerationPanel, useAi } from "./ai"; -import { type Requestornator, useMakeProxiedRequest } from "./queries"; -import { useRequestor } from "./reducer"; +import { useCallback, useEffect } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; +import { RequestDetailsPageV2 } from "../RequestDetailsPage/RequestDetailsPageV2"; +import { NavigationFrame, NavigationPanel } from "./NavigationPanel"; +import { RequestorPageContent } from "./RequestorPageContent"; import { useRoutes } from "./routes"; -import { BACKGROUND_LAYER } from "./styles"; -import { useMakeWebsocketRequest } from "./useMakeWebsocketRequest"; +import { useRequestorStore } from "./store"; import { useRequestorHistory } from "./useRequestorHistory"; -import { useRequestorSubmitHandler } from "./useRequestorSubmitHandler"; -import { sortRequestornatorsDescending } from "./utils"; /** * Estimate the size of the main section based on the window width @@ -35,69 +26,11 @@ function getMainSectionWidth() { } export const RequestorPage = () => { - const { toast } = useToast(); - - const requestorState = useRequestor(); - // @ts-expect-error - This is helpful for debugging, soz - globalThis.requestorState = requestorState; - const { - // Routes panel - state: { routes }, - setRoutes, - setServiceBaseUrl, - selectRoute: handleSelectRoute, // TODO - Rename, just not sure to what - getActiveRoute, - - // Requestor input - // NOTE - `requestType` is an internal property used to determine if we're making a websocket request or not - state: { path, method, requestType }, - updatePath: handlePathInputChange, - updateMethod: handleMethodChange, - getIsInDraftMode, - addServiceUrlIfBarePath, - removeServiceUrlFromPath, - - // Request panel - state: { pathParams, queryParams, requestHeaders, body }, - setPathParams, - updatePathParamValues, - clearPathParams, - setQueryParams, - setRequestHeaders, - setBody, - handleRequestBodyTypeChange, - - // Request panel - Websocket message form - state: { websocketMessage }, - setWebsocketMessage, - - // Requests Panel tabs - state: { activeRequestsPanelTab }, - setActiveRequestsPanelTab, - shouldShowRequestTab, - - // Response Panel tabs - state: { activeResponsePanelTab }, - setActiveResponsePanelTab, - shouldShowResponseTab, - - // Response Panel response body - state: { activeResponse }, - setActiveResponse, - - // History (WIP) - state: { activeHistoryResponseTraceId }, - showResponseBodyFromHistory, - clearResponseBodyFromHistory, - } = requestorState; - - const selectedRoute = getActiveRoute(); - + const { id, requestType } = useParams(); // NOTE - This sets the `routes` and `serviceBaseUrl` in the reducer - useRoutes({ - setRoutes, - setServiceBaseUrl, - }); + useRoutes(); + + const { sidePanel } = useRequestorStore("sidePanel"); // NOTE - Use this to test overflow of requests panel // useEffect(() => { @@ -113,118 +46,16 @@ export const RequestorPage = () => { sessionHistory, recordRequestInSessionHistory, loadHistoricalRequest, - } = useRequestorHistory({ - routes, - handleSelectRoute, - setPath: handlePathInputChange, - setMethod: handleMethodChange, - setPathParams, - setBody, - setQueryParams, - setRequestHeaders, - showResponseBodyFromHistory, - }); + } = useRequestorHistory(); - const mostRecentRequestornatorForRoute = useMostRecentRequestornator( - { path, method, route: selectedRoute?.path }, - sessionHistory, - activeHistoryResponseTraceId, - ); - - const { mutate: makeRequest, isPending: isRequestorRequesting } = - useMakeProxiedRequest({ clearResponseBodyFromHistory, setActiveResponse }); - - // WIP - Allows us to connect to a websocket and send messages through it - const { - connect: connectWebsocket, - disconnect: disconnectWebsocket, - sendMessage: sendWebsocketMessage, - state: websocketState, - } = useMakeWebsocketRequest(); - - // Send a request when we submit the form - const onSubmit = useRequestorSubmitHandler({ - body, - addServiceUrlIfBarePath, - path, - method, - pathParams, - queryParams, - requestHeaders, - makeRequest, - connectWebsocket, - recordRequestInSessionHistory, - selectedRoute, - requestType, - }); - - const formRef = useRef(null); - - // FIXME / INVESTIGATE - Should this behavior change for websockets? - useHotkeys( - "mod+enter", - () => { - if (formRef.current) { - formRef.current.requestSubmit(); - } - }, - { - enableOnFormTags: ["input"], - }, - ); - - const { - enabled: aiEnabled, - isLoadingParameters, - fillInRequest, - testingPersona, - setTestingPersona, - showAiGeneratedInputsBanner, - setShowAiGeneratedInputsBanner, - setIgnoreAiInputsBanner, - } = useAi( - selectedRoute, - history, - { - setBody, - setQueryParams, - setPath: handlePathInputChange, - setRequestHeaders, - updatePathParamValues, - addServiceUrlIfBarePath, - }, - body, - ); - - useHotkeys( - "mod+g", - (e) => { - if (aiEnabled) { - // Prevent the "find in document" from opening in browser - e.preventDefault(); - if (!isLoadingParameters) { - toast({ - duration: 3000, - description: "Generating request parameters with AI", - }); - fillInRequest(); - } - } - }, - { - enableOnFormTags: ["input"], - }, - ); - - const [isAiTestGenerationPanelOpen, setIsAiTestGenerationPanelOpen] = - useState(false); - const toggleAiTestGenerationPanel = useCallback( - () => setIsAiTestGenerationPanelOpen((current) => !current), - [], - ); + const hasHistory = history.length > 0; + useEffect(() => { + if (id && hasHistory && requestType === "request") { + loadHistoricalRequest(id); + } + }, [id, loadHistoricalRequest, hasHistory, requestType]); const width = getMainSectionWidth(); - const isSmallScreen = useIsSmScreen(); const isLgScreen = useIsLgScreen(); const { minSize, maxSize } = usePanelConstraints({ @@ -234,97 +65,32 @@ export const RequestorPage = () => { minimalGroupSize: 944, }); - const { minSize: requestPanelMinSize, maxSize: requestPanelMaxSize } = - usePanelConstraints({ - // Change the groupId to `""` on small screens because we're not rendering - // the resizable panel group - groupId: isSmallScreen ? "" : "requestor-page-main-panel", - initialGroupSize: width, - minPixelSize: 300, - }); - - const requestContent = ( - - ); - - const responseContent = ( - + const [searchParams] = useSearchParams(); + const generateLinkToTrace = useCallback( + (traceId: string) => { + const search = searchParams.toString(); + return `/request/${traceId}${search ? `?${search}` : ""}`; + }, + [searchParams], ); return (
-
- -
- {isLgScreen && ( + {isLgScreen && sidePanel === "open" && ( <> { maxSize={maxSize} defaultSize={(320 / width) * 100} > - + + + { )} -
- + ) : ( + - {isSmallScreen ? ( - <> - {requestContent} - {responseContent} - - ) : ( - - - {requestContent} - - - - {responseContent} - - {isAiTestGenerationPanelOpen && !isSmallScreen && ( - <> - - - - - - )} - - )} -
+ )}
@@ -441,66 +131,3 @@ export const RequestorPage = () => { }; export default RequestorPage; - -/** - * When you select a route from the route side panel, - * this will look for the most recent request made against that route. - */ -function useMostRecentRequestornator( - requestInputs: { path: string; method: string; route?: string }, - all: Requestornator[], - activeHistoryResponseTraceId: string | null, -) { - return useMemo(() => { - if (activeHistoryResponseTraceId) { - return all.find( - (r: Requestornator) => - r?.app_responses?.traceId === activeHistoryResponseTraceId, - ); - } - - const matchingResponses = all?.filter( - (r: Requestornator) => - r?.app_requests?.requestRoute === requestInputs.route && - r?.app_requests?.requestMethod === requestInputs.method, - ); - - // Descending sort by updatedAt - matchingResponses?.sort(sortRequestornatorsDescending); - - const latestMatch = matchingResponses?.[0]; - - if (latestMatch) { - return latestMatch; - } - - // HACK - We can try to match against the exact request URL - // This is a fallback to support the case where the route doesn't exist, - // perhaps because we made a request to a service we are not explicitly monitoring - const matchingResponsesFallback = all?.filter( - (r: Requestornator) => - r?.app_requests?.requestUrl === requestInputs.path && - r?.app_requests?.requestMethod === requestInputs.method, - ); - - matchingResponsesFallback?.sort(sortRequestornatorsDescending); - - return matchingResponsesFallback?.[0]; - }, [all, requestInputs, activeHistoryResponseTraceId]); -} - -export const Title = (props: { children: React.ReactNode }) => ( -
-

- {props.children} -

-
-); diff --git a/studio/src/pages/RequestorPage/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent.tsx new file mode 100644 index 000000000..c1feefa92 --- /dev/null +++ b/studio/src/pages/RequestorPage/RequestorPageContent.tsx @@ -0,0 +1,363 @@ +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + usePanelConstraints, +} from "@/components/ui/resizable"; +import { useToast } from "@/components/ui/use-toast"; +import { useIsLgScreen, useKeySequence } from "@/hooks"; +import { cn } from "@/utils"; +import { useMemo, useRef } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { LogsTable } from "./LogsTable"; +import { RequestPanel } from "./RequestPanel"; +import { RequestorInput } from "./RequestorInput"; +import { RequestorTimeline } from "./RequestorTimeline"; +import { ResponsePanel } from "./ResponsePanel"; +import { AiTestGenerationPanel, useAi } from "./ai"; +import { type Requestornator, useMakeProxiedRequest } from "./queries"; +import { useActiveRoute, useRequestorStore } from "./store"; +import { BACKGROUND_LAYER } from "./styles"; +import { useMakeWebsocketRequest } from "./useMakeWebsocketRequest"; +import { useRequestorSubmitHandler } from "./useRequestorSubmitHandler"; +import { sortRequestornatorsDescending } from "./utils"; + +interface RequestorPageContentProps { + history: Requestornator[]; // Replace 'any[]' with the correct type + sessionHistory: Requestornator[]; + recordRequestInSessionHistory: (traceId: string) => void; + overrideTraceId?: string; +} + +export const RequestorPageContent: React.FC = ( + props, +) => { + const { + history, + recordRequestInSessionHistory, + overrideTraceId, + sessionHistory, + } = props; + + const { toast } = useToast(); + + const mostRecentRequestornatorForRoute = useMostRecentRequestornator( + sessionHistory, + overrideTraceId, + ); + + const traceId = + overrideTraceId ?? mostRecentRequestornatorForRoute?.app_responses?.traceId; + + const { mutate: makeRequest, isPending: isRequestorRequesting } = + useMakeProxiedRequest(); + + // WIP - Allows us to connect to a websocket and send messages through it + const { + connect: connectWebsocket, + disconnect: disconnectWebsocket, + sendMessage: sendWebsocketMessage, + state: websocketState, + } = useMakeWebsocketRequest(); + + // Send a request when we submit the form + const onSubmit = useRequestorSubmitHandler({ + makeRequest, + connectWebsocket, + recordRequestInSessionHistory, + }); + + const formRef = useRef(null); + + // FIXME / INVESTIGATE - Should this behavior change for websockets? + useHotkeys( + "mod+enter", + () => { + if (formRef.current) { + formRef.current.requestSubmit(); + } + }, + { + enableOnFormTags: ["input"], + }, + ); + + const { + enabled: aiEnabled, + isLoadingParameters, + fillInRequest, + testingPersona, + setTestingPersona, + showAiGeneratedInputsBanner, + setShowAiGeneratedInputsBanner, + setIgnoreAiInputsBanner, + } = useAi(history); + + const isLgScreen = useIsLgScreen(); + + const { logsPanel, timelinePanel, aiPanel, togglePanel } = useRequestorStore( + "togglePanel", + "logsPanel", + "timelinePanel", + "aiPanel", + ); + + useHotkeys( + "mod+g", + (e) => { + if (aiEnabled) { + // Prevent the "find in document" from opening in browser + e.preventDefault(); + if (!isLoadingParameters) { + toast({ + duration: 3000, + description: "Generating request parameters with AI", + }); + fillInRequest(); + } + } + }, + { + enableOnFormTags: ["input"], + }, + ); + + useKeySequence( + ["g", "l"], + () => { + togglePanel("logsPanel"); + }, + { description: "Open logs panel" }, + ); + + useKeySequence( + ["g", "t"], + () => { + togglePanel("timelinePanel"); + }, + { description: "Open timeline panel" }, + ); + + useKeySequence( + ["g", "i"], + () => { + togglePanel("aiPanel"); + }, + { + description: "Open AI assistant panel", + }, + ); + + const requestContent = ( + + ); + + const responseContent = ( + + ); + + const { minSize: requestPanelMinSize, maxSize: requestPanelMaxSize } = + usePanelConstraints({ + // Change the groupId to `""` on small screens because we're not rendering + // the resizable panel group + groupId: "requestor-page-request-panel-group", + initialGroupSize: getMainSectionWidth(), + minPixelSize: 200, + dimension: "width", + }); + + return ( +
+ + + + + + {requestContent} + + + + {responseContent} + + + + {timelinePanel === "open" && ( + <> + + + + + + )} + {logsPanel === "open" && ( + <> + + + + + + )} + {aiPanel === "open" && ( + <> + + + + + + )} + +
+ ); +}; + +/** + * When you select a route from the route side panel, + * this will look for the most recent request made against that route. + */ +function useMostRecentRequestornator( + all: Requestornator[], + overrideTraceId: string | null = null, +) { + const { path: routePath } = useActiveRoute(); + const { path, method, activeHistoryResponseTraceId } = useRequestorStore( + "path", + "method", + "activeHistoryResponseTraceId", + ); + + const traceId = overrideTraceId ?? activeHistoryResponseTraceId; + return useMemo(() => { + if (traceId) { + return all.find( + (r: Requestornator) => r?.app_responses?.traceId === traceId, + ); + } + + const matchingResponses = all?.filter( + (r: Requestornator) => + r?.app_requests?.requestRoute === routePath && + r?.app_requests?.requestMethod === method, + ); + + // Descending sort by updatedAt + matchingResponses?.sort(sortRequestornatorsDescending); + + const latestMatch = matchingResponses?.[0]; + + if (latestMatch) { + return latestMatch; + } + + // HACK - We can try to match against the exact request URL + // This is a fallback to support the case where the route doesn't exist, + // perhaps because we made a request to a service we are not explicitly monitoring + const matchingResponsesFallback = all?.filter( + (r: Requestornator) => + r?.app_requests?.requestUrl === path && + r?.app_requests?.requestMethod === method, + ); + + matchingResponsesFallback?.sort(sortRequestornatorsDescending); + + return matchingResponsesFallback?.[0]; + }, [all, routePath, method, path, traceId]); +} + +/** + * Estimate the size of the main section based on the window width + */ +function getMainSectionWidth() { + return window.innerWidth - 400; +} diff --git a/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/RequestorSessionHistoryContext.tsx b/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/RequestorSessionHistoryContext.tsx deleted file mode 100644 index 705790701..000000000 --- a/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/RequestorSessionHistoryContext.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * This file contains the context and provider for the session history of the requestor. - * By session history, we mean the list of traceIds that the requestor has created, - * _during the current lifetime of the tab/browser window_. - * - * Using context for this allows us to persist the "session history" of the requestor - * across different pages of the app. - * - * If the user refreshes the page, the session history will be cleared. - * - */ - -import type React from "react"; -import { type ReactNode, createContext, useState } from "react"; - -type RequestorTraceId = string; - -type SessionHistoryContextType = { - sessionHistory: RequestorTraceId[]; - recordRequestInSessionHistory: (traceId: RequestorTraceId) => void; -}; - -export const SessionHistoryContext = createContext< - SessionHistoryContextType | undefined ->(undefined); - -export const RequestorSessionHistoryProvider: React.FC<{ - children: ReactNode; -}> = ({ children }) => { - const [sessionHistory, setSessionHistoryTraceIds] = useState< - RequestorTraceId[] - >([]); - - const recordRequestInSessionHistory = (traceId: RequestorTraceId) => { - setSessionHistoryTraceIds((currentSessionHistory) => [ - traceId, - ...currentSessionHistory, - ]); - }; - - return ( - - {children} - - ); -}; diff --git a/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/hooks.ts b/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/hooks.ts deleted file mode 100644 index 96e3c3e29..000000000 --- a/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/hooks.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from "react"; -import { SessionHistoryContext } from "./RequestorSessionHistoryContext"; - -export const useSessionHistory = () => { - const context = useContext(SessionHistoryContext); - if (!context) { - throw new Error( - "useSessionHistory must be used within a SessionHistoryProvider", - ); - } - return context; -}; diff --git a/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/index.ts b/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/index.ts deleted file mode 100644 index 12353de7d..000000000 --- a/studio/src/pages/RequestorPage/RequestorSessionHistoryContext/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { RequestorSessionHistoryProvider } from "./RequestorSessionHistoryContext"; -export { useSessionHistory } from "./hooks"; diff --git a/studio/src/pages/RequestorPage/RequestorTimeline.tsx b/studio/src/pages/RequestorPage/RequestorTimeline.tsx index 9c8c3198f..4743f4626 100644 --- a/studio/src/pages/RequestorPage/RequestorTimeline.tsx +++ b/studio/src/pages/RequestorPage/RequestorTimeline.tsx @@ -5,6 +5,7 @@ import { extractWaterfallTimeStats, } from "@/components/Timeline"; import { useAsWaterfall } from "@/components/Timeline/hooks/useAsWaterfall"; +import { Button } from "@/components/ui/button"; import { ResizableHandle, ResizablePanel, @@ -14,17 +15,21 @@ import { import { useIsSmScreen } from "@/hooks"; import { useOtelTrace } from "@/queries"; import { cn } from "@/utils"; +import { Icon } from "@iconify/react"; +import { Cross1Icon } from "@radix-ui/react-icons"; +import { Tabs } from "@radix-ui/react-tabs"; import type { ReactNode } from "react"; import { useOrphanLogs } from "../RequestDetailsPage/RequestDetailsPageV2/useOrphanLogs"; +import { CustomTabTrigger, CustomTabsContent, CustomTabsList } from "./Tabs"; +import { useRequestorStore } from "./store"; type Props = { - traceId: string; + traceId?: string; }; -export function RequestorTimeline(props: Props) { - const { traceId } = props; +export function RequestorTimeline({ traceId = "" }: Props) { const { data: spans } = useOtelTrace(traceId); - + const { togglePanel } = useRequestorStore("togglePanel"); const orphanLogs = useOrphanLogs(traceId, spans ?? []); const { waterfall } = useAsWaterfall(spans ?? [], orphanLogs); const { minStart, duration } = extractWaterfallTimeStats(waterfall); @@ -38,39 +43,84 @@ export function RequestorTimeline(props: Props) { }); return ( - - + + Timeline +
+ +
+
+ - {!isSmallScreen && ( - <> - + ) : ( + + - - - - - - + {!isSmallScreen && ( + <> + + + + + + + + )} + + + + + +
+
)} - - - - - - - + + + ); +} + +function TimelineEmptyState() { + return ( +
+
+
+ +
+

No timeline data found

+

+ There is currently no timeline data to display. This could be because + no events have been recorded yet, or the trace data is not available. +

+
+
); } diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody.tsx b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody.tsx index a47e795b6..ede9e9c1e 100644 --- a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody.tsx +++ b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody.tsx @@ -1,4 +1,5 @@ import { CodeMirrorJsonEditor, SubSectionHeading } from "@/components/Timeline"; +import { TextOrJsonViewer } from "@/components/Timeline/DetailsList/TextJsonViewer"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -17,14 +18,12 @@ import type { Requestornator } from "../queries"; import { type RequestorActiveResponse, isRequestorActiveResponse, -} from "../reducer/state"; +} from "../store/types"; export function ResponseBody({ - headersSlot, response, className, }: { - headersSlot?: React.ReactNode; response?: Requestornator | RequestorActiveResponse; className?: string; }) { @@ -48,10 +47,7 @@ export function ResponseBody({
- {headersSlot} - - - +
); } @@ -63,15 +59,7 @@ export function ResponseBody({
- {headersSlot} - - - +
); } @@ -82,7 +70,6 @@ export function ResponseBody({
- {headersSlot} @@ -92,12 +79,10 @@ export function ResponseBody({ // TODO - Stylize if (body?.type === "unknown") { - return ( - - ); + return ; } - return ; + return ; } if (!isRequestorActiveResponse(response)) { @@ -111,15 +96,12 @@ export function ResponseBody({
- {headersSlot} - - - +
); } @@ -129,10 +111,7 @@ export function ResponseBody({ return (
- {headersSlot} - - - +
); } @@ -140,14 +119,11 @@ export function ResponseBody({ function UnknownResponse({ className, - headersSlot, }: { - headersSlot: React.ReactNode; className?: string; }) { return (
- {headersSlot}
diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponsePanel.tsx b/studio/src/pages/RequestorPage/ResponsePanel/ResponsePanel.tsx index f569f568f..971c2640f 100644 --- a/studio/src/pages/RequestorPage/ResponsePanel/ResponsePanel.tsx +++ b/studio/src/pages/RequestorPage/ResponsePanel/ResponsePanel.tsx @@ -1,29 +1,19 @@ -import RobotIcon from "@/assets/Robot.svg"; -import { - CollapsibleKeyValueTableV2, - SubSectionHeading, -} from "@/components/Timeline"; -import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; +import { KeyValueTable } from "@/components/Timeline/DetailsList/KeyValueTableV2"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs } from "@/components/ui/tabs"; import { SENSITIVE_HEADERS, cn, parsePathFromRequestUrl } from "@/utils"; -import { CaretDownIcon, CaretRightIcon } from "@radix-ui/react-icons"; -import { useState } from "react"; +import { Icon } from "@iconify/react"; +import { memo } from "react"; import { Method, StatusCode } from "../RequestorHistory"; -import { RequestorTimeline } from "../RequestorTimeline"; import { CustomTabTrigger, CustomTabsContent, CustomTabsList } from "../Tabs"; import type { Requestornator } from "../queries"; -import type { ResponsePanelTab } from "../reducer"; +import type { ResponsePanelTab } from "../store"; +import { useActiveRoute, useRequestorStore, useServiceBaseUrl } from "../store"; import { type RequestorActiveResponse, isRequestorActiveResponse, -} from "../reducer/state"; -import { type RequestType, isWsRequest } from "../types"; +} from "../store/types"; +import { isWsRequest } from "../types"; import type { WebSocketState } from "../useMakeWebsocketRequest"; import { FailedRequest, ResponseBody } from "./ResponseBody"; import { @@ -33,35 +23,36 @@ import { } from "./Websocket"; type Props = { - activeResponse: RequestorActiveResponse | null; - activeResponsePanelTab: ResponsePanelTab; - setActiveResponsePanelTab: (tab: string) => void; - shouldShowResponseTab: (tab: ResponsePanelTab) => boolean; tracedResponse?: Requestornator; isLoading: boolean; - requestType: RequestType; websocketState: WebSocketState; - openAiTestGenerationPanel: () => void; - isAiTestGenerationPanelOpen: boolean; - removeServiceUrlFromPath: (url: string) => string; }; -export function ResponsePanel({ - activeResponse, - activeResponsePanelTab, - setActiveResponsePanelTab, - shouldShowResponseTab, +export const ResponsePanel = memo(function ResponsePanel({ tracedResponse, isLoading, - requestType, websocketState, - openAiTestGenerationPanel, - isAiTestGenerationPanelOpen, - removeServiceUrlFromPath, }: Props) { + const { + activeResponse, + visibleResponsePanelTabs, + activeResponsePanelTab, + setActiveResponsePanelTab, + } = useRequestorStore( + "activeResponse", + "visibleResponsePanelTabs", + "activeResponsePanelTab", + "setActiveResponsePanelTab", + ); + const shouldShowResponseTab = (tab: ResponsePanelTab): boolean => { + return visibleResponsePanelTabs.includes(tab); + }; + + const { requestType } = useActiveRoute(); + const { removeServiceUrlFromPath } = useServiceBaseUrl(); + // NOTE - If we have a "raw" response, we want to render that, so we can (e.g.,) show binary data const responseToRender = activeResponse ?? tracedResponse; - const isFailure = isRequestorActiveResponse(responseToRender) ? responseToRender.isFailure : responseToRender?.app_responses?.isFailure; @@ -73,131 +64,95 @@ export function ResponsePanel({ : responseToRender?.app_responses?.responseHeaders; const shouldShowMessages = shouldShowResponseTab("messages"); - const traceId = tracedResponse?.app_responses.traceId; - - const [isOpen, setIsOpen] = useState(true); return (
-
- - - - {responseToRender ? ( - - ) : ( - "Response" - )} - - {shouldShowMessages && ( - Messages + + + + {responseToRender ? ( + + ) : ( + "Response" )} -
- + + {shouldShowMessages && ( + Messages + )} + + Headers + {responseHeaders && Object.keys(responseHeaders).length > 1 && ( + + ({Object.keys(responseHeaders).length}) + + )} + + + + } + FailState={} + EmptyState={} + > + + + + + } + FailState={ + isWsRequest(requestType) ? ( + + ) : ( + + ) + } + EmptyState={ + isWsRequest(requestType) ? ( + + ) : ( + + ) + } + > +
+
- - - } - FailState={} - EmptyState={} - > - - - - - } - FailState={ - isWsRequest(requestType) ? ( - - ) : ( - - ) - } - EmptyState={ - isWsRequest(requestType) ? ( - - ) : ( - - ) - } - > -
- - } - response={responseToRender} - // HACK - To support absolutely positioned bottom toolbar - className={cn(showBottomToolbar && "pb-2")} - /> - {traceId && ( - - - - {isOpen ? ( - - ) : ( - - )} - Timeline - - - - - - - )} -
-
-
- -
+ + + + {responseHeaders && ( + + )} + +
); -} +}); /** * Helper component for handling loading/failure/empty states in tab content @@ -259,6 +214,7 @@ function ResponseSummary({ "font-mono", "whitespace-nowrap", "overflow-ellipsis", + "text-xs", "ml-2", "pt-0.5", // HACK - to adjust baseline of mono font to look good next to sans )} @@ -272,12 +228,20 @@ function ResponseSummary({ function NoResponse() { return ( -
-
- Enter a URL and hit send to see a response -
-
- Or load a past request from your history +
+
+
+ +
+

+ Enter a URL and hit Send to see a response +

+
+

Or load a past request from your history

+
); diff --git a/studio/src/pages/RequestorPage/RoutesCombobox.tsx b/studio/src/pages/RequestorPage/RoutesCombobox.tsx deleted file mode 100644 index 398bee1e5..000000000 --- a/studio/src/pages/RequestorPage/RoutesCombobox.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { CaretSortIcon, CheckIcon, PlusIcon } from "@radix-ui/react-icons"; -import * as React from "react"; - -import { KeyboardShortcutKey } from "@/components/KeyboardShortcut"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { useIsLgScreen } from "@/hooks"; -import { cn } from "@/utils"; -import { useHotkeys } from "react-hotkeys-hook"; -import { RouteItem } from "./RoutesPanel"; -import type { ProbedRoute } from "./queries"; -import { AddRoutesDialog } from "./routes/AddRouteButton"; - -type RoutesComboboxProps = { - routes?: ProbedRoute[]; - selectedRoute: ProbedRoute | null; - handleRouteClick: (route: ProbedRoute) => void; -}; - -export function RoutesCombobox(props: RoutesComboboxProps) { - const [openRoutesDialog, setOpenRoutesDialog] = React.useState(false); - const isLg = useIsLgScreen(); - useHotkeys("c", (e) => { - if (!isLg) { - e.preventDefault(); - setOpenRoutesDialog(true); - } - }); - - const [openApi, setOpenApi] = React.useState(false); - const { selectedRoute, routes, handleRouteClick } = props; - const [open, setOpen] = React.useState(false); - const [value, setValue] = React.useState( - selectedRoute ? `${selectedRoute.method}-${selectedRoute.path}` : undefined, - ); - - return ( - <> - - - - - - - - - { - setOpenRoutesDialog(true); - setOpen(false); - }} - > -
- - Add New -
- C -
- - No route found. - - {routes?.map((route) => { - const identifier = `${route.method}-${route.path}-${route.routeOrigin}`; - return ( - { - setValue(currentValue === value ? "" : currentValue); - handleRouteClick(route); - setOpen(false); - }} - > - - - - ); - })} - -
-
-
-
- - - ); -} diff --git a/studio/src/pages/RequestorPage/RoutesPanel.tsx b/studio/src/pages/RequestorPage/RoutesPanel.tsx deleted file mode 100644 index b378f639d..000000000 --- a/studio/src/pages/RequestorPage/RoutesPanel.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { Input } from "@/components/ui/input"; -import { cn, getHttpMethodTextColor } from "@/utils"; -import { - CaretDownIcon, - CaretRightIcon, - ClockIcon, - TrashIcon, -} from "@radix-ui/react-icons"; -import { useMemo, useState } from "react"; -import { RequestorHistory } from "./RequestorHistory"; -import { - type ProbedRoute, - type Requestornator, - useDeleteRoute, -} from "./queries"; -import { AddRouteButton } from "./routes"; -import { BACKGROUND_LAYER } from "./styles"; -import { isWsRequest } from "./types"; - -type RoutesPanelProps = { - routes?: ProbedRoute[]; - selectedRoute: ProbedRoute | null; - handleRouteClick: (route: ProbedRoute) => void; - deleteDraftRoute?: () => void; - history: Array; - loadHistoricalRequest: (traceId: string) => void; - removeServiceUrlFromPath: (path: string) => string; -}; - -export function RoutesPanel({ - routes, - selectedRoute, - handleRouteClick, - deleteDraftRoute, - history, - loadHistoricalRequest, - removeServiceUrlFromPath, -}: RoutesPanelProps) { - const hasAnyPreviouslyDetectedRoutes = useMemo(() => { - return routes?.some((r) => !r.currentlyRegistered) ?? false; - }, [routes]); - - const hasAnyDraftRoutes = useMemo(() => { - return routes?.some((r) => r.isDraft) ?? false; - }, [routes]); - - const hasAnyUserAddedRoutes = useMemo(() => { - return ( - routes?.some((r) => r.routeOrigin === "custom" && !r.isDraft) ?? false - ); - }, [routes]); - - const hasAnyOpenApiRoutes = useMemo(() => { - return routes?.some((r) => r.routeOrigin === "open_api") ?? false; - }, [routes]); - - const [filterValue, setFilterValue] = useState(""); - const filteredRoutes = useMemo(() => { - const cleanFilter = filterValue.trim().toLowerCase(); - if (cleanFilter.length < 3 && routes) { - return routes; - } - return routes?.filter((r) => r.path.includes(filterValue)); - }, [filterValue, routes]); - - const prevDetectedRoutes = useMemo(() => { - return ( - filteredRoutes?.filter( - (r) => r.routeOrigin === "discovered" && !r.currentlyRegistered, - ) ?? [] - ); - }, [filteredRoutes]); - - const detectedRoutes = useMemo(() => { - return ( - filteredRoutes?.filter( - (r) => r.routeOrigin === "discovered" && r.currentlyRegistered, - ) ?? [] - ); - }, [filteredRoutes]); - - const openApiRoutes = useMemo(() => { - return filteredRoutes?.filter((r) => r.routeOrigin === "open_api") ?? []; - }, [filteredRoutes]); - - const userAddedRoutes = useMemo(() => { - return ( - filteredRoutes?.filter((r) => r.routeOrigin === "custom" && !r.isDraft) ?? - [] - ); - }, [filteredRoutes]); - - const draftRoutes = useMemo(() => { - return filteredRoutes?.filter((r) => r.isDraft) ?? []; - }, [filteredRoutes]); - - const hasAnyHistory = useMemo(() => { - return history.length > 0; - }, [history]); - - const filteredHistory = useMemo(() => { - const cleanFilter = filterValue.trim().toLowerCase(); - if (cleanFilter.length < 3 && history) { - return history; - } - return history?.filter((r) => - r.app_requests?.requestUrl?.includes(filterValue), - ); - }, [filterValue, history]); - - return ( -
-
-

- Routes -

-
- setFilterValue(e.target.value)} - /> - -
-
-
- {hasAnyHistory && ( - - )} - - {hasAnyDraftRoutes && ( - - )} - - {hasAnyUserAddedRoutes && ( - - )} - - - - {hasAnyPreviouslyDetectedRoutes && ( - - )} - - {hasAnyOpenApiRoutes && ( - - )} -
-
- ); -} - -type HistorySectionProps = { - history: Array; - loadHistoricalRequest: (traceId: string) => void; - removeServiceUrlFromPath: (path: string) => string; -}; - -function HistorySection({ - history, - loadHistoricalRequest, - removeServiceUrlFromPath, -}: HistorySectionProps) { - const [showHistorySection, setShowHistorySection] = useState(false); - const ShowHistorySectionIcon = showHistorySection - ? CaretDownIcon - : CaretRightIcon; - - return ( - <> -
-
{ - if (e.key === "Enter") { - setShowHistorySection((current) => !current); - } - }} - onClick={() => { - setShowHistorySection((current) => !current); - }} - > - - - - History - -
-
- {showHistorySection && ( - - )} - - ); -} - -type RoutesSectionProps = { - title: string; - routes: ProbedRoute[]; - selectedRoute: ProbedRoute | null; - handleRouteClick: (route: ProbedRoute) => void; - deleteDraftRoute?: () => void; -}; - -function RoutesSection(props: RoutesSectionProps) { - const { title, routes, selectedRoute, handleRouteClick, deleteDraftRoute } = - props; - - const [showRoutesSection, setShowRoutesSection] = useState(true); - const ShowRoutesSectionIcon = showRoutesSection - ? CaretDownIcon - : CaretRightIcon; - - return ( - <> -
{ - if (e.key === "Enter") { - setShowRoutesSection((current) => !current); - } - }} - onClick={() => { - setShowRoutesSection((current) => !current); - }} - > - - {title} -
- {showRoutesSection && ( -
- {routes?.map?.((route, index) => ( -
handleRouteClick(route)} - onKeyUp={(e) => { - if (e.key === "Enter") { - handleRouteClick(route); - } - }} - className={cn( - "flex items-center py-1 pl-5 pr-2 rounded cursor-pointer font-mono text-sm", - { - "bg-muted": selectedRoute === route, - "hover:bg-muted": selectedRoute !== route, - }, - )} - > - -
- ))} -
- )} - - ); -} - -export function RouteItem({ - route, - deleteDraftRoute, -}: { - route: ProbedRoute; - deleteDraftRoute?: () => void; -}) { - const { mutate: deleteRoute } = useDeleteRoute(); - const canDeleteRoute = - route.routeOrigin === "custom" || - !route.currentlyRegistered || - route.routeOrigin === "open_api"; - - const method = isWsRequest(route.requestType) ? "WS" : route.method; - return ( - <> - - {method} - - - {route.path} - - { - // TODO - Add a delete button here - canDeleteRoute && ( -
- { - e.stopPropagation(); - if (route.isDraft) { - deleteDraftRoute?.(); - } else { - deleteRoute({ path: route.path, method: route.method }); - } - }} - /> -
- ) - } - - ); -} diff --git a/studio/src/pages/RequestorPage/Tabs.tsx b/studio/src/pages/RequestorPage/Tabs.tsx index 7edeef691..76a5fac30 100644 --- a/studio/src/pages/RequestorPage/Tabs.tsx +++ b/studio/src/pages/RequestorPage/Tabs.tsx @@ -4,7 +4,7 @@ import type * as TabsPrimitive from "@radix-ui/react-tabs"; import * as React from "react"; import type { ComponentProps } from "react"; -const TAB_HEIGHT = "h-12"; +const TAB_HEIGHT = "h-8"; export const CustomTabsList = React.forwardRef< React.ElementRef, @@ -31,7 +31,7 @@ export function CustomTabTrigger(props: ComponentProps) { "text-left", TAB_HEIGHT, "ml-2", - "text-sm", + "text-xs", "font-normal", "border-b", "border-transparent", @@ -54,6 +54,9 @@ export const CustomTabsContent = React.forwardRef< ; - toggleAiTestGenerationPanel: () => void; - getActiveRoute: () => ProbedRoute; - removeServiceUrlFromPath: (path: string) => string; }) { const { isCopied, copyToClipboard } = useCopyToClipboard(); + const { togglePanel } = useRequestorStore("togglePanel"); + const activeRoute = useActiveRoute(); + const { removeServiceUrlFromPath } = useServiceBaseUrl(); const lastMatchingRequest = useMemo(() => { - const activeRoute = getActiveRoute(); - const match = history.find((response) => { const path = parsePathFromRequestUrl(response.app_requests?.requestUrl); @@ -49,7 +45,7 @@ export function AiTestGenerationPanel({ return true; } - // HACK - For requesets against non-detected routes, we can search for the exact request url... + // HACK - For requests against non-detected routes, we can search for the exact request url... if (response.app_requests?.requestUrl === activeRoute.path) { return true; } @@ -58,7 +54,7 @@ export function AiTestGenerationPanel({ }); return match ?? null; - }, [getActiveRoute, history, removeServiceUrlFromPath]); + }, [activeRoute, history, removeServiceUrlFromPath]); const [userInput, setUserInput] = useState(""); @@ -74,9 +70,10 @@ export function AiTestGenerationPanel({
@@ -115,10 +112,7 @@ export function AiTestGenerationPanel({
- diff --git a/studio/src/pages/RequestorPage/ai/ai-test-generation.ts b/studio/src/pages/RequestorPage/ai/ai-test-generation.ts index 89a7c84f7..939767f16 100644 --- a/studio/src/pages/RequestorPage/ai/ai-test-generation.ts +++ b/studio/src/pages/RequestorPage/ai/ai-test-generation.ts @@ -1,4 +1,4 @@ -import { type OtelSpans, useOtelTrace } from "@/queries"; +import { useOtelTrace } from "@/queries"; import { getRequestMethod, getRequestUrl, @@ -9,6 +9,7 @@ import { isFetchSpan, } from "@/utils"; import { formatHeaders, redactSensitiveHeaders } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useMemo } from "react"; import type { Requestornator } from "../queries"; import { appRequestToHttpRequest, appResponseToHttpRequest } from "./utils"; @@ -98,9 +99,9 @@ function cleanPrompt(prompt: string) { // NOTE - This only focuses on exceptions! Will need to improve it in the future // TODO - Also add error logs or fetch errors -function serializeTraceForLLM(trace: OtelSpans) { - const events = trace.flatMap((span) => span.events); - const exceptions = events.filter((event) => event.name === "exception"); +function serializeTraceForLLM(trace: Array) { + const events = trace.flatMap((span) => span.events ?? []); + const exceptions = events.filter((event) => event?.name === "exception"); const exceptionsContext = exceptions.reduce( (result, exception) => { result.push( diff --git a/studio/src/pages/RequestorPage/ai/ai.ts b/studio/src/pages/RequestorPage/ai/ai.ts index 986c05676..9ea4e07e6 100644 --- a/studio/src/pages/RequestorPage/ai/ai.ts +++ b/studio/src/pages/RequestorPage/ai/ai.ts @@ -1,16 +1,15 @@ import { useToast } from "@/components/ui/use-toast"; import { useAiEnabled } from "@/hooks/useAiEnabled"; import { errorHasMessage, isJson } from "@/utils"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useHandler } from "@fiberplane/hooks"; +import { useEffect, useMemo, useState } from "react"; import { z } from "zod"; import { createFormDataParameter } from "../FormDataForm/data"; -import { - type KeyValueParameter, - createKeyValueParameters, -} from "../KeyValueForm"; -import type { ProbedRoute, Requestornator } from "../queries"; -import type { RequestorBody } from "../reducer"; -import { isRequestorBodyType } from "../reducer/request-body"; +import { createKeyValueParameters } from "../KeyValueForm"; +import type { Requestornator } from "../queries"; +import type { RequestorBody } from "../store"; +import { useRequestorStore, useServiceBaseUrl } from "../store"; +import { isRequestorBodyType } from "../store/request-body"; import { useAiRequestData } from "./generate-request-data"; export const FRIENDLY = "Friendly" as const; @@ -18,23 +17,29 @@ export const HOSTILE = "QA" as const; export type AiTestingPersona = "Friendly" | "QA"; -type FormSetters = { - setBody: (body: string | RequestorBody) => void; - setQueryParams: (params: KeyValueParameter[]) => void; - setRequestHeaders: (params: KeyValueParameter[]) => void; - setPath: (path: string) => void; - updatePathParamValues: (pathParams: { key: string; value: string }[]) => void; - addServiceUrlIfBarePath: (path: string) => string; -}; - -export function useAi( - selectedRoute: ProbedRoute | null, - requestHistory: Array, - formSetters: FormSetters, - body: RequestorBody, -) { +export function useAi(requestHistory: Array) { const { toast } = useToast(); const isAiEnabled = useAiEnabled(); + const { addServiceUrlIfBarePath } = useServiceBaseUrl(); + const { + setBody, + setQueryParams, + updatePath: setPath, + setRequestHeaders, + updatePathParamValues, + body, + activeRoute, + getMatchingMiddleware, + } = useRequestorStore( + "setBody", + "setQueryParams", + "updatePath", + "setRequestHeaders", + "updatePathParamValues", + "body", + "activeRoute", + "getMatchingMiddleware", + ); const { ignoreAiInputsBanner, setIgnoreAiInputsBanner } = useIgnoreAiGeneratedInputsBanner(); @@ -42,15 +47,6 @@ export function useAi( const [showAiGeneratedInputsBanner, setShowAiGeneratedInputsBanner] = useState(false); - const { - setBody, - setQueryParams, - setPath, - setRequestHeaders, - updatePathParamValues, - addServiceUrlIfBarePath, - } = formSetters; - const bodyType = body.type; // Testing persona determines what kind of request data will get generated by the AI @@ -63,9 +59,15 @@ export function useAi( }, [requestHistory]); const { isFetching: isLoadingParameters, refetch: generateRequestData } = - useAiRequestData(selectedRoute, bodyType, recentHistory, testingPersona); + useAiRequestData( + activeRoute, + getMatchingMiddleware(), + bodyType, + recentHistory, + testingPersona, + ); - const fillInRequest = useCallback(() => { + const fillInRequest = useHandler(() => { generateRequestData().then(({ data, isError, error }) => { if (isError) { toast({ @@ -168,16 +170,7 @@ export function useAi( setShowAiGeneratedInputsBanner(true); }); - }, [ - generateRequestData, - setBody, - setPath, - setQueryParams, - setRequestHeaders, - updatePathParamValues, - toast, - addServiceUrlIfBarePath, - ]); + }); return { showAiGeneratedInputsBanner: diff --git a/studio/src/pages/RequestorPage/ai/generate-request-data.ts b/studio/src/pages/RequestorPage/ai/generate-request-data.ts index cda2523ab..4d044d5c1 100644 --- a/studio/src/pages/RequestorPage/ai/generate-request-data.ts +++ b/studio/src/pages/RequestorPage/ai/generate-request-data.ts @@ -1,10 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import type { ProbedRoute, Requestornator } from "../queries"; -import type { RequestBodyType } from "../reducer"; +import type { Requestornator } from "../queries"; +import type { RequestBodyType } from "../store"; +import type { ProbedRoute } from "../types"; import { simplifyHistoryEntry } from "./utils"; const fetchAiRequestData = ( route: ProbedRoute | null, + middleware: ProbedRoute[] | null, bodyType: RequestBodyType, history: Array, persona: string, @@ -25,6 +27,7 @@ const fetchAiRequestData = ( history: simplifiedHistory, persona, openApiSpec, + middleware, }), }).then(async (r) => { if (!r.ok) { @@ -37,13 +40,15 @@ const fetchAiRequestData = ( export function useAiRequestData( route: ProbedRoute | null, + matchingMiddleware: ProbedRoute[] | null, bodyType: RequestBodyType, history: Array, persona = "Friendly", ) { return useQuery({ queryKey: ["generateRequest"], - queryFn: () => fetchAiRequestData(route, bodyType, history, persona), + queryFn: () => + fetchAiRequestData(route, matchingMiddleware, bodyType, history, persona), enabled: false, retry: false, }); diff --git a/studio/src/pages/RequestorPage/ai/summarize-traces.ts b/studio/src/pages/RequestorPage/ai/summarize-traces.ts index cf9952dab..229624800 100644 --- a/studio/src/pages/RequestorPage/ai/summarize-traces.ts +++ b/studio/src/pages/RequestorPage/ai/summarize-traces.ts @@ -2,7 +2,6 @@ import { FPX_REQUEST_HANDLER_FILE, FPX_REQUEST_HANDLER_SOURCE_CODE, } from "@/constants"; -import type { OtelSpans } from "@/queries"; import { fetchSourceLocation } from "@/queries"; import { getErrorEvents, @@ -15,9 +14,10 @@ import { getString, } from "@/utils"; import { formatHeaders, redactSensitiveHeaders } from "@/utils"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { useQuery } from "@tanstack/react-query"; -async function summarizeError(trace?: OtelSpans) { +async function summarizeError(trace?: Array) { if (!trace) { return null; } @@ -56,7 +56,7 @@ async function summarizeError(trace?: OtelSpans) { * - Fetch-related events * - Error logs */ -export function serializeTraceForLLM(trace: OtelSpans) { +export function serializeTraceForLLM(trace: Array) { return trace.reduce( (result, span) => { if (span.name === "request") { @@ -149,7 +149,7 @@ export function serializeTraceForLLM(trace: OtelSpans) { ); } -export function useSummarizeError(trace?: OtelSpans) { +export function useSummarizeError(trace?: Array) { return useQuery({ queryKey: ["summarizeError"], queryFn: () => summarizeError(trace), @@ -165,7 +165,7 @@ function trimLines(input: string) { .join("\n"); } -function getHandlerFromTrace(trace: OtelSpans) { +function getHandlerFromTrace(trace: Array) { for (const span of trace) { if (span.name === "request") { return getString(span.attributes[FPX_REQUEST_HANDLER_SOURCE_CODE]); @@ -173,7 +173,7 @@ function getHandlerFromTrace(trace: OtelSpans) { } } -function getSourceFileFromTrace(trace: OtelSpans) { +function getSourceFileFromTrace(trace: Array) { for (const span of trace) { if (span.name === "request") { return getString(span.attributes[FPX_REQUEST_HANDLER_FILE]); diff --git a/studio/src/pages/RequestorPage/index.ts b/studio/src/pages/RequestorPage/index.ts index 5ce741daa..deaa17bbc 100644 --- a/studio/src/pages/RequestorPage/index.ts +++ b/studio/src/pages/RequestorPage/index.ts @@ -1,3 +1,2 @@ export { RequestorPage } from "./RequestorPage"; -export { RequestorSessionHistoryProvider } from "./RequestorSessionHistoryContext"; export { ResponseBodyText } from "./ResponsePanel"; diff --git a/studio/src/pages/RequestorPage/queries/hooks/constants.ts b/studio/src/pages/RequestorPage/queries/hooks/constants.ts new file mode 100644 index 000000000..e9c8dd9c7 --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/hooks/constants.ts @@ -0,0 +1 @@ +export const REQUESTOR_REQUESTS_KEY = "requestorRequests"; diff --git a/studio/src/pages/RequestorPage/queries/hooks/index.ts b/studio/src/pages/RequestorPage/queries/hooks/index.ts new file mode 100644 index 000000000..4f92f389a --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/hooks/index.ts @@ -0,0 +1,9 @@ +export { useAddRoutes, type Route } from "./useAddRoute"; +export { useDeleteRoute } from "./useDeleteRoute"; +export { useFetchRequestorRequests } from "./useFetchRequestorRequests"; +export { + useMakeProxiedRequest, + type MakeProxiedRequestQueryFn, +} from "./useMakeProxiedRequest"; +export { useOpenApiParse } from "./useOpenApiParse"; +export { useProbedRoutes } from "./useProbedRoute.ts"; diff --git a/studio/src/pages/RequestorPage/queries/hooks/useAddRoute.ts b/studio/src/pages/RequestorPage/queries/hooks/useAddRoute.ts new file mode 100644 index 000000000..6a81b8f22 --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/hooks/useAddRoute.ts @@ -0,0 +1,36 @@ +import { PROBED_ROUTES_KEY } from "@/queries"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +export type Route = { + path: string; + method: string; + handler?: string; + handlerType?: "route" | "middleware"; + routeOrigin?: "discovered" | "custom" | "open_api"; + openApiSpec?: string; + requestType?: "http" | "websocket"; + // NOTE - Added on the frontend, not stored in DB + isDraft?: boolean; +}; + +async function addRoutes(routes: Route | Route[]) { + return fetch("/v0/app-routes", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(routes), + }).then((r) => r.json()); +} + +export function useAddRoutes() { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: addRoutes, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PROBED_ROUTES_KEY] }); + }, + }); + + return mutation; +} diff --git a/studio/src/pages/RequestorPage/queries/hooks/useDeleteRoute.ts b/studio/src/pages/RequestorPage/queries/hooks/useDeleteRoute.ts new file mode 100644 index 000000000..163d9ddc5 --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/hooks/useDeleteRoute.ts @@ -0,0 +1,26 @@ +import { PROBED_ROUTES_KEY } from "@/queries"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +function deleteRoute({ + path, + method, +}: { + path: string; + method: string; +}) { + return fetch(`/v0/app-routes/${method}/${encodeURIComponent(path)}`, { + method: "DELETE", + }).then((r) => r.json()); +} + +export function useDeleteRoute() { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: deleteRoute, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PROBED_ROUTES_KEY] }); + }, + }); + + return mutation; +} diff --git a/studio/src/pages/RequestorPage/queries/hooks/useFetchRequestorRequests.ts b/studio/src/pages/RequestorPage/queries/hooks/useFetchRequestorRequests.ts new file mode 100644 index 000000000..d30a17352 --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/hooks/useFetchRequestorRequests.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { REQUESTOR_REQUESTS_KEY } from "./constants"; + +const fetchQuery = () => fetch("/v0/all-requests").then((r) => r.json()); + +export function useFetchRequestorRequests() { + return useQuery({ + queryKey: [REQUESTOR_REQUESTS_KEY], + queryFn: fetchQuery, + }); +} diff --git a/studio/src/pages/RequestorPage/queries.ts b/studio/src/pages/RequestorPage/queries/hooks/useMakeProxiedRequest.ts similarity index 59% rename from studio/src/pages/RequestorPage/queries.ts rename to studio/src/pages/RequestorPage/queries/hooks/useMakeProxiedRequest.ts index c7a1ca9a1..26609a66c 100644 --- a/studio/src/pages/RequestorPage/queries.ts +++ b/studio/src/pages/RequestorPage/queries/hooks/useMakeProxiedRequest.ts @@ -1,167 +1,23 @@ -import { PROBED_ROUTES_KEY } from "@/queries"; -import { validate } from "@scalar/openapi-parser"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { z } from "zod"; -import { reduceFormDataParameters } from "./FormDataForm"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { reduceFormDataParameters } from "../../FormDataForm"; import { type KeyValueParameter, reduceKeyValueParameters, -} from "./KeyValueForm"; -import type { - RequestorActiveResponse, - RequestorBody, - RequestorResponseBody, -} from "./reducer/state"; -import { RequestMethodSchema, RequestTypeSchema } from "./types"; - -export const ProbedRouteSchema = z.object({ - path: z.string(), - method: RequestMethodSchema, - handler: z.string(), - handlerType: z.enum(["route", "middleware"]), - currentlyRegistered: z.boolean(), - routeOrigin: z.enum(["discovered", "custom", "open_api"]), - openApiSpec: z.string().optional(), - requestType: RequestTypeSchema, - // NOTE - Added on the frontend, not stored in DB - isDraft: z.boolean().optional(), -}); - -export type ProbedRoute = z.infer; - -const JsonSchema: z.ZodType = z.lazy(() => - z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.array(JsonSchema), - z.record(JsonSchema), - ]), -); - -type JsonSchemaType = z.infer; - -// TODO - Use validation schema -export type Requestornator = { - app_requests: { - id: number; - requestUrl: string; - requestMethod: string; - requestRoute: string; - requestHeaders?: Record | null; - requestQueryParams?: Record | null; - requestPathParams?: Record | null; - requestBody?: JsonSchemaType; - updatedAt: string; - }; - // NOTE - can be undefined if request failed, at least that happened to me locally - app_responses: { - id: number; - responseStatusCode: string; - responseBody: string; - responseHeaders: Record; - traceId: string; - isFailure: boolean; - failureReason: string | null; - updatedAt: string; - }; -}; - -const REQUESTOR_REQUESTS_KEY = "requestorRequests"; - -type ProbedRoutesResponse = { - baseUrl: string; - routes: ProbedRoute[]; -}; - -function getProbedRoutes(): Promise { - return fetch("/v0/app-routes").then((r) => r.json()); -} - -export function useProbedRoutes() { - return useQuery({ - queryKey: [PROBED_ROUTES_KEY], - queryFn: getProbedRoutes, - }); -} - -export type Route = { - path: string; - method: string; - handler?: string; - handlerType?: "route" | "middleware"; - routeOrigin?: "discovered" | "custom" | "open_api"; - openApiSpec?: string; - requestType?: "http" | "websocket"; - // NOTE - Added on the frontend, not stored in DB - isDraft?: boolean; -}; - -async function addRoutes(routes: Route | Route[]) { - return fetch("/v0/app-routes", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(routes), - }).then((r) => r.json()); -} - -export function useAddRoutes() { - const queryClient = useQueryClient(); - const mutation = useMutation({ - mutationFn: addRoutes, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [PROBED_ROUTES_KEY] }); - }, - }); - - return mutation; -} - -function deleteRoute({ - path, - method, -}: { - path: string; - method: string; -}) { - return fetch(`/v0/app-routes/${method}/${encodeURIComponent(path)}`, { - method: "DELETE", - }).then((r) => r.json()); -} - -export function useDeleteRoute() { - const queryClient = useQueryClient(); - const mutation = useMutation({ - mutationFn: deleteRoute, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [PROBED_ROUTES_KEY] }); - }, - }); - - return mutation; -} - -export function useFetchRequestorRequests() { - return useQuery({ - queryKey: [REQUESTOR_REQUESTS_KEY], - queryFn: () => fetch("/v0/all-requests").then((r) => r.json()), - }); -} +} from "../../KeyValueForm"; +import type { RequestorBody, RequestorResponseBody } from "../../store"; +import { useRequestorStore } from "../../store"; +import { REQUESTOR_REQUESTS_KEY } from "./constants"; export type MakeProxiedRequestQueryFn = ReturnType< typeof useMakeProxiedRequest >["mutate"]; -export function useMakeProxiedRequest({ - clearResponseBodyFromHistory, - setActiveResponse, -}: { - clearResponseBodyFromHistory: () => void; - setActiveResponse: (response: RequestorActiveResponse | null) => void; -}) { +export function useMakeProxiedRequest() { + const { clearResponseBodyFromHistory, setActiveResponse } = useRequestorStore( + "clearResponseBodyFromHistory", + "setActiveResponse", + ); + const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: makeProxiedRequest, @@ -190,7 +46,7 @@ export function useMakeProxiedRequest({ return mutation; } -export function makeProxiedRequest({ +function makeProxiedRequest({ addServiceUrlIfBarePath, path, method, @@ -389,17 +245,3 @@ function createBody(body: RequestorBody) { function createUrlEncodedBody(body: Record) { return new URLSearchParams(body).toString(); } - -export function useOpenApiParse(openApiSpec: string) { - const mutation = useMutation({ - mutationFn: async (openApiSpec: string) => { - const { valid, schema } = await validate(openApiSpec); - if (!valid) { - throw new Error("Invalid OpenAPI spec"); - } - return schema; - }, - mutationKey: [openApiSpec], - }); - return mutation; -} diff --git a/studio/src/pages/RequestorPage/queries/hooks/useOpenApiParse.ts b/studio/src/pages/RequestorPage/queries/hooks/useOpenApiParse.ts new file mode 100644 index 000000000..778f61da5 --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/hooks/useOpenApiParse.ts @@ -0,0 +1,15 @@ +import { validate } from "@scalar/openapi-parser"; +import { useMutation } from "@tanstack/react-query"; + +export function useOpenApiParse(openApiSpec: string) { + return useMutation({ + mutationFn: async (openApiSpec: string) => { + const { valid, schema } = await validate(openApiSpec); + if (!valid) { + throw new Error("Invalid OpenAPI spec"); + } + return schema; + }, + mutationKey: [openApiSpec], + }); +} diff --git a/studio/src/pages/RequestorPage/queries/hooks/useProbedRoute.ts b/studio/src/pages/RequestorPage/queries/hooks/useProbedRoute.ts new file mode 100644 index 000000000..18026def5 --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/hooks/useProbedRoute.ts @@ -0,0 +1,19 @@ +import { PROBED_ROUTES_KEY } from "@/queries"; +import { useQuery } from "@tanstack/react-query"; +import type { ProbedRoute } from "../../types"; + +type ProbedRoutesResponse = { + baseUrl: string; + routes: ProbedRoute[]; +}; + +function getProbedRoutes(): Promise { + return fetch("/v0/app-routes").then((r) => r.json()); +} + +export function useProbedRoutes() { + return useQuery({ + queryKey: [PROBED_ROUTES_KEY], + queryFn: getProbedRoutes, + }); +} diff --git a/studio/src/pages/RequestorPage/queries/index.ts b/studio/src/pages/RequestorPage/queries/index.ts new file mode 100644 index 000000000..00d5a033b --- /dev/null +++ b/studio/src/pages/RequestorPage/queries/index.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +export const JsonSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(JsonSchema), + z.record(JsonSchema), + ]), +); + +export type JsonSchemaType = z.infer; + +// TODO - Use validation schema +export type Requestornator = { + app_requests: { + id: number; + requestUrl: string; + requestMethod: string; + requestRoute: string; + requestHeaders?: Record | null; + requestQueryParams?: Record | null; + requestPathParams?: Record | null; + requestBody?: JsonSchemaType; + updatedAt: string; + }; + // NOTE - can be undefined if request failed, at least that happened to me locally + app_responses: { + id: number; + responseStatusCode: string; + responseBody: string; + responseHeaders: Record; + traceId: string; + isFailure: boolean; + failureReason: string | null; + updatedAt: string; + }; +}; + +export * from "./hooks"; diff --git a/studio/src/pages/RequestorPage/reducer/index.ts b/studio/src/pages/RequestorPage/reducer/index.ts deleted file mode 100644 index e9056fd64..000000000 --- a/studio/src/pages/RequestorPage/reducer/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { useRequestor } from "./reducer"; -export type { ResponsePanelTab, RequestsPanelTab } from "./tabs"; -export type { - RequestBodyType, - RequestorBody, - RequestorResponseBody, - RequestorState, -} from "./state"; diff --git a/studio/src/pages/RequestorPage/reducer/persistence.ts b/studio/src/pages/RequestorPage/reducer/persistence.ts deleted file mode 100644 index a080a9740..000000000 --- a/studio/src/pages/RequestorPage/reducer/persistence.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useBeforeUnload } from "react-router-dom"; -import { addSessionIdToState } from "./session-persistence-key"; -import { - LOCAL_STORAGE_KEY, - type SavedRequestorState, - SavedRequestorStateSchema, -} from "./state"; - -/** - * Hook that saves the UI state to local storage when the component unmounts, - * but clears the UI state in local storage when there's a hard refresh - * - * Uses a ref to the state to avoid constantly saving to local storage as the state updates - */ -export function useSaveUiState(state: SavedRequestorState) { - const stateRef = useRef(state); - - // Update the ref whenever the state changes - useEffect(() => { - stateRef.current = state; - }, [state]); - - const saveUiState = useCallback(() => { - try { - const state = - // Fixes issue where having multiple tabs open would disrupt the persisted requestor state of other tabs - addSessionIdToState(SavedRequestorStateSchema.parse(stateRef.current)); - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); - } catch { - // Ignore errors - console.error("Error saving state to local storage"); - } - }, []); - - // When we unmount, save the current state of UI to the browser history - // This allows us to reload the page state when you press "Back" in the browser, or otherwise navigate back to this page - useEffect(() => { - return saveUiState; - }, [saveUiState]); - - // When the page unloads, remove the UI state from local storage - // This allows us to clear the UI state when the user closes the tab or hard-navigates away - useBeforeUnload( - useCallback(() => { - // NOTE - `removeItem` doesn't throw errors, so we don't need a try-catch - localStorage?.removeItem?.(LOCAL_STORAGE_KEY); - }, []), - ); -} diff --git a/studio/src/pages/RequestorPage/reducer/reducer.ts b/studio/src/pages/RequestorPage/reducer/reducer.ts deleted file mode 100644 index 3d6999848..000000000 --- a/studio/src/pages/RequestorPage/reducer/reducer.ts +++ /dev/null @@ -1,881 +0,0 @@ -import { useCallback, useReducer } from "react"; -import { enforceFormDataTerminalDraftParameter } from "../FormDataForm"; -import type { KeyValueParameter } from "../KeyValueForm"; -import { enforceTerminalDraftParameter } from "../KeyValueForm/hooks"; -import type { ProbedRoute } from "../queries"; -import { findMatchedRoute } from "../routes"; -import { - type RequestMethod, - type RequestMethodInputValue, - type RequestType, - isWsRequest, -} from "../types"; -import { useSaveUiState } from "./persistence"; -import { addContentTypeHeader, setBodyTypeReducer } from "./reducers"; -import { - type RequestBodyType, - type RequestorActiveResponse, - type RequestorBody, - type RequestorState, - createInitialState, - initialState, -} from "./state"; -import { - type RequestsPanelTab, - type ResponsePanelTab, - getVisibleRequestPanelTabs, - getVisibleResponsePanelTabs, - isRequestsPanelTab, - isResponsePanelTab, -} from "./tabs"; - -const _getActiveRoute = (state: RequestorState): ProbedRoute => { - return ( - state.selectedRoute ?? { - path: state.path, - method: state.method, - requestType: state.requestType, - handler: "", - handlerType: "route", - currentlyRegistered: false, - routeOrigin: "custom", - isDraft: true, - } - ); -}; - -const SET_ROUTES = "SET_ROUTES" as const; -const SET_SERVICE_BASE_URL = "SET_SERVICE_BASE_URL" as const; -const PATH_UPDATE = "PATH_UPDATE" as const; -const METHOD_UPDATE = "METHOD_UPDATE" as const; -const SELECT_ROUTE = "SELECT_ROUTE" as const; -const SET_PATH_PARAMS = "SET_PATH_PARAMS" as const; -const REPLACE_PATH_PARAM_VALUES = "REPLACE_PATH_PARAM_VALUES" as const; -const CLEAR_PATH_PARAMS = "CLEAR_PATH_PARAMS" as const; -const SET_QUERY_PARAMS = "SET_QUERY_PARAMS" as const; -const SET_HEADERS = "SET_HEADERS" as const; -const SET_BODY = "SET_BODY" as const; -const CLEAR_BODY = "CLEAR_BODY" as const; -const SET_BODY_TYPE = "SET_BODY_TYPE" as const; -const SET_WEBSOCKET_MESSAGE = "SET_WEBSOCKET_MESSAGE" as const; -const LOAD_HISTORICAL_REQUEST = "LOAD_HISTORICAL_REQUEST" as const; -const CLEAR_HISTORICAL_REQUEST = "CLEAR_HISTORICAL_REQUEST" as const; -const SET_ACTIVE_RESPONSE = "SET_ACTIVE_RESPONSE" as const; -const SET_ACTIVE_REQUESTS_PANEL_TAB = "SET_ACTIVE_REQUESTS_PANEL_TAB" as const; -const SET_ACTIVE_RESPONSE_PANEL_TAB = "SET_ACTIVE_RESPONSE_PANEL_TAB" as const; - -type RequestorAction = - | { - type: typeof SET_ROUTES; - payload: ProbedRoute[]; - } - | { - type: typeof SET_SERVICE_BASE_URL; - payload: string; - } - | { - type: typeof PATH_UPDATE; - payload: string; - } - | { - type: typeof METHOD_UPDATE; - payload: { - method: RequestMethod; - requestType: RequestType; - }; - } - | { - type: typeof SELECT_ROUTE; - payload: ProbedRoute; - } - | { - type: typeof SET_PATH_PARAMS; - payload: KeyValueParameter[]; - } - | { - // NOTE - This is the action that the AI generated inputs use to "replace" existing path params - type: typeof REPLACE_PATH_PARAM_VALUES; - payload: { key: string; value: string }[]; - } - | { - type: typeof CLEAR_PATH_PARAMS; - } - | { - type: typeof SET_QUERY_PARAMS; - payload: KeyValueParameter[]; - } - | { - type: typeof SET_HEADERS; - payload: KeyValueParameter[]; - } - | { - type: typeof SET_BODY; - payload: RequestorBody; - } - | { - type: typeof CLEAR_BODY; - } - | { - type: typeof SET_BODY_TYPE; - payload: { - type: RequestBodyType; - isMultipart?: boolean; - }; - } - | { - type: typeof SET_WEBSOCKET_MESSAGE; - payload: string; - } - | { - type: typeof LOAD_HISTORICAL_REQUEST; - payload: { - traceId: string; - }; - } - | { - type: typeof CLEAR_HISTORICAL_REQUEST; - } - | { - type: typeof SET_ACTIVE_RESPONSE; - payload: RequestorActiveResponse | null; - } - | { - type: typeof SET_ACTIVE_REQUESTS_PANEL_TAB; - payload: RequestsPanelTab; - } - | { - type: typeof SET_ACTIVE_RESPONSE_PANEL_TAB; - payload: ResponsePanelTab; - }; - -function requestorReducer( - state: RequestorState, - action: RequestorAction, -): RequestorState { - switch (action.type) { - case SET_ROUTES: { - const nextRoutes = action.payload; - const matchedRoute = findMatchedRoute( - nextRoutes, - removeBaseUrl(state.serviceBaseUrl, state.path), - state.method, - state.requestType, - ); - const nextSelectedRoute = matchedRoute ? matchedRoute.route : null; - - const nextPathParams = matchedRoute - ? extractMatchedPathParams(matchedRoute) - : extractPathParams(state.path).map(mapPathParamKey); - - return { - ...state, - routes: nextRoutes, - selectedRoute: nextSelectedRoute, - pathParams: nextPathParams, - }; - } - case SET_SERVICE_BASE_URL: { - return { - ...state, - serviceBaseUrl: action.payload, - path: addBaseUrl(action.payload, state.path, { forceChangeHost: true }), - }; - } - case PATH_UPDATE: { - const nextPath = action.payload; - const matchedRoute = findMatchedRoute( - state.routes, - removeBaseUrl(state.serviceBaseUrl, nextPath), - state.method, - state.requestType, - ); - const nextSelectedRoute = matchedRoute ? matchedRoute.route : null; - // This logic will reconcile the path param values with what the user is typing - // When the route is in a draft state, something kinda funky happens, where a path param will appear - // but if you fill it in and save it, then the path params disappear... - // Ask Brett to explain it more if that's confusing - const nextPathParams = matchedRoute - ? extractMatchedPathParams(matchedRoute) - : extractPathParams(nextPath).map(mapPathParamKey); - - // If the selected route changed, we want to clear the active history response trace id - const nextActiveHistoryResponseTraceId = - state.selectedRoute === nextSelectedRoute - ? state.activeHistoryResponseTraceId - : null; - - return { - ...state, - path: action.payload, - selectedRoute: nextSelectedRoute, - pathParams: nextPathParams, - activeHistoryResponseTraceId: nextActiveHistoryResponseTraceId, - }; - } - case METHOD_UPDATE: { - const { method, requestType } = action.payload; - const matchedRoute = findMatchedRoute( - state.routes, - removeBaseUrl(state.serviceBaseUrl, state.path), - method, - requestType, - ); - const nextSelectedRoute = matchedRoute ? matchedRoute.route : null; - // See comment below for why we want to do this dance - const nextVisibleRequestsPanelTabs = getVisibleRequestPanelTabs( - action.payload, - ); - const nextActiveRequestsPanelTab = nextVisibleRequestsPanelTabs.includes( - state.activeRequestsPanelTab, - ) - ? state.activeRequestsPanelTab - : nextVisibleRequestsPanelTabs[0]; - - const nextVisibleResponsePanelTabs = getVisibleResponsePanelTabs( - action.payload, - ); - const nextActiveResponsePanelTab = nextVisibleResponsePanelTabs.includes( - state.activeResponsePanelTab, - ) - ? state.activeResponsePanelTab - : nextVisibleResponsePanelTabs[0]; - return addContentTypeHeader({ - ...state, - method, - requestType, - selectedRoute: nextSelectedRoute, - - // Fixes the case where we had "body" tab selected, but then switch to a GET route - // and the "body" tab isn't visible for GET routes - visibleRequestsPanelTabs: nextVisibleRequestsPanelTabs, - activeRequestsPanelTab: nextActiveRequestsPanelTab, - - // Fixes the case where the "messages" tab is selected for a websocket route, - // but then we switch to a non-websocket route and the "messages" tab contents remain visible - visibleResponsePanelTabs: nextVisibleResponsePanelTabs, - activeResponsePanelTab: nextActiveResponsePanelTab, - - // HACK - This allows us to stop showing the response body for a historical request - activeHistoryResponseTraceId: null, - }); - } - case SELECT_ROUTE: { - // See comment below for why we want to do this dance - const nextMethod = probedRouteToInputMethod(action.payload); - const nextRequestType = action.payload.requestType; - - // The visible tabs in the requests panel are based on the request type and method - // If we switch to a new route, it's possible that the "Body" or "Websocket Message" tabs - // are no longer visible, so we need to update the currently active tab on the requests panel - const nextVisibleRequestsPanelTabs = getVisibleRequestPanelTabs({ - requestType: nextRequestType, - method: nextMethod, - }); - const nextActiveRequestsPanelTab = nextVisibleRequestsPanelTabs.includes( - state.activeRequestsPanelTab, - ) - ? state.activeRequestsPanelTab - : nextVisibleRequestsPanelTabs[0]; - - // The visible tabs in the response panel are based on the request type (http or websocket) - // If we switch to a new route, it's possible that the "Websocket Message" tab - // is no longer visible, so we need to update the currently active tab on the response panel - const nextVisibleResponsePanelTabs = getVisibleResponsePanelTabs({ - requestType: nextRequestType, - }); - let nextActiveResponsePanelTab = nextVisibleResponsePanelTabs.includes( - state.activeResponsePanelTab, - ) - ? state.activeResponsePanelTab - : nextVisibleResponsePanelTabs[0]; - - // One more thing, if the debug tab is selected but we switch to an http route, - // we want to switch to the "body" tab instead of the "debug" tab - const didSelectedRouteChange = state.selectedRoute !== action.payload; - const isDebugTabCurrentlySelected = - state.activeResponsePanelTab === "debug"; - - if (didSelectedRouteChange && isDebugTabCurrentlySelected) { - // If the selected route changed and the debug tab is selected, - // we want to switch to the "body" tab - nextActiveResponsePanelTab = "response"; - } - - return addContentTypeHeader({ - ...state, - selectedRoute: action.payload, - - // Reset form values, but preserve things like path params, query params, headers, etc - path: addBaseUrl(state.serviceBaseUrl, action.payload.path, { - requestType: nextRequestType, - }), - method: nextMethod, - requestType: nextRequestType, - - // TODO - We could merge these with existing path params to re-use existing values - // But maybe that'd be annoying? - // Example would be: If user just had `userId` param set to `123`, - // and we select a new route that has `userId` in the path, we'd - // have it re-use the existing `userId` param, not create a new blank one. - // - pathParams: extractPathParams(action.payload.path).map(mapPathParamKey), - - // Fixes the case where we had "body" tab selected, but then switch to a GET route - // and the "body" tab isn't visible for GET routes - visibleRequestsPanelTabs: nextVisibleRequestsPanelTabs, - activeRequestsPanelTab: nextActiveRequestsPanelTab, - - // Fixes the case where the "messages" tab is selected for a websocket route, - // but then we switch to a non-websocket route and the "messages" tab contents remain visible - visibleResponsePanelTabs: nextVisibleResponsePanelTabs, - activeResponsePanelTab: nextActiveResponsePanelTab, - - // HACK - This allows us to stop showing the response body for a historical request - activeHistoryResponseTraceId: null, - - activeResponse: null, - }); - } - case SET_PATH_PARAMS: { - // FIXME - This will be buggy in the case where there is a route like - // `/users/:id/otheruser/:idOtherUser` - // An edge case but... would be annoying - const nextPath = action.payload.reduce((accPath, param) => { - if (param.enabled) { - return accPath.replace(`:${param.key}`, param.value || param.key); - } - - return accPath; - }, state.selectedRoute?.path ?? state.path); - return { - ...state, - path: addBaseUrl(state.serviceBaseUrl, nextPath), - pathParams: action.payload, - }; - } - case REPLACE_PATH_PARAM_VALUES: { - const replacements = action.payload; - const nextPathParams = state.pathParams.map((pathParam) => { - const replacement = replacements?.find((p) => p?.key === pathParam.key); - if (!replacement) { - return pathParam; - } - return { - ...pathParam, - value: replacement.value, - enabled: !!replacement.value, - }; - }); - return { ...state, pathParams: nextPathParams }; - } - case CLEAR_PATH_PARAMS: { - const nextPathParams = state.pathParams.map((pathParam) => ({ - ...pathParam, - value: "", - enabled: false, - })); - return { ...state, pathParams: nextPathParams }; - } - case SET_QUERY_PARAMS: { - return { ...state, queryParams: action.payload }; - } - case SET_HEADERS: { - return { ...state, requestHeaders: action.payload }; - } - case SET_BODY: { - const nextBody = action.payload; - if (nextBody.type === "form-data") { - const nextBodyValue = enforceFormDataTerminalDraftParameter( - nextBody.value, - ); - const shouldForceMultipart = nextBodyValue.some( - (param) => param.value.value instanceof File, - ); - return addContentTypeHeader({ - ...state, - body: { - type: nextBody.type, - isMultipart: shouldForceMultipart || nextBody.isMultipart, - value: nextBodyValue, - }, - }); - } - return { ...state, body: nextBody }; - } - case CLEAR_BODY: { - const nextBody = - state.body.type === "form-data" - ? { - type: "form-data" as const, - value: enforceFormDataTerminalDraftParameter([]), - isMultipart: state.body.isMultipart, - } - : state.body.type === "file" - ? { type: state.body.type, value: undefined } - : { type: state.body.type, value: "" }; - return { ...state, body: nextBody }; - } - case SET_BODY_TYPE: { - return addContentTypeHeader(setBodyTypeReducer(state, action.payload)); - } - case SET_WEBSOCKET_MESSAGE: { - return { ...state, websocketMessage: action.payload }; - } - case LOAD_HISTORICAL_REQUEST: { - return { - ...state, - activeHistoryResponseTraceId: action.payload.traceId, - activeResponse: null, - }; - } - case CLEAR_HISTORICAL_REQUEST: { - return { ...state, activeHistoryResponseTraceId: null }; - } - case SET_ACTIVE_RESPONSE: { - return { ...state, activeResponse: action.payload }; - } - case SET_ACTIVE_REQUESTS_PANEL_TAB: { - return { ...state, activeRequestsPanelTab: action.payload }; - } - case SET_ACTIVE_RESPONSE_PANEL_TAB: { - return { ...state, activeResponsePanelTab: action.payload }; - } - default: - return state; - } -} - -// Not in use -export const routeEquality = (a: ProbedRoute, b: ProbedRoute): boolean => { - return ( - a.path === b.path && - a.method === b.method && - a.routeOrigin === b.routeOrigin && - a.requestType === b.requestType - ); -}; - -/** - * State management api for the RequestorPage - * - * Uses `useReducer` under the hood - */ -export function useRequestor() { - const [state, originalDispatch] = useReducer( - requestorReducer, - initialState, - createInitialState, - ); - - // Create a custom dispatch in case we wanna do some debugging - const dispatch = useCallback((action: RequestorAction) => { - // NOTE - Useful for debugging! - // console.log("Dispatching action:", action); - originalDispatch(action); - }, []); - - useSaveUiState(state); - - const setRoutes = useCallback( - (routes: ProbedRoute[]) => { - dispatch({ type: SET_ROUTES, payload: routes }); - }, - [dispatch], - ); - - const setServiceBaseUrl = useCallback( - (serviceBaseUrl: string) => { - dispatch({ type: SET_SERVICE_BASE_URL, payload: serviceBaseUrl }); - }, - [dispatch], - ); - - const updatePath = useCallback( - (path: string) => { - dispatch({ type: PATH_UPDATE, payload: path }); - }, - [dispatch], - ); - - /** - * Updates the method and request type based on the input value from a RequestMethodComboBox - * If the input value is "WS", the request type is set to "websocket" and the method is set to "GET" - * - * @param methodInputValue - Assumed to come from a RequestMethodComboBox, which could include the value "WS" - */ - const updateMethod = useCallback( - (methodInputValue: RequestMethodInputValue) => { - const requestType = methodInputValue === "WS" ? "websocket" : "http"; - const method = methodInputValue === "WS" ? "GET" : methodInputValue; - dispatch({ type: METHOD_UPDATE, payload: { method, requestType } }); - }, - [dispatch], - ); - - const selectRoute = useCallback( - (route: ProbedRoute) => { - dispatch({ type: SELECT_ROUTE, payload: route }); - }, - [dispatch], - ); - - const setPathParams = useCallback( - (pathParams: KeyValueParameter[]) => { - dispatch({ type: SET_PATH_PARAMS, payload: pathParams }); - }, - [dispatch], - ); - - const updatePathParamValues = useCallback( - (pathParams: { key: string; value: string }[]) => { - dispatch({ type: REPLACE_PATH_PARAM_VALUES, payload: pathParams }); - }, - [dispatch], - ); - - const clearPathParams = useCallback(() => { - dispatch({ type: CLEAR_PATH_PARAMS }); - }, [dispatch]); - - const setQueryParams = useCallback( - (queryParams: KeyValueParameter[]) => { - const parametersWithDraft = enforceTerminalDraftParameter(queryParams); - dispatch({ type: SET_QUERY_PARAMS, payload: parametersWithDraft }); - }, - [dispatch], - ); - - const setRequestHeaders = useCallback( - (headers: KeyValueParameter[]) => { - const parametersWithDraft = enforceTerminalDraftParameter(headers); - dispatch({ type: SET_HEADERS, payload: parametersWithDraft }); - }, - [dispatch], - ); - - const setBody = useCallback( - (body: undefined | string | RequestorBody) => { - if (body === undefined) { - dispatch({ type: CLEAR_BODY }); - } else if (typeof body === "string") { - dispatch({ type: SET_BODY, payload: { type: "text", value: body } }); - } else { - dispatch({ type: SET_BODY, payload: body }); - } - }, - [dispatch], - ); - - const setWebsocketMessage = useCallback( - (websocketMessage: string | undefined) => { - dispatch({ - type: SET_WEBSOCKET_MESSAGE, - payload: websocketMessage ?? "", - }); - }, - [dispatch], - ); - - const handleRequestBodyTypeChange = useCallback( - (requestBodyType: RequestBodyType, isMultipart?: boolean) => { - dispatch({ - type: SET_BODY_TYPE, - payload: { type: requestBodyType, isMultipart }, - }); - }, - [dispatch], - ); - - const setActiveRequestsPanelTab = useCallback( - (tab: string) => { - if (isRequestsPanelTab(tab)) { - dispatch({ type: SET_ACTIVE_REQUESTS_PANEL_TAB, payload: tab }); - } - }, - [dispatch], - ); - - const setActiveResponsePanelTab = useCallback( - (tab: string) => { - if (isResponsePanelTab(tab)) { - dispatch({ type: SET_ACTIVE_RESPONSE_PANEL_TAB, payload: tab }); - } - }, - [dispatch], - ); - - const showResponseBodyFromHistory = useCallback( - (traceId: string) => { - dispatch({ type: LOAD_HISTORICAL_REQUEST, payload: { traceId } }); - }, - [dispatch], - ); - - const clearResponseBodyFromHistory = useCallback(() => { - dispatch({ type: CLEAR_HISTORICAL_REQUEST }); - }, [dispatch]); - - /** - * When there's no selected route, we return a "draft" route, - * which will not appear in the sidebar - */ - const getActiveRoute = (): ProbedRoute => _getActiveRoute(state); - - /** - * Helper that removes the service url from a path, - * otherwise it returns the path unchanged - */ - const removeServiceUrlFromPath = useCallback( - (path: string) => { - return removeBaseUrl(state.serviceBaseUrl, path); - }, - [state.serviceBaseUrl], - ); - - /** - * Helper that adds the service url to a path if it doesn't already have a host - */ - const addServiceUrlIfBarePath = useCallback( - (path: string) => { - return addBaseUrl(state.serviceBaseUrl, path, { - requestType: state.requestType, - }); - }, - [state.serviceBaseUrl, state.requestType], - ); - - /** - * We consider the inputs in "draft" mode when there's no matching route in the side bar - */ - const getIsInDraftMode = useCallback((): boolean => { - return !state.selectedRoute; - }, [state.selectedRoute]); - - /** - * Helper to determine whether or not to show a tab in the requests panel - */ - const shouldShowRequestTab = useCallback( - (tab: RequestsPanelTab): boolean => { - return state.visibleRequestsPanelTabs.includes(tab); - }, - [state.visibleRequestsPanelTabs], - ); - - /** - * Helper to determine whether or not to show a tab in the response panel - */ - const shouldShowResponseTab = useCallback( - (tab: ResponsePanelTab): boolean => { - return state.visibleResponsePanelTabs.includes(tab); - }, - [state.visibleResponsePanelTabs], - ); - - /** - * Sets the active response in the response panel - * This refers to the ACTUAL response body returned through our proxy, - * which allows us to show things like binary data in the response panel - */ - const setActiveResponse = useCallback( - (response: RequestorActiveResponse | null) => { - dispatch({ type: SET_ACTIVE_RESPONSE, payload: response }); - }, - [dispatch], - ); - - return { - state, - dispatch, - - // Api - setRoutes, - setServiceBaseUrl, - selectRoute, - - // Form fields - updatePath, - updateMethod, - setPathParams, - updatePathParamValues, - clearPathParams, - setQueryParams, - setRequestHeaders, - setBody, - handleRequestBodyTypeChange, - addServiceUrlIfBarePath, - removeServiceUrlFromPath, - - // Websocket form - setWebsocketMessage, - - // Requests Panel tabs - setActiveRequestsPanelTab, - shouldShowRequestTab, - - // Response Panel tabs - setActiveResponsePanelTab, - shouldShowResponseTab, - - // Response Panel response body - setActiveResponse, - - // Selectors - getActiveRoute, - getIsInDraftMode, - - // History (WIP) - showResponseBodyFromHistory, - clearResponseBodyFromHistory, - }; -} -function probedRouteToInputMethod(route: ProbedRoute): RequestMethod { - const method = route.method.toUpperCase(); - switch (method) { - case "GET": - return "GET"; - case "POST": - return "POST"; - case "PUT": - return "PUT"; - case "DELETE": - return "DELETE"; - case "OPTIONS": - return "OPTIONS"; - case "PATCH": - return "PATCH"; - case "HEAD": - return "HEAD"; - default: - return "GET"; - } -} - -/** - * Extracts path parameters from a path - * - * @TODO - Rewrite to use Hono router - * - * @param path - * @returns - */ -function extractPathParams(path: string) { - const regex = /\/(:[a-zA-Z0-9_-]+)/g; - - const result: Array = []; - // let match = regex.exec(path); - let lastIndex = -1; - while (true) { - const match = regex.exec(path); - - if (match === null) { - break; - } - - // Check if the regex is stuck in an infinite loop - if (regex.lastIndex === lastIndex) { - break; - } - lastIndex = regex.lastIndex; - - // HACK - Remove the `:` at the beginning of the match, to make things consistent with Hono router path param matching - const keyWithoutColon = match[1].slice(1); - result.push(keyWithoutColon); - } - return result; -} - -function mapPathParamKey(key: string) { - return { key, value: "", id: key, enabled: false }; -} - -function extractMatchedPathParams( - matchedRoute: ReturnType, -) { - return Object.entries(matchedRoute?.pathParamValues ?? {}).map( - ([key, value]) => { - const nextValue = value === `:${key}` ? "" : value; - return { - ...mapPathParamKey(key), - value: nextValue, - enabled: !!nextValue, - }; - }, - ); -} - -/** - * Removes the base url from a path so we can try to match a route... - */ -const removeBaseUrl = (serviceBaseUrl: string, path: string) => { - if (!pathHasValidBaseUrl(path)) { - return path; - } - - if (!pathHasValidBaseUrl(serviceBaseUrl)) { - return path; - } - - const serviceHost = new URL(serviceBaseUrl).host; - const servicePort = new URL(serviceBaseUrl).port; - - const pathHost = new URL(path).host; - const pathPort = new URL(path).port; - - // TODO - Make this work with query params!!! - if (pathHost === serviceHost && pathPort === servicePort) { - return new URL(path).pathname; - } - - return path; -}; - -const addBaseUrl = ( - serviceBaseUrl: string, - path: string, - { - requestType, - forceChangeHost, - }: { requestType?: RequestType; forceChangeHost?: boolean } = { - requestType: "http", - forceChangeHost: false, - }, -) => { - // NOTE - This is necessary to allow the user to type new base urls... even though we replace the base url whenever they switch routes - if (pathHasValidBaseUrl(path) && !forceChangeHost) { - return path; - } - - // HACK - Fix this later, not a great pattern - if (pathHasValidBaseUrl(path) && forceChangeHost) { - const safeBaseUrl = serviceBaseUrl.endsWith("/") - ? serviceBaseUrl.slice(0, -1) - : serviceBaseUrl; - const parsedPath = new URL(path); - const search = parsedPath.search; - return `${safeBaseUrl}${parsedPath.pathname}${search}`; - } - - const parsedBaseUrl = new URL(serviceBaseUrl); - if (requestType && isWsRequest(requestType)) { - parsedBaseUrl.protocol = "ws"; - } - let updatedBaseUrl = parsedBaseUrl.toString(); - if (updatedBaseUrl.endsWith("/")) { - updatedBaseUrl = updatedBaseUrl.slice(0, -1); - } - if (path?.startsWith(updatedBaseUrl)) { - return path; - } - - const safePath = path?.startsWith("/") ? path : `/${path}`; - return `${updatedBaseUrl}${safePath}`; -}; - -function pathHasValidBaseUrl(path: string) { - try { - new URL(path); - return true; - } catch { - return false; - } -} diff --git a/studio/src/pages/RequestorPage/reducer/reducers/index.ts b/studio/src/pages/RequestorPage/reducer/reducers/index.ts deleted file mode 100644 index 66b7546ce..000000000 --- a/studio/src/pages/RequestorPage/reducer/reducers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./content-type"; -export * from "./set-body-type"; diff --git a/studio/src/pages/RequestorPage/reducer/session-persistence-key.ts b/studio/src/pages/RequestorPage/reducer/session-persistence-key.ts deleted file mode 100644 index 9eb1b2092..000000000 --- a/studio/src/pages/RequestorPage/reducer/session-persistence-key.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { objectWithKey } from "@/utils"; -import type { SavedRequestorState } from "./state"; - -// NOTE - This will be unique to the tab/session, and refreshed whenever the page is reloaded. -// This allows us to NOT load the UI state saved in local storage when the user has Studio open in another tab. -const CURRENT_SESSION_ID = crypto.randomUUID(); - -// We add this key to the persisted session state to determine if the state is from the current session -const SESSION_ID_KEY = "_persistenceSessionId"; - -/** - * Checks if the state in local storage is from the current tab's session - */ -export function isCurrentSessionState(state: unknown) { - if (objectWithKey(state, SESSION_ID_KEY)) { - return state[SESSION_ID_KEY] === CURRENT_SESSION_ID; - } - return false; -} - -/** - * Adds the session ID to the state to indicate that the persisted state is from the current tab's session - */ -export function addSessionIdToState(state: SavedRequestorState) { - return { - ...state, - // Fixes issue where having multiple tabs open would disrupt the persisted requestor state of other tabs - [SESSION_ID_KEY]: CURRENT_SESSION_ID, - }; -} diff --git a/studio/src/pages/RequestorPage/routes/hooks.ts b/studio/src/pages/RequestorPage/routes/hooks.ts index 946d29383..81435c4f7 100644 --- a/studio/src/pages/RequestorPage/routes/hooks.ts +++ b/studio/src/pages/RequestorPage/routes/hooks.ts @@ -1,12 +1,9 @@ import { useEffect, useMemo } from "react"; -import { type ProbedRoute, useProbedRoutes } from "../queries"; +import { useProbedRoutes } from "../queries"; +import { useRequestorStore } from "../store"; +import type { ProbedRoute } from "../types"; import { WEBSOCKETS_ENABLED } from "../webSocketFeatureFlag"; -type UseRoutesOptions = { - setRoutes: (routes: ProbedRoute[]) => void; - setServiceBaseUrl: (serviceBaseUrl: string) => void; -}; - /** * Suuuuper hacky way to check if a route is using the standard Hono * boilerplate to upgrade to a websocket connection. @@ -41,7 +38,22 @@ const filterRoutes = (routes: ProbedRoute[]) => { }); }; -export function useRoutes({ setRoutes, setServiceBaseUrl }: UseRoutesOptions) { +/** + * Filter the routes and middleware that are currently registered. + */ +const filterActive = (routesAndMiddleware: ProbedRoute[]) => { + return routesAndMiddleware.filter((r) => { + return r.currentlyRegistered; + }); +}; + +export function useRoutes() { + const { setRoutes, setServiceBaseUrl, setRoutesAndMiddleware } = + useRequestorStore( + "setRoutes", + "setServiceBaseUrl", + "setRoutesAndMiddleware", + ); const { data: routesAndMiddleware, isLoading, isError } = useProbedRoutes(); const routes = useMemo(() => { const routes = filterRoutes(routesAndMiddleware?.routes ?? []); @@ -57,12 +69,18 @@ export function useRoutes({ setRoutes, setServiceBaseUrl }: UseRoutesOptions) { ); }, [routesAndMiddleware]); + const activeRoutesAndMiddleware = useMemo(() => { + const activeRoutes = filterActive(routesAndMiddleware?.routes ?? []); + activeRoutes.sort((a, b) => b.registrationOrder - a.registrationOrder); + return activeRoutes; + }, [routesAndMiddleware]); + // HACK - Antipattern, add serviceBaseUrl to the reducer based off of external changes // HACK - Defaults to localhost:8787 if not set const serviceBaseUrl = routesAndMiddleware?.baseUrl ?? "http://localhost:8787"; useEffect(() => { - console.log("setting serviceBaseUrl", serviceBaseUrl); + console.debug("setting serviceBaseUrl", serviceBaseUrl); setServiceBaseUrl(serviceBaseUrl); }, [serviceBaseUrl, setServiceBaseUrl]); @@ -72,9 +90,13 @@ export function useRoutes({ setRoutes, setServiceBaseUrl }: UseRoutesOptions) { setRoutes(routes); }, [routes, setRoutes]); + // HACK - Antipattern, add routes and middleware to the reducer based off of external changes + useEffect(() => { + setRoutesAndMiddleware(activeRoutesAndMiddleware); + }, [activeRoutesAndMiddleware, setRoutesAndMiddleware]); + return { isError, isLoading, - routes, }; } diff --git a/studio/src/pages/RequestorPage/routes/index.ts b/studio/src/pages/RequestorPage/routes/index.ts index 48ac3bf1c..4ea18ee7d 100644 --- a/studio/src/pages/RequestorPage/routes/index.ts +++ b/studio/src/pages/RequestorPage/routes/index.ts @@ -1,3 +1,3 @@ export { useRoutes } from "./hooks"; -export { findMatchedRoute } from "./match"; +export { findMatchedRoute, findAllSmartRouterMatches } from "./match"; export { AddRouteButton } from "./AddRouteButton"; diff --git a/studio/src/pages/RequestorPage/routes/match.test.ts b/studio/src/pages/RequestorPage/routes/match.test.ts index 246380ec7..38d7b69b1 100644 --- a/studio/src/pages/RequestorPage/routes/match.test.ts +++ b/studio/src/pages/RequestorPage/routes/match.test.ts @@ -1,18 +1,20 @@ -import type { ProbedRoute } from "../queries"; -import type { RequestMethod, RequestType } from "../types"; -import { findSmartRouterMatches } from "./match"; +import type { ProbedRoute, RequestMethod, RequestType } from "../types"; +import { findFirstSmartRouterMatch } from "./match"; const toRoute = ( path: string, method: RequestMethod, requestType: RequestType, + currentlyRegistered = false, + registrationOrder = -1, ) => ({ path, method, requestType, handler: "", handlerType: "route" as const, - currentlyRegistered: false, + currentlyRegistered, + registrationOrder, routeOrigin: "custom" as const, isDraft: false, }); @@ -29,32 +31,48 @@ describe("findSmartRouterMatch", () => { ]; it("should return a match for the given pathname and method", () => { - const match = findSmartRouterMatches(routes, "/test", "GET", "http"); - console.log("/test match", match); + const match = findFirstSmartRouterMatch(routes, "/test", "GET", "http"); expect(match).toBeTruthy(); }); it("should return a match for the given pathname and method", () => { - const match = findSmartRouterMatches(routes, "/test", "POST", "http"); + const match = findFirstSmartRouterMatch(routes, "/test", "POST", "http"); expect(match).toBeTruthy(); }); // NOTE - Basically just testing router behavior but this is a sanity check for me it("should return null if no match is found (params in route)", () => { - const match = findSmartRouterMatches(routes, "/users", "GET", "http"); + const match = findFirstSmartRouterMatch(routes, "/users", "GET", "http"); expect(match).toBeNull(); }); it("should return a match for the given pathname and method", () => { - const match = findSmartRouterMatches(routes, "/users/1", "GET", "http"); - expect(match).toMatchObject({ - path: "/users/:userId", - method: "GET", - isWs: false, + const match = findFirstSmartRouterMatch(routes, "/users/1", "GET", "http"); + expect(match).toBeTruthy(); + expect(match?.route).toBeDefined(); + expect(match?.route?.method).toBe("GET"); + expect(match?.route?.path).toBe("/users/:userId"); + expect(match?.pathParams).toMatchObject({ + userId: "1", }); }); }); +describe("findFirstSmartRouterMatch - registered routes precedence", () => { + const routes: ProbedRoute[] = [ + // Unregesterd route - should not be matched first + toRoute("/test/:k", "GET", "http", false, -1), + // Registered route - should be matched first + toRoute("/test/:key", "GET", "http", true, 1), + ]; + + it("should return registered route with higher precedence", () => { + const match = findFirstSmartRouterMatch(routes, "/test/123", "GET", "http"); + expect(match).toBeTruthy(); + expect(match?.route?.path).toBe("/test/:key"); + }); +}); + // describe("findMatchedRoute", () => { // const routes: ProbedRoute[] = [ // { path: "/test", method: "GET", isWs: false }, diff --git a/studio/src/pages/RequestorPage/routes/match.ts b/studio/src/pages/RequestorPage/routes/match.ts index 02f49888b..7cd4f17a9 100644 --- a/studio/src/pages/RequestorPage/routes/match.ts +++ b/studio/src/pages/RequestorPage/routes/match.ts @@ -1,15 +1,35 @@ +import type { ParamIndexMap, ParamStash } from "hono/router"; import { RegExpRouter } from "hono/router/reg-exp-router"; import { SmartRouter } from "hono/router/smart-router"; import { TrieRouter } from "hono/router/trie-router"; -import type { ProbedRoute } from "../queries"; +import type { ProbedRoute } from "../types"; type MatchedRouteResult = { route: ProbedRoute; - pathParamValues?: + pathParams?: | Record | Record; } | null; +/** + * Looks for a single matching route given the pathname, method, and request type + * Precedence should always occur as follows: + * - First checks registered routes + * - Then checks unregistered routes + * + * As of writing, precedence is enforced via sorting the routes (in a helper function for finding smart router matches) + * + * If the router throws an error when matching, we return the first route that matches exactly on: + * - pathname + * - method + * - requestType + * + * @param routes + * @param pathname + * @param method + * @param requestType + * @returns + */ export function findMatchedRoute( routes: ProbedRoute[], pathname: string | undefined, @@ -17,17 +37,15 @@ export function findMatchedRoute( requestType: "http" | "websocket", ): MatchedRouteResult { if (pathname && method) { - const smartMatch = findSmartRouterMatches( + const smartMatch = findFirstSmartRouterMatch( routes, pathname, method, requestType, ); + if (smartMatch?.route) { - return { - route: smartMatch.route, - pathParamValues: smartMatch.pathParams, - }; + return { route: smartMatch.route, pathParams: smartMatch.pathParams }; } } @@ -46,14 +64,33 @@ export function findMatchedRoute( } /** - * Prototype of doing route matching in the browser with Hono itself + * Return the first matching route (or middleware!) from the smart router */ -export function findSmartRouterMatches( +export function findFirstSmartRouterMatch( routes: ProbedRoute[], pathname: string, method: string, requestType: "http" | "websocket", ) { + return ( + findAllSmartRouterMatches(routes, pathname, method, requestType)?.[0] ?? + null + ); +} + +/** + * Returns all matching routes (or middleware!) from the smart router + */ +export function findAllSmartRouterMatches( + unsortedRoutes: ProbedRoute[], + pathname: string, + method: string, + requestType: "http" | "websocket", +) { + // HACK - Sort with registered routes first, then unregistered routes + // Look at the sortRoutesForMatching function for more details + const routes = sortRoutesForMatching(unsortedRoutes); + // HACK - We need to be able to associate route handlers back to the ProbedRoute definition const functionHandlerLookupTable: Map<() => void, ProbedRoute> = new Map(); @@ -89,29 +126,14 @@ export function findSmartRouterMatches( const routeMatches = matches.map((match) => { const handler = match[0]; + const pathParams = match[1]; return { route: functionHandlerLookupTable.get(handler as () => void), - pathParams: match[1], + pathParams, }; }); - // Sort draft routes after non-draft routes - routeMatches.sort((a, b) => { - const aIsDraft = !!a?.route?.isDraft; - const bIsDraft = !!b?.route?.isDraft; - if (aIsDraft && bIsDraft) { - return 0; - } - if (aIsDraft) { - return 1; - } - if (bIsDraft) { - return -1; - } - return 0; - }); - - return routeMatches[0]; + return routeMatches; } catch (e) { console.error("Error matching routes", e); return null; @@ -138,6 +160,50 @@ const isMatchResultEmpty = , T>( } }; +/** + * Sorts routes for matching, with registered routes first, then unregistered routes + * + * - for registered routes: + * - `registrationOrder` (ascending) + * - for unregistered routes: + * - `isDraft` status (`false` before `true`) + * + * @NOTE - Creates a new array, does not mutate the input + */ +function sortRoutesForMatching(unsortedRoutes: ProbedRoute[]) { + const routes = [...unsortedRoutes]; + + routes.sort((a, b) => { + const aIsRegistered = a.currentlyRegistered; + const bIsRegistered = b.currentlyRegistered; + const aIsDraft = a.isDraft; + const bIsDraft = b.isDraft; + + // First, sort by registration status + if (aIsRegistered !== bIsRegistered) { + return aIsRegistered ? -1 : 1; + } + + // Then, If registration status is the same, sort by draft status + if (aIsDraft !== bIsDraft) { + return aIsDraft ? 1 : -1; + } + + // Then, sort by registration order + if (aIsRegistered && bIsRegistered) { + return a.registrationOrder - b.registrationOrder; + } + + // If both registration and draft status are the same, sort by registration order + return a.registrationOrder - b.registrationOrder; + }); + + return routes; +} + +/** + * Transforms a route match result into an array of matches with path parameter values + */ const unpackMatches = , T>( result: ReturnType, ) => { @@ -145,7 +211,14 @@ const unpackMatches = , T>( if (result.length === 2) { for (const m of result[0]) { if (m[0] !== undefined) { - matches.push(m); + const handler = m[0]; + const paramIndexMap = m[1]; + const paramStash = result[1]; + const pathParams = paramIndexMapToObject(paramIndexMap, paramStash); + matches.push([handler, pathParams] as [ + unknown, + Record, + ]); } } } @@ -158,3 +231,18 @@ const unpackMatches = , T>( } return matches; }; + +const paramIndexMapToObject = ( + paramIndexMap: ParamIndexMap, + paramStash: ParamStash, +): Record => { + return Object.fromEntries( + Object.entries(paramIndexMap).map(([key, value]) => { + // For a RegExpRouter, the paramStash is the result of a regex match + // and the values are the indices of the parsed values in the match + // so we need to map the indices to the actual values + const actualValue = paramStash?.[value]; + return [key, actualValue]; + }), + ); +}; diff --git a/studio/src/pages/RequestorPage/reducer/reducers/content-type.ts b/studio/src/pages/RequestorPage/store/content-type.ts similarity index 79% rename from studio/src/pages/RequestorPage/reducer/reducers/content-type.ts rename to studio/src/pages/RequestorPage/store/content-type.ts index ef691d5e2..be61340c8 100644 --- a/studio/src/pages/RequestorPage/reducer/reducers/content-type.ts +++ b/studio/src/pages/RequestorPage/store/content-type.ts @@ -1,9 +1,10 @@ import { type KeyValueParameter, enforceTerminalDraftParameter, -} from "../../KeyValueForm"; -import { isDraftParameter } from "../../KeyValueForm/data"; -import type { RequestorBody, RequestorState } from "../state"; +} from "../KeyValueForm"; +import { isDraftParameter } from "../KeyValueForm/data"; +import type { RequestResponseSlice } from "./slices/types"; +import type { RequestorBody } from "./types"; /** * This makes sure to synchronize the content type header with the body type. @@ -20,7 +21,7 @@ import type { RequestorBody, RequestorState } from "../state"; * * - If the body is a text, we want to set/update the content type to text/plain */ -export function addContentTypeHeader(state: RequestorState): RequestorState { +export function updateContentTypeHeaderInState(state: RequestResponseSlice) { const currentHeaders = state.requestHeaders; const currentContentTypeHeader = getCurrentContentType(state); @@ -37,10 +38,7 @@ export function addContentTypeHeader(state: RequestorState): RequestorState { nextHeaders = removeHeader(currentHeaders, updateOperation.value); } - return { - ...state, - requestHeaders: enforceTerminalDraftParameter(nextHeaders), - }; + state.requestHeaders = enforceTerminalDraftParameter(nextHeaders); } function addHeader( @@ -64,6 +62,8 @@ function removeHeader( return currentHeaders.filter((p) => p.id !== header.id); } +// NOTE - This logic is partly duplicated in `useRequestorSubmitHandler` +// We should refactor to share this logic function mapBodyToContentType(body: RequestorBody) { if (body.type === "form-data" && body.isMultipart) { return "multipart/form-data"; @@ -72,10 +72,9 @@ function mapBodyToContentType(body: RequestorBody) { return "application/x-www-form-urlencoded"; } - // TODO - Sniff the file extension, this could otherwise be very very annoying - // if we keep resetting their content type header when it's already set to like "application/iamge" + // NOTE - Uses the mime type of the file, but falls back to application/octet-stream if (body.type === "file") { - return "application/octet-stream"; + return body.value?.type ?? "application/octet-stream"; } if (body.type === "json") { @@ -88,7 +87,7 @@ function mapBodyToContentType(body: RequestorBody) { return "text/plain"; } -function getCurrentContentType(state: RequestorState) { +function getCurrentContentType(state: RequestResponseSlice) { const currentContentType = state.requestHeaders.find( (header) => header.key?.toLowerCase() === "content-type", ); @@ -99,7 +98,7 @@ function getCurrentContentType(state: RequestorState) { } function getUpdateOperation( - state: RequestorState, + state: RequestResponseSlice, currentContentTypeHeader: KeyValueParameter | null, ) { const canHaveBody = state.method !== "GET" && state.method !== "HEAD"; @@ -130,12 +129,6 @@ function getUpdateOperation( return null; } - // If the body is a file, we don't want to add the content type header, in order to give the user the ability to set the content type themselves - // If the user doesn't define a content type, fetch will just add application/octet-stream - if (currentBody.type === "file") { - return null; - } - // Add the content type header return { type: "add", @@ -169,18 +162,6 @@ function getUpdateOperation( }; } - if (currentBody.type === "file") { - if ( - currentContentTypeHeader.value?.startsWith("text/") || - currentContentTypeHeader.value?.startsWith("application/json") - ) { - return { - type: "remove", - value: currentContentTypeHeader, - }; - } - } - // Update the content type header return { type: "update", diff --git a/studio/src/pages/RequestorPage/store/index.ts b/studio/src/pages/RequestorPage/store/index.ts new file mode 100644 index 000000000..db2075d88 --- /dev/null +++ b/studio/src/pages/RequestorPage/store/index.ts @@ -0,0 +1,55 @@ +export type { ResponsePanelTab, RequestsPanelTab } from "./tabs"; +export type { + RequestBodyType, + RequestorBody, + RequestorResponseBody, +} from "./types"; +import { memoize } from "proxy-memoize"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; +import { useShallow } from "zustand/react/shallow"; +import { requestResponseSlice } from "./slices/requestResponseSlice"; +import { routesSlice } from "./slices/routesSlice"; +import { tabsSlice } from "./slices/tabsSlice"; +import type { Store } from "./slices/types"; +import { uiSlice } from "./slices/uiSlice"; +import { websocketSlice } from "./slices/websocketSlice"; +import { _getActiveRoute } from "./utils"; +export { useServiceBaseUrl } from "./useServiceBaseUrl"; + +export type RequestorState = Store; +export const useRequestorStoreRaw = create()( + devtools( + immer((...a) => ({ + ...routesSlice(...a), + ...websocketSlice(...a), + ...tabsSlice(...a), + ...requestResponseSlice(...a), + ...uiSlice(...a), + })), + { name: "RequestorStore" }, + ), +); + +const getActiveRoute = memoize(_getActiveRoute); + +export function useActiveRoute() { + return useRequestorStoreRaw(useShallow(getActiveRoute)); +} + +export function useRequestorStore( + ...items: Array +): Pick { + const obj = useRequestorStoreRaw( + useShallow((state) => { + const result = {} as Pick; + for (const item of items) { + result[item as K] = state[item] as T[K]; + } + + return result; + }), + ); + return obj as Pick; +} diff --git a/studio/src/pages/RequestorPage/reducer/request-body.ts b/studio/src/pages/RequestorPage/store/request-body.ts similarity index 100% rename from studio/src/pages/RequestorPage/reducer/request-body.ts rename to studio/src/pages/RequestorPage/store/request-body.ts diff --git a/studio/src/pages/RequestorPage/reducer/reducers/set-body-type.ts b/studio/src/pages/RequestorPage/store/set-body-type.ts similarity index 66% rename from studio/src/pages/RequestorPage/reducer/reducers/set-body-type.ts rename to studio/src/pages/RequestorPage/store/set-body-type.ts index 94e831bfc..bb1498d99 100644 --- a/studio/src/pages/RequestorPage/reducer/reducers/set-body-type.ts +++ b/studio/src/pages/RequestorPage/store/set-body-type.ts @@ -1,5 +1,6 @@ -import { enforceFormDataTerminalDraftParameter } from "../../FormDataForm"; -import type { RequestBodyType, RequestorState } from "../state"; +import { enforceFormDataTerminalDraftParameter } from "../FormDataForm"; +import type { RequestResponseSlice } from "./slices/types"; +import type { RequestBodyType } from "./types"; /** * This reducer is responsible for setting the body type of the request. @@ -7,8 +8,8 @@ import type { RequestBodyType, RequestorState } from "../state"; * It's messy, but it's its own function! SO we can start testing it. Some day. * We have big plans for this reducer function. Big plans. */ -export function setBodyTypeReducer( - state: RequestorState, +export function setBodyTypeInState( + state: RequestResponseSlice, { type: newBodyType, isMultipart, @@ -16,52 +17,41 @@ export function setBodyTypeReducer( type: RequestBodyType; isMultipart?: boolean; }, -): RequestorState { +): void { const oldBodyValue = state.body.value; const oldBodyType = state.body.type; // Handle the case where the body type is the same, but the multipart flag is different if (oldBodyType === newBodyType) { // HACK - Refactor if (state.body.type === "form-data") { - return { - ...state, - body: { - ...state.body, - isMultipart: !!isMultipart, - }, - }; + state.body.isMultipart = !!isMultipart; } - return state; + + return; } // Handle the case where the body type is changing to form-data, so we want to clear the body value if (newBodyType === "form-data") { - return { - ...state, - body: { - type: newBodyType, - isMultipart: !!isMultipart, - value: enforceFormDataTerminalDraftParameter([]), - }, + state.body = { + type: newBodyType, + isMultipart: !!isMultipart, + value: enforceFormDataTerminalDraftParameter([]), }; + return; } // Handle the case where the body type is changing to file, so we want to clear the body value and make it undefined if (newBodyType === "file") { - return { - ...state, - body: { type: newBodyType, value: undefined }, - }; + state.body = { type: newBodyType, value: undefined }; + return; } // At this point, we know the next body type is going to be text or json, soooo // Let's handle the case where the body type is changing to text or json, // meaning we want to clear the body value and make it an empty string if (oldBodyType === "form-data") { - return { - ...state, - body: { type: newBodyType, value: "" }, - }; + state.body = { type: newBodyType, value: "" }; //, + return; } // HACK - These new few lines makes things clearer for typescript, but are a nightmare to read and reason about, i'm so sorry @@ -69,8 +59,5 @@ export function setBodyTypeReducer( Array.isArray(oldBodyValue) || oldBodyValue instanceof File; const newBodyValue = isNonTextOldBody ? "" : oldBodyValue; - return { - ...state, - body: { type: newBodyType, value: newBodyValue }, - }; + state.body = { type: newBodyType, value: newBodyValue }; //, } diff --git a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts new file mode 100644 index 000000000..046e4f6de --- /dev/null +++ b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts @@ -0,0 +1,199 @@ +import type { StateCreator } from "zustand"; +import { enforceFormDataTerminalDraftParameter } from "../../FormDataForm"; +import type { KeyValueParameter } from "../../KeyValueForm"; +import { enforceTerminalDraftParameter } from "../../KeyValueForm"; +import { findMatchedRoute } from "../../routes"; +import { updateContentTypeHeaderInState } from "../content-type"; +import { setBodyTypeInState } from "../set-body-type"; +import { + addBaseUrl, + extractMatchedPathParams, + extractPathParams, + mapPathParamKey, + removeBaseUrl, +} from "../utils"; +import type { RequestResponseSlice, Store } from "./types"; + +export const requestResponseSlice: StateCreator< + Store, + [["zustand/immer", never], ["zustand/devtools", never]], + [], + RequestResponseSlice +> = (set, get) => ({ + serviceBaseUrl: "http://localhost:8787", + path: "", + method: "GET", + requestType: "http", + body: { + type: "json", + value: "", + }, + pathParams: [], + queryParams: enforceTerminalDraftParameter([]), + requestHeaders: enforceTerminalDraftParameter([]), + + setServiceBaseUrl: (serviceBaseUrl) => + set((state) => { + state.serviceBaseUrl = serviceBaseUrl; + state.path = addBaseUrl(serviceBaseUrl, state.path, { + forceChangeHost: true, + }); + }), + + updatePath: (path) => + set((state) => { + const matchedRoute = findMatchedRoute( + state.routes, + removeBaseUrl(state.serviceBaseUrl, path), + state.method, + state.requestType, + ); + const nextActiveRoute = matchedRoute ? matchedRoute.route : null; + const nextPathParams = matchedRoute + ? extractMatchedPathParams(matchedRoute) + : extractPathParams(path).map(mapPathParamKey); + + state.path = path; + state.activeRoute = nextActiveRoute; + state.pathParams = nextPathParams; + state.activeHistoryResponseTraceId = + state.activeRoute === nextActiveRoute + ? state.activeHistoryResponseTraceId + : null; + }), + + updateMethod: (methodInputValue) => + set((state) => { + const requestType = methodInputValue === "WS" ? "websocket" : "http"; + const method = methodInputValue === "WS" ? "GET" : methodInputValue; + + state.method = method; + state.requestType = requestType; + + // Update other state properties based on the new method and request type + // (e.g., activeRoute, visibleRequestsPanelTabs, activeRequestsPanelTab, etc.) + // You might want to move some of this logic to separate functions or slices + }), + + setPathParams: (pathParams) => + set((state) => { + const nextPath = pathParams.reduce((accPath, param) => { + if (param.enabled) { + return accPath.replace(`:${param.key}`, param.value || param.key); + } + return accPath; + }, state.activeRoute?.path ?? state.path); + + state.path = addBaseUrl(state.serviceBaseUrl, nextPath); + state.pathParams = pathParams; + }), + + updatePathParamValues: (pathParams) => + set((state) => { + state.pathParams = state.pathParams.map( + (pathParam: KeyValueParameter) => { + const replacement = pathParams?.find((p) => p?.key === pathParam.key); + if (!replacement) { + return pathParam; + } + + return { + ...pathParam, + value: replacement.value, + enabled: !!replacement.value, + }; + }, + ); + }), + + clearPathParams: () => + set((state) => { + state.pathParams = state.pathParams.map((pathParam) => ({ + ...pathParam, + value: "", + enabled: false, + })); + }), + + setQueryParams: (queryParams) => + set((state) => { + state.queryParams = enforceTerminalDraftParameter(queryParams); + }), + + setRequestHeaders: (headers) => + set((state) => { + state.requestHeaders = enforceTerminalDraftParameter(headers); + }), + + setBody: (body) => + set((state) => { + if (body === undefined) { + state.body = + state.body.type === "form-data" + ? { + type: "form-data", + value: enforceFormDataTerminalDraftParameter([]), + isMultipart: state.body.isMultipart, + } + : state.body.type === "file" + ? { type: state.body.type, value: undefined } + : { type: state.body.type, value: "" }; + } else if (typeof body === "string") { + state.body = { type: "text", value: body }; + } else { + if (body.type === "form-data") { + const nextBodyValue = enforceFormDataTerminalDraftParameter( + body.value, + ); + const shouldForceMultipart = nextBodyValue.some( + (param) => param.value.value instanceof File, + ); + state.body = { + type: body.type, + isMultipart: shouldForceMultipart || body.isMultipart, + value: nextBodyValue, + }; + updateContentTypeHeaderInState(state); + } else if (body.type === "file") { + // When the user adds a file, we want to use the file's type as the content type header + state.body = body; + updateContentTypeHeaderInState(state); + } else { + state.body = body; + } + } + }), + + handleRequestBodyTypeChange: (requestBodyType, isMultipart) => + set((state) => { + setBodyTypeInState(state, { type: requestBodyType, isMultipart }); + updateContentTypeHeaderInState(state); + }), + + // TODO - change the function ref when the serviceBaseUrl is updated + removeServiceUrlFromPath: (path: string) => + removeBaseUrl(get().serviceBaseUrl, path), + + activeHistoryResponseTraceId: null, + activeResponse: null, + showResponseBodyFromHistory: (traceId) => + set((state) => { + state.activeHistoryResponseTraceId = traceId; + state.activeResponse = null; + }), + clearResponseBodyFromHistory: () => + set((state) => { + state.activeHistoryResponseTraceId = null; + }), + setActiveResponse: (response) => + set((state) => { + state.activeResponse = response; + }), + + /** Session history related state */ + sessionHistory: [], + recordRequestInSessionHistory: (traceId: string) => + set((state) => { + state.sessionHistory.push(traceId); + }), +}); diff --git a/studio/src/pages/RequestorPage/store/slices/routesSlice.ts b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts new file mode 100644 index 000000000..ab1685756 --- /dev/null +++ b/studio/src/pages/RequestorPage/store/slices/routesSlice.ts @@ -0,0 +1,176 @@ +import type { StateCreator } from "zustand"; +import { findAllSmartRouterMatches, findMatchedRoute } from "../../routes"; +import type { ProbedRoute, RequestMethod } from "../../types"; +import { updateContentTypeHeaderInState } from "../content-type"; +import { + getVisibleRequestPanelTabs, + getVisibleResponsePanelTabs, +} from "../tabs"; +import { + addBaseUrl, + extractMatchedPathParams, + extractPathParams, + mapPathParamKey, + pathHasValidBaseUrl, + removeBaseUrl, +} from "../utils"; +import type { RoutesSlice, Store } from "./types"; + +export const routesSlice: StateCreator< + Store, + [["zustand/immer", never], ["zustand/devtools", never]], + [], + RoutesSlice +> = (set, get) => ({ + routes: [], + activeRoute: null, + + setRoutes: (routes) => + set((state) => { + const matchedRoute = findMatchedRoute( + routes, + removeBaseUrl(state.serviceBaseUrl, state.path), + state.method, + state.requestType, + ); + const nextSelectedRoute = matchedRoute ? matchedRoute.route : null; + const nextPathParams = matchedRoute + ? extractMatchedPathParams(matchedRoute) + : extractPathParams(state.path).map(mapPathParamKey); + + state.routes = routes; + state.activeRoute = nextSelectedRoute; + state.pathParams = nextPathParams; + }), + + setActiveRoute: (route) => + set((state) => { + const nextMethod = probedRouteToInputMethod(route); + const nextRequestType = route.requestType; + + state.activeRoute = route; + state.path = addBaseUrl(state.serviceBaseUrl, route.path, { + requestType: nextRequestType, + }); + state.method = nextMethod; + state.requestType = nextRequestType; + state.pathParams = extractPathParams(route.path).map(mapPathParamKey); + state.activeHistoryResponseTraceId = null; + state.activeResponse = null; + + // Update tabs (you might want to move this logic to a separate slice) + state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({ + requestType: nextRequestType, + method: nextMethod, + }); + state.activeRequestsPanelTab = state.visibleRequestsPanelTabs.includes( + state.activeRequestsPanelTab, + ) + ? state.activeRequestsPanelTab + : state.visibleRequestsPanelTabs[0]; + + state.visibleResponsePanelTabs = getVisibleResponsePanelTabs({ + requestType: nextRequestType, + }); + state.activeResponsePanelTab = state.visibleResponsePanelTabs.includes( + state.activeResponsePanelTab, + ) + ? state.activeResponsePanelTab + : state.visibleResponsePanelTabs[0]; + + // Add content type header (you might want to move this to a separate function) + updateContentTypeHeaderInState(state); + }), + + routesAndMiddleware: [], + setRoutesAndMiddleware: (routesAndMiddleware) => + set((state) => { + state.routesAndMiddleware = routesAndMiddleware; + }), + + getMatchingMiddleware: () => { + const state = get(); + const { + path, + method, + requestType, + serviceBaseUrl, + routes, + routesAndMiddleware, + } = state; + + const canMatchMiddleware = + !pathHasValidBaseUrl(path) || path.startsWith(serviceBaseUrl); + + // NOTE - We can only match middleware for the service we're monitoring anyhow + // If someone is making a request to jsonplaceholder, we don't wanna + // match middleware that might fire for an internal goose api call + if (!canMatchMiddleware) { + return null; + } + + const matchedRoute = findMatchedRoute( + routes, + removeBaseUrl(serviceBaseUrl, path), + method, + requestType, + )?.route; + + if (!matchedRoute) { + return null; + } + + const indexOfMatchedRoute = matchedRoute + ? routesAndMiddleware.indexOf(matchedRoute) + : -1; + + // NOTE - `routesAndMiddleware` is already filtered for all registered handlers + // and sorted in descending order by registration order. + // (So the last element is the most recently registered) + // This is why we can just slice the array from the matched route + // index onwards and only check for matching middleware. + const registeredHandlersBeforeRoute = + indexOfMatchedRoute > -1 + ? routesAndMiddleware.slice(indexOfMatchedRoute) + : []; + + const filteredMiddleware = registeredHandlersBeforeRoute.filter( + (r) => r.handlerType === "middleware", + ); + + const middlewareMatches = findAllSmartRouterMatches( + filteredMiddleware, + removeBaseUrl(state.serviceBaseUrl, path), + method, + requestType, + ); + + const middleware = []; + for (const m of middlewareMatches ?? []) { + if (m?.route && m.route?.handlerType === "middleware") { + middleware.push(m.route); + } + } + return middleware; + }, +}); + +const SUPPORTED_METHODS: Array = [ + "GET", + "POST", + "PUT", + "DELETE", + "OPTIONS", + "PATCH", + "HEAD", +]; +// Helper functions +function probedRouteToInputMethod(route: ProbedRoute): RequestMethod { + const method = route.method.toUpperCase() as RequestMethod; + // Validate that the method is supported + if (SUPPORTED_METHODS.includes(method)) { + return method; + } + + return "GET"; +} diff --git a/studio/src/pages/RequestorPage/store/slices/tabsSlice.ts b/studio/src/pages/RequestorPage/store/slices/tabsSlice.ts new file mode 100644 index 000000000..bf605a19b --- /dev/null +++ b/studio/src/pages/RequestorPage/store/slices/tabsSlice.ts @@ -0,0 +1,35 @@ +import type { StateCreator } from "zustand"; +import type { RequestsPanelTab, ResponsePanelTab } from ".."; +import type { TabsSlice } from "./types"; + +export const tabsSlice: StateCreator< + TabsSlice, + [["zustand/immer", never], ["zustand/devtools", never]] +> = (set) => ({ + activeRequestsPanelTab: "params", + visibleRequestsPanelTabs: ["params", "headers"], + activeResponsePanelTab: "response", + visibleResponsePanelTabs: ["response", "headers"], + + setActiveRequestsPanelTab: (tab) => + set((state) => { + if (isRequestsPanelTab(tab)) { + state.activeRequestsPanelTab = tab; + } + }), + setActiveResponsePanelTab: (tab) => + set((state) => { + if (isResponsePanelTab(tab)) { + state.activeResponsePanelTab = tab; + } + }), +}); + +// Helper functions +function isRequestsPanelTab(tab: string): tab is RequestsPanelTab { + return ["params", "headers", "body", "websocket"].includes(tab); +} + +function isResponsePanelTab(tab: string): tab is ResponsePanelTab { + return ["response", "messages", "headers"].includes(tab); +} diff --git a/studio/src/pages/RequestorPage/store/slices/types.ts b/studio/src/pages/RequestorPage/store/slices/types.ts new file mode 100644 index 000000000..22ec7e14d --- /dev/null +++ b/studio/src/pages/RequestorPage/store/slices/types.ts @@ -0,0 +1,92 @@ +import type { + RequestBodyType, + RequestorBody, + RequestsPanelTab, + ResponsePanelTab, +} from ".."; +import type { KeyValueParameter } from "../../KeyValueForm"; +import type { ProbedRoute } from "../../types"; +import type { + RequestMethod, + RequestMethodInputValue, + RequestType, +} from "../../types"; +import type { RequestorActiveResponse } from "../types"; + +type RequestorTraceId = string; + +export interface RequestResponseSlice { + serviceBaseUrl: string; + path: string; + method: RequestMethod; + requestType: RequestType; + body: RequestorBody; + pathParams: KeyValueParameter[]; + queryParams: KeyValueParameter[]; + requestHeaders: KeyValueParameter[]; + setServiceBaseUrl: (serviceBaseUrl: string) => void; + updatePath: (path: string) => void; + updateMethod: (methodInputValue: RequestMethodInputValue) => void; + setPathParams: (pathParams: KeyValueParameter[]) => void; + updatePathParamValues: (pathParams: { key: string; value: string }[]) => void; + clearPathParams: () => void; + setQueryParams: (queryParams: KeyValueParameter[]) => void; + setRequestHeaders: (headers: KeyValueParameter[]) => void; + setBody: (body: undefined | string | RequestorBody) => void; + handleRequestBodyTypeChange: ( + requestBodyType: RequestBodyType, + isMultipart?: boolean, + ) => void; + /** Response related state */ + activeHistoryResponseTraceId: string | null; + activeResponse: RequestorActiveResponse | null; + + showResponseBodyFromHistory: (traceId: string) => void; + clearResponseBodyFromHistory: () => void; + setActiveResponse: (response: RequestorActiveResponse | null) => void; + + /** Session history related state */ + sessionHistory: RequestorTraceId[]; + recordRequestInSessionHistory: (traceId: RequestorTraceId) => void; +} + +export interface RoutesSlice { + routes: ProbedRoute[]; + activeRoute: ProbedRoute | null; + setRoutes: (routes: ProbedRoute[]) => void; + setActiveRoute: (route: ProbedRoute) => void; + + routesAndMiddleware: ProbedRoute[]; + getMatchingMiddleware: () => null | ProbedRoute[]; + setRoutesAndMiddleware: (routesAndMiddleware: ProbedRoute[]) => void; +} + +export interface TabsSlice { + activeRequestsPanelTab: RequestsPanelTab; + visibleRequestsPanelTabs: RequestsPanelTab[]; + activeResponsePanelTab: ResponsePanelTab; + visibleResponsePanelTabs: ResponsePanelTab[]; + setActiveRequestsPanelTab: (tab: string) => void; + setActiveResponsePanelTab: (tab: string) => void; +} + +export interface WebsocketSlice { + websocketMessage: string; + setWebsocketMessage: (websocketMessage: string | undefined) => void; +} + +export interface UISlice { + sidePanel: PanelState; + logsPanel: PanelState; + timelinePanel: PanelState; + aiPanel: PanelState; + togglePanel: (panelName: Exclude) => void; +} + +export type PanelState = "open" | "closed"; + +export type Store = RequestResponseSlice & + RoutesSlice & + TabsSlice & + WebsocketSlice & + UISlice; diff --git a/studio/src/pages/RequestorPage/store/slices/uiSlice.ts b/studio/src/pages/RequestorPage/store/slices/uiSlice.ts new file mode 100644 index 000000000..04a2ab833 --- /dev/null +++ b/studio/src/pages/RequestorPage/store/slices/uiSlice.ts @@ -0,0 +1,19 @@ +import { isLgScreen } from "@/utils"; +import type { StateCreator } from "zustand"; +import type { UISlice } from "./types"; + +export const uiSlice: StateCreator< + UISlice, + [["zustand/immer", never], ["zustand/devtools", never]] +> = (set) => { + return { + sidePanel: isLgScreen() ? "open" : "closed", + logsPanel: "closed", + timelinePanel: "closed", + aiPanel: "closed", + togglePanel: (panelName) => + set((state) => ({ + [panelName]: state[panelName] === "open" ? "closed" : "open", + })), + }; +}; diff --git a/studio/src/pages/RequestorPage/store/slices/websocketSlice.ts b/studio/src/pages/RequestorPage/store/slices/websocketSlice.ts new file mode 100644 index 000000000..c1a092cdc --- /dev/null +++ b/studio/src/pages/RequestorPage/store/slices/websocketSlice.ts @@ -0,0 +1,13 @@ +import type { StateCreator } from "zustand"; +import type { WebsocketSlice } from "./types"; + +export const websocketSlice: StateCreator< + WebsocketSlice, + [["zustand/immer", never], ["zustand/devtools", never]] +> = (set) => ({ + websocketMessage: "", + setWebsocketMessage: (websocketMessage) => + set((state) => { + state.websocketMessage = websocketMessage ?? ""; + }), +}); diff --git a/studio/src/pages/RequestorPage/reducer/tabs.ts b/studio/src/pages/RequestorPage/store/tabs.ts similarity index 86% rename from studio/src/pages/RequestorPage/reducer/tabs.ts rename to studio/src/pages/RequestorPage/store/tabs.ts index 2b264406d..5c6c00b24 100644 --- a/studio/src/pages/RequestorPage/reducer/tabs.ts +++ b/studio/src/pages/RequestorPage/store/tabs.ts @@ -27,7 +27,11 @@ export const getVisibleRequestPanelTabs = (route: { return ["params", "headers", "body"]; }; -export const ResponsePanelTabSchema = z.enum(["response", "messages", "debug"]); +export const ResponsePanelTabSchema = z.enum([ + "response", + "messages", + "headers", +]); export type ResponsePanelTab = z.infer; @@ -39,7 +43,7 @@ export const getVisibleResponsePanelTabs = (route: { requestType: RequestType; }): ResponsePanelTab[] => { if (route.requestType === "websocket") { - return ["response", "messages", "debug"]; + return ["response", "messages", "headers"]; } - return ["response", "debug"]; + return ["response", "headers"]; }; diff --git a/studio/src/pages/RequestorPage/reducer/state.ts b/studio/src/pages/RequestorPage/store/types.ts similarity index 56% rename from studio/src/pages/RequestorPage/reducer/state.ts rename to studio/src/pages/RequestorPage/store/types.ts index d5912fc33..6725459b7 100644 --- a/studio/src/pages/RequestorPage/reducer/state.ts +++ b/studio/src/pages/RequestorPage/store/types.ts @@ -1,13 +1,11 @@ import { z } from "zod"; +import { KeyValueParameterSchema } from "../KeyValueForm"; import { - KeyValueParameterSchema, - enforceTerminalDraftParameter, -} from "../KeyValueForm"; -import { ProbedRouteSchema } from "../queries"; -import { RequestMethodSchema, RequestTypeSchema } from "../types"; -import { addContentTypeHeader } from "./reducers"; + ProbedRouteSchema, + RequestMethodSchema, + RequestTypeSchema, +} from "../types"; import { RequestorBodySchema } from "./request-body"; -import { isCurrentSessionState } from "./session-persistence-key"; import { RequestsPanelTabSchema, ResponsePanelTabSchema } from "./tabs"; const RequestorResponseBodySchema = z.discriminatedUnion("type", [ @@ -73,7 +71,10 @@ export type RequestorActiveResponse = z.infer< export const RequestorStateSchema = z.object({ routes: z.array(ProbedRouteSchema).describe("All routes"), - selectedRoute: ProbedRouteSchema.nullable().describe( + routesAndMiddleware: z + .array(ProbedRouteSchema) + .describe("All routes and middleware"), + activeRoute: ProbedRouteSchema.nullable().describe( "Indicates which route to highlight in the routes panel", ), @@ -127,95 +128,3 @@ export type RequestorState = z.infer; export type RequestorBody = RequestorState["body"]; export type RequestBodyType = RequestorBody["type"]; - -export const initialState: RequestorState = addContentTypeHeader({ - routes: [], - selectedRoute: null, - path: "/", - serviceBaseUrl: "http://localhost:8787", - method: "GET", - requestType: "http", - - pathParams: [], - queryParams: enforceTerminalDraftParameter([]), - requestHeaders: enforceTerminalDraftParameter([]), - body: { - type: "json", - value: "", - }, - - websocketMessage: "", - - activeRequestsPanelTab: "params", - visibleRequestsPanelTabs: ["params", "headers"], - - activeResponsePanelTab: "response", - visibleResponsePanelTabs: ["response", "debug"], - - // HACK - This is used to force us to show a response body for a request loaded from history - activeHistoryResponseTraceId: null, - - activeResponse: null, -}); - -/** - * Initializer for the reducer's state that attempts to load the UI state from local storage - * If the UI state is not found, it returns the default initial state - */ -export const createInitialState = (initial: RequestorState) => { - const savedState = loadUiStateFromLocalStorage(); - return savedState ? { ...initial, ...savedState } : initial; -}; - -/** - * A subset of the RequestorState that is saved to local storage. - * We don't save things like `routes` since that could be crufty, - * and will be refetched when the page reloads anyhow - */ -export const SavedRequestorStateSchema = RequestorStateSchema.pick({ - path: true, - method: true, - requestType: true, - pathParams: true, - queryParams: true, - requestHeaders: true, - body: true, - activeRequestsPanelTab: true, - visibleRequestsPanelTabs: true, - activeResponsePanelTab: true, - visibleResponsePanelTabs: true, -}); - -export type SavedRequestorState = z.infer; - -const isSavedRequestorState = ( - state: unknown, -): state is SavedRequestorState => { - const result = SavedRequestorStateSchema.safeParse(state); - if (!result.success) { - console.error( - "SavedRequestorState validation failed:", - result.error.format(), - ); - } - return result.success; -}; - -export const LOCAL_STORAGE_KEY = "requestorUiState"; - -function loadUiStateFromLocalStorage(): SavedRequestorState | null { - const possibleUiState = localStorage.getItem(LOCAL_STORAGE_KEY); - if (!possibleUiState) { - return null; - } - - try { - const uiState = JSON.parse(possibleUiState); - if (isSavedRequestorState(uiState) && isCurrentSessionState(uiState)) { - return uiState; - } - return null; - } catch { - return null; - } -} diff --git a/studio/src/pages/RequestorPage/store/useServiceBaseUrl.ts b/studio/src/pages/RequestorPage/store/useServiceBaseUrl.ts new file mode 100644 index 000000000..532c7d11f --- /dev/null +++ b/studio/src/pages/RequestorPage/store/useServiceBaseUrl.ts @@ -0,0 +1,31 @@ +import { useHandler } from "@fiberplane/hooks"; +import { useRequestorStore } from "."; +import type { RequestType } from "../types"; +import { addBaseUrl, removeBaseUrl } from "./utils"; + +const addServiceUrlIfBarePath = ( + serviceBaseUrl: string, + path: string, + requestType: RequestType, +) => { + return addBaseUrl(serviceBaseUrl, path, { + requestType: requestType, + }); +}; + +export const useServiceBaseUrl = () => { + const { requestType, serviceBaseUrl } = useRequestorStore( + "requestType", + "serviceBaseUrl", + ); + + return { + serviceBaseUrl, + addServiceUrlIfBarePath: useHandler((path: string) => + addServiceUrlIfBarePath(serviceBaseUrl, path, requestType), + ), + removeServiceUrlFromPath: useHandler((path: string) => { + return removeBaseUrl(serviceBaseUrl, path); + }), + }; +}; diff --git a/studio/src/pages/RequestorPage/store/utils.ts b/studio/src/pages/RequestorPage/store/utils.ts new file mode 100644 index 000000000..082135d9d --- /dev/null +++ b/studio/src/pages/RequestorPage/store/utils.ts @@ -0,0 +1,180 @@ +import type { findMatchedRoute } from "../routes"; +import type { ProbedRoute } from "../types"; +import { type RequestMethod, type RequestType, isWsRequest } from "../types"; +import type { RequestorState } from "./types"; + +export const _getActiveRoute = (state: RequestorState): ProbedRoute => { + return ( + state.activeRoute ?? { + path: state.path, + method: state.method, + requestType: state.requestType, + handler: "", + handlerType: "route", + currentlyRegistered: false, + registrationOrder: -1, + routeOrigin: "custom", + isDraft: true, + } + ); +}; + +// Not in use +export const routeEquality = (a: ProbedRoute, b: ProbedRoute): boolean => { + return ( + a.path === b.path && + a.method === b.method && + a.routeOrigin === b.routeOrigin && + a.requestType === b.requestType + ); +}; + +export function probedRouteToInputMethod(route: ProbedRoute): RequestMethod { + const method = route.method.toUpperCase(); + switch (method) { + case "GET": + return "GET"; + case "POST": + return "POST"; + case "PUT": + return "PUT"; + case "DELETE": + return "DELETE"; + case "OPTIONS": + return "OPTIONS"; + case "PATCH": + return "PATCH"; + case "HEAD": + return "HEAD"; + default: + return "GET"; + } +} + +/** + * Extracts path parameters from a path + * + * @TODO - Rewrite to use Hono router + * + * @param path + * @returns + */ +export function extractPathParams(path: string) { + const regex = /\/(:[a-zA-Z0-9_-]+)/g; + + const result: Array = []; + // let match = regex.exec(path); + let lastIndex = -1; + while (true) { + const match = regex.exec(path); + + if (match === null) { + break; + } + + // Check if the regex is stuck in an infinite loop + if (regex.lastIndex === lastIndex) { + break; + } + lastIndex = regex.lastIndex; + + // HACK - Remove the `:` at the beginning of the match, to make things consistent with Hono router path param matching + const keyWithoutColon = match[1].slice(1); + result.push(keyWithoutColon); + } + return result; +} + +export function mapPathParamKey(key: string) { + return { key, value: "", id: key, enabled: false }; +} + +export function extractMatchedPathParams( + matchedRoute: ReturnType, +) { + return Object.entries(matchedRoute?.pathParams ?? {}).map(([key, value]) => { + const nextValue = value === `:${key}` ? "" : value; + return { + ...mapPathParamKey(key), + value: nextValue, + enabled: !!nextValue, + }; + }); +} + +/** + * Removes the base url from a path so we can try to match a route... + */ +export const removeBaseUrl = (serviceBaseUrl: string, path: string) => { + if (!pathHasValidBaseUrl(path)) { + return path; + } + + if (!pathHasValidBaseUrl(serviceBaseUrl)) { + return path; + } + + const serviceHost = new URL(serviceBaseUrl).host; + const servicePort = new URL(serviceBaseUrl).port; + + const pathHost = new URL(path).host; + const pathPort = new URL(path).port; + + // TODO - Make this work with query params!!! + if (pathHost === serviceHost && pathPort === servicePort) { + return new URL(path).pathname; + } + + return path; +}; + +export const addBaseUrl = ( + serviceBaseUrl: string, + path: string, + { + requestType, + forceChangeHost, + }: { requestType?: RequestType; forceChangeHost?: boolean } = { + requestType: "http", + forceChangeHost: false, + }, +) => { + // NOTE - This is necessary to allow the user to type new base urls... even though we replace the base url whenever they switch routes + if (pathHasValidBaseUrl(path) && !forceChangeHost) { + return path; + } + + // HACK - Fix this later, not a great pattern + if (pathHasValidBaseUrl(path) && forceChangeHost) { + const safeBaseUrl = serviceBaseUrl.endsWith("/") + ? serviceBaseUrl.slice(0, -1) + : serviceBaseUrl; + const parsedPath = new URL(path); + const search = parsedPath.search; + return `${safeBaseUrl}${parsedPath.pathname}${search}`; + } + + const parsedBaseUrl = new URL(serviceBaseUrl); + if (requestType && isWsRequest(requestType)) { + parsedBaseUrl.protocol = "ws"; + } + let updatedBaseUrl = parsedBaseUrl.toString(); + if (updatedBaseUrl.endsWith("/")) { + updatedBaseUrl = updatedBaseUrl.slice(0, -1); + } + if (path?.startsWith(updatedBaseUrl)) { + return path; + } + + const safePath = path?.startsWith("/") ? path : `/${path}`; + return `${updatedBaseUrl}${safePath}`; +}; + +export function pathHasValidBaseUrl(path: string) { + try { + new URL(path); + return true; + } catch { + return false; + } +} diff --git a/studio/src/pages/RequestorPage/types.ts b/studio/src/pages/RequestorPage/types.ts index b1af5b773..af38833d6 100644 --- a/studio/src/pages/RequestorPage/types.ts +++ b/studio/src/pages/RequestorPage/types.ts @@ -1,6 +1,14 @@ import { z } from "zod"; import { WEBSOCKETS_ENABLED } from "./webSocketFeatureFlag"; +export type PanelState = "open" | "closed"; + +export type Panels = { + timeline: PanelState; + aiTestGeneration: PanelState; + logs: PanelState; +}; + export const RequestMethodSchema = z.enum([ "GET", "POST", @@ -30,3 +38,24 @@ export type RequestType = z.infer; export const isWsRequest = (requestType: RequestType) => WEBSOCKETS_ENABLED && requestType === "websocket"; + +export const ProbedRouteSchema = z.object({ + path: z.string(), + method: RequestMethodSchema, + handler: z.string(), + handlerType: z.enum(["route", "middleware"]), + currentlyRegistered: z.boolean(), + registrationOrder: z.number().default(-1), + routeOrigin: z.enum(["discovered", "custom", "open_api"]), + openApiSpec: z.string().optional(), + requestType: RequestTypeSchema, + // NOTE - Added on the frontend, not stored in DB + isDraft: z + .boolean() + .optional() + .describe( + "Added on the frontend, not stored in DB. This is only true when the user is typing a path, and none of the routes in the sidebar match.", + ), +}); + +export type ProbedRoute = z.infer; diff --git a/studio/src/pages/RequestorPage/useMakeWebsocketRequest.ts b/studio/src/pages/RequestorPage/useMakeWebsocketRequest.ts index 1c08baab2..66722a5d3 100644 --- a/studio/src/pages/RequestorPage/useMakeWebsocketRequest.ts +++ b/studio/src/pages/RequestorPage/useMakeWebsocketRequest.ts @@ -1,5 +1,5 @@ import { useHandler } from "@fiberplane/hooks"; -import { useCallback, useReducer, useRef } from "react"; +import { useReducer, useRef } from "react"; // Define action types const WEBSOCKET_CONNECTING = "WEBSOCKET_CONNECTING"; @@ -106,13 +106,13 @@ export function useMakeWebsocketRequest() { // NOTE - Unsure if we should manually dispatch the disconnect event here or if the WebSocket API will handle it // I had a case in the UI where the reducer state wasn't in sync with the actual state of the socket for some reason... - const disconnect = useCallback(() => { + const disconnect = useHandler(() => { if (socket.current) { socket.current.close(); } - }, []); + }); - const sendMessage = useCallback((message: string) => { + const sendMessage = useHandler((message: string) => { if (socket.current) { socket.current.send(message); const sentMessage = { @@ -126,7 +126,7 @@ export function useMakeWebsocketRequest() { "Tried to send message but WebSocket connection not established", ); } - }, []); + }); return { connect, diff --git a/studio/src/pages/RequestorPage/useRequestorHistory.ts b/studio/src/pages/RequestorPage/useRequestorHistory.ts index 31e92ad04..4a3717e88 100644 --- a/studio/src/pages/RequestorPage/useRequestorHistory.ts +++ b/studio/src/pages/RequestorPage/useRequestorHistory.ts @@ -1,49 +1,38 @@ import { removeQueryParams } from "@/utils"; +import { useHandler } from "@fiberplane/hooks"; import { useMemo } from "react"; -import { - type KeyValueParameter, - createKeyValueParameters, -} from "./KeyValueForm"; -import { useSessionHistory } from "./RequestorSessionHistoryContext"; -import { - type ProbedRoute, - type Requestornator, - useFetchRequestorRequests, -} from "./queries"; +import { createKeyValueParameters } from "./KeyValueForm"; +import { type Requestornator, useFetchRequestorRequests } from "./queries"; import { findMatchedRoute } from "./routes"; -import { - type RequestMethodInputValue, - isRequestMethod, - isWsRequest, -} from "./types"; +import { useRequestorStore } from "./store"; +import { isRequestMethod, isWsRequest } from "./types"; import { sortRequestornatorsDescending } from "./utils"; -type RequestorHistoryHookArgs = { - routes: ProbedRoute[]; - handleSelectRoute: (r: ProbedRoute, pathParams?: KeyValueParameter[]) => void; - setPath: (path: string) => void; - setMethod: (method: RequestMethodInputValue) => void; - setBody: (body: string | undefined) => void; - setPathParams: (headers: KeyValueParameter[]) => void; - setQueryParams: (params: KeyValueParameter[]) => void; - setRequestHeaders: (headers: KeyValueParameter[]) => void; - showResponseBodyFromHistory: (traceId: string) => void; -}; - -export function useRequestorHistory({ - routes, - handleSelectRoute, - setPath, - setMethod, - setRequestHeaders, - setBody, - setQueryParams, - showResponseBodyFromHistory, -}: RequestorHistoryHookArgs) { +export function useRequestorHistory() { const { sessionHistory: sessionHistoryTraceIds, recordRequestInSessionHistory, - } = useSessionHistory(); + routes, + setActiveRoute: handleSelectRoute, + updatePath: setPath, + updateMethod: setMethod, + setRequestHeaders, + setQueryParams, + setBody, + showResponseBodyFromHistory, + } = useRequestorStore( + "sessionHistory", + "recordRequestInSessionHistory", + "routes", + "setActiveRoute", + "updatePath", + "setBody", + "setQueryParams", + "updateMethod", + "setRequestHeaders", + "showResponseBodyFromHistory", + ); + const { data: allRequests } = useFetchRequestorRequests(); // Keep a history of recent requests and responses @@ -57,7 +46,7 @@ export function useRequestorHistory({ }, [allRequests]); // This feels wrong... but it's a way to load a past request back into the UI - const loadHistoricalRequest = (traceId: string) => { + const loadHistoricalRequest = useHandler((traceId: string) => { recordRequestInSessionHistory(traceId); showResponseBodyFromHistory(traceId); const match = history.find((r) => r.app_responses?.traceId === traceId); @@ -79,16 +68,20 @@ export function useRequestorHistory({ ); if (matchedRoute) { - const pathParamsObject = match.app_requests.requestPathParams ?? {}; - const pathParams = createKeyValueParameters( - Object.entries(pathParamsObject).map(([key, value]) => ({ - key, - value, - })), - ); - + // const pathParamsObject = match.app_requests.requestPathParams ?? {}; + // const pathParams = createKeyValueParameters( + // Object.entries(pathParamsObject).map(([key, value]) => ({ + // key, + // value, + // })), + // ); + + // TODO - Handle path params // NOTE - Helps us set path parameters correctly - handleSelectRoute(matchedRoute.route, pathParams); + handleSelectRoute( + matchedRoute.route, + // pathParams + ); // @ts-expect-error - We don't handle ALL methods well yet if (matchedRoute.route.method === "ALL") { @@ -181,7 +174,7 @@ export function useRequestorHistory({ ); } } - }; + }); // Keep a local history of requests that the user has made in the UI // This should be a subset of the full history diff --git a/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts b/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts index 03429f00b..88afba0fc 100644 --- a/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts +++ b/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts @@ -1,134 +1,138 @@ import { useToast } from "@/components/ui/use-toast"; -import { useCallback } from "react"; +import { useHandler } from "@fiberplane/hooks"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import type { KeyValueParameter } from "./KeyValueForm"; -import type { MakeProxiedRequestQueryFn, ProbedRoute } from "./queries"; -import type { RequestorBody, RequestorState, useRequestor } from "./reducer"; +import type { MakeProxiedRequestQueryFn } from "./queries"; +import type { RequestorBody } from "./store"; +import { useRequestorStore } from "./store"; +import { useServiceBaseUrl } from "./store/useServiceBaseUrl"; import { isWsRequest } from "./types"; export function useRequestorSubmitHandler({ - requestType, - selectedRoute, - body, - path, - addServiceUrlIfBarePath, - method, - pathParams, - queryParams, - requestHeaders, makeRequest, connectWebsocket, recordRequestInSessionHistory, }: { - addServiceUrlIfBarePath: ReturnType< - typeof useRequestor - >["addServiceUrlIfBarePath"]; - selectedRoute: ProbedRoute | null; - body: RequestorBody; - path: string; - method: string; - pathParams: KeyValueParameter[]; - queryParams: KeyValueParameter[]; - requestHeaders: KeyValueParameter[]; makeRequest: MakeProxiedRequestQueryFn; connectWebsocket: (wsUrl: string) => void; recordRequestInSessionHistory: (traceId: string) => void; - requestType: RequestorState["requestType"]; }) { const { toast } = useToast(); - return useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - if (isWsRequest(requestType)) { - const url = addServiceUrlIfBarePath(path); - connectWebsocket(url); - toast({ - description: "Connecting to websocket", - }); - return; - } + const { id } = useParams(); + const urlHasId = !!id; + const navigate = useNavigate(); + const [params] = useSearchParams(); + const { + activeRoute, + body, + path, + method, + pathParams, + queryParams, + requestHeaders, + requestType, + showResponseBodyFromHistory, + } = useRequestorStore( + "activeRoute", + "body", + "path", + "method", + "pathParams", + "queryParams", + "requestHeaders", + "requestType", + "showResponseBodyFromHistory", + ); - // TODO - Make it clear in the UI that we're auto-adding this header - const contentTypeHeader = getContentTypeHeader(body); - const contentLength = getContentLength(body); - const modifiedHeaders = [ - contentTypeHeader - ? { - key: "Content-Type", - value: contentTypeHeader, - enabled: true, - id: "fpx-content-type", - } - : null, - contentLength !== null - ? { - key: "Content-Length", - value: contentLength, - enabled: true, - id: "fpx-content-length", - } - : null, - ...requestHeaders, - ].filter(Boolean) as KeyValueParameter[]; + const { addServiceUrlIfBarePath } = useServiceBaseUrl(); + const { activeHistoryResponseTraceId } = useRequestorStore(); + return useHandler((e: React.FormEvent) => { + e.preventDefault(); + // TODO - Make it clear in the UI that we're auto-adding this header + const canHaveBody = + !isWsRequest(requestType) && !["GET", "DELETE"].includes(method); + const contentTypeHeader = canHaveBody ? getContentTypeHeader(body) : null; + const contentLength = canHaveBody ? getContentLength(body) : null; + const modifiedHeaders = [ + contentTypeHeader + ? { + key: "Content-Type", + value: contentTypeHeader, + enabled: true, + id: "fpx-content-type", + } + : null, + contentLength !== null + ? { + key: "Content-Length", + value: contentLength, + enabled: true, + id: "fpx-content-length", + } + : null, + ...requestHeaders, + ].filter( + (element) => + element && + element.key.toLowerCase() !== "x-fpx-trace-id" && + element.value !== activeHistoryResponseTraceId, + ) as KeyValueParameter[]; - // TODO - Check me - if (isWsRequest(requestType)) { - const url = addServiceUrlIfBarePath(path); - connectWebsocket(url); - toast({ - description: "Connecting to websocket", - }); - return; - } + if (isWsRequest(requestType)) { + const url = addServiceUrlIfBarePath(path); + connectWebsocket(url); + toast({ + description: "Connecting to websocket", + }); + return; + } + + makeRequest( + { + addServiceUrlIfBarePath, + path, + method, + body, + headers: modifiedHeaders, + pathParams, + queryParams, + route: activeRoute?.path, + }, + { + onSuccess(data) { + const traceId = data?.traceId; - makeRequest( - { - addServiceUrlIfBarePath, - path, - method, - body, - headers: modifiedHeaders, - pathParams, - queryParams, - route: selectedRoute?.path, + // If there's an id, we're navigating to a specific request + // otherwise the newest trace will automatically be shown + if (urlHasId) { + navigate({ + pathname: `/requests/${traceId}`, + search: params.toString(), + }); + } + + if (traceId && typeof traceId === "string") { + recordRequestInSessionHistory(traceId); + showResponseBodyFromHistory(traceId); + } else { + console.error( + "RequestorPage: onSuccess: traceId is not a string", + data, + ); + } }, - { - onSuccess(data) { - const traceId = data?.traceId; - if (traceId && typeof traceId === "string") { - recordRequestInSessionHistory(traceId); - } else { - console.error( - "RequestorPage: onSuccess: traceId is not a string", - data, - ); - } - }, - onError(error) { - // TODO - Show Toast - console.error("Submit error!", error); - }, + onError(error) { + // TODO - Show Toast + console.error("Submit error!", error); }, - ); - }, - [ - requestType, - body, - requestHeaders, - makeRequest, - addServiceUrlIfBarePath, - path, - method, - pathParams, - queryParams, - connectWebsocket, - toast, - recordRequestInSessionHistory, - selectedRoute, - ], - ); + }, + ); + }); } +// NOTE - This logic is partly duplicated in `reducer/reducers/content-type.ts` +// We should refactor to share this logic function getContentTypeHeader(body: RequestorBody): string | null { switch (body.type) { case "json": @@ -144,8 +148,11 @@ function getContentTypeHeader(body: RequestorBody): string | null { } return "application/x-www-form-urlencoded"; } - case "file": - return "application/octet-stream"; + case "file": { + const file = body.value; + // TODO - What if file is undefined? + return file?.type ?? "application/octet-stream"; + } default: return "text/plain"; } diff --git a/studio/src/pages/RequestsPage/RequestsPage.tsx b/studio/src/pages/RequestsPage/RequestsPage.tsx index b5eb68be7..e7706d91d 100644 --- a/studio/src/pages/RequestsPage/RequestsPage.tsx +++ b/studio/src/pages/RequestsPage/RequestsPage.tsx @@ -2,9 +2,10 @@ import { DataTable } from "@/components/ui/DataTable"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useToast } from "@/components/ui/use-toast"; -import { type OtelTrace, useOtelTraces } from "@/queries"; +import { useOtelTraces } from "@/queries"; import { cn } from "@/utils"; import { isFpxTraceError } from "@/utils"; +import type { OtelTrace } from "@fiberplane/fpx-types"; import { TrashIcon } from "@radix-ui/react-icons"; import { type Row, getPaginationRowModel } from "@tanstack/react-table"; import { useCallback, useMemo } from "react"; diff --git a/studio/src/pages/RequestsPage/columns.tsx b/studio/src/pages/RequestsPage/columns.tsx index 3edbe3431..4460bd758 100644 --- a/studio/src/pages/RequestsPage/columns.tsx +++ b/studio/src/pages/RequestsPage/columns.tsx @@ -3,7 +3,6 @@ import type { ColumnDef } from "@tanstack/react-table"; import { RequestMethod } from "@/components/Timeline"; import { Status } from "@/components/ui/status"; -import type { OtelTrace } from "@/queries"; import { getRequestMethod, getRequestPath, @@ -11,6 +10,7 @@ import { isFpxRequestSpan, isFpxTraceError, } from "@/utils"; +import type { OtelTrace } from "@fiberplane/fpx-types"; import { Link } from "react-router-dom"; import { Timestamp } from "../RequestDetailsPage/Timestamp"; @@ -129,7 +129,7 @@ export const columns: ColumnDef[] = [ if (!startTime) { return ; } - return ; + return ; }, meta: { // NOTE - This is how to hide a cell depending on breakpoint! diff --git a/studio/src/pages/SettingsPage/SettingsPage.tsx b/studio/src/pages/SettingsPage/SettingsPage.tsx index 2489c0b03..aaac6e5b0 100644 --- a/studio/src/pages/SettingsPage/SettingsPage.tsx +++ b/studio/src/pages/SettingsPage/SettingsPage.tsx @@ -112,7 +112,7 @@ function SettingsLayout({ settings }: { settings: Settings }) { {FPX_WORKER_PROXY_TAB} -
+
diff --git a/studio/src/queries/index.ts b/studio/src/queries/index.ts index d2d4e0ad6..54b3ff5eb 100644 --- a/studio/src/queries/index.ts +++ b/studio/src/queries/index.ts @@ -19,10 +19,4 @@ export { isMizuOrphanLog, } from "./traces-interop"; -export { - type OtelSpan, - type OtelSpans, - type OtelTrace, - useOtelTrace, - useOtelTraces, -} from "./traces-otel"; +export { useOtelTrace, useOtelTraces } from "./traces-otel"; diff --git a/studio/src/queries/traces-interop.ts b/studio/src/queries/traces-interop.ts index ea6e7e731..0f9c320e2 100644 --- a/studio/src/queries/traces-interop.ts +++ b/studio/src/queries/traces-interop.ts @@ -14,15 +14,15 @@ const CallerLocationSchema = z.object({ const MizuOrphanLogSchema = z.object({ id: z.number(), traceId: z.string(), - timestamp: z.string(), + timestamp: z.coerce.date(), level: z.string(), // TODO - use enum from db schema? message: z.union([z.string(), z.null()]), args: z.array(z.unknown()), // NOTE - arguments passed to console.* callerLocation: CallerLocationSchema.nullish(), ignored: z.boolean().nullish(), service: z.string().nullish(), - createdAt: z.string(), - updatedAt: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), relatedSpanId: z.string().nullish(), }); diff --git a/studio/src/queries/traces-otel.ts b/studio/src/queries/traces-otel.ts index 39b10e559..1b7e1f5b0 100644 --- a/studio/src/queries/traces-otel.ts +++ b/studio/src/queries/traces-otel.ts @@ -1,84 +1,13 @@ +import { + OtelSpanSchema, + type OtelTrace, + type TraceDetailSpansResponse, + type TraceListResponse, +} from "@fiberplane/fpx-types"; import { type QueryFunctionContext, useQuery } from "@tanstack/react-query"; import { z } from "zod"; import { MIZU_TRACES_KEY } from "./queries"; -const OtelAttributesSchema = z.record( - z.string(), - z.union([ - z.object({ - String: z.string(), - }), - z.string(), - z.object({ - Int: z.number(), - }), - z.number(), - - // NOTE - It's possible the middleware is setting null for some attributes - // We need this null case here defensively - // FP-4019 - z.null(), - - // z.boolean(), - // z.undefined(), - // z.record( - // z.string(), - // z.union([ - // z.string(), - // z.number(), - // z.null() - // ]) - // ), - ]), -); - -export type OtelAttributes = z.infer; - -const OtelStatusSchema = z.object({ - code: z.number(), - message: z.string().nullish(), -}); - -export type OtelStatus = z.infer; - -const OtelEventSchema = z.object({ - name: z.string(), - timestamp: z.string(), // ISO 8601 format - attributes: OtelAttributesSchema, -}); - -export type OtelEvent = z.infer; - -export const OtelSpanSchema = z - .object({ - trace_id: z.string(), - span_id: z.string(), - parent_span_id: z.union([z.string(), z.null()]), - name: z.string(), - trace_state: z.string().nullish(), - flags: z.number().optional(), // This determines whether or not the trace will be sampled - kind: z.string(), - start_time: z.string(), // ISO 8601 format - end_time: z.string(), // ISO 8601 format - attributes: OtelAttributesSchema, - status: OtelStatusSchema.optional(), - - // This is where we will store logs that happened along the way - events: z.array(OtelEventSchema), - - // Links to related traces, etc - links: z.array( - z.object({ - trace_id: z.string(), - span_id: z.string(), - trace_state: z.string(), - attributes: OtelAttributesSchema, - flags: z.number().optional(), - }), - ), - }) - .passthrough(); // HACK - Passthrough to vendorify traces - export const TRACES_KEY = "otelTrace"; export function useOtelTrace(traceId: string) { @@ -90,68 +19,22 @@ export function useOtelTrace(traceId: string) { const SpansSchema = z.array(OtelSpanSchema); -export type OtelSpan = z.infer; -export type OtelSpans = z.infer; - -const OtelTraceSchema = z.object({ - traceId: z.string(), - spans: SpansSchema, -}); - -export type OtelTrace = z.infer; - async function fetchOtelTrace(context: QueryFunctionContext<[string, string]>) { const traceId = context.queryKey[1]; - return fetch(`/v1/traces/${traceId}/spans`, { + const response = await fetch(`/v1/traces/${traceId}/spans`, { mode: "cors", - }) - .then((response) => response.json()) - .then((spans: { parsedPayload: unknown }[]) => - spans.map((span) => span.parsedPayload), - ) - .then((spans) => { - // For inspection, uncomment the following line: - // console.log("spans", spans); - return spans; - }) - .then((data) => SpansSchema.parse(data)); + }); + const json = (await response.json()) as TraceDetailSpansResponse; + return SpansSchema.parse(json); } export function useOtelTraces() { return useQuery({ queryKey: [MIZU_TRACES_KEY], - queryFn: (): Promise => { - return fetch("/v1/traces") - .then((res) => res.json()) - .then((r) => { - // Uncomment the following line for inspection: - // console.log("Otel Traces before decoding:", r); - return r; - }) - .then((r) => { - return r.map( - (t: { - traceId: string; - spans: { parsedPayload: unknown }[]; - }) => { - return { - traceId: t.traceId, - spans: t.spans.map((span) => toOtelSpan(span.parsedPayload)), - }; - }, - ); - }); + queryFn: async (): Promise => { + const response = await fetch("/v1/traces"); + const json = (await response.json()) as TraceListResponse; + return json; }, }); } - -function toOtelSpan(t: unknown): OtelSpan | null { - const result = OtelSpanSchema.safeParse(t); - if (!result.success) { - console.error("OtelSpanSchema parse error:", result.error.format()); - return null; - } - return { - ...result.data, - }; -} diff --git a/studio/src/utils/index.ts b/studio/src/utils/index.ts index e49a2c2f3..ec2701a01 100644 --- a/studio/src/utils/index.ts +++ b/studio/src/utils/index.ts @@ -2,6 +2,7 @@ import { type ClassValue, clsx } from "clsx"; import { format } from "date-fns"; import { twMerge } from "tailwind-merge"; +export * from "./screen-size"; export * from "./vendorify-traces"; export * from "./otel-helpers"; export { renderFullLogMessage } from "./render-log-message"; diff --git a/studio/src/utils/otel-helpers.ts b/studio/src/utils/otel-helpers.ts index bdc53b4cb..2128fd3a5 100644 --- a/studio/src/utils/otel-helpers.ts +++ b/studio/src/utils/otel-helpers.ts @@ -9,12 +9,12 @@ import { FPX_RESPONSE_BODY, SpanKind, } from "@/constants"; -import type { OtelSpan } from "@/queries"; import type { OtelAttributes, OtelEvent, + OtelSpan, OtelTrace, -} from "@/queries/traces-otel"; +} from "@fiberplane/fpx-types"; export const isErrorLogEvent = (event: OtelEvent) => { return event.name === "log" && getString(event.attributes.level) === "error"; @@ -29,11 +29,11 @@ const isErrorEvent = (event: OtelEvent) => { }; export const hasErrorEvent = (span: OtelSpan) => { - return span.events.some(isErrorEvent); + return span.events?.some(isErrorEvent); }; export const getErrorEvents = (span: OtelSpan) => { - return span.events.filter(isErrorEvent); + return span.events?.filter(isErrorEvent) ?? []; }; export function isFpxRequestSpan(span: OtelSpan) { diff --git a/studio/src/utils/screen-size.ts b/studio/src/utils/screen-size.ts new file mode 100644 index 000000000..688e32d77 --- /dev/null +++ b/studio/src/utils/screen-size.ts @@ -0,0 +1,7 @@ +export function isMedia(query: string) { + return window.matchMedia(query).matches; +} + +export function isLgScreen() { + return isMedia("(min-width: 1024px)"); +} diff --git a/studio/src/utils/vendorify-traces.ts b/studio/src/utils/vendorify-traces.ts index 87228183d..0faceb78c 100644 --- a/studio/src/utils/vendorify-traces.ts +++ b/studio/src/utils/vendorify-traces.ts @@ -1,5 +1,6 @@ import { CF_BINDING_TYPE } from "@/constants"; -import type { MizuOrphanLog, OtelSpan } from "@/queries"; +import type { MizuOrphanLog } from "@/queries"; +import type { OtelSpan } from "@fiberplane/fpx-types"; import { z } from "zod"; import { getRequestBody, getRequestUrl, getString } from "./otel-helpers"; diff --git a/studio/tailwind.config.js b/studio/tailwind.config.js index 112afc168..d946a6cac 100644 --- a/studio/tailwind.config.js +++ b/studio/tailwind.config.js @@ -73,8 +73,8 @@ module.exports = { }, }, animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", + "accordion-down": "accordion-down 0.1s ease-out", + "accordion-up": "accordion-up 0.1s ease-out", }, }, }, diff --git a/www/.prettierrc.mjs b/www/.prettierrc.mjs index 7e619305d..d829c0ac2 100644 --- a/www/.prettierrc.mjs +++ b/www/.prettierrc.mjs @@ -1,12 +1,14 @@ /** @type {import("prettier").Config} */ export default { plugins: ["prettier-plugin-astro"], + useTabs: false, + trailingComma: "none", overrides: [ { files: "*.astro", options: { - parser: "astro", - }, - }, - ], + parser: "astro" + } + } + ] }; diff --git a/www/astro.config.mjs b/www/astro.config.mjs index 40590901a..1f02b421f 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -1,4 +1,6 @@ import { rehypeHeadingIds } from "@astrojs/markdown-remark"; +import partytown from "@astrojs/partytown"; +import sitemap from "@astrojs/sitemap"; import starlight from "@astrojs/starlight"; import icon from "astro-icon"; import { defineConfig } from "astro/config"; @@ -6,57 +8,95 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings"; // https://astro.build/config export default defineConfig({ + site: "https://fiberplane.com", redirects: { - "/docs": "/docs/get-started", + "/docs": "/docs/get-started" }, experimental: { - contentIntellisense: true, + contentIntellisense: true }, integrations: [ starlight({ logo: { src: "@/assets/fp-logo.png", - replacesTitle: true, + replacesTitle: true }, title: "Fiberplane", description: "Fiberplane is an API client and a local debugging companion for Hono API.", social: { github: "https://github.com/fiberplane/fpx", - discord: "https://discord.com/invite/cqdY6SpfVR", + discord: "https://discord.com/invite/cqdY6SpfVR" }, sidebar: [ { label: "Quickstart", - items: ["docs/get-started"], + items: ["docs/get-started"] }, { label: "Components", - autogenerate: { directory: "docs/components" }, + autogenerate: { directory: "docs/components" } }, { label: "Features", - autogenerate: { directory: "docs/features" }, + autogenerate: { directory: "docs/features" } }, + { + label: "nav", + items: [ + { link: "/changelog", label: "Changelog" }, + { link: "/blog", label: "Blog" }, + { link: "/docs", label: "Docs" } + ] + } + ], + head: [ + { + tag: "script", + attrs: { + type: "text/partytown", + src: "https://www.googletagmanager.com/gtag/js?id=G-FMRLG4PY3L", + async: true + } + }, + { + tag: "script", + attrs: { + type: "text/partytown" + }, + content: ` + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', 'G-FMRLG4PY3L'); + ` + } ], components: { Header: "@/components/Header.astro", Pagination: "@/components/Pagination.astro", ThemeProvider: "@/components/ThemeProvider.astro", Sidebar: "@/components/Sidebar.astro", + Hero: "@/components/Hero.astro" }, customCss: ["@/main.css"], expressiveCode: { themes: ["github-dark", "github-light"], styleOverrides: { - borderRadius: "var(--border-radius)", - }, - }, + borderRadius: "var(--border-radius)" + } + } }), // NOTE: if we ever go to server rendering or hybrid rendering, // we'll need to specify manually which icon sets to include // https://github.com/natemoo-re/astro-icon?tab=readme-ov-file#configinclude icon(), + sitemap(), + partytown({ + config: { + forward: ["dataLayer.push"] + } + }) ], markdown: { rehypePlugins: [ @@ -64,9 +104,9 @@ export default defineConfig({ [ rehypeAutolinkHeadings, { - behavior: "wrap", - }, - ], - ], - }, + behavior: "wrap" + } + ] + ] + } }); diff --git a/www/biome.jsonc b/www/biome.jsonc index 8a5a872a5..895cacde6 100644 --- a/www/biome.jsonc +++ b/www/biome.jsonc @@ -2,6 +2,6 @@ "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", "extends": ["../biome.jsonc"], "files": { - "ignore": ["dist", "node_modules"], - }, + "ignore": ["dist", "node_modules", "src/*.ts.example"] + } } diff --git a/www/package.json b/www/package.json index cea560c6f..458d0ca90 100644 --- a/www/package.json +++ b/www/package.json @@ -9,15 +9,17 @@ "preview": "astro preview", "staging": "wrangler pages deploy ./dist --env staging", "format": "prettier --write .", - "lint:ci": "biome ci .", + "lint:ci": "prettier --check .", "astro": "astro" }, "dependencies": { "@astrojs/check": "^0.9.3", "@astrojs/markdown-remark": "^5.2.0", - "@astrojs/starlight": "^0.26.4", - "@iconify-json/lucide": "^1.2.4", - "astro": "^4.15.8", + "@astrojs/partytown": "^2.1.2", + "@astrojs/sitemap": "^3.1.6", + "@astrojs/starlight": "^0.27.1", + "@iconify-json/lucide": "^1.1.208", + "astro": "^4.15.6", "astro-icon": "^1.1.1", "rehype-autolink-headings": "^7.1.0", "rollup": "^4.22.0", diff --git a/www/src/assets/fpx-hero-screenshot.png b/www/src/assets/fpx-hero-screenshot.png new file mode 100644 index 000000000..fdf6c2028 Binary files /dev/null and b/www/src/assets/fpx-hero-screenshot.png differ diff --git a/www/src/components/Header.astro b/www/src/components/Header.astro index 665674d9a..6090c72d6 100644 --- a/www/src/components/Header.astro +++ b/www/src/components/Header.astro @@ -3,8 +3,15 @@ import Search from "@astrojs/starlight/components/Search.astro"; import SiteTitle from "@astrojs/starlight/components/SiteTitle.astro"; import SocialIcons from "@astrojs/starlight/components/SocialIcons.astro"; import type { Props } from "@astrojs/starlight/props"; +import type { SidebarEntry } from "node_modules/@astrojs/starlight/utils/navigation"; -const currentPath = Astro.url.pathname; +type NavEntry = SidebarEntry & { type: "link" }; + +const { sidebar } = Astro.props; +const nav = sidebar.find( + (entry) => entry.type === "group" && entry.label === "nav" +) as { entries: NavEntry[] } | undefined; +const navEntries = nav?.entries || []; ---
@@ -12,14 +19,20 @@ const currentPath = Astro.url.pathname;
- + { + navEntries.length > 0 && ( + + ) + }