From 0b36dd652b17458a39cea41b62f93a2e21bc85de Mon Sep 17 00:00:00 2001 From: Bill Yang Date: Fri, 22 May 2026 10:43:41 -0400 Subject: [PATCH 1/4] custom queries --- SECURITY_SCAN_REPORT.md | 280 ++++++++++++ client/messages/cs.json | 11 +- client/messages/de.json | 11 +- client/messages/en.json | 11 +- client/messages/es.json | 11 +- client/messages/fr.json | 11 +- client/messages/it.json | 11 +- client/messages/ja.json | 11 +- client/messages/ko.json | 11 +- client/messages/pl.json | 11 +- client/messages/pt.json | 11 +- client/messages/uk.json | 11 +- client/messages/zh.json | 11 +- .../api/analytics/endpoints/customQuery.ts | 51 +++ client/src/api/analytics/endpoints/index.ts | 10 + .../src/api/analytics/hooks/useCustomQuery.ts | 27 ++ .../app/[site]/[privateKey]/query/page.tsx | 2 + .../app/[site]/components/Sidebar/Sidebar.tsx | 7 + client/src/app/[site]/journeys/page.tsx | 16 +- client/src/app/[site]/layout.tsx | 3 +- client/src/app/[site]/query/page.tsx | 403 ++++++++++++++++++ .../src/api/analytics/generateCustomQuery.ts | 171 ++++++++ server/src/api/analytics/index.ts | 2 + .../src/api/analytics/runCustomQuery.test.ts | 69 +++ server/src/api/analytics/runCustomQuery.ts | 85 ++++ .../analytics/utils/customQueryValidation.ts | 218 ++++++++++ server/src/index.ts | 4 + server/src/lib/openrouter.test.ts | 53 +++ server/src/lib/openrouter.ts | 13 +- 29 files changed, 1524 insertions(+), 22 deletions(-) create mode 100644 SECURITY_SCAN_REPORT.md create mode 100644 client/src/api/analytics/endpoints/customQuery.ts create mode 100644 client/src/api/analytics/hooks/useCustomQuery.ts create mode 100644 client/src/app/[site]/[privateKey]/query/page.tsx create mode 100644 client/src/app/[site]/query/page.tsx create mode 100644 server/src/api/analytics/generateCustomQuery.ts create mode 100644 server/src/api/analytics/runCustomQuery.test.ts create mode 100644 server/src/api/analytics/runCustomQuery.ts create mode 100644 server/src/api/analytics/utils/customQueryValidation.ts create mode 100644 server/src/lib/openrouter.test.ts diff --git a/SECURITY_SCAN_REPORT.md b/SECURITY_SCAN_REPORT.md new file mode 100644 index 000000000..cd69459b7 --- /dev/null +++ b/SECURITY_SCAN_REPORT.md @@ -0,0 +1,280 @@ +# Rybbit Security Review — Final Report + +## Executive Summary + +This review consolidates 25 verified raw findings into **16 distinct issues** (overlapping reports merged). + +| Severity | Count | +|----------|-------| +| High | 7 | +| Medium | 4 | +| Low | 5 | +| Info | 1 (latent) | + +The High-severity findings cluster into two dominant themes: + +1. **ClickHouse SQL injection** reachable on public or API-key-scoped analytics endpoints, enabling cross-tenant data exfiltration. +2. **CSRF / credentialed cross-site access** created by all-origins CORS reflection (`callback(null, true)` + `credentials: true`) combined with `SameSite=None` session cookies and no CSRF protection on the custom Fastify routes. + +### Top 3 to fix first + +1. **Fix the ClickHouse SQL injection vectors** (`getSqlParam` url_param:/utm_ branch and `getJourneys` stepFilters). These bypass schema validation and standard escaping, and are reachable with only public-site or scoped-key access. Validate against the allow-list before interpolation and use `SqlString.escape` for map keys / string literals. +2. **Lock down CORS + cookie CSRF posture.** Replace origin reflection with a trusted-origin allow-list, stop combining `credentials:true` with reflected origins, set `SameSite=Lax` (or add CSRF tokens / strict Origin checks if `None` is genuinely required for embedding), and populate `trustedOrigins` from the production `BASE_URL`. +3. **Authorize the GSC OAuth callback.** Add `getUserHasAdminAccessToSite(req, siteId)` after resolving `siteId` from `state`, and replace the plaintext `state=` with a signed/server-stored single-use nonce binding `{userId, siteId}`. + +--- + +## HIGH Severity + +### H-1. ClickHouse SQL injection via unvalidated `parameter` on `/sites/:siteId/metric` +**CWE-89 (Injection)** — *Merged from findings #2 and #8.* + +- **Sink:** `server/src/api/analytics/utils/getFilterStatement.ts:62-71` +- **Source:** `server/src/api/analytics/getMetric.ts:56` (reads `parameter` from querystring), `:354` (`getSqlParam(parameter)`) +- **Route:** `server/src/index.ts:268` — `fastify.get("/sites/:siteId/metric", publicSite, getMetric)`, no querystring schema + +`getSqlParam()` builds a ClickHouse map accessor by raw string-templating the parameter key with no escaping. For `url_param:` it returns `url_parameters['${paramName}']` and for `utm_` it returns `url_parameters['${utm}']`. **Both branches `return` before the validating `filterParamSchema.parse(parameter)` at line 98**, so they never hit the enum allow-list. The result is interpolated directly into SELECT and WHERE positions in `getMetric.ts`. Because the route has no Fastify querystring schema and uses `publicSite`, `parameter` arrives fully attacker-controlled. + +PoC payload breaks out of the map-key string literal: +``` +parameter=url_param:x'] FROM events WHERE site_id=1 UNION SELECT password,1,1 FROM secret-- +``` + +- **Impact:** A public-site or API/private-link-key requester can inject arbitrary SQL into the analytics query, enabling cross-tenant exfiltration (UNION dumping of other sites' events) and resource-exhausting queries. No authenticated session required for public sites. +- **Remediation:** Move `filterParamSchema.parse(parameter)` to the **top** of `getSqlParam` so the url_param:/utm_ branches cannot bypass it, OR escape the map key as `url_parameters[${SqlString.escape(paramName)}]` — matching the existing `feature_flag` branch (line 58) which already does `feature_flags[${SqlString.escape(key)}]`. Additionally add a Fastify querystring schema to the `/metric` route for edge validation. + +--- + +### H-2. ClickHouse SQL injection in `/sites/:siteId/journeys` via `stepFilters` +**CWE-89 (Injection)** — *Merged from findings #3 and #14.* + +- **File:** `server/src/api/analytics/getJourneys.ts:42-66` (parse + sink at line 64) +- **Route:** `server/src/index.ts:290` (`publicSite`) + +`stepFilters` is `JSON.parse`d with **no Zod schema** into a path map. The exact-match branch builds `journey[${stepIndex}] = '${path.replace(/'/g, "''")}'` using only MySQL-style quote-doubling. ClickHouse also honors backslash escapes inside string literals, so `''` doubling is the wrong escaping. A path of `\' OR 1=1 OR journey[1]='` produces: +``` +journey[1] = '\'' OR 1=1 OR journey[1]=''' +``` +The `\'` is an escaped quote, the doubled sequence closes the literal early, and the trailing ` OR 1=1 OR ...` executes as raw SQL. (The wildcard branch runs `patternToRegex` first, which doubles backslashes, so it is not affected.) Separately, a non-string JSON value (e.g. `{"0": 5}`) reaching `path.includes("*")` throws a 500 (trivial DoS). + +- **Impact:** SQL injection into the journeys HAVING clause → arbitrary SQL / cross-tenant data access on any public or keyed site. +- **Remediation:** Validate `stepFilters` with a Zod schema (numeric keys → string values, with length caps) before use, and replace the hand-rolled `.replace(...)` with `SqlString.escape(path)` (and on the regex string), matching `getFunnel`/`getFilterStatement` conventions. + +--- + +### H-3. GSC OAuth callback: IDOR + missing CSRF nonce +**CWE-639 (Broken Access Control)** — *Merged from findings #1 and #4 (plaintext-token aspect tracked in M-3 / L-3).* + +- **File:** `server/src/api/gsc/callback.ts:36-112` +- **Initiator:** `server/src/api/gsc/connect.ts:20,34` (checks access, sets `state = numericSiteId.toString()`) + +`gscCallback` reads the target `siteId` from the OAuth `state` query parameter (`const siteId = Number(state)`, line 36). The **only** authorization check is that a valid session exists (lines 42-45). It never calls `getUserHasAccessToSite` / `getUserHasAdminAccessToSite` for that `siteId` before writing to `gscConnections` (insert/update at lines 88-112). The `state` is an unsigned plaintext `siteId` with no nonce/HMAC, so the callback's trust in it is misplaced. + +Any authenticated user can complete the OAuth flow with **their own** Google account and pass `state=`, causing the server to write the attacker's Google access/refresh tokens into the victim site's `gscConnections` row (overwriting an existing connection via the UPDATE branch). + +- **Impact:** (a) Connection hijack / data injection — the victim's dashboard surfaces the attacker's Search Console data; (b) DoS — clobber a legitimate connection; (c) OAuth CSRF / connection-fixation due to the unbound `state`. +- **Remediation:** After resolving `siteId` from `state`, call `getUserHasAdminAccessToSite(req, siteId)` and reject with 403 if false. Replace the plaintext `state` with a signed/HMAC'd or server-stored single-use nonce encoding both `siteId` and the initiating `userId`, verified on callback. + +--- + +### H-4. Permissive CORS: all origins reflected with credentials enabled +**CWE-942** — *Merged from findings #5 and #11.* + +- **File:** `server/src/index.ts:211-218` + +`@fastify/cors` is registered with an origin callback that unconditionally calls `callback(null, true)` for every origin, combined with `credentials: true` and all state-changing methods. This reflects the requesting Origin into `Access-Control-Allow-Origin` while setting `Access-Control-Allow-Credentials: true`, on every authenticated route (sites, organizations, stripe, admin) — not just the intentionally-public tracking endpoints. + +```js +server.register(cors, { + origin: (_origin, callback) => { callback(null, true); }, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + credentials: true, +}); +``` + +- **Impact:** Any malicious site a logged-in user visits can issue credentialed cross-origin requests and **read the JSON responses** (Allow-Origin reflects their origin), exfiltrating analytics/org data. This compounds with H-5 below for state-changing CSRF. +- **Remediation:** Do not reflect arbitrary origins when `credentials:true`. Maintain an allow-list (production dashboard URL + localhost in dev) and only echo the Origin if listed. For genuinely public ingestion endpoints (`/api/track`, `/api/identify`, `/session-replay/record`, embed-stats, tracking-config, feature-flags/evaluate) keep `credentials:false` with scoped/wildcard CORS. + +--- + +### H-5. Session cookies `SameSite=None` with no CSRF token on custom routes +**CWE-352 (CSRF)** — *finding #6.* + +- **File:** `server/src/lib/auth.ts:253-259` + +better-auth's `defaultCookieAttributes.sameSite` is `'none'` in production, so the session cookie is attached to all cross-site requests. Combined with H-4 (open CORS) and the absence of any CSRF-token middleware on the custom Fastify route groups, every authenticated state-changing endpoint is reachable cross-site with the victim's cookie. `trustedOrigins` is hardcoded to `['http://localhost:3002']` and excludes the production `BASE_URL`, so better-auth's own origin protection is misconfigured for production. + +- **Impact:** CSRF against all authenticated custom routes (delete sites, change subscription, add/remove org members) without per-request token validation. +- **Remediation:** Set `SameSite=Lax` (or `Strict`) unless cross-site embedding genuinely requires `None`; if `None` is required, add CSRF-token validation or strict Origin/Referer checks on all state-changing custom routes. Populate `trustedOrigins` from the production `BASE_URL`. + +> **Note:** H-4 and H-5 are mutually reinforcing and should be fixed together. Neither alone fully closes the cross-site attack surface. + +--- + +### H-6. AppSumo webhook has no signature/secret verification +**CWE-345** — *finding #7.* + +- **File:** `server/src/api/as/webhook.ts:52-101`; route `server/src/index.ts:423` + +`POST /api/as/webhook` is registered with no auth middleware. The handler only runs `validateWebhookPayload` (presence of `license_key`/`event` + event allow-list). There is **no HMAC/shared-secret verification** against the request body, unlike the Stripe webhook (`constructEvent` with `STRIPE_WEBHOOK_SECRET`). The handler then writes/updates `appsumo.licenses`, including activating/upgrading licenses and transferring an `organization_id` onto an attacker-chosen `license_key`. The upgrade/downgrade handlers fall back to "any license with an organization" when `prev_license_key` is unknown. + +- **Impact:** Any unauthenticated party reaching the endpoint can forge purchase/activate/upgrade/downgrade/deactivate events — creating an "active" license bound to a real organization (subscription-tier escalation) or deactivating a victim's license. +- **Remediation:** Verify an AppSumo-provided signature/HMAC over the raw request body using a shared secret (mirroring the Stripe pattern) before processing; reject unsigned/invalid requests with 401. + +--- + +### H-7. Session replay recordings served under the public-site access gate +**CWE-200 (Sensitive Data Exposure)** — *finding #23.* + +- **File:** `server/src/index.ts:328-329` + +`GET /sites/:siteId/session-replay/list` and `GET /sites/:siteId/session-replay/:sessionId` are registered with the `publicSite` preHandler. `allowPublicSiteAccess` (`server/src/lib/auth-middleware.ts:153-165`) grants access whenever `site.public` is true (or a matching `x-private-key`/API key is supplied) **without any authenticated session**. `getSessionReplayEvents` then returns the full reconstructed rrweb event stream — the complete recorded DOM of a visitor's session, routinely containing page content, URLs, and non-masked form input. + +- **Impact:** Any time a site is flagged `public` (intending to share an aggregate dashboard), or for anyone holding a private-link key, all stored session replays become listable and fully downloadable/replayable unauthenticated. An attacker can enumerate sessions and harvest captured PII / page content. +- **Remediation:** Gate these endpoints behind authenticated site access (`authSite` / `requireSiteAccess`) even when the aggregate dashboard is public — matching the existing `authSite` gate already on the DELETE replay route (`index.ts:330`). Replays are categorically more sensitive than aggregate metrics. + +--- + +## MEDIUM Severity + +### M-1. Org admin can create/assign the higher `owner` role, bypassing role hierarchy +**CWE-269 (Privilege Escalation)** — *Merged from findings #9 and #10.* + +- **Files:** `server/src/api/user/createUserInOrganization.ts:50-112`; `server/src/api/user/addUserToOrganization.ts:34-86` +- **Routes:** registered with `authOnly` (`index.ts:364`) + +Both `POST /organizations/:organizationId/users` and `POST /organizations/:organizationId/members` self-authorize on `role === 'admin' OR 'owner'`, then accept an arbitrary `role` of `admin|member|owner` and insert directly into the `member` table. The better-auth org plugin treats `owner` as the highest role and would not let an admin mint an owner — but these custom routes sidestep that hierarchy. A mere admin can create a new credentialed `owner` account (password hashed via `ctx.password.hash`) or promote a confederate to `owner`. + +- **Impact:** An org admin self-escalates to owner-equivalent control (remove other owners/admins, delete/transfer the org, billing). +- **Remediation:** Disallow assigning a role higher than the caller's own. An `admin` caller may create/assign only `member`/`admin`, never `owner`. Restrict `owner` creation to existing owners (or system admins). Centralize the check in both endpoints. + +--- + +### M-2. Insecure default secrets in `.env.example` and compose defaults +**CWE-1188** — *finding #12.* + +- **File:** `.env.example:7,22,26` (and `docker-compose*.yml` fallbacks) + +Ships `BETTER_AUTH_SECRET=insecure-secret`, `CLICKHOUSE_PASSWORD=frog`, `POSTGRES_PASSWORD=frog`. Compose files bake in `${POSTGRES_PASSWORD:-frog}`, `${CLICKHOUSE_PASSWORD:-frog}`, `${REDIS_PASSWORD:-changeme}`. An operator who copies the example or relies on compose defaults runs with a publicly known auth-signing secret (forgeable session tokens → account takeover) and guessable DB credentials. (`setup.sh` does generate a random secret, but the example/compose paths remain a foot-gun.) + +- **Remediation:** Do not ship a usable default for `BETTER_AUTH_SECRET` — leave it empty and fail startup if unset. Remove weak password defaults from compose fallbacks; require operators to set them. + +--- + +### M-3. Cloud docker-compose publishes datastores to all interfaces with weak default credentials +**CWE-1188** — *finding #25.* + +- **File:** `docker-compose.cloud.yml:49-81` + +The cloud variant maps Postgres `5432`, ClickHouse `8123/9000`, and Redis `6379` to the host **without** binding to `127.0.0.1` (the non-cloud compose binds backend/client to localhost). Credentials default to `frog`/`changeme`; a comment on the Redis port states it is "Exposed to internet for remote connections", and ClickHouse `network.xml` binds `listen_host 0.0.0.0`. + +- **Impact:** On any host where these ports are not firewalled, the entire analytics dataset and app metadata (users, orgs, billing) are directly reachable; with default creds an attacker gets full read/write without touching the application. +- **Remediation:** Bind datastore host port mappings to `127.0.0.1` (or remove host port mappings and rely on the internal docker network). Require strong generated passwords; do not expose Redis to the internet. + +--- + +### M-4. GSC OAuth tokens stored in plaintext +**CWE-312** — *Merged from findings #13 and #19 (the CSRF/IDOR aspects of these reports are covered by H-3).* + +- **File:** `server/src/api/gsc/callback.ts:88-112` + +The callback writes Google `access_token` and `refresh_token` directly into the `gscConnections` Postgres table with no encryption at rest. Refresh tokens are long-lived credentials. On token-exchange failure the full Google error body is logged (line 71), which can include grant details. + +- **Impact:** A Postgres compromise (or any SQL-read access — see M-3's exposed Postgres port) yields usable long-lived Google refresh tokens for every connected site. +- **Remediation:** Encrypt OAuth tokens at rest (application-level envelope encryption with a KMS-managed key, or pgcrypto). Avoid logging raw token-exchange error bodies. + +--- + +## LOW Severity + +### L-1. API key accepted via query string +**CWE-598** — *finding #15.* + +- **File:** `server/src/lib/auth-utils.ts:242-243,316-317` + +`checkApiKey`/`getUserIdFromRequest` accept the key from `?api_key=` as an alternative to the `Authorization: Bearer` header. Query-string secrets are captured in access logs, reverse-proxy logs (Caddy), browser history, and the Referer header. The same scoped key grants read and admin actions. + +- **Remediation:** Accept keys only via the `Authorization` header. If query keys must remain for compatibility, document as testing-only and strip them from all logging/Referer. + +--- + +### L-2. Private-link key has 48-bit entropy and is compared non-constant-time +**CWE-208** — *finding #16.* + +- **File:** `server/src/lib/auth-utils.ts:352`; generation at `server/src/api/sites/updateSitePrivateLinkConfig.ts:36` + +The `x-private-key` header is compared with `===` (not constant-time), and the key is generated as `crypto.randomBytes(6).toString('hex')` — only 48 bits / 12 hex chars. The low entropy is the material weakness; the non-constant-time compare is a minor timing oracle. This key guards unauthenticated read access to a non-public site's full analytics. + +- **Remediation:** Generate with substantially more entropy (e.g. `crypto.randomBytes(24)`) and compare with `crypto.timingSafeEqual` on equal-length buffers. + +--- + +### L-3. Sensitive data logged: full user record on signup, AppSumo payload/license keys +**CWE-532** — *Merged from findings #17, #18, and #20.* + +- **Files:** `server/src/lib/auth.ts:264` (`console.log(u)` on every signup — dumps email/name/PII, bypassing the structured logger); `server/src/api/as/webhook.ts:66,84,108-161` (logs full payload + `license_key`/`prev_license_key` at info level). Fastify logger is configured at `debug` level (`index.ts:178-182`). + +`license_key` is the bearer secret granting subscription tier (and the webhook is forgeable per H-6), so logging it persists replayable secrets. The user object exposes PII. + +- **Remediation:** Remove `console.log(u)` (or log only a non-PII id at debug). Do not log full webhook payloads; redact/hash `license_key`/`prev_license_key`. Set production log level to info/warn. + +--- + +### L-4. No security response headers (HSTS/CSP/X-Frame-Options/etc.) +**CWE-693** — *finding #21.* + +- **File:** `server/src/index.ts:211-225`; `Caddyfile` + +No `@fastify/helmet` (or Caddy `header` directives) is registered. No HSTS, CSP, X-Content-Type-Options, X-Frame-Options/frame-ancestors, or Referrer-Policy. Given the dashboard renders session-replay content and a public iframe widget, the absence of CSP/frame controls reduces defense-in-depth against XSS and clickjacking. + +- **Remediation:** Register `@fastify/helmet` (or add Caddy `header` directives) for HSTS, a restrictive CSP, `nosniff`, frame-ancestors, and Referrer-Policy. Scope frame-ancestors for the intended embed widget. + +--- + +### L-5. `@fastify/rate-limit` declared but never registered +**CWE-770** — *finding #22.* + +- **File:** `package.json:39` (dependency); `server/src/index.ts:268,290,306,327,442-443` (unprotected routes) + +`@fastify/rate-limit` is a declared dependency but `fastify.register(rateLimit, ...)` is never called. The only throttling is inside better-auth's apiKey plugin (API-key requests only). High-volume public endpoints — `POST /api/track`, `/api/identify`, `/session-replay/record/:siteId`, `/site/:siteId/feature-flags/evaluate` — and the better-auth login routes have no app-level rate limit, each accepting up to the 10MB global body limit. Self-host login is captcha-free. + +- **Impact:** Unauthenticated event-flooding / ClickHouse data pollution, feature-flag-evaluation abuse (each triggers geo lookup + Postgres query), and credential-stuffing/brute-force against login with no throttle. +- **Remediation:** Register `@fastify/rate-limit` globally and/or apply per-route `config.rateLimit` to public ingestion and auth endpoints, keyed by IP. Tighten limits in cloud vs self-host. + +--- + +## INFO (Latent — not currently exploitable) + +### I-1. Session/geo data interpolated into innerHTML without HTML-escaping in globe tooltips +**CWE-79 (XSS)** — *finding #24.* + +- **File:** `client/src/app/[site]/globe/utils/timelineTooltipBuilder.ts:63-108`; sinks at `useOpenLayersTimelineLayer.ts:280,417`, `useOpenLayersCoordinatesLayer.ts:199-210` + +`buildTooltipHTML()` interpolates `session.city`, `country`, `browser`, `operating_system`, `device_type`, `referrerText`, `pageviews/events`, and `session_id` into an HTML string with **no escaping**, assigned via `innerHTML`. All values originate from data ingested through the public `/api/track` endpoint. + +- **Status:** **Not provably exploitable today** — the directly attacker-controlled free-form fields are normalized upstream before reaching the sink: `referrer` is reduced to a hostname (`extractDomain`) or a fixed channel enum, `user_id` is rendered through `renderToStaticMarkup` (escaped), and city/browser/os/device come from MaxMind geo and UA-parser enum tables. The risk is that escaping is entirely absent, so safety depends on every current and future source field staying server-normalized — fragile against a new field, an import mapper, or a UA-parser edge case. +- **Remediation:** HTML-escape every interpolated value (reuse the `escapeHTML` helper already implemented in `client/src/app/widget/[siteId]/route.ts:267`, or DOMPurify), and prefer building DOM nodes with `textContent` over string concatenation. + +--- + +## Appendix: Deduplication Map + +| Report ID(s) | Consolidated as | +|--------------|-----------------| +| #2, #8 | H-1 (metric SQL injection) | +| #3, #14 | H-2 (journeys SQL injection) | +| #1, #4 | H-3 (GSC callback IDOR/CSRF) | +| #5, #11 | H-4 (permissive CORS) | +| #6 | H-5 (SameSite=None CSRF) | +| #7 | H-6 (AppSumo webhook) | +| #23 | H-7 (public session replays) | +| #9, #10 | M-1 (owner-role escalation) | +| #12 | M-2 (default secrets) | +| #25 | M-3 (exposed datastores) | +| #13, #19 | M-4 (plaintext GSC tokens) | +| #15 | L-1 (API key in query) | +| #16 | L-2 (private-link key entropy) | +| #17, #18, #20 | L-3 (sensitive logging) | +| #21 | L-4 (missing security headers) | +| #22 | L-5 (no rate limiting) | +| #24 | I-1 (globe tooltip XSS, latent) | \ No newline at end of file diff --git a/client/messages/cs.json b/client/messages/cs.json index 53279ca14..196abbda5 100644 --- a/client/messages/cs.json +++ b/client/messages/cs.json @@ -74,6 +74,7 @@ "AA5h7P": "Výkon", "LlNItA": "", "XHLYdJ": "Cíle", + "qj71j1": "", "9d/EEp": "Produktová analytika", "5yUsUe": "Přehrávání", "puhcpn": "Trychtýře", @@ -107,7 +108,6 @@ "Z5dM3R": "Odkazující stránka", "Qp1beM": "Cesta", "9a9+ww": "", - "qj71j1": "", "y1Z3or": "Jazyk", "TE4fIS": "", "lnaWo/": "Region", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Metrika Web Vitals pro měření výkonu webu.", "O8lxEI": "Podívej se na dokumentaci Web Vitals od Google pro prahové hodnoty.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "Přehrávání relací je vypnuto", "Tb0/AL": "Přehrávání relací zvětší analytický skript", "CRjhOx": "8× více", diff --git a/client/messages/de.json b/client/messages/de.json index c60b38da5..59be82840 100644 --- a/client/messages/de.json +++ b/client/messages/de.json @@ -74,6 +74,7 @@ "AA5h7P": "Leistung", "LlNItA": "", "XHLYdJ": "Ziele", + "qj71j1": "Abfrage", "9d/EEp": "Produktanalyse", "5yUsUe": "Wiedergabe", "puhcpn": "Trichter", @@ -107,7 +108,6 @@ "Z5dM3R": "Referrer", "Qp1beM": "Pfad", "9a9+ww": "Titel", - "qj71j1": "Abfrage", "y1Z3or": "Sprache", "TE4fIS": "Stadt", "lnaWo/": "Region", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Web Vitals-Metrik zur Messung der Website-Leistung.", "O8lxEI": "Prüfen Sie die Web Vitals-Dokumentation von Google für Schwellenwerte.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "Session Replay ist deaktiviert", "Tb0/AL": "Session Replay macht das Analytics-Skript", "CRjhOx": "8x größer", diff --git a/client/messages/en.json b/client/messages/en.json index 7783f8e76..aa36f3e8b 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -74,6 +74,7 @@ "AA5h7P": "Performance", "LlNItA": "Bots", "XHLYdJ": "Goals", + "qj71j1": "Query", "9d/EEp": "Product Analytics", "5yUsUe": "Replay", "puhcpn": "Funnels", @@ -107,7 +108,6 @@ "Z5dM3R": "Referrer", "Qp1beM": "Path", "9a9+ww": "Title", - "qj71j1": "Query", "y1Z3or": "Language", "TE4fIS": "City", "lnaWo/": "Region", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Web Vitals metric for measuring website performance.", "O8lxEI": "Check Google's Web Vitals documentation for thresholds.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "What do you want to query?", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "Session Replay is Disabled", "Tb0/AL": "Session replay will make the analytics script", "CRjhOx": "8x larger", diff --git a/client/messages/es.json b/client/messages/es.json index 19285c379..f5db901af 100644 --- a/client/messages/es.json +++ b/client/messages/es.json @@ -74,6 +74,7 @@ "AA5h7P": "Rendimiento", "LlNItA": "", "XHLYdJ": "Objetivos", + "qj71j1": "Consulta", "9d/EEp": "Analíticas de Producto", "5yUsUe": "Repetición", "puhcpn": "Embudos", @@ -107,7 +108,6 @@ "Z5dM3R": "Referidor", "Qp1beM": "Ruta", "9a9+ww": "Título", - "qj71j1": "Consulta", "y1Z3or": "Idioma", "TE4fIS": "Ciudad", "lnaWo/": "Región", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Métrica de Web Vitals para medir el rendimiento del sitio web.", "O8lxEI": "Consulta la documentación de Web Vitals de Google para los umbrales.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "La repetición de sesión está desactivada", "Tb0/AL": "La repetición de sesión hará que el script de analíticas sea", "CRjhOx": "8x más grande", diff --git a/client/messages/fr.json b/client/messages/fr.json index cd6cce129..33d494eea 100644 --- a/client/messages/fr.json +++ b/client/messages/fr.json @@ -74,6 +74,7 @@ "AA5h7P": "Performance", "LlNItA": "", "XHLYdJ": "Objectifs", + "qj71j1": "Requête", "9d/EEp": "Analyse produit", "5yUsUe": "Replay", "puhcpn": "Entonnoirs", @@ -107,7 +108,6 @@ "Z5dM3R": "Référent", "Qp1beM": "Chemin", "9a9+ww": "Titre", - "qj71j1": "Requête", "y1Z3or": "Langue", "TE4fIS": "Ville", "lnaWo/": "Région", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Métrique Web Vitals pour mesurer la performance du site web.", "O8lxEI": "Consultez la documentation Web Vitals de Google pour les seuils.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "Le Session Replay est désactivé", "Tb0/AL": "Le Session Replay rendra le script analytique", "CRjhOx": "8x plus grand", diff --git a/client/messages/it.json b/client/messages/it.json index 9db38a142..ef41e0567 100644 --- a/client/messages/it.json +++ b/client/messages/it.json @@ -74,6 +74,7 @@ "AA5h7P": "Prestazioni", "LlNItA": "", "XHLYdJ": "Obiettivi", + "qj71j1": "Query", "9d/EEp": "Analisi del Prodotto", "5yUsUe": "Replay", "puhcpn": "Funnel", @@ -107,7 +108,6 @@ "Z5dM3R": "Referrer", "Qp1beM": "Percorso", "9a9+ww": "Titolo", - "qj71j1": "Query", "y1Z3or": "Lingua", "TE4fIS": "Città", "lnaWo/": "Regione", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Metrica Web Vitals per misurare le prestazioni del sito web.", "O8lxEI": "Controlla la documentazione Web Vitals di Google per le soglie.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "La riproduzione della sessione è Disabilitata", "Tb0/AL": "La riproduzione della sessione renderà lo script di analisi", "CRjhOx": "8x più grande", diff --git a/client/messages/ja.json b/client/messages/ja.json index 52152b504..3e2685760 100644 --- a/client/messages/ja.json +++ b/client/messages/ja.json @@ -74,6 +74,7 @@ "AA5h7P": "パフォーマンス", "LlNItA": "", "XHLYdJ": "目標", + "qj71j1": "クエリ", "9d/EEp": "プロダクトアナリティクス", "5yUsUe": "リプレイ", "puhcpn": "ファネル", @@ -107,7 +108,6 @@ "Z5dM3R": "リファラー", "Qp1beM": "パス", "9a9+ww": "タイトル", - "qj71j1": "クエリ", "y1Z3or": "言語", "TE4fIS": "都市", "lnaWo/": "地域", @@ -683,6 +683,15 @@ "4Urwt8": "ウェブ バイタル", "/Vcvr8": "Webサイトパフォーマンス測定用のWeb Vitals指標。", "O8lxEI": "しきい値についてはGoogleのWeb Vitalsドキュメントをご確認ください。", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "セッションリプレイは無効です", "Tb0/AL": "セッションリプレイを有効にすると、アナリティクススクリプトが", "CRjhOx": "8倍大きく", diff --git a/client/messages/ko.json b/client/messages/ko.json index 60b9e1e59..c08d38698 100644 --- a/client/messages/ko.json +++ b/client/messages/ko.json @@ -74,6 +74,7 @@ "AA5h7P": "성능", "LlNItA": "", "XHLYdJ": "목표", + "qj71j1": "쿼리", "9d/EEp": "제품 분석", "5yUsUe": "리플레이", "puhcpn": "퍼널", @@ -107,7 +108,6 @@ "Z5dM3R": "리퍼러", "Qp1beM": "경로", "9a9+ww": "제목", - "qj71j1": "쿼리", "y1Z3or": "언어", "TE4fIS": "도시", "lnaWo/": "지역", @@ -683,6 +683,15 @@ "4Urwt8": "웹 바이탈", "/Vcvr8": "웹사이트 성능을 측정하는 Web Vitals 지표.", "O8lxEI": "임계값은 Google의 Web Vitals 문서를 확인하세요.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "세션 리플레이가 비활성화됨", "Tb0/AL": "세션 리플레이는 분석 스크립트를", "CRjhOx": "8배 더 크게", diff --git a/client/messages/pl.json b/client/messages/pl.json index fb408974b..bcd633d93 100644 --- a/client/messages/pl.json +++ b/client/messages/pl.json @@ -74,6 +74,7 @@ "AA5h7P": "Wydajność", "LlNItA": "", "XHLYdJ": "Cele", + "qj71j1": "Zapytanie", "9d/EEp": "Analityka produktowa", "5yUsUe": "Odtwarzanie", "puhcpn": "Lejki", @@ -107,7 +108,6 @@ "Z5dM3R": "Źródło odesłania", "Qp1beM": "Ścieżka", "9a9+ww": "Tytuł", - "qj71j1": "Zapytanie", "y1Z3or": "Język", "TE4fIS": "Miasto", "lnaWo/": "Region", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Metryka Web Vitals do mierzenia wydajności strony.", "O8lxEI": "Sprawdź dokumentację Google Web Vitals, aby poznać progi.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "Odtwarzanie sesji jest wyłączone", "Tb0/AL": "Odtwarzanie sesji spowoduje, że skrypt analityczny będzie", "CRjhOx": "8x większy", diff --git a/client/messages/pt.json b/client/messages/pt.json index 08600c4fc..b62bbf82d 100644 --- a/client/messages/pt.json +++ b/client/messages/pt.json @@ -74,6 +74,7 @@ "AA5h7P": "Desempenho", "LlNItA": "", "XHLYdJ": "Metas", + "qj71j1": "Consulta", "9d/EEp": "Análise de Produto", "5yUsUe": "Replay", "puhcpn": "Funis", @@ -107,7 +108,6 @@ "Z5dM3R": "Referenciador", "Qp1beM": "Caminho", "9a9+ww": "Título", - "qj71j1": "Consulta", "y1Z3or": "Idioma", "TE4fIS": "Cidade", "lnaWo/": "Região", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Métrica Web Vitals para medir o desempenho do site.", "O8lxEI": "Consulte a documentação de Web Vitals do Google para os limites.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "Replay de Sessão Desativado", "Tb0/AL": "O replay de sessão tornará o script de análise", "CRjhOx": "8x maior", diff --git a/client/messages/uk.json b/client/messages/uk.json index 1b4423c60..b6f24a7e8 100644 --- a/client/messages/uk.json +++ b/client/messages/uk.json @@ -74,6 +74,7 @@ "AA5h7P": "Продуктивність", "LlNItA": "", "XHLYdJ": "Цілі", + "qj71j1": "", "9d/EEp": "Продуктова аналітика", "5yUsUe": "Відтворення", "puhcpn": "Воронки", @@ -107,7 +108,6 @@ "Z5dM3R": "Джерело переходу", "Qp1beM": "Шлях", "9a9+ww": "", - "qj71j1": "", "y1Z3or": "Мова", "TE4fIS": "", "lnaWo/": "Регіон", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vital", "/Vcvr8": "Метрика Web Vitals для вимірювання продуктивності вебсайту.", "O8lxEI": "Перевірте документацію Google Web Vitals для отримання порогових значень.", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "Відтворення сеансів вимкнено", "Tb0/AL": "Відтворення сеансів зробить аналітичний скрипт", "CRjhOx": "у 8 разів більшим", diff --git a/client/messages/zh.json b/client/messages/zh.json index 136df25a0..b6f3477e6 100644 --- a/client/messages/zh.json +++ b/client/messages/zh.json @@ -74,6 +74,7 @@ "AA5h7P": "性能", "LlNItA": "", "XHLYdJ": "目标", + "qj71j1": "查询", "9d/EEp": "产品分析", "5yUsUe": "会话回放", "puhcpn": "漏斗", @@ -107,7 +108,6 @@ "Z5dM3R": "来源 URL", "Qp1beM": "路径", "9a9+ww": "标题", - "qj71j1": "查询", "y1Z3or": "语言", "TE4fIS": "城市", "lnaWo/": "地区", @@ -683,6 +683,15 @@ "4Urwt8": "Web Vitals", "/Vcvr8": "用于衡量网站性能的 Web Vitals 指标。", "O8lxEI": "检查 Google 的 Web Vitals 文档了解阈值。", + "k1sXXZ": "Failed to generate query", + "SyINSq": "Failed to run query", + "Vn7Wtu": "", + "Pc+tM3": "Generate", + "yaMHMB": "Results", + "u3e151": "{count} rows", + "0eYAOP": "Not run", + "uDXLhQ": "No rows returned", + "ICdFJO": "Run a query", "4h8Ert": "会话回放已禁用", "Tb0/AL": "会话回放将使分析脚本", "CRjhOx": "大 8 倍", diff --git a/client/src/api/analytics/endpoints/customQuery.ts b/client/src/api/analytics/endpoints/customQuery.ts new file mode 100644 index 000000000..0f9f42b0b --- /dev/null +++ b/client/src/api/analytics/endpoints/customQuery.ts @@ -0,0 +1,51 @@ +import { authedFetch } from "../../utils"; + +export type CustomQueryRow = Record; + +export type RunCustomQueryResponse = { + data: CustomQueryRow[]; + meta: { + queryId: string; + rowCount: number; + maxExecutionTimeSeconds: number; + maxRows: number; + }; +}; + +export type GenerateCustomQueryResponse = { + query: string; +}; + +export type CustomQueryGenerationMessage = { + role: "user" | "assistant"; + content: string; +}; + +export type GenerateCustomQueryRequest = { + prompt: string; + currentSiteId?: number; + currentQuery?: string; + history?: CustomQueryGenerationMessage[]; +}; + +export function runCustomQuery(organizationId: string, query: string) { + return authedFetch( + `/organizations/${organizationId}/analytics/query`, + undefined, + { + method: "POST", + data: { query }, + } + ); +} + +export function generateCustomQuery(organizationId: string, data: GenerateCustomQueryRequest) { + return authedFetch( + `/organizations/${organizationId}/analytics/query/generate`, + undefined, + { + method: "POST", + data, + } + ); +} diff --git a/client/src/api/analytics/endpoints/index.ts b/client/src/api/analytics/endpoints/index.ts index 61fd9ee1b..bc75e18e6 100644 --- a/client/src/api/analytics/endpoints/index.ts +++ b/client/src/api/analytics/endpoints/index.ts @@ -42,6 +42,16 @@ export type { SiteEventCountParams, } from "./events"; +// Custom query endpoints +export { generateCustomQuery, runCustomQuery } from "./customQuery"; +export type { + CustomQueryGenerationMessage, + CustomQueryRow, + GenerateCustomQueryRequest, + GenerateCustomQueryResponse, + RunCustomQueryResponse, +} from "./customQuery"; + // Errors endpoints export { fetchErrorNames, fetchErrorEvents, fetchErrorBucketed } from "./errors"; export type { diff --git a/client/src/api/analytics/hooks/useCustomQuery.ts b/client/src/api/analytics/hooks/useCustomQuery.ts new file mode 100644 index 000000000..2a8ed2d0a --- /dev/null +++ b/client/src/api/analytics/hooks/useCustomQuery.ts @@ -0,0 +1,27 @@ +import { useMutation } from "@tanstack/react-query"; +import { CustomQueryGenerationMessage, generateCustomQuery, runCustomQuery } from "../endpoints/customQuery"; + +export function useRunCustomQuery() { + return useMutation({ + mutationFn: ({ organizationId, query }: { organizationId: string; query: string }) => + runCustomQuery(organizationId, query), + }); +} + +export function useGenerateCustomQuery() { + return useMutation({ + mutationFn: ({ + organizationId, + prompt, + currentSiteId, + currentQuery, + history, + }: { + organizationId: string; + prompt: string; + currentSiteId?: number; + currentQuery?: string; + history?: CustomQueryGenerationMessage[]; + }) => generateCustomQuery(organizationId, { prompt, currentSiteId, currentQuery, history }), + }); +} diff --git a/client/src/app/[site]/[privateKey]/query/page.tsx b/client/src/app/[site]/[privateKey]/query/page.tsx new file mode 100644 index 000000000..f5dcbb998 --- /dev/null +++ b/client/src/app/[site]/[privateKey]/query/page.tsx @@ -0,0 +1,2 @@ +// Re-export the query page for private link routes +export { default } from "../../query/page"; diff --git a/client/src/app/[site]/components/Sidebar/Sidebar.tsx b/client/src/app/[site]/components/Sidebar/Sidebar.tsx index 5c7dd48a8..218ca9e5f 100644 --- a/client/src/app/[site]/components/Sidebar/Sidebar.tsx +++ b/client/src/app/[site]/components/Sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import { Bot, ChartColumnDecreasing, Code, + Database, File, Flag, FlaskConical, @@ -124,6 +125,12 @@ function SidebarContent() { icon={} /> + } + /> {t("Product Analytics")}
{!isMobileSite && !subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && ( diff --git a/client/src/app/[site]/journeys/page.tsx b/client/src/app/[site]/journeys/page.tsx index ac2e91d1c..78215d55f 100644 --- a/client/src/app/[site]/journeys/page.tsx +++ b/client/src/app/[site]/journeys/page.tsx @@ -54,9 +54,11 @@ export default function JourneysPage() {
-
+
- {t("{steps} steps", { steps: String(steps) })} + + {t("{steps} steps", { steps: String(steps) })} + setSteps(value)} @@ -91,17 +93,19 @@ export default function JourneysPage() {
)} -
+
{Array.from({ length: steps }, (_, i) => (
- {t("Step {number}", { number: String(i + 1) })} + + {t("Step {number}", { number: String(i + 1) })} +
))} diff --git a/client/src/app/[site]/layout.tsx b/client/src/app/[site]/layout.tsx index 4bc2b2254..8aa35c970 100644 --- a/client/src/app/[site]/layout.tsx +++ b/client/src/app/[site]/layout.tsx @@ -70,7 +70,8 @@ export default function SiteLayout({ children }: { children: React.ReactNode }) !pathname.includes("/realtime") && !pathname.includes("/replay") && !pathname.includes("/globe") && - !pathname.includes("/api-playground") &&
} + !pathname.includes("/api-playground") && + !pathname.includes("/query") &&
}
diff --git a/client/src/app/[site]/query/page.tsx b/client/src/app/[site]/query/page.tsx new file mode 100644 index 000000000..30656b5f2 --- /dev/null +++ b/client/src/app/[site]/query/page.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { AlignLeft, Loader2, Play, Plus, Sparkles, X } from "lucide-react"; +import { useExtracted } from "next-intl"; +import { useTheme } from "next-themes"; +import { useParams } from "next/navigation"; +import { FormEvent, useMemo, useRef, useState } from "react"; +import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; +import sql from "react-syntax-highlighter/dist/esm/languages/hljs/sql"; +import { vs, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import { format as formatSql } from "sql-formatter"; +import { useGetSite } from "../../../api/admin/hooks/useSites"; +import { CustomQueryGenerationMessage, CustomQueryRow } from "../../../api/analytics/endpoints"; +import { useGenerateCustomQuery, useRunCustomQuery } from "../../../api/analytics/hooks/useCustomQuery"; +import { Button } from "../../../components/ui/button"; +import { Input } from "../../../components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../components/ui/table"; +import { toast } from "../../../components/ui/sonner"; +import { useSetPageTitle } from "../../../hooks/useSetPageTitle"; +import { cn } from "../../../lib/utils"; + +SyntaxHighlighter.registerLanguage("sql", sql); + +const DEFAULT_QUERY = ""; + +type QueryTab = { + id: string; + name: string; + prompt: string; + query: string; + generationHistory: CustomQueryGenerationMessage[]; + rows: CustomQueryRow[]; + hasRun: boolean; +}; + +function createQueryTab(index: number): QueryTab { + return { + id: `${Date.now()}-${index}`, + name: `Query ${index}`, + prompt: "", + query: DEFAULT_QUERY, + generationHistory: [], + rows: [], + hasRun: false, + }; +} + +function formatCellValue(value: unknown) { + if (value === null || value === undefined) return ""; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function getColumns(rows: CustomQueryRow[]) { + const columns = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + columns.add(key); + } + } + return Array.from(columns); +} + +function formatQuery(query: string) { + try { + return formatSql(query, { + language: "sql", + keywordCase: "upper", + linesBetweenQueries: 1, + }); + } catch { + return query; + } +} + +function QueryEditor({ + value, + disabled, + isRunning, + onChange, + onFormat, + onRun, +}: { + value: string; + disabled: boolean; + isRunning: boolean; + onChange: (value: string) => void; + onFormat: () => void; + onRun: () => void; +}) { + const t = useExtracted(); + const { resolvedTheme } = useTheme(); + const highlightRef = useRef(null); + const lineNumberRef = useRef(null); + const lineCount = Math.max(1, value.split("\n").length); + const isDark = resolvedTheme === "dark"; + + const handleScroll = (event: React.UIEvent) => { + const { scrollLeft, scrollTop } = event.currentTarget; + if (highlightRef.current) { + highlightRef.current.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)`; + } + if (lineNumberRef.current) { + lineNumberRef.current.style.transform = `translateY(${-scrollTop}px)`; + } + }; + + return ( +
+
+
+
+
{t("Query")}
+
+ SQL +
+
+
+ + +
+
+ +
+
+ +
+
+
+
+ + {value || " "} + +
+
+