From 962a45176ecab864f61411ce2996dd71bbf6e30b Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Tue, 16 Jan 2024 09:01:34 +0100 Subject: [PATCH 01/10] setup sentry out of experimental (#9830) --- packages/cli-helpers/src/lib/index.ts | 5 +- .../src/commands/experimental/setupSentry.js | 29 ------- .../commands/setup/monitoring/monitoring.ts | 17 ++++ .../setup/monitoring/sentry/sentry.ts | 30 +++++++ .../monitoring/sentry/sentryHandler.ts} | 81 +++++++++---------- .../test-project/set-up-trusted-documents.ts | 15 +++- 6 files changed, 101 insertions(+), 76 deletions(-) delete mode 100644 packages/cli/src/commands/experimental/setupSentry.js create mode 100644 packages/cli/src/commands/setup/monitoring/monitoring.ts create mode 100644 packages/cli/src/commands/setup/monitoring/sentry/sentry.ts rename packages/cli/src/commands/{experimental/setupSentryHandler.js => setup/monitoring/sentry/sentryHandler.ts} (72%) diff --git a/packages/cli-helpers/src/lib/index.ts b/packages/cli-helpers/src/lib/index.ts index d55f3fa08123..1b294a27f9e5 100644 --- a/packages/cli-helpers/src/lib/index.ts +++ b/packages/cli-helpers/src/lib/index.ts @@ -100,7 +100,8 @@ export const writeFile = ( target: string, contents: string, { existingFiles = 'FAIL' }: { existingFiles?: ExistingFiles } = {}, - // TODO: Remove type cast + // TODO: Remove type cast by finding all places `writeFile` is used and + // making sure a proper task is passed in task: ListrTaskWrapper = {} as ListrTaskWrapper< never, Renderer @@ -115,7 +116,7 @@ export const writeFile = ( } if (exists && existingFiles === 'SKIP') { - task.skip() + task.skip(`Skipping update of \`./${path.relative(base, target)}\``) return } diff --git a/packages/cli/src/commands/experimental/setupSentry.js b/packages/cli/src/commands/experimental/setupSentry.js deleted file mode 100644 index 740dc67cbf2e..000000000000 --- a/packages/cli/src/commands/experimental/setupSentry.js +++ /dev/null @@ -1,29 +0,0 @@ -import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' - -import { getEpilogue } from './util' - -export const command = 'setup-sentry' - -export const description = 'Setup Sentry error and performance tracking' - -export const EXPERIMENTAL_TOPIC_ID = 4880 - -export const builder = (yargs) => { - yargs - .option('force', { - alias: 'f', - default: false, - description: 'Overwrite existing sentry.js config files', - type: 'boolean', - }) - .epilogue(getEpilogue(command, description, EXPERIMENTAL_TOPIC_ID, true)) -} - -export const handler = async (options) => { - recordTelemetryAttributes({ - command: 'experimental setup-sentry', - force: options.force, - }) - const { handler } = await import('./setupSentryHandler.js') - return handler(options) -} diff --git a/packages/cli/src/commands/setup/monitoring/monitoring.ts b/packages/cli/src/commands/setup/monitoring/monitoring.ts new file mode 100644 index 000000000000..56d875ab50c2 --- /dev/null +++ b/packages/cli/src/commands/setup/monitoring/monitoring.ts @@ -0,0 +1,17 @@ +import terminalLink from 'terminal-link' +import type { Argv } from 'yargs' + +import * as sentryCommand from './sentry/sentry.js' + +export const command = 'monitoring ' +export const description = 'Set up monitoring in your Redwood app' +export function builder(yargs: Argv) { + return yargs + .command(sentryCommand) + .epilogue( + `Also see the ${terminalLink( + 'Redwood CLI Reference', + 'https://redwoodjs.com/docs/cli-commands#setup-graphql' + )}` + ) +} diff --git a/packages/cli/src/commands/setup/monitoring/sentry/sentry.ts b/packages/cli/src/commands/setup/monitoring/sentry/sentry.ts new file mode 100644 index 000000000000..d736e079f768 --- /dev/null +++ b/packages/cli/src/commands/setup/monitoring/sentry/sentry.ts @@ -0,0 +1,30 @@ +import type { Argv } from 'yargs' + +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' + +export const command = 'sentry' + +export const description = 'Setup Sentry error and performance tracking' + +export const builder = (yargs: Argv) => { + return yargs.option('force', { + alias: 'f', + default: false, + description: 'Overwrite existing Sentry config files', + type: 'boolean', + }) +} + +export interface Args { + force: boolean +} + +export async function handler({ force }: Args) { + recordTelemetryAttributes({ + command: 'setup monitoring sentry', + force, + }) + + const { handler } = await import('./sentryHandler.js') + return handler({ force }) +} diff --git a/packages/cli/src/commands/experimental/setupSentryHandler.js b/packages/cli/src/commands/setup/monitoring/sentry/sentryHandler.ts similarity index 72% rename from packages/cli/src/commands/experimental/setupSentryHandler.js rename to packages/cli/src/commands/setup/monitoring/sentry/sentryHandler.ts index 3f1874f1e9f1..607a6d28a506 100644 --- a/packages/cli/src/commands/experimental/setupSentryHandler.js +++ b/packages/cli/src/commands/setup/monitoring/sentry/sentryHandler.ts @@ -7,23 +7,22 @@ import { addApiPackages, addEnvVarTask, addWebPackages, - colors as c, + colors, getPaths, isTypeScriptProject, prettify, writeFilesTask, } from '@redwoodjs/cli-helpers' -import { getConfigPath } from '@redwoodjs/project-config' import { errorTelemetry } from '@redwoodjs/telemetry' -import { writeFile } from '../../lib' +import type { Args } from './sentry' -const PATHS = getPaths() +const rwPaths = getPaths() -export const handler = async ({ force }) => { +export const handler = async ({ force }: Args) => { const extension = isTypeScriptProject() ? 'ts' : 'js' - const notes = [] + const notes: Array = [] const tasks = new Listr([ addApiPackages(['@envelop/sentry@5', '@sentry/node@7']), @@ -35,28 +34,29 @@ export const handler = async ({ force }) => { ), { title: 'Setting up Sentry on the API and web sides', - task: () => - writeFilesTask( + task: () => { + return writeFilesTask( { - [path.join(PATHS.api.lib, `sentry.${extension}`)]: fs + [path.join(rwPaths.api.lib, `sentry.${extension}`)]: fs .readFileSync( path.join(__dirname, 'templates/sentryApi.ts.template') ) .toString(), - [path.join(PATHS.web.src, 'lib', `sentry.${extension}`)]: fs + [path.join(rwPaths.web.src, 'lib', `sentry.${extension}`)]: fs .readFileSync( path.join(__dirname, 'templates/sentryWeb.ts.template') ) .toString(), }, { existingFiles: force ? 'OVERWRITE' : 'SKIP' } - ), + ) + }, }, { title: 'Implementing the Envelop plugin', task: (ctx) => { const graphqlHandlerPath = path.join( - PATHS.api.functions, + rwPaths.api.functions, `graphql.${extension}` ) @@ -103,7 +103,7 @@ export const handler = async ({ force }) => { title: "Replacing Redwood's Error boundary", task: () => { const contentLines = fs - .readFileSync(PATHS.web.app) + .readFileSync(rwPaths.web.app) .toString() .split('\n') @@ -135,50 +135,29 @@ export const handler = async ({ force }) => { contentLines.splice(0, 0, "import Sentry from 'src/lib/sentry'") fs.writeFileSync( - PATHS.web.app, + rwPaths.web.app, prettify('App.tsx', contentLines.join('\n')) ) }, }, - { - title: 'Adding config to redwood.toml...', - task: (_ctx, task) => { - const redwoodTomlPath = getConfigPath() - const configContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - if (!configContent.includes('[experimental.sentry]')) { - // Use string replace to preserve comments and formatting - writeFile( - redwoodTomlPath, - configContent.concat(`\n[experimental.sentry]\n enabled = true\n`), - { - overwriteExisting: true, // redwood.toml always exists - } - ) - } else { - task.skip( - `The [experimental.sentry] config block already exists in your 'redwood.toml' file.` - ) - } - }, - }, { title: 'One more thing...', task: (ctx) => { notes.push( - c.green( + colors.green( 'You will need to add `SENTRY_DSN` to `includeEnvironmentVariables` in redwood.toml.' ) ) if (ctx.addEnvelopPluginSkipped) { notes.push( - `${c.underline( + `${colors.underline( 'Make sure you implement the Sentry Envelop plugin:' )} https://redwoodjs.com/docs/cli-commands#sentry-envelop-plugin` ) } else { notes.push( - "Check out RedwoodJS forums' for more: https://community.redwoodjs.com/t/sentry-error-and-performance-monitoring-experimental/4880" + 'Check out the RedwoodJS forums for more: https://community.redwoodjs.com/t/sentry-error-and-performance-monitoring-experimental/4880' ) } }, @@ -189,8 +168,28 @@ export const handler = async ({ force }) => { await tasks.run() console.log(notes.join('\n')) } catch (e) { - errorTelemetry(process.argv, e.message) - console.error(c.error(e.message)) - process.exit(e?.exitCode || 1) + if (isErrorWithMessage(e)) { + errorTelemetry(process.argv, e.message) + console.error(colors.error(e.message)) + } + + if (isErrorWithExitCode(e)) { + process.exit(e.exitCode) + } + + process.exit(1) } } + +function isErrorWithMessage(e: unknown): e is { message: string } { + return !!e && typeof e === 'object' && 'message' in e +} + +function isErrorWithExitCode(e: unknown): e is { exitCode: number } { + return ( + !!e && + typeof e === 'object' && + 'exitCode' in e && + typeof e.exitCode === 'number' + ) +} diff --git a/tasks/test-project/set-up-trusted-documents.ts b/tasks/test-project/set-up-trusted-documents.ts index 0ecdb73c6231..5dd89f9fe41c 100644 --- a/tasks/test-project/set-up-trusted-documents.ts +++ b/tasks/test-project/set-up-trusted-documents.ts @@ -1,11 +1,15 @@ /* eslint-env node, es6*/ -import fs from 'node:fs' -import path from 'node:path' +import * as fs from 'node:fs' +import * as path from 'node:path' import { hideBin } from 'yargs/helpers' import yargs from 'yargs/yargs' -import { exec, getExecaOptions } from './util' +import { exec, getExecaOptions as utilGetExecaOptions } from './util' + +function getExecaOptions(cwd: string) { + return { ...utilGetExecaOptions(cwd), stdio: 'pipe' } +} const args = yargs(hideBin(process.argv)) .usage('Usage: $0 ') @@ -17,7 +21,6 @@ const args = yargs(hideBin(process.argv)) */ async function runCommand() { const OUTPUT_PROJECT_PATH = path.resolve(String(args._)) - await exec( 'yarn rw setup graphql trusted-documents', [], @@ -38,6 +41,10 @@ async function runCommand() { console.error('trustedDocuments = true not set in redwood.toml') console.error() console.error('Please run this command locally to make sure it works') + console.error() + console.error("For debugging purposes, here's the content of redwood.toml:") + console.error(redwoodTomlContent) + console.error() throw new Error('Failed to set up trusted-document') } From 7d42449c255bbc85647e8909465967bb93f3c560 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Tue, 16 Jan 2024 11:21:27 +0100 Subject: [PATCH 02/10] rw-studio-impersonation-cookie (#9836) --- packages/auth-providers/dbAuth/api/src/shared.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/auth-providers/dbAuth/api/src/shared.ts b/packages/auth-providers/dbAuth/api/src/shared.ts index 78cf1db0de46..c10cf02aa4c0 100644 --- a/packages/auth-providers/dbAuth/api/src/shared.ts +++ b/packages/auth-providers/dbAuth/api/src/shared.ts @@ -40,10 +40,17 @@ const getPort = () => { return getConfig(configPath).api.port } -// When in development environment, check for cookie in the request extension headers +// When in development environment, check for auth impersonation cookie // if user has generated graphiql headers const eventGraphiQLHeadersCookie = (event: APIGatewayProxyEvent) => { if (process.env.NODE_ENV === 'development') { + if (event.headers['rw-studio-impersonation-cookie']) { + return event.headers['rw-studio-impersonation-cookie'] + } + + // TODO: Remove code below when we remove the old way of passing the cookie + // from Studio, and decide it's OK to break compatibility with older Studio + // versions try { const jsonBody = JSON.parse(event.body ?? '{}') return ( From 1e6a12852f5ff31f9c6a2371addf983fc21ed359 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Tue, 16 Jan 2024 17:03:02 +0000 Subject: [PATCH 03/10] feat(studio): Switch to newer version of studio (#9799) We have rewritten studio and it now lives in its own repository. This PR switches out the current implementation to use this new one. --- __fixtures__/test-project/.redwood/README.md | 2 +- docs/docs/studio.md | 110 + docs/sidebars.js | 1 + .../commands/experimental/studioHandler.js | 50 - .../templates/opentelemetry.ts.template | 4 +- .../src/commands/{experimental => }/studio.js | 16 +- packages/cli/src/commands/studioHandler.js | 27 + packages/cli/src/index.js | 2 + .../templates/js/.redwood/README.md | 2 +- .../templates/ts/.redwood/README.md | 2 +- .../src/__tests__/config.test.ts | 84 +- .../fixtures/redwood.studio.dbauth.toml | 6 +- .../fixtures/redwood.studio.supabase.toml | 6 +- .../__tests__/fixtures/redwood.studio.toml | 4 +- packages/project-config/src/config.ts | 30 +- packages/studio/README.md | 187 - packages/studio/api/database.ts | 24 - .../api/fastify/plugins/withApiProxy.ts | 27 - packages/studio/api/fastify/spanIngester.ts | 136 - packages/studio/api/fastify/yoga.ts | 34 - packages/studio/api/graphql/yoga.ts | 308 -- packages/studio/api/index.ts | 96 - .../lib/authProviderEncoders/dbAuthEncoder.ts | 32 - .../netlifyAuthEncoder.ts | 33 - .../supabaseAuthEncoder.ts | 33 - packages/studio/api/lib/config.ts | 30 - packages/studio/api/lib/envars.ts | 18 - packages/studio/api/lib/filtering.ts | 85 - .../studio/api/lib/rewriteWebToUsePort.ts | 12 - packages/studio/api/lib/sql.ts | 50 - packages/studio/api/mail/index.ts | 493 --- packages/studio/api/migrations.ts | 167 - packages/studio/api/services/auth.ts | 33 - packages/studio/api/services/charts.ts | 183 - packages/studio/api/services/config.ts | 14 - .../studio/api/services/explore/graphql.ts | 68 - packages/studio/api/services/explore/span.ts | 39 - packages/studio/api/services/explore/trace.ts | 68 - packages/studio/api/services/graphqlSpans.ts | 21 - packages/studio/api/services/lists.ts | 67 - packages/studio/api/services/mail.ts | 234 -- packages/studio/api/services/prismaSpans.ts | 17 - packages/studio/api/services/span.ts | 227 -- packages/studio/api/services/sqlSpans.ts | 38 - packages/studio/api/services/util.ts | 95 - packages/studio/api/types.ts | 140 - packages/studio/build.mjs | 25 - packages/studio/package.json | 115 - packages/studio/tsconfig.json | 11 - packages/studio/web/.gitignore | 24 - packages/studio/web/index.html | 15 - packages/studio/web/postcss.config.cjs | 6 - .../web/src/BarLists/ModelsAccessedList.tsx | 104 - .../web/src/BarLists/SeriesTypeBarList.tsx | 225 -- .../web/src/Charts/SpanTreeMapChart.tsx | 50 - .../web/src/Charts/SpanTypeBarChart.tsx | 86 - .../src/Charts/SpanTypeTimeSeriesBarChart.tsx | 159 - .../src/Charts/SpanTypeTimeSeriesChart.tsx | 161 - .../studio/web/src/Components/CountCard.tsx | 64 - .../src/Components/Event/ErrorEventLink.tsx | 40 - .../web/src/Components/Event/EventModal.tsx | 125 - .../Feature/AncestorFeatureList.tsx | 30 - .../src/Components/Feature/CustomIcons.tsx | 29 - .../Feature/DescendantFeatureList.tsx | 34 - .../src/Components/Feature/FeatureLink.tsx | 37 - .../Components/Feature/TraceFeatureList.tsx | 30 - .../web/src/Components/Feature/features.ts | 32 - .../web/src/Components/LoadingSpinner.tsx | 14 - .../web/src/Components/Mail/MailRenderer.tsx | 219 -- .../web/src/Components/Panels/ErrorPanel.tsx | 17 - .../Components/Panels/InformationPanel.tsx | 17 - .../src/Components/Panels/WarningPanel.tsx | 17 - .../RedwoodGraphiQL/RedwoodGraphiQL.tsx | 173 - .../src/Components/RedwoodGraphiQL/styles.css | 19 - .../web/src/Components/SearchFilterBar.tsx | 62 - .../web/src/Components/Span/EventList.tsx | 47 - .../web/src/Components/Span/ResourceList.tsx | 43 - .../web/src/Components/Span/SpanDetails.tsx | 137 - .../web/src/Components/Span/SpanTypeLabel.tsx | 77 - .../web/src/Components/Trace/TraceDetails.tsx | 81 - .../Components/Tracing/EnhancementList.tsx | 59 - .../src/Components/Tracing/FlameTableView.tsx | 188 -- .../Components/Tracing/PrismaQueryView.tsx | 74 - .../src/Components/Tracing/TimelineView.tsx | 234 -- .../Context/SearchFilterContextProvider.tsx | 38 - .../studio/web/src/Layouts/MasterLayout.tsx | 325 -- packages/studio/web/src/Pages/ComingSoon.tsx | 80 - packages/studio/web/src/Pages/Config.tsx | 230 -- .../studio/web/src/Pages/Explore/Span.tsx | 115 - .../studio/web/src/Pages/Explore/SpanList.tsx | 135 - .../web/src/Pages/Explore/SpanTreeMap.tsx | 128 - .../studio/web/src/Pages/Explore/Trace.tsx | 208 -- .../web/src/Pages/Explore/TraceList.tsx | 172 - packages/studio/web/src/Pages/GraphiQL.tsx | 70 - .../studio/web/src/Pages/Mail/Preview.tsx | 260 -- packages/studio/web/src/Pages/Mail/Sink.tsx | 313 -- packages/studio/web/src/Pages/MapLanding.tsx | 29 - packages/studio/web/src/Pages/NotFound.tsx | 16 - packages/studio/web/src/Pages/Overview.tsx | 28 - packages/studio/web/src/Pages/Performance.tsx | 66 - .../web/src/assets/redwoodjs_diecut.svg | 1 - .../web/src/assets/redwoodjs_diecut_name.svg | 1 - packages/studio/web/src/index.css | 26 - packages/studio/web/src/main.tsx | 91 - packages/studio/web/src/util/polling.ts | 2 - packages/studio/web/src/util/spans.ts | 19 - packages/studio/web/src/util/trace.ts | 45 - packages/studio/web/src/util/ui.tsx | 15 - packages/studio/web/src/vite-env.d.ts | 9 - packages/studio/web/tailwind.config.cjs | 135 - packages/studio/web/tsconfig.json | 21 - packages/studio/web/tsconfig.node.json | 9 - packages/studio/web/vite.config.ts | 14 - yarn.lock | 3005 +---------------- 114 files changed, 262 insertions(+), 11579 deletions(-) create mode 100644 docs/docs/studio.md delete mode 100644 packages/cli/src/commands/experimental/studioHandler.js rename packages/cli/src/commands/{experimental => }/studio.js (55%) create mode 100644 packages/cli/src/commands/studioHandler.js delete mode 100644 packages/studio/README.md delete mode 100644 packages/studio/api/database.ts delete mode 100644 packages/studio/api/fastify/plugins/withApiProxy.ts delete mode 100644 packages/studio/api/fastify/spanIngester.ts delete mode 100644 packages/studio/api/fastify/yoga.ts delete mode 100644 packages/studio/api/graphql/yoga.ts delete mode 100644 packages/studio/api/index.ts delete mode 100644 packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts delete mode 100644 packages/studio/api/lib/authProviderEncoders/netlifyAuthEncoder.ts delete mode 100644 packages/studio/api/lib/authProviderEncoders/supabaseAuthEncoder.ts delete mode 100644 packages/studio/api/lib/config.ts delete mode 100644 packages/studio/api/lib/envars.ts delete mode 100644 packages/studio/api/lib/filtering.ts delete mode 100644 packages/studio/api/lib/rewriteWebToUsePort.ts delete mode 100644 packages/studio/api/lib/sql.ts delete mode 100644 packages/studio/api/mail/index.ts delete mode 100644 packages/studio/api/migrations.ts delete mode 100644 packages/studio/api/services/auth.ts delete mode 100644 packages/studio/api/services/charts.ts delete mode 100644 packages/studio/api/services/config.ts delete mode 100644 packages/studio/api/services/explore/graphql.ts delete mode 100644 packages/studio/api/services/explore/span.ts delete mode 100644 packages/studio/api/services/explore/trace.ts delete mode 100644 packages/studio/api/services/graphqlSpans.ts delete mode 100644 packages/studio/api/services/lists.ts delete mode 100644 packages/studio/api/services/mail.ts delete mode 100644 packages/studio/api/services/prismaSpans.ts delete mode 100644 packages/studio/api/services/span.ts delete mode 100644 packages/studio/api/services/sqlSpans.ts delete mode 100644 packages/studio/api/services/util.ts delete mode 100644 packages/studio/api/types.ts delete mode 100644 packages/studio/build.mjs delete mode 100644 packages/studio/package.json delete mode 100644 packages/studio/tsconfig.json delete mode 100644 packages/studio/web/.gitignore delete mode 100644 packages/studio/web/index.html delete mode 100644 packages/studio/web/postcss.config.cjs delete mode 100644 packages/studio/web/src/BarLists/ModelsAccessedList.tsx delete mode 100644 packages/studio/web/src/BarLists/SeriesTypeBarList.tsx delete mode 100644 packages/studio/web/src/Charts/SpanTreeMapChart.tsx delete mode 100644 packages/studio/web/src/Charts/SpanTypeBarChart.tsx delete mode 100644 packages/studio/web/src/Charts/SpanTypeTimeSeriesBarChart.tsx delete mode 100644 packages/studio/web/src/Charts/SpanTypeTimeSeriesChart.tsx delete mode 100644 packages/studio/web/src/Components/CountCard.tsx delete mode 100644 packages/studio/web/src/Components/Event/ErrorEventLink.tsx delete mode 100644 packages/studio/web/src/Components/Event/EventModal.tsx delete mode 100644 packages/studio/web/src/Components/Feature/AncestorFeatureList.tsx delete mode 100644 packages/studio/web/src/Components/Feature/CustomIcons.tsx delete mode 100644 packages/studio/web/src/Components/Feature/DescendantFeatureList.tsx delete mode 100644 packages/studio/web/src/Components/Feature/FeatureLink.tsx delete mode 100644 packages/studio/web/src/Components/Feature/TraceFeatureList.tsx delete mode 100644 packages/studio/web/src/Components/Feature/features.ts delete mode 100644 packages/studio/web/src/Components/LoadingSpinner.tsx delete mode 100644 packages/studio/web/src/Components/Mail/MailRenderer.tsx delete mode 100644 packages/studio/web/src/Components/Panels/ErrorPanel.tsx delete mode 100644 packages/studio/web/src/Components/Panels/InformationPanel.tsx delete mode 100644 packages/studio/web/src/Components/Panels/WarningPanel.tsx delete mode 100644 packages/studio/web/src/Components/RedwoodGraphiQL/RedwoodGraphiQL.tsx delete mode 100644 packages/studio/web/src/Components/RedwoodGraphiQL/styles.css delete mode 100644 packages/studio/web/src/Components/SearchFilterBar.tsx delete mode 100644 packages/studio/web/src/Components/Span/EventList.tsx delete mode 100644 packages/studio/web/src/Components/Span/ResourceList.tsx delete mode 100644 packages/studio/web/src/Components/Span/SpanDetails.tsx delete mode 100644 packages/studio/web/src/Components/Span/SpanTypeLabel.tsx delete mode 100644 packages/studio/web/src/Components/Trace/TraceDetails.tsx delete mode 100644 packages/studio/web/src/Components/Tracing/EnhancementList.tsx delete mode 100644 packages/studio/web/src/Components/Tracing/FlameTableView.tsx delete mode 100644 packages/studio/web/src/Components/Tracing/PrismaQueryView.tsx delete mode 100644 packages/studio/web/src/Components/Tracing/TimelineView.tsx delete mode 100644 packages/studio/web/src/Context/SearchFilterContextProvider.tsx delete mode 100644 packages/studio/web/src/Layouts/MasterLayout.tsx delete mode 100644 packages/studio/web/src/Pages/ComingSoon.tsx delete mode 100644 packages/studio/web/src/Pages/Config.tsx delete mode 100644 packages/studio/web/src/Pages/Explore/Span.tsx delete mode 100644 packages/studio/web/src/Pages/Explore/SpanList.tsx delete mode 100644 packages/studio/web/src/Pages/Explore/SpanTreeMap.tsx delete mode 100644 packages/studio/web/src/Pages/Explore/Trace.tsx delete mode 100644 packages/studio/web/src/Pages/Explore/TraceList.tsx delete mode 100644 packages/studio/web/src/Pages/GraphiQL.tsx delete mode 100644 packages/studio/web/src/Pages/Mail/Preview.tsx delete mode 100644 packages/studio/web/src/Pages/Mail/Sink.tsx delete mode 100644 packages/studio/web/src/Pages/MapLanding.tsx delete mode 100644 packages/studio/web/src/Pages/NotFound.tsx delete mode 100644 packages/studio/web/src/Pages/Overview.tsx delete mode 100644 packages/studio/web/src/Pages/Performance.tsx delete mode 100644 packages/studio/web/src/assets/redwoodjs_diecut.svg delete mode 100644 packages/studio/web/src/assets/redwoodjs_diecut_name.svg delete mode 100644 packages/studio/web/src/index.css delete mode 100644 packages/studio/web/src/main.tsx delete mode 100644 packages/studio/web/src/util/polling.ts delete mode 100644 packages/studio/web/src/util/spans.ts delete mode 100644 packages/studio/web/src/util/trace.ts delete mode 100644 packages/studio/web/src/util/ui.tsx delete mode 100644 packages/studio/web/src/vite-env.d.ts delete mode 100644 packages/studio/web/tailwind.config.cjs delete mode 100644 packages/studio/web/tsconfig.json delete mode 100644 packages/studio/web/tsconfig.node.json delete mode 100644 packages/studio/web/vite.config.ts diff --git a/__fixtures__/test-project/.redwood/README.md b/__fixtures__/test-project/.redwood/README.md index f22b586a47cc..8829edb84776 100644 --- a/__fixtures__/test-project/.redwood/README.md +++ b/__fixtures__/test-project/.redwood/README.md @@ -18,7 +18,6 @@ You don't need to commit any other contents of this directory to your version co | :---------------- | :------- | | commandCache.json | This file contains mappings to assist the Redwood CLI in efficiently executing commands. | | schema.graphql | This is the GraphQL schema which has been automatically generated from your Redwood project. | -| studio.db | The sqlite database used by the experimental `rw exp studio` feature. | | telemetry.txt | Contains a unique ID used for telemetry. This value is rotated every 24 hours to protect your project's anonymity. | | test.db | The sqlite database used when running tests. | @@ -32,6 +31,7 @@ You don't need to commit any other contents of this directory to your version co | telemetry | Stores the recent telemetry that the Redwood CLI has generated. You may inspect these files to see everything Redwood is anonymously collecting. | | types | Stores the results of type generation. | | updateCheck | Stores a file which contains the results of checking for Redwood updates. | +| studio | Used to store data for `rw studio` | We try to keep this README up to date but you may, from time to time, find other files or directories in this `.redwood` directory that have not yet been documented here. This is likely nothing to worry about but feel free to let us know and we'll update this list. diff --git a/docs/docs/studio.md b/docs/docs/studio.md new file mode 100644 index 000000000000..c37d896fe072 --- /dev/null +++ b/docs/docs/studio.md @@ -0,0 +1,110 @@ +--- +description: RedwoodJS Studio is a package used during development to gain runtime insights into a project. +--- + +# Studio + +RedwoodJS Studio is a package used during development to gain runtime insights into a project. + +## Motivation + +Redwood provides tools that lets developers "get to work on what makes your application special, instead of wasting cycles choosing and re-choosing various technologies and configurations."[1](https://github.com/redwoodjs/redwood/blob/main/README.md). + +Much happens while your app processes a request: Invoke a function; handle a GraphQL request; resolve the request with a service; build and execute a SQL statement; connect to the database; handle the query response; further resolve the response so in contains all the data needed; return the result ... and more. + +While [logging](https://redwoodjs.com/docs/logger) can show you some of these steps, there is no easy way to see how they relate to each other, compare, or break down individual timings. Observability needed to debug, iterate, try out, and refactor your code is lacking. + +We hope Studio helps solve this problem with an observability tool that combines: + +* Tracing with OpenTelemetry (service and GraphQL) + +* SQL statement logging + +* general metrics (how many invocations) + +* GraphiQL playground with impersonated authentication + +With Studio, it is easier to: + +* identify slow running SQL statements without reviewing captured log files + +* identify and improve N+1 queries by comparing before and after traces + +* impersonate the user authentication headers in GraphiQL + +Redwood Studio is a command line tool which offers a web UI aimed at providing insights into your application via OpenTelemetry ingestion and other development conveniences like auth-impersonation within GraphiQL. + +### Demo +
+ +
+ +### Setup +There is no setup needed to begin using the studio; simply execute the following command to start the studio at `localhost:4318`: +```bash +yarn rw studio +``` +The first time you run this command it will likely install the studio package which may take a small amount of time. + +#### OpenTelemetry +If you want studio to pick up telemetry from you app automatically please ensure you've setup opentelemetry. A guide on this can be found [here](https://community.redwoodjs.com/t/opentelemetry-support-experimental/4772?u=josh-walker-gm) + +### Features +#### TOML +The following TOML options are now available which can control the studio behaviour. +```toml +[studio.graphiql.authImpersonation] + # authProvider = undefined (default value) + jwtSecret = 'secret' + # userId = undefined (default value) + # email = undefined (default value) + # roles = undefined (default value) +``` + +#### GraphiQL Auth Impersonation + +##### DbAuth + +Requires `SESSION_SECRET` envar for cookie encryption. + +TOML example: + +```toml +[studio.graphiql.authImpersonation] + authProvider = "dbAuth" + email = "user@example.com" + userId = "1" +``` + +##### Netlify + +Since Netlify does not expose the JWT secret used to sign the token in production, impersonation requires a `jwtSecret` to encode and decode the auth token. + +TOML example: + +```toml +[studio.graphiql.authImpersonation] + authProvider = "netlify" + email = "user@example.com" + userId = "1" + jwtSecret = "some-secret-setting" +``` + +##### Supabase + +Requires `SUPABASE_JWT_SECRET` envar for JWT signing. + +TOML example: + +```toml +[studio.graphiql.authImpersonation] + authProvider = "supabase" + email = "user@example.com" + userId = "1" +``` + +#### Database File +Studio stores the ingested telemetry to `studio/prisma.db` within the `.redwood` folder. You should not need to touch this file other than if you wish to delete it to erase any existing telemetry data. + +## Availability +The setup command is currently available from the `canary` version of Redwood. You can try this out in a new project by running `yarn rw upgrade --tag canary` and following any general upgrade steps recommend on the [forums](https://community.redwoodjs.com/c/announcements/releases-and-upgrade-guides/18). diff --git a/docs/sidebars.js b/docs/sidebars.js index 2c877425e401..4be162237cfa 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -185,6 +185,7 @@ module.exports = { 'serverless-functions', 'services', 'storybook', + 'studio', 'testing', 'toast-notifications', { diff --git a/packages/cli/src/commands/experimental/studioHandler.js b/packages/cli/src/commands/experimental/studioHandler.js deleted file mode 100644 index 4ca9d7000d2b..000000000000 --- a/packages/cli/src/commands/experimental/studioHandler.js +++ /dev/null @@ -1,50 +0,0 @@ -import fs from 'fs-extra' - -import { getConfigPath } from '@redwoodjs/project-config' - -import { writeFile } from '../../lib' -import { isModuleInstalled, installRedwoodModule } from '../../lib/packages' - -import { command, description, EXPERIMENTAL_TOPIC_ID } from './studio' -import { printTaskEpilogue } from './util' - -export const handler = async (options) => { - printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID) - try { - // Check the module is installed - if (!isModuleInstalled('@redwoodjs/studio')) { - console.log( - 'The studio package is not installed, installing it for you, this may take a moment...' - ) - await installRedwoodModule('@redwoodjs/studio') - console.log('Studio package installed successfully.') - - console.log('Adding config to redwood.toml...') - const redwoodTomlPath = getConfigPath() - const configContent = fs.readFileSync(redwoodTomlPath, 'utf-8') - - if (!configContent.includes('[experimental.studio]')) { - // Use string replace to preserve comments and formatting - writeFile( - redwoodTomlPath, - configContent.concat(`\n[experimental.studio]\n enabled = true\n`), - { - overwriteExisting: true, // redwood.toml always exists - } - ) - } else { - console.log( - `The [experimental.studio] config block already exists in your 'redwood.toml' file.` - ) - } - } - - // Import studio and start it - const { start } = await import('@redwoodjs/studio') - await start({ open: options.open }) - } catch (e) { - console.log('Cannot start the development studio') - console.log(e) - process.exit(1) - } -} diff --git a/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template b/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template index a73897fac5cc..26e9d28c8af7 100644 --- a/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template +++ b/packages/cli/src/commands/experimental/templates/opentelemetry.ts.template @@ -23,12 +23,12 @@ const resource = Resource.default().merge( }) ) -const studioPort = getConfig().experimental.studio.basePort +const studioPort = getConfig().studio.basePort const exporter = new OTLPTraceExporter({ // Update this URL to point to where your OTLP compatible collector is listening // The redwood development studio (`yarn rw exp studio`) can collect your // telemetry at `http://127.0.0.1:/v1/traces` (default PORT is 4318) - url: `http://127.0.0.1:${studioPort}/v1/traces`, + url: `http://127.0.0.1:${studioPort}/.redwood/functions/otel-trace`, concurrencyLimit: 64, }) diff --git a/packages/cli/src/commands/experimental/studio.js b/packages/cli/src/commands/studio.js similarity index 55% rename from packages/cli/src/commands/experimental/studio.js rename to packages/cli/src/commands/studio.js index 644a350644b0..dd88a23dbe05 100644 --- a/packages/cli/src/commands/experimental/studio.js +++ b/packages/cli/src/commands/studio.js @@ -1,24 +1,18 @@ import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -import { getEpilogue } from './util' - export const command = 'studio' export const description = 'Run the Redwood development studio' -export const EXPERIMENTAL_TOPIC_ID = 4771 - export function builder(yargs) { - yargs - .option('open', { - default: true, - description: 'Open the studio in your browser', - }) - .epilogue(getEpilogue(command, description, EXPERIMENTAL_TOPIC_ID, true)) + yargs.option('open', { + default: true, + description: 'Open the studio in your browser', + }) } export async function handler(options) { recordTelemetryAttributes({ - command: 'experimental studio', + command: 'studio', open: options.open, }) const { handler } = await import('./studioHandler.js') diff --git a/packages/cli/src/commands/studioHandler.js b/packages/cli/src/commands/studioHandler.js new file mode 100644 index 000000000000..51c1d862e4f9 --- /dev/null +++ b/packages/cli/src/commands/studioHandler.js @@ -0,0 +1,27 @@ +import { setTomlSetting } from '@redwoodjs/cli-helpers' + +import { isModuleInstalled, installModule } from '../lib/packages' + +export const handler = async (options) => { + try { + // Check the module is installed + if (!isModuleInstalled('@redwoodjs/studio')) { + console.log( + 'The studio package is not installed, installing it for you, this may take a moment...' + ) + await installModule('@redwoodjs/studio', '11.0.0') + console.log('Studio package installed successfully.') + + console.log('Adding config to redwood.toml...') + setTomlSetting('studio', 'enabled', true) + } + + // Import studio and start it + const { serve } = await import('@redwoodjs/studio') + await serve({ open: options.open }) + } catch (e) { + console.log('Cannot start the development studio') + console.log(e) + process.exit(1) + } +} diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js index c0130ea2feac..d2a16cb0e867 100644 --- a/packages/cli/src/index.js +++ b/packages/cli/src/index.js @@ -27,6 +27,7 @@ import * as prismaCommand from './commands/prisma' import * as recordCommand from './commands/record' import * as serveCommand from './commands/serve' import * as setupCommand from './commands/setup' +import * as studioCommand from './commands/studio' import * as testCommand from './commands/test' import * as tstojsCommand from './commands/ts-to-js' import * as typeCheckCommand from './commands/type-check' @@ -206,6 +207,7 @@ async function runYargs() { .command(recordCommand) .command(serveCommand) .command(setupCommand) + .command(studioCommand) .command(testCommand) .command(tstojsCommand) .command(typeCheckCommand) diff --git a/packages/create-redwood-app/templates/js/.redwood/README.md b/packages/create-redwood-app/templates/js/.redwood/README.md index f22b586a47cc..8829edb84776 100644 --- a/packages/create-redwood-app/templates/js/.redwood/README.md +++ b/packages/create-redwood-app/templates/js/.redwood/README.md @@ -18,7 +18,6 @@ You don't need to commit any other contents of this directory to your version co | :---------------- | :------- | | commandCache.json | This file contains mappings to assist the Redwood CLI in efficiently executing commands. | | schema.graphql | This is the GraphQL schema which has been automatically generated from your Redwood project. | -| studio.db | The sqlite database used by the experimental `rw exp studio` feature. | | telemetry.txt | Contains a unique ID used for telemetry. This value is rotated every 24 hours to protect your project's anonymity. | | test.db | The sqlite database used when running tests. | @@ -32,6 +31,7 @@ You don't need to commit any other contents of this directory to your version co | telemetry | Stores the recent telemetry that the Redwood CLI has generated. You may inspect these files to see everything Redwood is anonymously collecting. | | types | Stores the results of type generation. | | updateCheck | Stores a file which contains the results of checking for Redwood updates. | +| studio | Used to store data for `rw studio` | We try to keep this README up to date but you may, from time to time, find other files or directories in this `.redwood` directory that have not yet been documented here. This is likely nothing to worry about but feel free to let us know and we'll update this list. diff --git a/packages/create-redwood-app/templates/ts/.redwood/README.md b/packages/create-redwood-app/templates/ts/.redwood/README.md index f22b586a47cc..8829edb84776 100644 --- a/packages/create-redwood-app/templates/ts/.redwood/README.md +++ b/packages/create-redwood-app/templates/ts/.redwood/README.md @@ -18,7 +18,6 @@ You don't need to commit any other contents of this directory to your version co | :---------------- | :------- | | commandCache.json | This file contains mappings to assist the Redwood CLI in efficiently executing commands. | | schema.graphql | This is the GraphQL schema which has been automatically generated from your Redwood project. | -| studio.db | The sqlite database used by the experimental `rw exp studio` feature. | | telemetry.txt | Contains a unique ID used for telemetry. This value is rotated every 24 hours to protect your project's anonymity. | | test.db | The sqlite database used when running tests. | @@ -32,6 +31,7 @@ You don't need to commit any other contents of this directory to your version co | telemetry | Stores the recent telemetry that the Redwood CLI has generated. You may inspect these files to see everything Redwood is anonymously collecting. | | types | Stores the results of type generation. | | updateCheck | Stores a file which contains the results of checking for Redwood updates. | +| studio | Used to store data for `rw studio` | We try to keep this README up to date but you may, from time to time, find other files or directories in this `.redwood` directory that have not yet been documented here. This is likely nothing to worry about but feel free to let us know and we'll update this list. diff --git a/packages/project-config/src/__tests__/config.test.ts b/packages/project-config/src/__tests__/config.test.ts index 4ae182850c27..4f1509757358 100644 --- a/packages/project-config/src/__tests__/config.test.ts +++ b/packages/project-config/src/__tests__/config.test.ts @@ -67,20 +67,6 @@ describe('getConfig', () => { "streamingSsr": { "enabled": false, }, - "studio": { - "basePort": 4318, - "graphiql": { - "authImpersonation": { - "authProvider": undefined, - "email": undefined, - "jwtSecret": "secret", - "roles": undefined, - "userId": undefined, - }, - "endpoint": "graphql", - }, - "inMemory": false, - }, "useSDLCodeGenForGraphQLTypes": false, }, "generate": { @@ -95,6 +81,20 @@ describe('getConfig', () => { "notifications": { "versionUpdates": [], }, + "studio": { + "basePort": 4318, + "graphiql": { + "authImpersonation": { + "authProvider": undefined, + "email": undefined, + "jwtSecret": "secret", + "roles": undefined, + "userId": undefined, + }, + "endpoint": "graphql", + }, + "inMemory": false, + }, "web": { "a11y": true, "apiUrl": "/.redwood/functions", @@ -116,8 +116,8 @@ describe('getConfig', () => { const config = getConfig(path.join(__dirname, './fixtures/redwood.toml')) expect(config.web.port).toEqual(8888) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual('graphql') + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql') }) describe('with studio configs', () => { @@ -126,27 +126,23 @@ describe('getConfig', () => { path.join(__dirname, './fixtures/redwood.studio.toml') ) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual( - 'graphql-endpoint' - ) + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql-endpoint') }) it('merges studio configs with dbAuth impersonation', () => { const config = getConfig( path.join(__dirname, './fixtures/redwood.studio.dbauth.toml') ) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual('graphql') - expect( - config.experimental.studio.graphiql?.authImpersonation?.authProvider - ).toEqual('dbAuth') - expect( - config.experimental.studio.graphiql?.authImpersonation?.email - ).toEqual('user@example.com') - expect( - config.experimental.studio.graphiql?.authImpersonation?.userId - ).toEqual('1') + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql') + expect(config.studio.graphiql?.authImpersonation?.authProvider).toEqual( + 'dbAuth' + ) + expect(config.studio.graphiql?.authImpersonation?.email).toEqual( + 'user@example.com' + ) + expect(config.studio.graphiql?.authImpersonation?.userId).toEqual('1') }) it('merges studio configs with supabase impersonation', () => { @@ -154,20 +150,18 @@ describe('getConfig', () => { path.join(__dirname, './fixtures/redwood.studio.supabase.toml') ) - expect(config.experimental.studio.inMemory).toEqual(false) - expect(config.experimental.studio.graphiql?.endpoint).toEqual('graphql') - expect( - config.experimental.studio.graphiql?.authImpersonation?.authProvider - ).toEqual('supabase') - expect( - config.experimental.studio.graphiql?.authImpersonation?.email - ).toEqual('supauser@example.com') - expect( - config.experimental.studio.graphiql?.authImpersonation?.userId - ).toEqual('1') - expect( - config.experimental.studio.graphiql?.authImpersonation?.jwtSecret - ).toEqual('supa-secret') + expect(config.studio.inMemory).toEqual(false) + expect(config.studio.graphiql?.endpoint).toEqual('graphql') + expect(config.studio.graphiql?.authImpersonation?.authProvider).toEqual( + 'supabase' + ) + expect(config.studio.graphiql?.authImpersonation?.email).toEqual( + 'supauser@example.com' + ) + expect(config.studio.graphiql?.authImpersonation?.userId).toEqual('1') + expect(config.studio.graphiql?.authImpersonation?.jwtSecret).toEqual( + 'supa-secret' + ) }) }) diff --git a/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml b/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml index ab084302c179..638b8f319dbb 100644 --- a/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml +++ b/packages/project-config/src/__tests__/fixtures/redwood.studio.dbauth.toml @@ -1,10 +1,10 @@ [web] port = 8888 -[experimental.studio] +[studio] inMemory = false - [experimental.studio.graphiql] + [studio.graphiql] endpoint = "graphql" - [experimental.studio.graphiql.authImpersonation] + [studio.graphiql.authImpersonation] authProvider = "dbAuth" email = "user@example.com" userId = "1" diff --git a/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml b/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml index 2e1bf5b7b774..dc0792646587 100644 --- a/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml +++ b/packages/project-config/src/__tests__/fixtures/redwood.studio.supabase.toml @@ -1,10 +1,10 @@ [web] port = 8888 -[experimental.studio] +[studio] inMemory = false - [experimental.studio.graphiql] + [studio.graphiql] endpoint = "graphql" - [experimental.studio.graphiql.authImpersonation] + [studio.graphiql.authImpersonation] authProvider = "supabase" email = "supauser@example.com" jwtSecret = "supa-secret" diff --git a/packages/project-config/src/__tests__/fixtures/redwood.studio.toml b/packages/project-config/src/__tests__/fixtures/redwood.studio.toml index c7d68eb4cd06..65803423fb73 100644 --- a/packages/project-config/src/__tests__/fixtures/redwood.studio.toml +++ b/packages/project-config/src/__tests__/fixtures/redwood.studio.toml @@ -1,6 +1,6 @@ [web] port = 8888 -[experimental.studio] +[studio] inMemory = false - [experimental.studio.graphiql] + [studio.graphiql] endpoint = "graphql-endpoint" diff --git a/packages/project-config/src/config.ts b/packages/project-config/src/config.ts index e92b7617a0d6..e8dcdfda4146 100644 --- a/packages/project-config/src/config.ts +++ b/packages/project-config/src/config.ts @@ -98,13 +98,13 @@ export interface Config { notifications: { versionUpdates: string[] } + studio: StudioConfig experimental: { opentelemetry: { enabled: boolean wrapApi: boolean apiSdk?: string } - studio: StudioConfig cli: { autoInstall: boolean plugins: CLIPlugin[] @@ -165,25 +165,25 @@ const DEFAULT_CONFIG: Config = { notifications: { versionUpdates: [], }, + studio: { + basePort: 4318, + inMemory: false, + graphiql: { + endpoint: 'graphql', + authImpersonation: { + authProvider: undefined, + userId: undefined, + email: undefined, + roles: undefined, + jwtSecret: 'secret', + }, + }, + }, experimental: { opentelemetry: { enabled: false, wrapApi: true, }, - studio: { - basePort: 4318, - inMemory: false, - graphiql: { - endpoint: 'graphql', - authImpersonation: { - authProvider: undefined, - userId: undefined, - email: undefined, - roles: undefined, - jwtSecret: 'secret', - }, - }, - }, cli: { autoInstall: true, plugins: [ diff --git a/packages/studio/README.md b/packages/studio/README.md deleted file mode 100644 index 00fc5207b1d9..000000000000 --- a/packages/studio/README.md +++ /dev/null @@ -1,187 +0,0 @@ -# Redwood Studio [Experimental] - -RedwoodJS Studio is an experimental package used during development to gain runtime insights into a project. - -## Motivation - -Redwood provides tools that lets developers "get to work on what makes your application special, instead of wasting cycles choosing and re-choosing various technologies and configurations."[1](https://github.com/redwoodjs/redwood/blob/main/README.md). - -Much happens while your app processes a request: Invoke a function; handle a GraphQL request; resolve the request with a service; build and execute a SQL statement; connect to the database; handle the query response; further resolve the response so in contains all the data needed; return the result ... and more. - -While [logging](https://redwoodjs.com/docs/logger) can show you some of these steps, there is no easy way to see how the relate to each other, compare, or break down individual timings. Observability needed to debug, iterate, try out, and refactor your code is lacking. - -We hope Studio helps solve this problem with an observability tool that combines: - -* Tracing with OpenTelemetry (service and GraphQL) -* SQL statement logging -* general metrics (how many invocations) -* GraphiQL playground with impersonated authentication - -With Studio, it is easier to: - -* identify slow running SQL statements without reviewing captured log files -* identify and improve N+1 queries by comparing before and after traces -* impersonate the user authentication headers in GraphiQL - -## Running Studio - -To run the redwood studio simply execute the following redwood cli command: -```bash -yarn rw experimental studio -``` - -## Studio Config - -You may provide the following configuration options in your `redwood.toml` file to control the behaviour of the studio. - -```toml -[experimental.studio] - # Determines whether the studio should run with an in memory database or persist the data to a file in your project within `./redwood` - inMemory = false - -[experimental.studio.graphiql] - endpoint = 'graphql' - -[experimental.studio.graphiql.authImpersonation] - # authProvider = undefined (default value) - jwtSecret = 'secret' - # userId = undefined (default value) - # email = undefined (default value) - # roles = undefined (default value) -``` - -## OpenTelemetry Ingestion - -The redwood studio can ingest your OpenTelemetry data and indeed requires this data to power the insights that it is able to provide. - -To enable ingestion of OpenTelemetry tracing into the studio please provide the following export location for your tracing data within `opentelemetry.js` to be `http://127.0.0.1:4318/v1/traces` which is the default generated by the OpenTelemetry setup command. -```ts -const exporter = new OTLPTraceExporter({ - // Update this URL to point to where your OTLP compatible collector is listening - // The redwood development studio (`yarn rw exp studio`) can collect your telemetry at `http://127.0.0.1:4318/v1/traces` - url: 'http://127.0.0.1:4318/v1/traces', -}) -``` - -## GraphiQL Auth Impersonation - -### DbAuth - -Requires `SESSION_SECRET` envar for cookie encryption. - -TOML example: - -```toml -[web] - port = 8888 -[experimental.studio] - inMemory = false -[experimental.studio.graphiql] - endpoint = "graphql" -[experimental.studio.graphiql.authImpersonation] - authProvider = "dbAuth" - email = "user@example.com" - userId = "1" -``` - -### Netlify - -Since Netlify does not expose the JWT secret used to sign the token in production, impersonation requires a `jwtSecret` to encode and decode the auth token. - -TOML example: - -```toml -[web] - port = 8888 -[experimental.studio] - inMemory = false -[experimental.studio.graphiql] - endpoint = "graphql" -[experimental.studio.graphiql.authImpersonation] - authProvider = "netlify" - email = "user@example.com" - userId = "1" - jwtSecret = "some-secret-setting" -``` - -### Supabase - -Requires `SUPABASE_JWT_SECRET` envar for JWT signing. - -TOML example: - -```toml -[web] - port = 8888 -[experimental.studio] - inMemory = false -[experimental.studio.graphiql] - endpoint = "graphql" -[experimental.studio.graphiql.authImpersonation] - authProvider = "supabase" - email = "user@example.com" - userId = "1" -``` - -## Future - -Since Studio is experiment, its feature set will change. Some will be added, others improved, and several perhaps removed. - -Some ideas to improve the Studio are: - -* More metric widgets - * time from launch - * count of queries/services/functions - * etc -* Annotations - * add warnings on slow queries - * add warning on possible N+1 -* Charts and visualizations - * Line charts of request over time - * Histograms of executing timings per request -* Track errors - * Capture api-side errors for review - * Perhaps via Envelop GraphQL plugin -* Instrument web side -* ChatGPT UI to ask questions about schema -* Prisma ER diagrams -* Send api logs via Pino transport -* Search - * Prisma - * Tracing - * Errors -* Notification on warnings - * issue to fix like n+1 -* Custom dashboards with specific charts, or service or GraphQL focus -* Intelligence - * anomalies - * trends - * warn on issues before they happen -* More tags and data per request - * Prisma version - * RedwoodJS Version - -## Troubleshooting -If you have problems relating to the `@swc` packages then please try adding the following configuration to your `.yarnrc.yml` - -```yml -supportedArchitectures: - os: - - darwin - - linux - - win32 - cpu: - - arm64 - - arm - - x64 - - ia32 - libc: - - glibc - - musl -``` - -## Contributing - -We welcome your [feedback](https://community.redwoodjs.com/t/redwood-studio-experimental/4771) and also your contributions to improve Studio. - -For more [information on contributing](https://github.com/redwoodjs/redwood/blob/main/CONTRIBUTING.md) see: https://github.com/redwoodjs/redwood/blob/main/CONTRIBUTING.md diff --git a/packages/studio/api/database.ts b/packages/studio/api/database.ts deleted file mode 100644 index 95c7925e22ff..000000000000 --- a/packages/studio/api/database.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from 'path' - -import { open } from 'sqlite' -import type { Database } from 'sqlite' -import sqlite3 from 'sqlite3' - -import { getPaths, getConfig } from '@redwoodjs/project-config' - -let db: Database - -export const getDatabase = async () => { - // Switch between in-memory and file-based database based on toml config - const filename = getConfig().experimental.studio.inMemory - ? ':memory:' - : path.join(getPaths().generated.base, 'studio.db') - - if (db === undefined) { - db = await open({ - filename, - driver: sqlite3.Database, - }) - } - return db -} diff --git a/packages/studio/api/fastify/plugins/withApiProxy.ts b/packages/studio/api/fastify/plugins/withApiProxy.ts deleted file mode 100644 index e07846cae68a..000000000000 --- a/packages/studio/api/fastify/plugins/withApiProxy.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { FastifyHttpProxyOptions } from '@fastify/http-proxy' -import httpProxy from '@fastify/http-proxy' -import type { FastifyInstance } from 'fastify' - -export interface ApiProxyOptions { - apiUrl: string - apiHost: string - rewritePrefix?: string -} - -const withApiProxy = async ( - fastify: FastifyInstance, - { apiUrl, apiHost, rewritePrefix }: ApiProxyOptions -) => { - const proxyOpts: FastifyHttpProxyOptions = { - upstream: apiHost, - prefix: apiUrl, - rewritePrefix, - disableCache: true, - } - - fastify.register(httpProxy, proxyOpts) - - return fastify -} - -export default withApiProxy diff --git a/packages/studio/api/fastify/spanIngester.ts b/packages/studio/api/fastify/spanIngester.ts deleted file mode 100644 index 8653777166d5..000000000000 --- a/packages/studio/api/fastify/spanIngester.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { FastifyInstance } from 'fastify' - -import { getDatabase } from '../database' -import { retypeSpan } from '../services/span' -import type { - RawAttribute, - RestructuredAttributes, - RawEvent, - RestructuredEvent, - RestructuredSpan, - ResourceSpan, -} from '../types' - -function restructureAttributes(rawAttributes: RawAttribute[]) { - const restructuredAttributes: RestructuredAttributes = {} - for (const rawAttribute of rawAttributes) { - // Value is typically under a key such as "boolValue", "stringValue", etc. just take whatever one we find - const keys = Object.keys(rawAttribute.value) - const valueIdentifier = keys.length > 0 ? keys[0] : undefined - if (valueIdentifier === undefined) { - continue - } - switch (valueIdentifier) { - case 'stringValue': - restructuredAttributes[rawAttribute.key] = rawAttribute.value - .stringValue as string - break - case 'intValue': - restructuredAttributes[rawAttribute.key] = parseInt( - rawAttribute.value.intValue as string - ) - break - case 'boolValue': - restructuredAttributes[rawAttribute.key] = rawAttribute.value - .boolValue as boolean - break - default: - // If value is "{}" pass null instead, otherwise just pass whatever it happens to be - restructuredAttributes[rawAttribute.key] = rawAttribute.value.value - ? JSON.stringify(rawAttribute.value.value) - : null - break - } - } - return restructuredAttributes -} - -function restructureEvents(rawEvents: RawEvent[]) { - const restructuredEvents: RestructuredEvent[] = [] - for (const rawEvent of rawEvents) { - const restructuredEvent: RestructuredEvent = { - name: rawEvent.name, - time: rawEvent.timeUnixNano, - attributes: restructureAttributes(rawEvent.attributes), - } - restructuredEvents.push(restructuredEvent) - } - return restructuredEvents -} - -export default async function routes(fastify: FastifyInstance, _options: any) { - fastify.post('/v1/traces', async (request, _reply) => { - const data: { resourceSpans: ResourceSpan[] } = request.body as any - - const db = await getDatabase() - const spanInsertStatement = await db.prepare( - 'INSERT INTO span (id, trace, parent, name, kind, status_code, status_message, start_nano, end_nano, duration_nano, events, attributes, resources) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, json(?), json(?), json(?)) RETURNING id;' - ) - - // TODO: Consider better typing here` - const spans: RestructuredSpan[] = [] - - // TODO: Consider less nesting if possible - for (const resourceSpan of data.resourceSpans) { - const resources = restructureAttributes(resourceSpan.resource.attributes) - for (const scopeSpan of resourceSpan.scopeSpans) { - for (const span of scopeSpan.spans) { - const restructuredSpan: RestructuredSpan = { - // Include the standard properties - trace: span.traceId, - id: span.spanId, - parent: span.parentSpanId, - name: span.name, - kind: span.kind, - statusCode: span.status?.code, - statusMessage: span.status?.message, - startNano: span.startTimeUnixNano, - endNano: span.endTimeUnixNano, - // Compute and store a duration for ease in analytics - durationNano: Number( - BigInt(span.endTimeUnixNano) - BigInt(span.startTimeUnixNano) - ).toString(), - } - - // TODO: Consider better handling of events - if (span.events) { - restructuredSpan.events = restructureEvents(span.events) - } - // Add attributes - if (span.attributes) { - restructuredSpan.attributes = restructureAttributes(span.attributes) - } - if (resources) { - restructuredSpan.resourceAttributes = resources - } - spans.push(restructuredSpan) - } - } - } - - for (const span of spans) { - // Insert the span - const spanInsertResult = await spanInsertStatement.get( - span.id, - span.trace, - span.parent, - span.name, - span.kind, - span.statusCode, - span.statusMessage, - span.startNano, - span.endNano, - span.durationNano, - JSON.stringify(span.events), - JSON.stringify(span.attributes), - JSON.stringify(span.resourceAttributes) - ) - if (spanInsertResult.id) { - await retypeSpan(undefined, { id: spanInsertResult.id }) - } - return spanInsertResult - } - - return {} - }) -} diff --git a/packages/studio/api/fastify/yoga.ts b/packages/studio/api/fastify/yoga.ts deleted file mode 100644 index 5a874c28ad8b..000000000000 --- a/packages/studio/api/fastify/yoga.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' -import type { YogaServerInstance } from 'graphql-yoga' - -export default async function routes( - fastify: FastifyInstance, - { - yoga, - }: { - yoga: YogaServerInstance< - { - req: FastifyRequest - reply: FastifyReply - }, - {} - > - } -) { - fastify.route({ - url: '/graphql', - method: ['GET', 'POST', 'OPTIONS'], - handler: async (req, reply) => { - const response = await yoga.handleNodeRequest(req, { - req, - reply, - }) - for (const [name, value] of response.headers) { - reply.header(name, value) - } - reply.status(response.status) - reply.send(response.body) - return reply - }, - }) -} diff --git a/packages/studio/api/graphql/yoga.ts b/packages/studio/api/graphql/yoga.ts deleted file mode 100644 index b90ea06c46f3..000000000000 --- a/packages/studio/api/graphql/yoga.ts +++ /dev/null @@ -1,308 +0,0 @@ -import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify' -import { JSONDefinition, JSONResolver } from 'graphql-scalars' -import { createYoga, createSchema } from 'graphql-yoga' - -import { authProvider, generateAuthHeaders } from '../services/auth' -import { - spanTypeTimeline, - spanTreeMapData, - spanTypeTimeSeriesData, -} from '../services/charts' -import { studioConfig, webConfig } from '../services/config' -import { span, spans } from '../services/explore/span' -import { traces, trace, traceCount } from '../services/explore/trace' -import { seriesTypeBarList, modelsAccessedList } from '../services/lists' -import { - mails, - truncate as truncateMails, - getMailRenderers as mailRenderers, - getMailTemplates as mailTemplates, - getMailComponents as mailComponents, - getRenderedMail as mailRenderedMail, -} from '../services/mail' -import { prismaQuerySpans } from '../services/prismaSpans' -import { retypeSpans, truncateSpans } from '../services/span' -import { getAncestorSpans, getDescendantSpans } from '../services/util' - -export const setupYoga = (fastify: FastifyInstance) => { - const schema = createSchema<{ - req: FastifyRequest - reply: FastifyReply - }>({ - typeDefs: /* GraphQL */ ` - ${JSONDefinition} - - # HTTP - type HttpSpan { - id: String! - span: Span - } - - # GraphQL - type GraphQLSpan { - id: String! - span: Span - } - - # Traces - type Trace { - id: String - spans: [Span] - } - - # Spans - type Span { - # From OTEL - id: String - trace: String - parent: String - name: String - kind: Int - statusCode: Int - statusMessage: String - startNano: String - endNano: String - durationNano: String - events: [JSON] - attributes: JSON - resources: JSON - - # Enrichments - type: String - brief: String - descendantSpans: [Span] - ancestorSpans: [Span] - } - - type SpanTypeTimelineData { - data: [JSON] - keys: [String!] - index: String - legend: JSON - axisLeft: JSON - axisBottom: JSON - } - - # Charts - Line Time Series - type TimeSeriesType { - ts: String! - generic: Float - graphql: Float - http: Float - prisma: Float - redwoodfunction: Float - redwoodservice: Float - sql: Float - } - - # Lists - Series Type Lists - type SeriesTypeList { - series_type: String! - series_name: String - quantity: Int! - } - - type ModelsAccessedList { - model: String! - model_count: Int! - } - - type PrismaQuerySpan { - id: String - trace: String - parent_id: String - parent_trace: String - name: String - method: String - model: String - prisma_name: String - start_nano: String - end_nano: String - duration_nano: String - duration_ms: String - duration_sec: String - db_statement: String - } - - type GraphQLSpan { - id: String - parent: String - name: String - field_name: String - type_name: String - start_nano: String - end_nano: String - duration_nano: String - } - - type GraphiQLConfig { - endpoint: String - authImpersonation: AuthImpersonationConfig - } - - type AuthImpersonationConfig { - authProvider: String - userId: String - email: String - roles: [String] - jwtSecret: String - } - - type StudioConfig { - basePort: Int! - inMemory: Boolean - graphiql: GraphiQLConfig - } - - type WebConfig { - graphqlEndpoint: String - } - - type AuthHeaders { - authProvider: String - cookie: String - authorization: String - } - - # Mail - type Mail { - id: String - data: JSON - envelope: JSON - created_at: Int - } - type MailTemplate { - id: Int! - name: String! - path: String! - updatedAt: Int! - } - type MailRenderer { - id: Int! - name: String! - isDefault: Boolean! - updatedAt: Int! - } - type MailTemplateComponent { - id: Int! - mailTemplateId: Int! - name: String! - propsTemplate: String - updatedAt: Int! - } - type RenderedMail { - html: String - text: String - error: String - } - - type Query { - prismaQueries(id: String!): [PrismaQuerySpan]! - authProvider: String - studioConfig: StudioConfig - webConfig: WebConfig - generateAuthHeaders(userId: String): AuthHeaders - - # Explore - Tracing - traceCount: Int - trace(traceId: String): Trace - traces(searchFilter: String): [Trace] - - # Explore - Span - span(spanId: String!): Span - spans(searchFilter: String): [Span] - - # Charts - spanTypeTimeline( - timeLimit: Int! - timeBucket: Int! - ): SpanTypeTimelineData - spanTypeTimeSeriesData(timeLimit: Int!): [TimeSeriesType] - - # Lists - seriesTypeBarList(timeLimit: Int!): [SeriesTypeList] - modelsAccessedList(timeLimit: Int!): [ModelsAccessedList] - - # Maps - spanTreeMapData(spanId: String): JSON - - # Mail - mails: [Mail] - mailTemplates: [MailTemplate] - mailRenderers: [MailRenderer] - mailComponents: [MailTemplateComponent] - mailRenderedMail( - componentId: Int! - rendererId: Int! - propsJSON: String - ): RenderedMail - } - - type Mutation { - retypeSpans: Boolean! - truncateSpans: Boolean! - truncateMails: Boolean! - } - `, - resolvers: { - JSON: JSONResolver, - Mutation: { - retypeSpans, - truncateSpans, - truncateMails, - }, - Query: { - studioConfig, - webConfig, - authProvider, - generateAuthHeaders, - prismaQueries: prismaQuerySpans, - // Explore - Tracing - traceCount, - trace, - traces, - // Explore - Span - span, - spans, - // Charts - spanTypeTimeline, - spanTypeTimeSeriesData, - // Lists - modelsAccessedList, - seriesTypeBarList, - // Maps - spanTreeMapData, - // Mail - mails, - mailTemplates, - mailRenderers, - mailComponents, - mailRenderedMail, - }, - Span: { - descendantSpans: async (span, _args, _ctx) => { - return getDescendantSpans(span.id) - }, - ancestorSpans: async (span, _args, _ctx) => { - return getAncestorSpans(span.id) - }, - }, - }, - }) - - const yoga = createYoga<{ - req: FastifyRequest - reply: FastifyReply - }>({ - schema, - logging: { - debug: (...args) => args.forEach((arg) => fastify.log.debug(arg)), - info: (...args) => args.forEach((arg) => fastify.log.info(arg)), - warn: (...args) => args.forEach((arg) => fastify.log.warn(arg)), - error: (...args) => args.forEach((arg) => fastify.log.error(arg)), - }, - graphiql: true, - }) - - return yoga -} diff --git a/packages/studio/api/index.ts b/packages/studio/api/index.ts deleted file mode 100644 index 2459917ad4d1..000000000000 --- a/packages/studio/api/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -import path from 'node:path' - -import fastifyStatic from '@fastify/static' -import Fastify from 'fastify' -import type { FastifyInstance } from 'fastify' -import open from 'open' - -import withApiProxy from './fastify/plugins/withApiProxy' -import spanRoutes from './fastify/spanIngester' -import yogaRoutes from './fastify/yoga' -import { setupYoga } from './graphql/yoga' -import { getStudioConfig, getWebConfig } from './lib/config' -import { rewriteWebToUsePort } from './lib/rewriteWebToUsePort' -import { - registerMailRelatedWatchers, - startServer as startMailServer, - stopServer as stopMailServer, -} from './mail' -import { runMigrations } from './migrations' - -const HOST = 'localhost' - -let fastify: FastifyInstance - -export const start = async ( - { open: autoOpen }: { open: boolean } = { open: false } -) => { - process.on('SIGTERM', async () => { - await stop() - }) - process.on('SIGINT', async () => { - await stop() - }) - process.on('beforeExit', async () => { - await stop() - }) - - // DB Setup - await runMigrations() - - // Fasitfy Setup - fastify = Fastify({ - logger: { - level: 'info', - timestamp: () => `,"time":"${new Date(Date.now()).toISOString()}"`, - }, - disableRequestLogging: true, - }) - - // Plugins - - // Graphql Proxy - Takes studio "/proxies/graphql" and forwards to the projects graphql endpoint - const webConfig = getWebConfig() - const graphqlEndpoint = - webConfig.apiGraphQLUrl ?? - `http://${webConfig.host}:${webConfig.port}${webConfig.apiUrl}/graphql` - fastify = await withApiProxy(fastify, { - apiHost: `http://${webConfig.host}:${webConfig.port}`, - apiUrl: `/proxies/graphql`, - // Strip the initial scheme://host:port from the graphqlEndpoint - rewritePrefix: '/' + graphqlEndpoint.split('/').slice(3).join('/'), - }) - - const studioPort = getStudioConfig().basePort - const webPath = path.join(__dirname, '..', '..', 'dist', 'web') - - rewriteWebToUsePort(webPath, studioPort) - - // GraphQL - const yogaServer = setupYoga(fastify) - - // Routes - fastify.register(spanRoutes) - fastify.register(yogaRoutes, { yoga: yogaServer }) - // Statically serve the web side (React) - fastify.register(fastifyStatic, { root: webPath }) - - fastify.listen({ port: studioPort, host: HOST }) - fastify.ready(() => { - console.log(`Studio API listening on ${HOST}:${studioPort}`) - - if (autoOpen) { - open(`http://${HOST}:${studioPort}`) - } - }) - - // SMTP Server - console.log("Starting Studio's SMTP Server...") - startMailServer() - registerMailRelatedWatchers() -} - -const stop = async () => { - await fastify?.close() - await stopMailServer() -} diff --git a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts deleted file mode 100644 index 653ea878864a..000000000000 --- a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { v4 as uuidv4 } from 'uuid' - -import { SESSION_SECRET } from '../envars' - -const isNumeric = (id: string) => { - return /^\d+$/.test(id) -} - -export const getDBAuthHeader = async (userId?: string) => { - if (!userId) { - throw new Error('Require an unique id to generate session cookie') - } - - if (!SESSION_SECRET) { - throw new Error( - 'dbAuth requires a SESSION_SECRET environment variable that is used to encrypt session cookies. Use `yarn rw g secret` to create one, then add to your `.env` file. DO NOT check this variable in your version control system!!' - ) - } - - const { - default: { encryptSession }, - } = await import('@redwoodjs/auth-dbauth-api') - - const id = isNumeric(userId) ? parseInt(userId) : userId - const cookie = encryptSession(JSON.stringify({ id }) + ';' + uuidv4()) - - return { - authProvider: 'dbAuth', - cookie: `session=${cookie}`, - authorization: `Bearer ${userId}`, - } -} diff --git a/packages/studio/api/lib/authProviderEncoders/netlifyAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/netlifyAuthEncoder.ts deleted file mode 100644 index 0e6cd6aaa90c..000000000000 --- a/packages/studio/api/lib/authProviderEncoders/netlifyAuthEncoder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import jwt from 'jsonwebtoken' - -const getExpiryTime = () => { - return Date.now() + 3600 * 1000 -} - -export const getNetlifyAuthHeader = ( - userId?: string, - email?: string, - secret?: string -) => { - const payload = { - exp: getExpiryTime(), - sub: userId ?? 'test-user-id', - email: email ?? 'user@example.com', - app_metadata: { - provider: 'email', - }, - user_metadata: {}, - roles: [], - } - - // in dev, Netlify simply decodes as there is no access to the actual secret used to sign the JWT - if (!secret) { - throw new Error('No secret provided for Netlify auth provider') - } - const token = jwt.sign(payload, secret) - - return { - authProvider: 'netlify', - authorization: `Bearer ${token}`, - } -} diff --git a/packages/studio/api/lib/authProviderEncoders/supabaseAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/supabaseAuthEncoder.ts deleted file mode 100644 index 8ce443e7a550..000000000000 --- a/packages/studio/api/lib/authProviderEncoders/supabaseAuthEncoder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import jwt from 'jsonwebtoken' - -import { SUPABASE_JWT_SECRET } from '../envars' - -const getExpiryTime = () => { - return Date.now() + 3600 * 1000 -} - -export const getSupabaseAuthHeader = (userId?: string, email?: string) => { - if (!SUPABASE_JWT_SECRET) { - throw new Error('SUPABASE_JWT_SECRET env var is not set.') - } - - const payload = { - aud: 'authenticated', - exp: getExpiryTime(), - sub: userId ?? 'test-user-id', - email: email ?? 'user@example.com', - app_metadata: { - provider: 'email', - }, - user_metadata: {}, - role: 'authenticated', - roles: [], - } - - const token = jwt.sign(payload, SUPABASE_JWT_SECRET) - - return { - authProvider: 'supabase', - authorization: `Bearer ${token}`, - } -} diff --git a/packages/studio/api/lib/config.ts b/packages/studio/api/lib/config.ts deleted file mode 100644 index 0b9683b81149..000000000000 --- a/packages/studio/api/lib/config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getConfig } from '@redwoodjs/project-config' - -import type { ApiConfig, StudioConfig, WebConfig } from '../types' - -export const getApiConfig = (): ApiConfig => { - return getConfig().api -} - -export const getWebConfig = (): WebConfig => { - const web = getConfig().web - const apiUrl = web.apiUrl - - // Construct the graphql url from apiUrl by default - // But if apiGraphQLUrl is specified, use that instead - const studioConfig = getStudioConfig() - const graphql = studioConfig.graphiql?.endpoint ?? 'graphql' - const graphqlEndpoint = - web.apiGraphQLUrl ?? `http://${web.host}:${web.port}${apiUrl}/${graphql}` - - const webConfigWithGraphQlEndpoint = { - ...getConfig().web, - graphqlEndpoint, - } - - return webConfigWithGraphQlEndpoint -} - -export const getStudioConfig = (): StudioConfig => { - return getConfig().experimental.studio -} diff --git a/packages/studio/api/lib/envars.ts b/packages/studio/api/lib/envars.ts deleted file mode 100644 index cf7ed04bfaab..000000000000 --- a/packages/studio/api/lib/envars.ts +++ /dev/null @@ -1,18 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import dotenv from 'dotenv' - -import { getPaths } from '@redwoodjs/internal' - -const getRedwoodAppEnvVars = () => { - const basePath = getPaths().base - const envPath = path.join(basePath, '.env') - const envFile = fs.readFileSync(envPath, 'utf8') - const buf = Buffer.from(envFile) - - return dotenv.parse(buf) -} - -export const SESSION_SECRET = getRedwoodAppEnvVars().SESSION_SECRET -export const SUPABASE_JWT_SECRET = getRedwoodAppEnvVars().SUPABASE_JWT_SECRET diff --git a/packages/studio/api/lib/filtering.ts b/packages/studio/api/lib/filtering.ts deleted file mode 100644 index d19c8c707ea1..000000000000 --- a/packages/studio/api/lib/filtering.ts +++ /dev/null @@ -1,85 +0,0 @@ -function isValidColumn(column: string) { - return [ - 'id', - 'trace', - 'parent', - 'name', - 'type', - 'start', - 'end', - 'duration', - ].includes(column) -} - -function renameColumn(column: string) { - if (column === 'start') { - return 'start_nano' - } - if (column === 'end') { - return 'end_nano' - } - if (column === 'duration') { - return 'duration_nano' - } - if (column === 'status') { - return 'status_code' - } - return column -} - -export function extractFiltersFromString(filterString: string) { - const filters: any = {} - - const searchFilters = filterString.split(' ') - - // Handle `limit` - const limitFilters = searchFilters.filter((filter) => - filter.startsWith('limit:') - ) - if (limitFilters.length > 1) { - throw new Error('Cannot contain more than one limit') - } else if (limitFilters?.length === 1) { - const limitNumber = parseInt(limitFilters[0].split(':')[1]) - if (isNaN(limitNumber)) { - throw new Error('Limit must be a number') - } - filters.limit = limitNumber - } - - // Handle `sort` - const sortFilters = searchFilters.filter((filter) => - filter.startsWith('sort:') - ) - const sorts = [] - for (const sortFilter of sortFilters) { - const sortColumn = sortFilter.split(':')[1] - if (!isValidColumn(sortColumn)) { - throw new Error(`Cannot sort by ${sortColumn}`) - } - const sortType = sortFilter.split(':')[2] - if (!['asc', 'desc'].includes(sortType)) { - throw new Error(`Cannot sort by ${sortType}`) - } - sorts.push({ - column: renameColumn(sortColumn), - type: sortType.toUpperCase(), - }) - } - filters.sorts = sorts - - // Specific filters - const whereKeys = ['name', 'type', 'id', 'trace', 'parent', 'status'] - filters.where = {} - for (const whereKey of whereKeys) { - const whereFilters = searchFilters.filter((filter) => - filter.startsWith(`${whereKey}:`) - ) - if (whereFilters.length > 1) { - throw new Error(`Cannot contain more than one ${whereKey} filter`) - } else if (whereFilters.length === 1) { - filters.where[renameColumn(whereKey)] = whereFilters[0].split(':')[1] - } - } - - return filters -} diff --git a/packages/studio/api/lib/rewriteWebToUsePort.ts b/packages/studio/api/lib/rewriteWebToUsePort.ts deleted file mode 100644 index ca50d76be309..000000000000 --- a/packages/studio/api/lib/rewriteWebToUsePort.ts +++ /dev/null @@ -1,12 +0,0 @@ -import fs from 'fs' -import path from 'path' - -export function rewriteWebToUsePort(webPath: string, studioPort: number) { - const indexHtmlPath = path.join(webPath, 'index.html') - let indexHtml = fs.readFileSync(indexHtmlPath, 'utf8') - indexHtml = indexHtml.replace( - 'RWJS_STUDIO_BASE_PORT=4318', - 'RWJS_STUDIO_BASE_PORT=' + studioPort - ) - fs.writeFileSync(indexHtmlPath, indexHtml) -} diff --git a/packages/studio/api/lib/sql.ts b/packages/studio/api/lib/sql.ts deleted file mode 100644 index 7ab3bf529c84..000000000000 --- a/packages/studio/api/lib/sql.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * SUPER WARNING: Beware this does not escape all values! - * If you do sql inject then congrats on hacking into your own local telemetry data 🎉 - */ -export function generateSelectWithFilters( - select: string, - table: string, - filters: any -) { - const sorts = [] - - // Extract out sorts - if (filters.sorts) { - sorts.push(...filters.sorts) - delete filters.sorts - } - - // Parameters must be prefixed with `$` for sqlite - const sqlFilters: any = {} - Object.keys(filters).forEach((key) => { - if (filters[key]) { - sqlFilters[`$${key}`] = filters[key] - } - }) - - const where = Object.keys(sqlFilters.$where) - .map((key) => { - const value = sqlFilters.$where[key] - if (value.includes('%') || value.includes('_')) { - return `${key} LIKE '${value}'` - } - return `${key} = '${value}'` - }) - .join(' AND ') - delete sqlFilters.$where - - // Return the SQL and the filters for execution with .all or .get etc - return [ - `SELECT ${select} FROM ${table} ${where ? `WHERE ${where}` : ''} ${ - sorts.length > 0 - ? `ORDER BY ${sorts - .map((sort) => { - return `${sort.column} ${sort.type}` - }) - .join(',')}` - : '' - } ${sqlFilters.$limit ? 'LIMIT $limit' : ''} `, - sqlFilters, - ] -} diff --git a/packages/studio/api/mail/index.ts b/packages/studio/api/mail/index.ts deleted file mode 100644 index 12674f42540b..000000000000 --- a/packages/studio/api/mail/index.ts +++ /dev/null @@ -1,493 +0,0 @@ -import path from 'node:path' - -import * as swc from '@swc/core' -import chokidar from 'chokidar' -import fs from 'fs-extra' -import { simpleParser as simpleMailParser } from 'mailparser' -import { SMTPServer } from 'smtp-server' - -import { getPaths } from '@redwoodjs/project-config' - -import { getDatabase } from '../database' -import { getStudioConfig } from '../lib/config' - -let smtpServer: SMTPServer - -async function insertMailIntoDatabase(mail: any, envelope: any) { - const db = await getDatabase() - const sql = ` - INSERT INTO mail (data, envelope) VALUES (?, ?); - ` - await db.run(sql, [JSON.stringify(mail), JSON.stringify(envelope)]) -} - -export function startServer() { - smtpServer = new SMTPServer({ - banner: 'RedwoodJS Studio SMTP Server', - authOptional: true, - hideSTARTTLS: true, - onData(stream, session, callback) { - simpleMailParser(stream, {}, async (err, mail) => { - if (err) { - console.error('Error parsing mail:') - console.error(err) - } else { - await insertMailIntoDatabase(mail, session.envelope) - } - callback() - }) - }, - }) - - const port = getStudioConfig().basePort + 1 - - smtpServer.listen(port, undefined, () => { - console.log('Studio SMTP Server listening on ' + port) - }) -} - -export async function stopServer() { - await new Promise((resolve) => { - smtpServer.close(() => { - resolve(null) - }) - }) -} - -export function registerMailRelatedWatchers() { - // NOTE: So we clear the dist directory on each build so for now I'm just going to - // watch the dist directory and when it changes I'll reload the mailer and - // mail templates. I would bet this is not ideal in terms of performance. - - const distWatcher = chokidar.watch('**/*.*', { - cwd: getPaths().api.dist, - ignoreInitial: true, - usePolling: true, - interval: 500, - }) - process.on('SIGINT', async () => { - await distWatcher.close() - }) - - // I had to turn on polling to get the watcher to work so now I'm not sure this - // debounce is necessary - especially since the debounce is shorter than the poll - // interval. I'm going to leave it for now. - let debounceTimer: NodeJS.Timeout | undefined = undefined - const listenOnEventsForDist = ['ready', 'add', 'change'] - for (let i = 0; i < listenOnEventsForDist.length; i++) { - distWatcher.on(listenOnEventsForDist[i], async () => { - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(async () => { - await updateMailAnalysis() - }, 250) - }) - } -} - -async function updateMailAnalysis() { - console.log('Reanalysing mailer and mail templates...') - try { - await updateMailRenderers() - await updateMailTemplates() - } catch (error) { - console.error('Error updating mailer and mail templates:') - console.error(error) - console.error( - 'You may need to rebuild your redwood app or restart the studio' - ) - } -} - -function getFilesInDir(dir: string) { - const files: string[] = [] - const dirFiles = fs.readdirSync(dir) - for (const file of dirFiles) { - if (fs.statSync(path.join(dir, file)).isDirectory()) { - files.push(...getFilesInDir(path.join(dir, file))) - } else { - files.push(path.join(dir, file)) - } - } - return files -} - -export async function updateMailTemplates() { - const mailTemplateDistDir = path.join(getPaths().api.dist, 'mail') - if (!fs.existsSync(mailTemplateDistDir)) { - return - } - - const distFiles = getFilesInDir(mailTemplateDistDir).filter((file) => - file.endsWith('.js') - ) - const srcFiles = getFilesInDir(path.join(getPaths().api.src, 'mail')).filter( - // The src file must have a corresponding dist file - (file) => { - const correspondingDistEntry = - file - .replace(path.join('api', 'src'), path.join('api', 'dist')) - .substring(0, file.lastIndexOf('.') + 1) + '.js' - return distFiles.includes(correspondingDistEntry) - } - ) - - const db = await getDatabase() - - // Clear out any mail template that are no longer in the mailer - await db.run( - `DELETE FROM mail_template WHERE path NOT IN (${srcFiles - .map(() => '?') - .join(',')});`, - srcFiles - ) - - // Insert the mail templates - for (let i = 0; i < srcFiles.length; i++) { - const nameWithExt = path.basename(srcFiles[i]) - const name = nameWithExt.substring(0, nameWithExt.lastIndexOf('.')) - - const existingTemplate = await db.get( - `SELECT id FROM mail_template WHERE path = ?;`, - srcFiles[i] - ) - if (existingTemplate) { - // Update the values - await db.run( - `UPDATE mail_template SET name = ?, updated_at = ? WHERE id = ?;`, - [name, Date.now(), existingTemplate.id] - ) - } else { - // Insert the values - await db.run( - `INSERT INTO mail_template (name, path, updated_at) VALUES (?, ?, ?);`, - [name, srcFiles[i], Date.now()] - ) - } - - const templateId = - existingTemplate?.id ?? - ( - await db.get( - `SELECT id FROM mail_template WHERE path = ?;`, - srcFiles[i] - ) - )?.id - - // Get the components from the AST of the src file - const components = getMailTemplateComponents(srcFiles[i]) - - // Insert the components - for (let j = 0; j < components.length; j++) { - const existingComponent = await db.get( - `SELECT id FROM mail_template_component WHERE mail_template_id = ? AND name = ?;`, - [templateId, components[j].name] - ) - if (existingComponent) { - // Update the values - await db.run( - `UPDATE mail_template_component SET props_template = ?, updated_at = ? WHERE id = ?;`, - [components[j].propsTemplate, Date.now(), existingComponent.id] - ) - } else { - // Insert the values - await db.run( - `INSERT INTO mail_template_component (mail_template_id, name, props_template, updated_at) VALUES (?, ?, ?, ?);`, - [ - templateId, - components[j].name, - components[j].propsTemplate, - Date.now(), - ] - ) - } - } - - // Delete any components that are no longer in the src file - await db.run( - `DELETE FROM mail_template_component WHERE mail_template_id = ? AND name NOT IN (${components - .map(() => '?') - .join(',')});`, - [templateId, ...components.map((c) => c.name)] - ) - } - console.log(` - Analysed ${srcFiles.length} mail templates`) - - // Delete any mail template components that no longer have a corresponding mail template - await db.run( - `DELETE FROM mail_template_component WHERE mail_template_id NOT IN (SELECT id FROM mail_template);` - ) -} - -function generatePropsTemplate(param: swc.Param | swc.Pattern | null) { - // No param means no props template - if (!param) { - return null - } - - // Get the pattern - const pattern = param.type === 'Parameter' ? param.pat : param - if (!pattern) { - return null - } - - // Attempt to generate a props template from the pattern - let propsTemplate = 'Provide your props here as JSON' - try { - switch (pattern.type) { - case 'Identifier': - propsTemplate = `{${pattern.value}: ?}` - break - case 'AssignmentPattern': - if (pattern.left.type === 'ObjectPattern') { - propsTemplate = `{${pattern.left.properties - .map((p: any) => { - return `\n "${p.key.value}": ?` - }) - .join(',')}\n}` - } - break - case 'ObjectPattern': - propsTemplate = `{${pattern.properties - .map((p: any) => { - return `\n "${p.key.value}": ?` - }) - .join(',')}\n}` - break - } - } catch (_error) { - // ignore for now, we'll fallback to the generic props template - } - - // Fallback to a generic props template if we can't figure out anything more helpful - return propsTemplate -} - -function extractNameAndPropsTemplate( - component: swc.ModuleItem, - functionsAndVariables: swc.ModuleItem[] -): { - name: string - propsTemplate: string | null -} { - switch (component.type) { - case 'ExportDeclaration': - // Arrow functions - if (component.declaration.type === 'VariableDeclaration') { - // We only support the identifier type for now - const identifier = component.declaration.declarations[0].id - if (identifier.type !== 'Identifier') { - throw new Error('Unexpected identifier type: ' + identifier.type) - } - // We only support arrow and normal functions for now - const expression = component.declaration.declarations[0].init - if (!expression) { - throw new Error('Unexpected undefined expression') - } - if ( - expression.type !== 'ArrowFunctionExpression' && - expression.type !== 'FunctionExpression' - ) { - throw new Error('Unexpected expression type: ' + expression.type) - } - return { - name: identifier.value, - propsTemplate: generatePropsTemplate(expression.params[0] ?? null), - } - } - - // Normal functions - if (component.declaration.type === 'FunctionDeclaration') { - return { - name: component.declaration.identifier.value, - propsTemplate: generatePropsTemplate( - component.declaration.params[0] ?? null - ), - } - } - - // Throw for anything else - throw new Error( - 'Unexpected declaration type: ' + component.declaration.type - ) - - case 'ExportDefaultExpression': - // Arrow functions - if (component.expression.type === 'ArrowFunctionExpression') { - return { - name: 'default', - propsTemplate: generatePropsTemplate( - component.expression.params[0] ?? null - ), - } - } - - // Variables defined elsewhere and then exported as default - if (component.expression.type === 'Identifier') { - const expression = component.expression - const variable = functionsAndVariables.find((v) => { - return ( - (v.type === 'FunctionDeclaration' && - v.identifier.value === expression.value) || // function - (v.type === 'VariableDeclaration' && - v.declarations[0].type === 'VariableDeclarator' && - v.declarations[0].id.type === 'Identifier' && - v.declarations[0].id.value === expression.value) // variable - ) - }) - if (variable) { - if (variable.type === 'FunctionDeclaration') { - return { - name: variable.identifier.value + ' (default)', - propsTemplate: generatePropsTemplate(variable.params[0] ?? null), - } - } - if (variable.type === 'VariableDeclaration') { - if (variable.declarations[0].id.type !== 'Identifier') { - throw new Error( - 'Unexpected identifier type: ' + - variable.declarations[0].id.type - ) - } - if ( - variable.declarations[0].init?.type !== 'FunctionExpression' && - variable.declarations[0].init?.type !== 'ArrowFunctionExpression' - ) { - throw new Error( - 'Unexpected init type: ' + variable.declarations[0].init?.type - ) - } - return { - name: variable.declarations[0].id.value + ' (default)', - propsTemplate: generatePropsTemplate( - variable.declarations[0].init?.params[0] ?? null - ), - } - } - } - } - - // Throw for anything else - throw new Error( - 'Unexpected expression type: ' + component.expression.type - ) - - case 'ExportDefaultDeclaration': - // Normal functions - if (component.decl.type === 'FunctionExpression') { - let name = 'default' - if (component.decl.identifier) { - name = component.decl.identifier.value - } - return { - name, - propsTemplate: generatePropsTemplate( - component.decl.params[0] ?? null - ), - } - } - - // Throw for anything else - throw new Error('Unexpected declaration type: ' + component.decl.type) - - default: - throw new Error('Unexpected component type: ' + component.type) - } -} - -function getMailTemplateComponents(templateFilePath: string) { - const ast = swc.parseFileSync(templateFilePath, { - syntax: templateFilePath.endsWith('.js') ? 'ecmascript' : 'typescript', - tsx: templateFilePath.endsWith('.tsx') || templateFilePath.endsWith('.jsx'), - }) - - const components: { name: string; propsTemplate: string | null }[] = [] - const functionsAndVariables = ast.body.filter((node: any) => { - return ( - node.type === 'VariableDeclaration' || node.type === 'FunctionDeclaration' - ) - }) - - const exportedComponents = ast.body.filter((node: any) => { - return [ - 'ExportDeclaration', - 'ExportDefaultDeclaration', - 'ExportDefaultExpression', - ].includes(node.type) - }) - for (let i = 0; i < exportedComponents.length; i++) { - try { - const { propsTemplate, name } = extractNameAndPropsTemplate( - exportedComponents[i], - functionsAndVariables - ) - components.push({ - name, - propsTemplate, - }) - } catch (error) { - console.error( - `Error extracting template component name and props template from ${templateFilePath}:` - ) - console.error(error) - } - } - - return components -} - -export async function updateMailRenderers() { - try { - const mailerFilePath = path.join(getPaths().api.dist, 'lib', 'mailer.js') - if (!fs.existsSync(mailerFilePath)) { - return - } - - // This is not particularly memory efficient, it'll grow each time the mailer is reloaded - // I do not currently believe there is a way to invalidate the module load cache - const suffix = `studio_${Date.now()}` - const importPath = mailerFilePath.replace('.js', `.${suffix}.js`) - fs.copyFileSync(mailerFilePath, importPath) - const mailer = (await import(`file://${importPath}`)).mailer - fs.removeSync(importPath) - const renderers = Object.keys(mailer.renderers) - const defaultRenderer = mailer.config.rendering.default - - const db = await getDatabase() - // Delete any renderers that are no longer in the mailer - const deleteSql = ` - DELETE FROM mail_renderer WHERE name NOT IN (${renderers - .map(() => '?') - .join(',')}); - ` - await db.run(deleteSql, renderers) - - for (let i = 0; i < renderers.length; i++) { - const existingRenderer = await db.get( - `SELECT id FROM mail_renderer WHERE name = ?;`, - renderers[i] - ) - if (existingRenderer) { - // Update the values - await db.run( - `UPDATE mail_renderer SET is_default = ?, updated_at = ? WHERE id = ?;`, - [ - renderers[i] === defaultRenderer ? 1 : 0, - Date.now(), - existingRenderer.id, - ] - ) - } else { - // Insert the values - await db.run( - `INSERT INTO mail_renderer (name, is_default, updated_at) VALUES (?, ?, ?);`, - [renderers[i], renderers[i] === defaultRenderer ? 1 : 0, Date.now()] - ) - } - } - } catch (error) { - console.error('Error reloading mailer:') - console.error(error) - } -} diff --git a/packages/studio/api/migrations.ts b/packages/studio/api/migrations.ts deleted file mode 100644 index 32c25f01785b..000000000000 --- a/packages/studio/api/migrations.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { Database } from 'sqlite' -import type sqlite3 from 'sqlite3' - -import { getDatabase } from './database' - -export async function runMigrations() { - const db = await getDatabase() - - await setupTables(db) - await setupViews(db) - - // span type and brief - await migrate000(db) - - // initial mail table - await migrate001(db) - - // -} - -async function migrate000(db: Database) { - const user_version = (await db.get(`PRAGMA user_version;`))['user_version'] - if (user_version !== 0) { - return - } - - // NOTE: PRAGMA user_version does not support prepared statement variables - const sql = ` - BEGIN TRANSACTION; - ALTER TABLE span ADD COLUMN type TEXT(255) DEFAULT NULL; - ALTER TABLE span ADD COLUMN brief TEXT(255) DEFAULT NULL; - PRAGMA user_version = ${user_version + 1}; - COMMIT; - ` - await db.exec(sql) -} - -async function migrate001(db: Database) { - const user_version = (await db.get(`PRAGMA user_version;`))['user_version'] - if (user_version !== 1) { - return - } - - // NOTE: PRAGMA user_version does not support variables - const sql = ` - BEGIN TRANSACTION; - CREATE TABLE IF NOT EXISTS mail ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - data JSON, - envelope JSON, - created_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - CREATE TABLE IF NOT EXISTS mail_template ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - path TEXT UNIQUE, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - CREATE TABLE IF NOT EXISTS mail_template_component ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - mail_template_id INTEGER NOT NULL, - name TEXT NOT NULL, - props_template TEXT, - updated_at INTEGER DEFAULT (strftime('%s', 'now')), - UNIQUE(mail_template_id, name) - ); - CREATE TABLE IF NOT EXISTS mail_renderer ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - is_default INTEGER DEFAULT 0, - updated_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - PRAGMA user_version = ${user_version + 1}; - COMMIT; - ` - await db.exec(sql) -} - -const setupTables = async ( - db: Database -) => { - // BIGINT for UnixNano times will break in 239 years (Fri Apr 11 2262 23:47:16 GMT+0000) - const spanTableSQL = ` - CREATE TABLE IF NOT EXISTS - span ( - id TEXT PRIMARY KEY, - trace TEXT NOT NULL, - parent TEXT, - name TEXT, - kind INTEGER, - status_code INTEGER, - status_message TEXT, - start_nano BIGINT, - end_nano BIGINT, - duration_nano BIGINT, - events JSON, - attributes JSON, - resources JSON - ); - ` - await db.exec(spanTableSQL) -} - -const setupViews = async ( - db: Database -) => { - const prismaQueriesView = ` - CREATE VIEW IF NOT EXISTS prisma_queries as SELECT DISTINCT - s.id, - s.trace, - s.parent as parent_id, - p.trace as parent_trace, - s.name, - json_extract(p. "attributes", '$.method') AS method, - json_extract(p. "attributes", '$.model') AS model, - json_extract(p. "attributes", '$.name') AS prisma_name, - s.start_nano, - s.end_nano, - s.duration_nano, - cast((s.duration_nano / 1000000.000) as REAL) as duration_ms, - cast((s.duration_nano / 1000000000.0000) as number) as duration_sec, - json_extract(s. "attributes", '$."db.statement"') AS db_statement - FROM - span s - JOIN span p ON s.trace = p.trace - WHERE - s. "name" = 'prisma:engine:db_query' - AND - p. "name" = 'prisma:client:operation' - ORDER BY s.start_nano desc, s.parent; -` - await db.exec(prismaQueriesView) - - const SQLSpansView = ` - CREATE VIEW IF NOT EXISTS sql_spans AS - SELECT DISTINCT - *, - cast((duration_nano / 1000000.000) as REAL) as duration_ms, - cast((duration_nano / 1000000000.0000) as number) as duration_sec - FROM - span - WHERE - json_extract(attributes, '$."db.statement"') IS NOT NULL - ORDER BY start_nano desc; -` - await db.exec(SQLSpansView) - - const graphQLSpansView = `CREATE VIEW IF NOT EXISTS graphql_spans AS - SELECT - id, - parent, - name, - json_extract(ATTRIBUTES, '$."graphql.resolver.fieldName"') AS field_name, - json_extract(ATTRIBUTES, '$."graphql.resolver.typeName"') AS type_name, - start_nano, - end_nano, - duration_nano - FROM - span - WHERE - field_name IS NOT NULL - OR type_name IS NOT NULL - ORDER BY - start_nano DESC;` - - await db.exec(graphQLSpansView) -} diff --git a/packages/studio/api/services/auth.ts b/packages/studio/api/services/auth.ts deleted file mode 100644 index bde31bad8090..000000000000 --- a/packages/studio/api/services/auth.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getDBAuthHeader } from '../lib/authProviderEncoders/dbAuthEncoder' -import { getNetlifyAuthHeader } from '../lib/authProviderEncoders/netlifyAuthEncoder' -import { getSupabaseAuthHeader } from '../lib/authProviderEncoders/supabaseAuthEncoder' -import { getStudioConfig } from '../lib/config' - -export const authProvider = async (_parent: unknown) => { - return getStudioConfig().graphiql?.authImpersonation?.authProvider -} - -export const generateAuthHeaders = async ( - _parent: unknown, - { userId }: { userId?: string } -) => { - const studioConfig = getStudioConfig() - - const provider = studioConfig.graphiql?.authImpersonation?.authProvider - const impersonateUserId = studioConfig.graphiql?.authImpersonation?.userId - const email = studioConfig.graphiql?.authImpersonation?.email - const secret = studioConfig.graphiql?.authImpersonation?.jwtSecret - - if (provider == 'dbAuth') { - return getDBAuthHeader(userId || impersonateUserId) - } - if (provider == 'netlify') { - return getNetlifyAuthHeader(userId || impersonateUserId, email, secret) - } - - if (provider == 'supabase') { - return getSupabaseAuthHeader(userId || impersonateUserId, email) - } - - return {} -} diff --git a/packages/studio/api/services/charts.ts b/packages/studio/api/services/charts.ts deleted file mode 100644 index 537a6ac4f0e0..000000000000 --- a/packages/studio/api/services/charts.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { getDatabase } from '../database' - -import { getDescendantSpans, getSpan } from './util' - -export async function spanTypeTimeSeriesData( - _parent: unknown, - { - timeLimit, - }: { - timeLimit: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare(` - SELECT - ts, - json_patch (json_object('ts', ts), - json_group_object (series_type, - duration_msec)) AS chartdata - FROM ( - SELECT - datetime (start_nano / 1000000000, - 'unixepoch', - 'utc') AS ts, - replace(coalesce(TYPE, 'generic'), '-', '') AS series_type, - sum(duration_nano / 1000000.0) AS duration_msec - FROM - span - GROUP BY - ts, - series_type - ORDER BY - start_nano ASC, - series_type) - WHERE - ts >= datetime ('now', ?, 'utc') - GROUP BY - ts - ORDER BY - ts ASC; - `) - - const result = await stmt.all(`-${timeLimit} seconds`) - await stmt.finalize() - const chartData = result.map((row) => JSON.parse(row['chartdata'])) - - return chartData -} - -export async function spanTypeTimeline( - _parent: unknown, - { - timeLimit, - timeBucket, - }: { - timeLimit: number - timeBucket: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare( - ` - SELECT *, FLOOR(start_nano / 1000000) AS start_milli FROM span - WHERE start_nano >= ?; - ` - ) - const result = await stmt.all(Date.now() - timeLimit * 1e9) - await stmt.finalize() - - const data: any[] = [] - - const typesWithStartMilli = result.map((span) => ({ - type: span.type, - start_milli: span.start_milli, - })) - const types = [ - ...new Set( - typesWithStartMilli.map((span) => - span.type === null ? 'generic' : span.type - ) - ), - ] - - const steps = Math.floor(timeLimit / timeBucket) - const now = Date.now() - for (let i = 0; i < steps; i++) { - const ago = (i + 1) * timeBucket - const windowStart = now - ago * 1e3 - const windowEnd = windowStart + timeBucket * 1e3 - const bucketSpans = typesWithStartMilli.filter( - (span) => span.start_milli >= windowStart && span.start_milli < windowEnd - ) - const bucketSpansCount = types.reduce((acc, type) => { - acc[type] = bucketSpans.filter((span) => span.type === type).length - return acc - }, {} as Record) - data.push({ - ago: (i + 1) * timeBucket, - ...bucketSpansCount, - }) - } - data.forEach((d) => { - types.map((t) => { - d[`${t}Color`] = 'hsl(176, 70%, 50%)' - }) - }) - - const keys = types - const index = 'ago' - const legend = { - dataFrom: 'keys', - anchor: 'bottom-right', - direction: 'column', - justify: false, - translateX: 120, - translateY: 0, - itemsSpacing: 2, - itemWidth: 100, - itemHeight: 20, - itemDirection: 'left-to-right', - itemOpacity: 0.85, - symbolSize: 20, - effects: [ - { - on: 'hover', - style: { - itemOpacity: 1, - }, - }, - ], - } - const axisLeft = { - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - legend: 'Count', - legendPosition: 'middle', - legendOffset: -40, - } - const axisBottom = { - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - legend: 'Seconds Ago', - legendPosition: 'middle', - legendOffset: 32, - } - - return { - data, - keys, - index, - legend, - axisLeft, - axisBottom, - } -} - -function buildTree(objects: any[], id: string) { - const tree: any = {} - - const root = objects.find((o) => o.id === id) - tree.id = root.id - tree.parent = root.parent - tree.name = root.name - tree.durationMilli = root.duration_nano / 1e6 - - const children = objects.filter((o) => o.parent === id) - if (children.length > 0) { - tree.children = children.map((c) => buildTree(objects, c.id)) - } - - return tree -} - -export async function spanTreeMapData( - _parent: unknown, - { spanId }: { spanId: string } -) { - const rootSpan = await getSpan(spanId) - const descendantSpans = await getDescendantSpans(spanId) - return buildTree([...descendantSpans, rootSpan], spanId) -} diff --git a/packages/studio/api/services/config.ts b/packages/studio/api/services/config.ts deleted file mode 100644 index 501bacaceb9a..000000000000 --- a/packages/studio/api/services/config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getApiConfig, getStudioConfig, getWebConfig } from '../lib/config' -import type { ApiConfig, StudioConfig, WebConfig } from '../types' - -export const apiConfig = async (_parent: unknown): Promise => { - return getApiConfig() -} - -export const webConfig = async (_parent: unknown): Promise => { - return getWebConfig() -} - -export const studioConfig = async (_parent: unknown): Promise => { - return getStudioConfig() -} diff --git a/packages/studio/api/services/explore/graphql.ts b/packages/studio/api/services/explore/graphql.ts deleted file mode 100644 index 93aa77a069ae..000000000000 --- a/packages/studio/api/services/explore/graphql.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { getDatabase } from '../../database' -import { restructureSpan } from '../span' - -export const graphqlCount = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT COUNT(1) FROM span WHERE - json_extract(attributes, \'$."graphql.operation.type"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.name"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.operationName"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.result"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.error"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.variables"\') IS NOT NULL - ;` - ) - const result = await stmt.get() - await stmt.finalize() - - return result['COUNT(1)'] -} - -export const graphqlSpans = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE - json_extract(attributes, \'$."graphql.operation.type"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.name"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.operationName"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.result"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.error"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.variables"\') IS NOT NULL - ;` - ) - const result = await stmt.all() - await stmt.finalize() - - return result.map((span: any) => { - return { id: span.id, span: restructureSpan(span) } - }) -} - -export const graphqlSpan = async ( - _parent: unknown, - { spanId }: { spanId: string } -) => { - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE - id = ? AND ( - json_extract(attributes, \'$."graphql.operation.type"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.name"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.operation.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.operationName"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.document"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.result"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.error"\') IS NOT NULL - OR json_extract(attributes, \'$."graphql.execute.variables"\') IS NOT NULL - );` - ) - const result = await stmt.get(spanId) - await stmt.finalize() - - return { id: result.id, span: restructureSpan(result) } -} diff --git a/packages/studio/api/services/explore/span.ts b/packages/studio/api/services/explore/span.ts deleted file mode 100644 index de4c922c3133..000000000000 --- a/packages/studio/api/services/explore/span.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { GraphQLError } from 'graphql' - -import { getDatabase } from '../../database' -import { extractFiltersFromString } from '../../lib/filtering' -import { generateSelectWithFilters } from '../../lib/sql' -import { restructureSpan } from '../span' - -export const span = async ( - _parent: unknown, - { spanId }: { spanId: string } -) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE id=?;') - const result = await stmt.get(spanId) - await stmt.finalize() - - return restructureSpan(result) -} - -export const spans = async ( - _parent: unknown, - { searchFilter }: { searchFilter?: string } -) => { - let filters: any = {} - try { - filters = searchFilter ? extractFiltersFromString(searchFilter) : {} - } catch (error) { - throw new GraphQLError(error as string) - } - - const db = await getDatabase() - const [sql, sqlFilters] = generateSelectWithFilters('*', 'span', filters) - - // To debug uncomment the following line - // console.log('spans', sql, { ...sqlFilters }) - - const result = await db.all(sql, { ...sqlFilters }) - return result.map(restructureSpan) -} diff --git a/packages/studio/api/services/explore/trace.ts b/packages/studio/api/services/explore/trace.ts deleted file mode 100644 index 6775eb89fcc9..000000000000 --- a/packages/studio/api/services/explore/trace.ts +++ /dev/null @@ -1,68 +0,0 @@ -// import { GraphQLError } from 'graphql' - -import { getDatabase } from '../../database' -// import { extractFiltersFromString } from '../../lib/filtering' -// import { generateSelectWithFilters } from '../../lib/sql' -import { restructureSpan } from '../span' - -export const traceCount = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare( - 'SELECT COUNT(DISINCT trace) AS trace_count FROM span;' - ) - const result = await stmt.get() - await stmt.finalize() - - return result['trace_count'] -} - -export const traces = async ( - _parent: unknown - // { searchFilter }: { searchFilter?: string } -) => { - // let filters: any = {} - // try { - // filters = searchFilter ? extractFiltersFromString(searchFilter) : {} - // } catch (error) { - // throw new GraphQLError(error as string) - // } - - // We cannot only select a subset of spans because we might miss spans which belong to returned traces - // TODO: We should first get a list of traceIds with the filters and then get all the spans for those traces. - // delete filters.limit - - const db = await getDatabase() - // const [sql, sqlFilters] = generateSelectWithFilters('*', 'span', filters) - - // To debug uncomment the following line - // console.log('traces', sql, { ...sqlFilters }) - - const result = await db.all('SELECT * FROM span;') - - const traceIds = [...new Set(result.map((span: any) => span.trace))] - const traces = [] - for (const traceId of traceIds) { - const traceSpans = result.filter((span: any) => span.trace === traceId) - traces.push({ - id: traceId, - spans: traceSpans.map((span: any) => restructureSpan(span)), - }) - } - - return traces -} - -export const trace = async ( - _parent: unknown, - { traceId }: { traceId: string } -) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE trace=?;') - const result = await stmt.all(traceId) - await stmt.finalize() - - return { - id: traceId, - spans: result.map((span: any) => restructureSpan(span)), - } -} diff --git a/packages/studio/api/services/graphqlSpans.ts b/packages/studio/api/services/graphqlSpans.ts deleted file mode 100644 index 6d6071926d7e..000000000000 --- a/packages/studio/api/services/graphqlSpans.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getDatabase } from '../database' - -export const graphQLSpans = async (_parent: any) => { - const db = await getDatabase() - - const stmt = await db.prepare(`SELECT * FROM graphql_spans;`) - - const result = await stmt.all() - await stmt.finalize() - - return result -} - -export const graphQLSpanCount = async (_parent: any) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT COUNT(1) FROM graphql_spans;') - const result = await stmt.get() - await stmt.finalize() - - return result['COUNT(1)'] -} diff --git a/packages/studio/api/services/lists.ts b/packages/studio/api/services/lists.ts deleted file mode 100644 index 01873e51d708..000000000000 --- a/packages/studio/api/services/lists.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getDatabase } from '../database' - -export async function seriesTypeBarList( - _parent: unknown, - { - timeLimit, - }: { - timeLimit: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare(` - SELECT - TYPE AS series_type, - CASE - WHEN instr(brief, '/*') > 0 THEN - substr(substr(brief, 1, instr(brief, '/*') - 1), 0, 255) - ELSE - brief - END AS series_name, - count(brief) AS quantity - FROM - span - WHERE - datetime (start_nano / 1000000000, 'unixepoch', 'utc') >= datetime ('now', ?, 'utc') - AND brief IS NOT NULL - GROUP BY - series_type, - series_name - ORDER BY - quantity DESC; - `) - - const result = await stmt.all(`-${timeLimit} seconds`) - await stmt.finalize() - - return result -} - -export async function modelsAccessedList( - _parent: unknown, - { - timeLimit, - }: { - timeLimit: number - } -) { - const db = await getDatabase() - const stmt = await db.prepare(` - SELECT - model, - count(model) AS model_count - FROM - prisma_queries - WHERE - datetime (start_nano / 1000000000, 'unixepoch', 'utc') >= datetime ('now', ?, 'utc') - GROUP BY - model - ORDER BY - model_count DESC, model ASC; - `) - - const result = await stmt.all(`-${timeLimit} seconds`) - await stmt.finalize() - - return result -} diff --git a/packages/studio/api/services/mail.ts b/packages/studio/api/services/mail.ts deleted file mode 100644 index da85b81702ff..000000000000 --- a/packages/studio/api/services/mail.ts +++ /dev/null @@ -1,234 +0,0 @@ -import path from 'node:path' - -import fs from 'fs-extra' - -import { getPaths } from '@redwoodjs/project-config' - -import { getDatabase } from '../database' - -export async function mails() { - const db = await getDatabase() - const sql = ` - SELECT - id, - data, - envelope, - created_at - FROM - mail - ORDER BY - created_at DESC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: row.id, - data: JSON.parse(row.data), - envelope: JSON.parse(row.envelope), - created_at: row.created_at, - } - }) -} - -export async function getRenderedMail( - _parent: unknown, - { - componentId, - rendererId, - propsJSON, - }: { componentId: number; rendererId: number; propsJSON?: string } -) { - const db = await getDatabase() - try { - // Get the component and the component's template - const component = await db.get( - ` - SELECT - name, - props_template, - mail_template_id - FROM - mail_template_component - WHERE - id = ? - ; - `, - componentId - ) - if (!component) { - throw new Error(`Component not found`) - } - - // Get the template - const template = await db.get( - ` - SELECT - path - FROM - mail_template - WHERE - id = ? - ; - `, - component.mail_template_id - ) - if (!template) { - throw new Error(`Template not found`) - } - - // Get the renderer - const renderer = await db.get( - ` - SELECT - name - FROM - mail_renderer - WHERE - id = ? - ; - `, - rendererId - ) - if (!renderer) { - throw new Error(`Renderer not found`) - } - - // Import the template component - const templateComponentDistPath = - template.path - .replace(path.join('api', 'src'), path.join('api', 'dist')) - .substring(0, template.path.lastIndexOf('.') + 1) + '.js' - - const templateImportPath = templateComponentDistPath.replace( - '.js', - `.studio_${Date.now()}.js` - ) - fs.copyFileSync(templateComponentDistPath, templateImportPath) - const templateComponent = (await import(`file://${templateImportPath}`)) - .default - fs.removeSync(templateImportPath) - - const Component = - component.name.indexOf('default') !== -1 - ? templateComponent.default - : templateComponent[component.name] - - // Import the mailer - const mailerFilePath = path.join(getPaths().api.dist, 'lib', 'mailer.js') - const mailerImportPath = mailerFilePath.replace( - '.js', - `.studio_${Date.now()}.js` - ) - fs.copyFileSync(mailerFilePath, mailerImportPath) - const mailer = (await import(`file://${mailerImportPath}`)).mailer - fs.removeSync(mailerImportPath) - - // Render the component - const props = propsJSON ? JSON.parse(propsJSON) : {} - const renderResult = await mailer.renderers[renderer.name].render( - Component(props), - {} // TODO: We need a way for the user to specify the render options - ) - - return { - html: renderResult.html, - text: renderResult.text, - } - } catch (error) { - return { - error: (error as Error).message, - } - } -} - -export async function getMailRenderers() { - const db = await getDatabase() - const sql = ` - SELECT - id, - name, - is_default, - updated_at - FROM - mail_renderer - ORDER BY - name ASC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: parseInt(row.id), - name: row.name, - isDefault: row.is_default === 1, - updatedAt: row.updated_at, - } - }) -} - -export async function getMailTemplates() { - const db = await getDatabase() - const sql = ` - SELECT - id, - name, - path, - updated_at - FROM - mail_template - ORDER BY - name ASC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: parseInt(row.id), - name: row.name, - path: row.path, - updatedAt: row.updated_at, - } - }) -} - -export async function getMailComponents() { - const db = await getDatabase() - const sql = ` - SELECT - id, - mail_template_id, - name, - props_template, - updated_at - FROM - mail_template_component - ORDER BY - name ASC - ; - ` - const rows = await db.all(sql) - return rows.map((row) => { - return { - id: parseInt(row.id), - mailTemplateId: parseInt(row.mail_template_id), - name: row.name, - propsTemplate: row.props_template, - updatedAt: row.updated_at, - } - }) -} - -export async function truncate() { - const db = await getDatabase() - const sql = ` - DELETE FROM mail; - ` - try { - await db.exec(sql) - } catch (error) { - console.error(error) - return false - } - return true -} diff --git a/packages/studio/api/services/prismaSpans.ts b/packages/studio/api/services/prismaSpans.ts deleted file mode 100644 index 3d1cb4e64bd5..000000000000 --- a/packages/studio/api/services/prismaSpans.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getDatabase } from '../database' - -export const prismaQuerySpans = async ( - _parent: any, - { id }: { id: string } -) => { - const db = await getDatabase() - - const stmt = await db.prepare( - 'SELECT * FROM prisma_queries WHERE trace = ? OR parent_trace = ? ORDER BY start_nano asc;' - ) - - const result = await stmt.all(id, id) - await stmt.finalize() - - return result -} diff --git a/packages/studio/api/services/span.ts b/packages/studio/api/services/span.ts deleted file mode 100644 index a809a14018f4..000000000000 --- a/packages/studio/api/services/span.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { getDatabase } from '../database' - -export const restructureSpan = (span: any) => { - if (span == null) { - return null - } - return { - id: span.id, - trace: span.trace, - parent: span.parent, - name: span.name, - kind: span.kind, - statusCode: span.status_code, - statusMessage: span.status_message, - startNano: span.start_nano, - endNano: span.end_nano, - durationNano: span.duration_nano, - events: JSON.parse(span.events), - attributes: JSON.parse(span.attributes), - resources: JSON.parse(span.resources), - type: span.type, - brief: span.brief, - } -} - -export async function retypeSpan(_parent: unknown, { id }: { id: number }) { - const db = await getDatabase() - - let lastID = undefined - - // HTTP Requests - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'http', - brief = substr(json_extract(attributes, '$.\"http.method\"') || ' ' || json_extract(attributes, '$.\"http.url\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"http.method\"') IS NOT NULL AND - id = ?; - `, - id - ) - ).lastID - - // GraphQL Requests - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'graphql', - brief = substr(COALESCE(json_extract(attributes, '$.\"graphql.operation.name\"'), json_extract(attributes, '$.\"graphql.execute.operationName\"')), 0, 255) - WHERE - ( - json_extract(attributes, '$.\"graphql.operation.type\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.name\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.operationName\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.result\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.error\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.variables\"') IS NOT NULL - ) AND - id = ?; - `, - id - ) - ).lastID - - // SQL Statements - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'sql', - brief = substr(json_extract(attributes, '$.\"db.statement\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"db.statement\"') IS NOT NULL AND - id = ?; - `, - id - ) - ).lastID - - // Prisma Operations - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'prisma', - brief = substr(json_extract(attributes, '$.\"name\"'), 0, 255) - WHERE - name LIKE 'prisma:client:operation%' AND - id = ?; - `, - id - ) - ).lastID - - // Redwood Services - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'redwood-service', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/services/') - + LENGTH('/services/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:services%' AND - id = ?; - `, - id - ) - ).lastID - - // Redwood Functions - lastID = ( - await db.run( - ` - UPDATE span SET - type = 'redwood-function', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/functions/') - + LENGTH('/functions/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:functions%' AND - id = ?; - `, - id - ) - ).lastID - - return lastID === undefined -} - -export async function retypeSpans(_parent: unknown) { - const db = await getDatabase() - - // HTTP Requests - await db.run(` - UPDATE span SET - type = 'http', - brief = substr(json_extract(attributes, '$.\"http.method\"') || ' ' || json_extract(attributes, '$.\"http.url\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"http.method\"') IS NOT NULL; - `) - - // GraphQL Requests - await db.run(` - UPDATE span SET - type = 'graphql', - brief = substr(COALESCE(json_extract(attributes, '$.\"graphql.operation.name\"'), json_extract(attributes, '$.\"graphql.execute.operationName\"')), 0, 255) - WHERE ( - json_extract(attributes, '$.\"graphql.operation.type\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.name\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.operation.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.operationName\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.document\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.result\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.error\"') IS NOT NULL OR - json_extract(attributes, '$.\"graphql.execute.variables\"') IS NOT NULL - ); - `) - - // SQL Statements - await db.run(` - UPDATE span SET - type = 'sql', - brief = substr(json_extract(attributes, '$.\"db.statement\"'), 0, 255) - WHERE - json_extract(attributes, '$.\"db.statement\"') IS NOT NULL; - `) - - // Prisma Operations - await db.run(` - UPDATE span SET - type = 'prisma', - brief = substr(json_extract(attributes, '$.\"name\"'), 0, 255) - WHERE - name LIKE 'prisma:client:operation%'; - `) - - // Redwood Services - await db.run(` - UPDATE span SET - type = 'redwood-service', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/services/') - + LENGTH('/services/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:services%'; - `) - - // Redwood Functions - await db.run(` - UPDATE span SET - type = 'redwood-function', - brief = substr( - SUBSTR( - json_extract(attributes, '$.\"code.filepath\"'), - INSTR(json_extract(attributes, '$.\"code.filepath\"'), '/functions/') - + LENGTH('/functions/') - ), 0, 255) - WHERE - name LIKE 'redwoodjs:api:functions%'; - `) - - return true -} - -export async function truncateSpans(_parent: unknown) { - const db = await getDatabase() - await db.exec(` - DELETE FROM span - `) - return true -} diff --git a/packages/studio/api/services/sqlSpans.ts b/packages/studio/api/services/sqlSpans.ts deleted file mode 100644 index 44602c9f832e..000000000000 --- a/packages/studio/api/services/sqlSpans.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getDatabase } from '../database' - -export const sqlSpans = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM sql_spans;') - const spans = await stmt.all() - await stmt.finalize() - - return spans.map((span) => restructureSpan(span)) -} - -export const sqlCount = async (_parent: unknown) => { - const db = await getDatabase() - const stmt = await db.prepare('SELECT COUNT(1) FROM sql_spans;') - const result = await stmt.get() - await stmt.finalize() - - return result['COUNT(1)'] -} - -const restructureSpan = (span: any) => { - const restructuredSpan = { - id: span.id, - trace: span.trace, - parent: span.parent, - name: span.name, - kind: span.kind, - statusCode: span.status_code, - statusMessage: span.status_message, - startNano: span.start_nano, - endNano: span.end_nano, - durationNano: span.duration_nano, - events: span.events, - attributes: span.attributes, - resources: span.resources, - } - return restructuredSpan -} diff --git a/packages/studio/api/services/util.ts b/packages/studio/api/services/util.ts deleted file mode 100644 index 59845d70b31e..000000000000 --- a/packages/studio/api/services/util.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { getDatabase } from '../database' - -import { restructureSpan } from './span' - -async function getAncestorSpanIDs(spanId: string): Promise { - // Note: generated with GPT because I am not a SQL expert - const query = ` - WITH RECURSIVE span_hierarchy AS ( - SELECT id, parent - FROM span - WHERE id = ? - UNION ALL - SELECT s.id, s.parent - FROM span s - JOIN span_hierarchy sh ON s.id = sh.parent - ) - SELECT id, parent - FROM span_hierarchy; - ` - - const db = await getDatabase() - const stmt = await db.prepare(query, spanId) - const result = await stmt.all() - await stmt.finalize() - - // Remove the span itself from the result - return result.map((row) => row.id).filter((id) => id !== spanId) -} - -export async function getAncestorSpans(spanId: string): Promise { - const ancestorSpanIDs = await getAncestorSpanIDs(spanId) - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE id IN (${ancestorSpanIDs - .map(() => '?') - .join(', ')});` - ) - const result = await stmt.all(...ancestorSpanIDs) - await stmt.finalize() - return result.map((span) => restructureSpan(span)) -} - -async function getDescendantSpanIDs(spanId: string): Promise { - // Note: generated with GPT because I am not a SQL expert - const query = ` - WITH RECURSIVE span_hierarchy AS ( - SELECT id, parent - FROM span - WHERE id = ? - UNION ALL - SELECT s.id, s.parent - FROM span s - JOIN span_hierarchy sh ON s.parent = sh.id - ) - SELECT id, parent - FROM span_hierarchy; - ` - - const db = await getDatabase() - const stmt = await db.prepare(query, spanId) - const result = await stmt.all() - await stmt.finalize() - - // Remove the span itself from the result - return result.map((row) => row.id).filter((id) => id !== spanId) -} - -export async function getDescendantSpans(spanId: string): Promise { - const descendantSpanIDs = await getDescendantSpanIDs(spanId) - const db = await getDatabase() - const stmt = await db.prepare( - `SELECT * FROM span WHERE id IN (${descendantSpanIDs - .map(() => '?') - .join(', ')});` - ) - const result = await stmt.all(...descendantSpanIDs) - await stmt.finalize() - return result.map((span) => restructureSpan(span)) -} - -export async function getChildSpans(spanId: string): Promise { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE parent=?;') - const result = await stmt.all(spanId) - await stmt.finalize() - return result -} - -export async function getSpan(spanId: string): Promise { - const db = await getDatabase() - const stmt = await db.prepare('SELECT * FROM span WHERE id=?;') - const result = await stmt.get(spanId) - await stmt.finalize() - return result -} diff --git a/packages/studio/api/types.ts b/packages/studio/api/types.ts deleted file mode 100644 index 9a5536374d60..000000000000 --- a/packages/studio/api/types.ts +++ /dev/null @@ -1,140 +0,0 @@ -export interface ResourceSpan { - scopeSpans: ScopeSpan[] - resource: { - attributes: RawAttribute[] - } -} - -export interface ScopeSpan { - scope: { - name: string - } - spans: RawSpan[] -} - -export interface RawSpan { - traceId: string - spanId: string - parentSpanId: string - name: string - kind: number - startTimeUnixNano: string - endTimeUnixNano: string - attributes?: RawAttribute[] - events?: RawEvent[] - status?: { - code?: number - message?: string - } -} - -export interface RawAttribute { - key: string - value: { - stringValue?: string - intValue?: string - boolValue?: boolean - value?: any - } -} - -export interface RawEvent { - timeUnixNano: string - name: string - attributes: RawAttribute[] -} - -export interface RestructuredAttributes { - [key: string]: string | number | boolean | null -} - -export interface RestructuredEvent { - name: string - time: string - attributes: RestructuredAttributes -} - -export interface RestructuredSpan { - trace: string - id: string - parent: string - name: string - kind: number - statusCode?: number - statusMessage?: string - startNano: string - endNano: string - durationNano: string - events?: RestructuredEvent[] - attributes?: RestructuredAttributes - resourceAttributes?: RestructuredAttributes -} - -export interface ApiConfig { - title: string - name?: string - host: string - port: number - path: string - // target: TargetEnum.NODE - schemaPath: string - serverConfig: string - debugPort?: number -} - -export interface WebConfig { - title: string - name?: string - host: string - port: number - path: string - // target: TargetEnum.BROWSER - // bundler: BundlerEnum - includeEnvironmentVariables: string[] - /** - * Specify the URL to your api-server. - * This can be an absolute path proxied on the current domain (`/.netlify/functions`), - * or a fully qualified URL (`https://api.example.org:8911/functions`). - * - * Note: This should not include the path to the GraphQL Server. - **/ - apiUrl: string - /** - * Optional: FQDN or absolute path to the GraphQL serverless function, without the trailing slash. - * This will override the apiUrl configuration just for the graphql function - * Example: `./redwood/functions/graphql` or `https://api.redwoodjs.com/graphql` - */ - apiGraphQLUrl?: string - - fastRefresh: boolean - a11y: boolean - sourceMap: boolean - graphqlEndpoint?: string -} - -export interface GraphiQLStudioConfig { - endpoint?: string - authImpersonation?: AuthImpersonationConfig -} - -export interface AuthImpersonationConfig { - authProvider?: string - jwtSecret?: string - userId?: string - email?: string - roles?: string[] -} - -export interface StudioConfig { - basePort: number - inMemory: boolean - graphiql?: GraphiQLStudioConfig -} - -export type SpanType = - | 'http' - | 'sql' - | 'graphql' - | 'prisma' - | 'redwood-service' - | null diff --git a/packages/studio/build.mjs b/packages/studio/build.mjs deleted file mode 100644 index 3061224c5289..000000000000 --- a/packages/studio/build.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import fs from 'node:fs' - -import * as esbuild from 'esbuild' -import fg from 'fast-glob' - -// Get source files -const sourceFiles = fg.sync(['./api/**/*.ts']) - -// Build general source files -const result = await esbuild.build({ - entryPoints: sourceFiles, - outdir: 'dist/api', - - format: 'cjs', - platform: 'node', - target: ['node20'], - - logLevel: 'info', - - // For visualizing dist. - // See https://esbuild.github.io/api/#metafile and https://esbuild.github.io/analyze/. - metafile: true, -}) - -fs.writeFileSync('meta.json', JSON.stringify(result.metafile, null, 2)) diff --git a/packages/studio/package.json b/packages/studio/package.json deleted file mode 100644 index aec9f3bff4ff..000000000000 --- a/packages/studio/package.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "name": "@redwoodjs/studio", - "version": "6.0.7", - "description": "Redwood's development studio", - "repository": { - "type": "git", - "url": "https://github.com/redwoodjs/redwood.git", - "directory": "packages/studio" - }, - "license": "MIT", - "main": "dist/api/index.js", - "files": [ - "dist" - ], - "scripts": { - "build": "yarn build:api && yarn build:web", - "build:api": "yarn node ./build.mjs && yarn build:types", - "build:pack": "yarn pack -o redwoodjs-studio.tgz", - "build:types": "tsc --build --verbose", - "build:watch": "nodemon --watch api --ext \"js,ts,tsx\" --ignore dist/api --exec \"yarn build\"", - "build:web": "cd web && vite build", - "prepublishOnly": "NODE_ENV=production yarn build" - }, - "dependencies": { - "@babel/runtime-corejs3": "7.23.6", - "@fastify/http-proxy": "9.3.0", - "@fastify/static": "6.12.0", - "@fastify/url-data": "5.4.0", - "@redwoodjs/internal": "6.0.7", - "@redwoodjs/project-config": "6.0.7", - "@swc/cli": "0.1.62", - "@swc/core": "1.3.60", - "ansi-colors": "4.1.3", - "chokidar": "3.5.3", - "core-js": "3.34.0", - "dotenv": "16.3.1", - "fast-json-parse": "1.0.3", - "fastify": "4.24.3", - "fastify-raw-body": "4.3.0", - "graphql": "16.8.1", - "graphql-scalars": "1.22.4", - "graphql-yoga": "5.1.0", - "jsonwebtoken": "9.0.2", - "lodash": "4.17.21", - "mailparser": "3.6.5", - "pretty-bytes": "5.6.0", - "qs": "6.11.2", - "smtp-server": "3.13.0", - "split2": "4.2.0", - "sqlite": "5.1.1", - "sqlite3": "5.1.6", - "uuid": "9.0.1", - "yargs": "17.7.2" - }, - "devDependencies": { - "@apollo/client": "3.8.8", - "@babel/cli": "7.23.4", - "@babel/core": "^7.22.20", - "@graphiql/plugin-explorer": "0.1.22", - "@graphiql/toolkit": "0.8.4", - "@headlessui/react": "1.7.15", - "@heroicons/react": "2.0.18", - "@nivo/bar": "0.83.0", - "@nivo/core": "0.83.0", - "@nivo/tooltip": "0.83.0", - "@nivo/treemap": "0.83.0", - "@tailwindcss/forms": "0.5.3", - "@tremor/react": "3.4.1", - "@types/aws-lambda": "8.10.126", - "@types/jsonwebtoken": "9.0.5", - "@types/lodash": "4.14.201", - "@types/mailparser": "3", - "@types/qs": "6.9.11", - "@types/react": "18.2.37", - "@types/react-dom": "18.2.15", - "@types/react-grid-layout": "1", - "@types/smtp-server": "3", - "@types/split2": "4.2.3", - "@types/uuid": "9.0.7", - "@types/yargs": "17.0.32", - "@vitejs/plugin-react": "4.2.1", - "autoprefixer": "10.4.16", - "aws-lambda": "1.0.7", - "buffer": "6.0.3", - "graphiql": "3.0.10", - "jest": "29.7.0", - "json-bigint-patch": "0.0.8", - "postcss": "8.4.31", - "pretty-ms": "7.0.1", - "react": "0.0.0-experimental-e5205658f-20230913", - "react-dom": "0.0.0-experimental-e5205658f-20230913", - "react-error-boundary": "4.0.11", - "react-grid-layout": "1.3.4", - "react-router-dom": "6.8.1", - "react-split-pane": "0.1.92", - "react-toastify": "9.1.3", - "tailwindcss": "3.3.5", - "typescript": "5.3.3", - "use-url-search-params": "2.5.1", - "vite": "4.5.1" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.60", - "@swc/core-darwin-x64": "1.3.60", - "@swc/core-linux-arm-gnueabihf": "1.3.60", - "@swc/core-linux-arm64-gnu": "1.3.60", - "@swc/core-linux-arm64-musl": "1.3.60", - "@swc/core-linux-x64-gnu": "1.3.60", - "@swc/core-linux-x64-musl": "1.3.60", - "@swc/core-win32-arm64-msvc": "1.3.60", - "@swc/core-win32-ia32-msvc": "1.3.60", - "@swc/core-win32-x64-msvc": "1.3.60" - }, - "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json deleted file mode 100644 index 6872f71d5add..000000000000 --- a/packages/studio/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.compilerOption.json", - "compilerOptions": { - "baseUrl": "./", - "rootDir": "api", - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "outDir": "dist/api", - }, - "include": ["api/**/*"], - "references": [{ "path": "../internal" }] -} diff --git a/packages/studio/web/.gitignore b/packages/studio/web/.gitignore deleted file mode 100644 index a547bf36d8d1..000000000000 --- a/packages/studio/web/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/packages/studio/web/index.html b/packages/studio/web/index.html deleted file mode 100644 index 4deac417d4d3..000000000000 --- a/packages/studio/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - RedwoodJS Studio - - - - -
- - - diff --git a/packages/studio/web/postcss.config.cjs b/packages/studio/web/postcss.config.cjs deleted file mode 100644 index 33ad091d26d8..000000000000 --- a/packages/studio/web/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/packages/studio/web/src/BarLists/ModelsAccessedList.tsx b/packages/studio/web/src/BarLists/ModelsAccessedList.tsx deleted file mode 100644 index 156437fdc108..000000000000 --- a/packages/studio/web/src/BarLists/ModelsAccessedList.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import { - BarList, - Card, - // Color, - Select, - SelectItem, - Title, - Bold, - Flex, - Text, -} from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_MODELS_ACCESSED_LIST = gql` - query QUERY_GET_MODELS_ACCESSED_LIST($timeLimit: Int!) { - modelsAccessedList(timeLimit: $timeLimit) { - model - model_count - } - } -` - -export default function ModelsAccessedList({ - name = 'Models Accessed List', - timeLimit, -}: { - name: string - timeLimit: number -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_MODELS_ACCESSED_LIST, { - variables: { - timeLimit: refreshSecondsAgo, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const agos = [30, 60, 120, 240, 480] - - const barListData = data.modelsAccessedList.map((item: any) => ({ - name: item.model, - value: item.model_count, - href: '', - icon: '', - })) - - return ( - - - {name} - - - - - Model - - - Count - - - - - ) -} diff --git a/packages/studio/web/src/BarLists/SeriesTypeBarList.tsx b/packages/studio/web/src/BarLists/SeriesTypeBarList.tsx deleted file mode 100644 index be728dacae0e..000000000000 --- a/packages/studio/web/src/BarLists/SeriesTypeBarList.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import { - BarList, - Card, - // Color, - Select, - SelectItem, - Title, - Bold, - Flex, - Text, -} from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SERIES_TYPE_BAR_LIST = gql` - query QUERY_GET_SERIES_TYPE_BAR_LIST($timeLimit: Int!) { - seriesTypeBarList(timeLimit: $timeLimit) { - quantity - series_name - series_type - } - } -` - -export default function SeriesTypeBarList({ - name = 'Bar List', - timeLimit, -}: { - name: string - timeLimit: number -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_SERIES_TYPE_BAR_LIST, { - variables: { - timeLimit: refreshSecondsAgo, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const agos = [30, 60, 120, 240, 480] - - const barListData = data.seriesTypeBarList.map((item: any) => ({ - name: item.series_name, - value: item.quantity, - href: '', - icon: function () { - switch (item.series_type) { - case 'graphql': - return ( - - - - ) - case 'prisma': - return ( - - - - ) - case 'sql': - return ( - - - - - - - ) - case 'http': - return ( - - - - ) - case 'generic': - return ( - - - - ) - case 'redwood-service': - return ( - - - - - ) - case 'redwood-function': - return ( - - - - - ) - default: - return ( - - - - ) - } - }, - })) - - return ( - - - {name} - - - - - Type - - - Count - - - - - ) -} diff --git a/packages/studio/web/src/Charts/SpanTreeMapChart.tsx b/packages/studio/web/src/Charts/SpanTreeMapChart.tsx deleted file mode 100644 index 1de3b3808373..000000000000 --- a/packages/studio/web/src/Charts/SpanTreeMapChart.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' - -import { ResponsiveTreeMap } from '@nivo/treemap' -import { useNavigate } from 'react-router-dom' - -export default function SpanTreeMapChart({ data }: { data: any }) { - const navigate = useNavigate() - - return ( - { - event.preventDefault() - if (event.button === 0) { - // Move to span view - if (event.ctrlKey) { - navigate(`/explorer/span/${node.data.id}`) - return - } - // Go up to parent span - if (event.shiftKey && node.data.parent != null) { - navigate(`/explorer/map/${node.data.parent}`) - return - } - // Go down to child span - navigate(`/explorer/map/${node.data.id}`) - return - } - }} - /> - ) -} diff --git a/packages/studio/web/src/Charts/SpanTypeBarChart.tsx b/packages/studio/web/src/Charts/SpanTypeBarChart.tsx deleted file mode 100644 index 23adcc1bb339..000000000000 --- a/packages/studio/web/src/Charts/SpanTypeBarChart.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react' - -import { useQuery, gql } from '@apollo/client' -import { ResponsiveBar } from '@nivo/bar' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SPAN_TYPE_TIMELINE = gql` - query GetSpanTypeTimeline($timeLimit: Int!, $timeBucket: Int!) { - spanTypeTimeline(timeLimit: $timeLimit, timeBucket: $timeBucket) { - data - keys - index - legend - axisLeft - axisBottom - } - } -` - -export default function SpanTypeBarChart({ - timeLimit, - timeBucket, -}: { - timeLimit: number - timeBucket: number -}) { - const { loading, error, data } = useQuery(QUERY_GET_SPAN_TYPE_TIMELINE, { - variables: { timeLimit, timeBucket }, - pollInterval: timeBucket * 1_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - return ( -
- -
- ) -} diff --git a/packages/studio/web/src/Charts/SpanTypeTimeSeriesBarChart.tsx b/packages/studio/web/src/Charts/SpanTypeTimeSeriesBarChart.tsx deleted file mode 100644 index c3e90bd57795..000000000000 --- a/packages/studio/web/src/Charts/SpanTypeTimeSeriesBarChart.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import type { Color } from '@tremor/react' -import { Card, Select, SelectItem, Flex, BarChart, Title } from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SPAN_TYPE_TIMESERIES = gql` - query QUERY_GET_SPAN_TYPE_TIMESERIES( - $timeLimit: Int! - $showGeneric: Boolean! - $showGraphql: Boolean! - $showHttp: Boolean! - $showPrisma: Boolean! - $showRedwoodFunction: Boolean! - $showRedwoodService: Boolean! - $showSql: Boolean! - ) { - spanTypeTimeSeriesData(timeLimit: $timeLimit) { - generic @include(if: $showGeneric) - graphql @include(if: $showGraphql) - http @include(if: $showHttp) - prisma @include(if: $showPrisma) - redwoodfunction @include(if: $showRedwoodFunction) - redwoodservice @include(if: $showRedwoodService) - sql @include(if: $showSql) - ts - } - } -` - -export default function SpanTypeTimeSeriesBarChart({ - name = 'Time Series Bar Chart', - timeLimit, - showGeneric = false, - showGraphql = false, - showHttp = false, - showPrisma = false, - showRedwoodFunction = false, - showRedwoodService = false, - showSql = false, -}: { - name: string - timeLimit: number - showGeneric?: boolean - showGraphql?: boolean - showHttp?: boolean - showPrisma?: boolean - showRedwoodFunction?: boolean - showRedwoodService?: boolean - showSql?: boolean -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_SPAN_TYPE_TIMESERIES, { - variables: { - timeLimit: refreshSecondsAgo, - showGeneric, - showGraphql, - showHttp, - showPrisma, - showRedwoodFunction, - showRedwoodService, - showSql, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const categories = [] - const colors = [] as Color[] - - const agos = [30, 60, 120, 240, 480] - - if (showGeneric) { - categories.push('generic') - colors.push('amber') - } - if (showGraphql) { - categories.push('graphql') - colors.push('pink') - } - if (showHttp) { - categories.push('http') - colors.push('emerald') - } - if (showPrisma) { - categories.push('prisma') - colors.push('lime') - } - if (showRedwoodFunction) { - categories.push('redwoodfunction') - colors.push('blue') - } - if (showRedwoodService) { - categories.push('redwoodservice') - colors.push('rose') - } - if (showSql) { - categories.push('sql') - colors.push('purple') - } - - return ( - - - {name} - - - - - ) -} diff --git a/packages/studio/web/src/Charts/SpanTypeTimeSeriesChart.tsx b/packages/studio/web/src/Charts/SpanTypeTimeSeriesChart.tsx deleted file mode 100644 index ca101ad8ad1d..000000000000 --- a/packages/studio/web/src/Charts/SpanTypeTimeSeriesChart.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React, { useState } from 'react' - -import { useQuery, gql } from '@apollo/client' -import type { Color } from '@tremor/react' -import { Card, Select, SelectItem, Flex, LineChart, Title } from '@tremor/react' - -import LoadingSpinner from '../Components/LoadingSpinner' -import ErrorPanel from '../Components/Panels/ErrorPanel' -import WarningPanel from '../Components/Panels/WarningPanel' - -const QUERY_GET_SPAN_TYPE_TIMESERIES = gql` - query QUERY_GET_SPAN_TYPE_TIMESERIES( - $timeLimit: Int! - $showGeneric: Boolean! - $showGraphql: Boolean! - $showHttp: Boolean! - $showPrisma: Boolean! - $showRedwoodFunction: Boolean! - $showRedwoodService: Boolean! - $showSql: Boolean! - ) { - spanTypeTimeSeriesData(timeLimit: $timeLimit) { - generic @include(if: $showGeneric) - graphql @include(if: $showGraphql) - http @include(if: $showHttp) - prisma @include(if: $showPrisma) - redwoodfunction @include(if: $showRedwoodFunction) - redwoodservice @include(if: $showRedwoodService) - sql @include(if: $showSql) - ts - } - } -` - -export default function SpanTypeTimeSeriesChart({ - name = 'Time Series Chart', - timeLimit, - showGeneric = false, - showGraphql = false, - showHttp = false, - showPrisma = false, - showRedwoodFunction = false, - showRedwoodService = false, - showSql = false, -}: { - name: string - timeLimit: number - showGeneric?: boolean - showGraphql?: boolean - showHttp?: boolean - showPrisma?: boolean - showRedwoodFunction?: boolean - showRedwoodService?: boolean - showSql?: boolean -}) { - const [refreshSecondsAgo, setRefreshSecondsAgo] = useState(timeLimit) - - const { loading, error, data } = useQuery(QUERY_GET_SPAN_TYPE_TIMESERIES, { - variables: { - timeLimit: refreshSecondsAgo, - showGeneric, - showGraphql, - showHttp, - showPrisma, - showRedwoodFunction, - showRedwoodService, - showSql, - }, - pollInterval: 5_000, - }) - - if (error) { - return - } - - if (loading) { - return ( -
- -
- ) - } - - if (!data) { - return ( - - ) - } - - const categories = [] - const colors = [] as Color[] - - if (showGeneric) { - categories.push('generic') - colors.push('amber') - } - if (showGraphql) { - categories.push('graphql') - colors.push('pink') - } - if (showHttp) { - categories.push('http') - colors.push('emerald') - } - if (showPrisma) { - categories.push('prisma') - colors.push('lime') - } - if (showRedwoodFunction) { - categories.push('redwoodfunction') - colors.push('blue') - } - if (showRedwoodService) { - categories.push('redwoodservice') - colors.push('rose') - } - if (showSql) { - categories.push('sql') - colors.push('purple') - } - - const agos = [30, 60, 120, 240, 480] - - const dataFormatter = (number: number) => - `${Intl.NumberFormat('us').format(number).toString()} ms` - - return ( - - - {name} - - - - - ) -} diff --git a/packages/studio/web/src/Components/CountCard.tsx b/packages/studio/web/src/Components/CountCard.tsx deleted file mode 100644 index 2672c4970d2a..000000000000 --- a/packages/studio/web/src/Components/CountCard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' - -import { EllipsisHorizontalIcon } from '@heroicons/react/24/outline' -import { NavLink } from 'react-router-dom' - -import LoadingSpinner from './LoadingSpinner' - -function CountCard({ - title, - icon: Icon, - colouring, - link, - loading, - value, - error, -}: { - title: string - icon: React.ForwardRefExoticComponent> - colouring: string - link: string - loading: boolean - value: any - error: any -}) { - return ( -
-
-
-
-

- {title} -

-
-
-

- {value ? ( - value - ) : error ? ( - 'error' - ) : loading ? ( - - ) : ( -

-
-
- - {' '} - View all - {title} stats - -
-
-
-
- ) -} - -export default CountCard diff --git a/packages/studio/web/src/Components/Event/ErrorEventLink.tsx b/packages/studio/web/src/Components/Event/ErrorEventLink.tsx deleted file mode 100644 index a2fe466e7d4c..000000000000 --- a/packages/studio/web/src/Components/Event/ErrorEventLink.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' - -import { LinkIcon } from '@heroicons/react/20/solid' -import { Bold, Card, Flex, Text } from '@tremor/react' -import { Link } from 'react-router-dom' - -export default function ErrorEventLink({ - event, - spanId, -}: { - event: any - spanId: string -}) { - const attributeCount = Object.keys(event.attributes || {}).length - return ( - - -
- - {event.name}( - {attributeCount === 1 - ? '1 attribute' - : `${attributeCount} attributes`} - ) - - - {new Date(Number(event.time / BigInt(1e6))).toISOString()} - -
- - - -
-
- ) -} diff --git a/packages/studio/web/src/Components/Event/EventModal.tsx b/packages/studio/web/src/Components/Event/EventModal.tsx deleted file mode 100644 index 24fcddc51c05..000000000000 --- a/packages/studio/web/src/Components/Event/EventModal.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { Fragment, useRef, useState } from 'react' - -import { Transition, Dialog } from '@headlessui/react' -import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' -import { - Bold, - Button, - Card, - Flex, - List, - ListItem, - Text, - Title, -} from '@tremor/react' - -import { displayTextOrJSON } from '../../util/ui' - -function DetailsModel({ open, setOpen, event }: any) { - const cancelButtonRef = useRef(null) - const data = Object.entries(event.attributes ?? {}).map(([name, value]) => ({ - name, - value, - })) - return ( - - - -
- - -
-
- - - - Event Information - - - Name - {event.name} - - - Time - {new Date(Number(event.time / BigInt(1e6))).toISOString()} - - - - Attributes - - - {data?.map((d) => ( - - {d.name} - {displayTextOrJSON(d.value)} - - ))} - - - - - - - -
-
-
-
- ) -} - -export default function EventModal({ event }: { event: any }) { - const [open, setOpen] = useState(false) - - const attributeCount = Object.keys(event.attributes || {}).length - return ( - - -
- - {event.name}( - {attributeCount === 1 - ? '1 attribute' - : `${attributeCount} attributes`} - ) - - - {new Date(Number(event.time / BigInt(1e6))).toISOString()} - -
- setOpen(true)} - /> - -
-
- ) -} diff --git a/packages/studio/web/src/Components/Feature/AncestorFeatureList.tsx b/packages/studio/web/src/Components/Feature/AncestorFeatureList.tsx deleted file mode 100644 index ec6cc8e4eedf..000000000000 --- a/packages/studio/web/src/Components/Feature/AncestorFeatureList.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' - -import { BarsArrowUpIcon } from '@heroicons/react/20/solid' -import { Card, Flex, Title, Italic, Text } from '@tremor/react' - -import FeatureLink from './FeatureLink' - -export default function AncestorFeatureList({ features }: { features: any[] }) { - return ( - - - - - Ancestor Features - - {features.length === 0 ? ( - - None found... - - ) : ( - <> - {features.map((feature: any) => ( - - ))} - - )} - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/CustomIcons.tsx b/packages/studio/web/src/Components/Feature/CustomIcons.tsx deleted file mode 100644 index ed9d719230ad..000000000000 --- a/packages/studio/web/src/Components/Feature/CustomIcons.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' - -export default function CustomIcons({ customs }: { customs: any[] }) { - if (customs.length === 0) { - return <> - } - - return ( - <> - {customs.map((custom, index) => { - const Icon = custom.icon - return ( -
- 0 ? 'md:ml-2 sm:ml-0' : '' - }`} - aria-hidden="true" - /> - {custom.value} -
- ) - })} - - ) -} diff --git a/packages/studio/web/src/Components/Feature/DescendantFeatureList.tsx b/packages/studio/web/src/Components/Feature/DescendantFeatureList.tsx deleted file mode 100644 index 6ef24813cc29..000000000000 --- a/packages/studio/web/src/Components/Feature/DescendantFeatureList.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' - -import { BarsArrowDownIcon } from '@heroicons/react/20/solid' -import { Card, Flex, Title, Italic, Text } from '@tremor/react' - -import FeatureLink from './FeatureLink' - -export default function DescendantFeatureList({ - features, -}: { - features: any[] -}) { - return ( - - - - - Descendant Features - - {features.length === 0 ? ( - - None found... - - ) : ( - <> - {features.map((feature: any) => ( - - ))} - - )} - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/FeatureLink.tsx b/packages/studio/web/src/Components/Feature/FeatureLink.tsx deleted file mode 100644 index 59db92aed7e3..000000000000 --- a/packages/studio/web/src/Components/Feature/FeatureLink.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' - -import { LinkIcon } from '@heroicons/react/20/solid' -import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline' -import { Card, Flex } from '@tremor/react' -import { Link } from 'react-router-dom' - -import { featureDisplayNames, featureIcons, featureColours } from './features' - -export default function FeatureLink({ feature }: { feature: any }) { - const Icon = featureIcons.get(feature.type) || QuestionMarkCircleIcon - return ( - - - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/TraceFeatureList.tsx b/packages/studio/web/src/Components/Feature/TraceFeatureList.tsx deleted file mode 100644 index 4cb73c7e2758..000000000000 --- a/packages/studio/web/src/Components/Feature/TraceFeatureList.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' - -import { Bars3Icon } from '@heroicons/react/20/solid' -import { Card, Flex, Italic, Title, Text } from '@tremor/react' - -import FeatureLink from './FeatureLink' - -export default function TraceFeatureList({ features }: { features: any[] }) { - return ( - - - - - Trace Features - - {features.length === 0 ? ( - - None found... - - ) : ( - <> - {features.map((feature: any) => ( - - ))} - - )} - - - ) -} diff --git a/packages/studio/web/src/Components/Feature/features.ts b/packages/studio/web/src/Components/Feature/features.ts deleted file mode 100644 index 1b36dbb8f02a..000000000000 --- a/packages/studio/web/src/Components/Feature/features.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - CircleStackIcon, - CodeBracketIcon, - ShareIcon, -} from '@heroicons/react/24/outline' - -export const featureDisplayNames = new Map([ - ['sql', 'SQL'], - ['http', 'HTTP'], - ['prisma', 'Prisma'], - ['redwood-service', 'RedwoodJS Service'], - ['redwood-function', 'RedwoodJS Function'], - ['graphql', 'GraphQL'], -]) - -export const featureIcons = new Map([ - ['sql', CircleStackIcon], - ['http', CodeBracketIcon], - ['prisma', CodeBracketIcon], - ['redwood-service', CodeBracketIcon], - ['redwood-function', CodeBracketIcon], - ['graphql', ShareIcon], -]) - -export const featureColours = new Map([ - ['sql', 'text-cyan-500'], - ['http', 'text-black'], - ['prisma', 'text-[#5a67d8]'], - ['redwood-service', 'text-[#370617]'], - ['redwood-function', 'text-[#370617]'], - ['graphql', 'text-fuchsia-500'], -]) diff --git a/packages/studio/web/src/Components/LoadingSpinner.tsx b/packages/studio/web/src/Components/LoadingSpinner.tsx deleted file mode 100644 index 847c687762d6..000000000000 --- a/packages/studio/web/src/Components/LoadingSpinner.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -import { Button } from '@tremor/react' - -function LoadingSpinner({ colour }: { colour?: string }) { - const _fill = colour ? `fill-[${colour}]` : 'fill-sinopia' - return ( -
-
- ) -} - -export default LoadingSpinner diff --git a/packages/studio/web/src/Components/Mail/MailRenderer.tsx b/packages/studio/web/src/Components/Mail/MailRenderer.tsx deleted file mode 100644 index ad41b193e8d5..000000000000 --- a/packages/studio/web/src/Components/Mail/MailRenderer.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' - -import { - ComputerDesktopIcon, - DevicePhoneMobileIcon, - DeviceTabletIcon, -} from '@heroicons/react/20/solid' -import { - DocumentChartBarIcon, - DocumentTextIcon, - CodeBracketIcon, - ArrowPathIcon, -} from '@heroicons/react/24/outline' -import { - Text, - Card, - Button, - Flex, - Select, - SelectItem, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Italic, -} from '@tremor/react' - -import ErrorPanel from '../Panels/ErrorPanel' - -// Note: the "+2" is to account for the borders -const PREVIEW_DIMENSIONS = [ - { - label: 'Desktop', - width: null, - height: null, - icon: ComputerDesktopIcon, - }, - { - label: 'iPhone 12 Pro', - width: 390 + 2, - height: 844 + 2, - icon: DevicePhoneMobileIcon, - }, - { - label: 'Pixel 5', - width: 393 + 2, - height: 851 + 2, - icon: DevicePhoneMobileIcon, - }, - { - label: 'iPad Air', - width: 820 + 2, - height: 1180 + 2, - icon: DeviceTabletIcon, - }, - { - label: 'Surface Pro 7', - width: 912 + 2, - height: 1368 + 2, - icon: DeviceTabletIcon, - }, -] - -function MailPreview({ - html, - text, - error, - additionalTabHeaders, - additionalTabPanels, -}: { - html: string | null - text: string | null - error?: any - additionalTabHeaders?: React.ReactElement - additionalTabPanels?: React.ReactElement[] -}) { - const iframeRef = useRef(null) - - const [selectedTabIndex, setSelectedTabIndex] = useState(0) - const [selectedPreviewDimension, setSelectedPreviewDimension] = useState( - PREVIEW_DIMENSIONS[0] - ) - const [isPreviewHorizontal, setIsPreviewHorizontal] = useState(false) - const [iframeWidth, setIframeWidth] = useState('100%') - const [iframeHeight, setIframeHeight] = useState('100%') - const [iframeContentHeight, setIframeContentHeight] = useState(0) - - useEffect(() => { - if (selectedPreviewDimension.label === 'Desktop') { - setIframeWidth('100%') - setIframeHeight(`${iframeContentHeight}px`) - } else { - if (isPreviewHorizontal) { - setIframeWidth(`${selectedPreviewDimension.height}px`) - setIframeHeight(`${selectedPreviewDimension.width}px`) - } else { - setIframeWidth(`${selectedPreviewDimension.width}px`) - setIframeHeight(`${selectedPreviewDimension.height}px`) - } - } - }, [selectedPreviewDimension, isPreviewHorizontal, iframeContentHeight]) - - // Note: I just couldn't get the iframe to resize properly on its own - // so I'm just going to poll and update the height if it changes - setInterval(() => { - setIframeContentHeight( - (iframeRef.current?.contentWindow?.document.body?.scrollHeight ?? 0) + 82 - ) - }, 250) - - const preprocessedHTML = - html?.replace( - '', - "" - ) ?? '' - - if (error) { - return ( -
- -
- ) - } - - return ( - - - - - HTML - Text - Raw HTML - {additionalTabHeaders ?? <>} - -
- - -
-
- - - {preprocessedHTML ? ( -