From de06b0abba5ac9b6c32aae8b6494cde7402ccfcb Mon Sep 17 00:00:00 2001 From: lucas Date: Sat, 30 May 2026 17:09:09 +0900 Subject: [PATCH] feat: add Tailcall config generator --- src/components/config/ConfigGenerator.tsx | 1111 +++++++++++++++++++++ src/components/playground/Playground.tsx | 23 +- src/constants/routes.ts | 1 + src/constants/titles.ts | 2 + src/pages/app/config.tsx | 22 + 5 files changed, 1158 insertions(+), 1 deletion(-) create mode 100644 src/components/config/ConfigGenerator.tsx create mode 100644 src/pages/app/config.tsx diff --git a/src/components/config/ConfigGenerator.tsx b/src/components/config/ConfigGenerator.tsx new file mode 100644 index 000000000..a31058e2c --- /dev/null +++ b/src/components/config/ConfigGenerator.tsx @@ -0,0 +1,1111 @@ +import React, {useEffect, useMemo, useState} from "react" +import {Clipboard, Download, FileCode2, FileJson2, FileText, Plus, RefreshCw, Trash2} from "lucide-react" + +type Format = "json" | "yaml" | "graphql" +type Preset = "starter" | "production" | "grpc" +type LinkType = "Config" | "Protobuf" | "Script" | "Cert" | "Key" | "Operation" | "Htpasswd" | "Jwks" | "Grpc" +type TelemetryExporter = "off" | "stdout" | "prometheus" | "otlp" +type SchemaStatus = "loading" | "synced" | "fallback" + +type JsonValue = string | number | boolean | null | JsonValue[] | {[key: string]: JsonValue} + +type KeyValue = { + key: string + value: string +} + +type LinkConfig = { + id: string + type: LinkType + src: string + protoPaths: string + headers: KeyValue[] +} + +type ConfigState = { + server: { + port: string + hostname: string + version: "HTTP1" | "HTTP2" + introspection: boolean + queryValidation: boolean + responseValidation: boolean + enableFederation: boolean + batchRequests: boolean + globalResponseTimeout: string + workers: string + graphQLRoute: string + statusRoute: string + } + upstream: { + connectTimeout: string + poolIdleTimeout: string + keepAliveInterval: string + keepAliveTimeout: string + httpCache: string + http2Only: boolean + allowedHeaders: string + batchMaxSize: string + batchDelay: string + batchHeaders: string + proxyUrl: string + } + telemetry: { + exporter: TelemetryExporter + requestHeaders: string + stdoutPretty: boolean + prometheusPath: string + otlpUrl: string + } + links: LinkConfig[] +} + +type FieldDescriptions = { + server: Record + upstream: Record + telemetry: Record +} + +type RuntimeSchema = { + definitions?: { + Server?: { + properties?: Record + } + Upstream?: { + properties?: Record + } + Telemetry?: { + properties?: Record + } + LinkType?: { + oneOf?: Array<{enum?: string[]}> + } + } +} + +const SCHEMA_URL = "https://raw.githubusercontent.com/tailcallhq/tailcall/main/generated/.tailcallrc.schema.json" + +const defaultLinkTypes: LinkType[] = [ + "Config", + "Protobuf", + "Script", + "Cert", + "Key", + "Operation", + "Htpasswd", + "Jwks", + "Grpc", +] + +const presets: Record = { + starter: { + server: { + port: "8000", + hostname: "0.0.0.0", + version: "HTTP1", + introspection: true, + queryValidation: false, + responseValidation: false, + enableFederation: false, + batchRequests: false, + globalResponseTimeout: "", + workers: "", + graphQLRoute: "/graphql", + statusRoute: "/status", + }, + upstream: { + connectTimeout: "10", + poolIdleTimeout: "60", + keepAliveInterval: "", + keepAliveTimeout: "", + httpCache: "", + http2Only: false, + allowedHeaders: "authorization,x-request-id", + batchMaxSize: "", + batchDelay: "", + batchHeaders: "", + proxyUrl: "", + }, + telemetry: { + exporter: "off", + requestHeaders: "", + stdoutPretty: true, + prometheusPath: "/metrics", + otlpUrl: "", + }, + links: [ + { + id: "main", + type: "Config", + src: "./tailcall.graphql", + protoPaths: "", + headers: [], + }, + ], + }, + production: { + server: { + port: "8000", + hostname: "0.0.0.0", + version: "HTTP2", + introspection: false, + queryValidation: true, + responseValidation: true, + enableFederation: false, + batchRequests: true, + globalResponseTimeout: "30", + workers: "", + graphQLRoute: "/graphql", + statusRoute: "/status", + }, + upstream: { + connectTimeout: "5", + poolIdleTimeout: "60", + keepAliveInterval: "30", + keepAliveTimeout: "10", + httpCache: "500", + http2Only: false, + allowedHeaders: "authorization,x-request-id,x-tenant-id", + batchMaxSize: "100", + batchDelay: "10", + batchHeaders: "authorization,x-request-id", + proxyUrl: "", + }, + telemetry: { + exporter: "prometheus", + requestHeaders: "x-request-id,x-tenant-id", + stdoutPretty: true, + prometheusPath: "/metrics", + otlpUrl: "", + }, + links: [ + { + id: "main", + type: "Config", + src: "./tailcall.graphql", + protoPaths: "", + headers: [], + }, + ], + }, + grpc: { + server: { + port: "8000", + hostname: "0.0.0.0", + version: "HTTP2", + introspection: true, + queryValidation: true, + responseValidation: false, + enableFederation: false, + batchRequests: false, + globalResponseTimeout: "30", + workers: "", + graphQLRoute: "/graphql", + statusRoute: "/status", + }, + upstream: { + connectTimeout: "10", + poolIdleTimeout: "90", + keepAliveInterval: "30", + keepAliveTimeout: "10", + httpCache: "", + http2Only: true, + allowedHeaders: "authorization,x-request-id", + batchMaxSize: "", + batchDelay: "", + batchHeaders: "", + proxyUrl: "", + }, + telemetry: { + exporter: "stdout", + requestHeaders: "x-request-id", + stdoutPretty: true, + prometheusPath: "/metrics", + otlpUrl: "", + }, + links: [ + { + id: "proto", + type: "Protobuf", + src: "./proto/service.proto", + protoPaths: "./proto", + headers: [{key: "authorization", value: "{{env.AUTH_TOKEN}}"}], + }, + ], + }, +} + +const clonePreset = (preset: Preset): ConfigState => JSON.parse(JSON.stringify(presets[preset])) as ConfigState + +const labelFromKey = (key: string): string => + key + .replace(/([A-Z])/g, " $1") + .replace(/^./, (value) => value.toUpperCase()) + .replace("Graph Q L", "GraphQL") + +const parseInteger = (value: string): number | undefined => { + const trimmed = value.trim() + if (trimmed === "") return undefined + const parsed = Number.parseInt(trimmed, 10) + return Number.isFinite(parsed) ? parsed : undefined +} + +const parseList = (value: string): string[] => + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + +const compactObject = (input: Record): Record => { + return Object.entries(input).reduce>((result, [key, value]) => { + if (value === undefined) return result + if (Array.isArray(value) && value.length === 0) return result + if (typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0) { + return result + } + result[key] = value + return result + }, {}) +} + +const buildRuntimeConfig = (state: ConfigState): Record => { + const routes = compactObject({ + graphQL: state.server.graphQLRoute.trim() || undefined, + status: state.server.statusRoute.trim() || undefined, + }) + + const batch = compactObject({ + maxSize: parseInteger(state.upstream.batchMaxSize), + delay: parseInteger(state.upstream.batchDelay), + headers: parseList(state.upstream.batchHeaders), + }) + + const upstream = compactObject({ + allowedHeaders: parseList(state.upstream.allowedHeaders), + batch, + connectTimeout: parseInteger(state.upstream.connectTimeout), + http2Only: state.upstream.http2Only || undefined, + httpCache: parseInteger(state.upstream.httpCache), + keepAliveInterval: parseInteger(state.upstream.keepAliveInterval), + keepAliveTimeout: parseInteger(state.upstream.keepAliveTimeout), + poolIdleTimeout: parseInteger(state.upstream.poolIdleTimeout), + proxy: state.upstream.proxyUrl.trim() ? {url: state.upstream.proxyUrl.trim()} : undefined, + }) + + let telemetryExport: JsonValue | undefined + + if (state.telemetry.exporter === "stdout") { + telemetryExport = {stdout: {pretty: state.telemetry.stdoutPretty}} + } else if (state.telemetry.exporter === "prometheus") { + telemetryExport = {prometheus: {path: state.telemetry.prometheusPath.trim() || "/metrics"}} + } else if (state.telemetry.exporter === "otlp" && state.telemetry.otlpUrl.trim()) { + telemetryExport = {otlp: {url: state.telemetry.otlpUrl.trim()}} + } + + const telemetry = compactObject({ + export: telemetryExport, + requestHeaders: parseList(state.telemetry.requestHeaders), + }) + + const links = state.links + .map((link) => + compactObject({ + id: link.id.trim() || undefined, + type: link.type, + src: link.src.trim(), + proto_paths: parseList(link.protoPaths), + headers: link.headers + .filter((header) => header.key.trim() && header.value.trim()) + .map((header) => ({key: header.key.trim(), value: header.value.trim()})), + }), + ) + .filter((link) => typeof link.src === "string" && link.src.length > 0) as JsonValue[] + + return compactObject({ + server: compactObject({ + apolloTracing: false, + batchRequests: state.server.batchRequests || undefined, + enableFederation: state.server.enableFederation || undefined, + globalResponseTimeout: parseInteger(state.server.globalResponseTimeout), + hostname: state.server.hostname.trim() || undefined, + introspection: state.server.introspection, + port: parseInteger(state.server.port), + queryValidation: state.server.queryValidation || undefined, + responseValidation: state.server.responseValidation || undefined, + routes, + version: state.server.version, + workers: parseInteger(state.server.workers), + }), + upstream, + telemetry, + links, + }) +} + +const yamlScalar = (value: string | number | boolean | null): string => { + if (typeof value === "string") { + if (/^[A-Za-z0-9_./:@{}-]+$/.test(value) && !["true", "false", "null"].includes(value)) return value + return JSON.stringify(value) + } + + if (value === null) return "null" + return String(value) +} + +const toYaml = (value: JsonValue, indent = 0): string => { + const indentation = " ".repeat(indent) + + if (Array.isArray(value)) { + if (value.length === 0) return "[]" + return value + .map((item) => { + if (typeof item === "object" && item !== null) { + const nested = toYaml(item, indent + 2) + return `${indentation}-\n${nested}` + } + + return `${indentation}- ${yamlScalar(item as string | number | boolean | null)}` + }) + .join("\n") + } + + if (typeof value === "object" && value !== null) { + const entries = Object.entries(value) + if (entries.length === 0) return "{}" + + return entries + .map(([key, item]) => { + if (typeof item === "object" && item !== null) { + return `${indentation}${key}:\n${toYaml(item, indent + 2)}` + } + + return `${indentation}${key}: ${yamlScalar(item as string | number | boolean | null)}` + }) + .join("\n") + } + + return `${indentation}${yamlScalar(value)}` +} + +const formatGraphQLValue = (value: JsonValue, enumKeys = new Set(["version", "type"])): string => { + if (Array.isArray(value)) return `[${value.map((item) => formatGraphQLValue(item, enumKeys)).join(", ")}]` + + if (typeof value === "object" && value !== null) { + const fields = Object.entries(value) + .map(([key, item]) => `${key}: ${formatGraphQLValue(item, enumKeys)}`) + .join(", ") + + return `{${fields}}` + } + + if (typeof value === "string") return enumKeys.has(value) ? value : JSON.stringify(value) + + return String(value) +} + +const toDirectiveArgs = (value: JsonValue): string => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return "" + + return Object.entries(value) + .map(([key, item]) => `${key}: ${formatGraphQLValue(item, new Set(["HTTP1", "HTTP2", ...defaultLinkTypes]))}`) + .join(", ") +} + +const toGraphQLConfig = (config: Record): string => { + const directives: string[] = [] + + if (config.server) directives.push(`@server(${toDirectiveArgs(config.server)})`) + if (config.upstream) directives.push(`@upstream(${toDirectiveArgs(config.upstream)})`) + if (config.telemetry) directives.push(`@telemetry(${toDirectiveArgs(config.telemetry)})`) + + const links = Array.isArray(config.links) ? config.links : [] + links.forEach((link) => { + directives.push(`@link(${toDirectiveArgs(link)})`) + }) + + return [ + `schema ${directives.join("\n ")}`, + "{", + " query: Query", + "}", + "", + "type Query {", + " _empty: String", + "}", + ].join("\n") +} + +const fieldDescriptionsFromSchema = (schema: RuntimeSchema): FieldDescriptions => ({ + server: Object.entries(schema.definitions?.Server?.properties ?? {}).reduce>( + (result, [key, value]) => { + result[key] = value.description ?? "" + return result + }, + {}, + ), + upstream: Object.entries(schema.definitions?.Upstream?.properties ?? {}).reduce>( + (result, [key, value]) => { + result[key] = value.description ?? "" + return result + }, + {}, + ), + telemetry: Object.entries(schema.definitions?.Telemetry?.properties ?? {}).reduce>( + (result, [key, value]) => { + result[key] = value.description ?? "" + return result + }, + {}, + ), +}) + +const resolveLinkTypes = (schema: RuntimeSchema): LinkType[] => { + const values = schema.definitions?.LinkType?.oneOf + ?.flatMap((option) => option.enum ?? []) + .filter((value): value is LinkType => defaultLinkTypes.includes(value as LinkType)) + + return values && values.length > 0 ? values : defaultLinkTypes +} + +const downloadFile = (filename: string, content: string): void => { + const blob = new Blob([content], {type: "text/plain;charset=utf-8"}) + const href = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = href + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(href) +} + +const inputClasses = + "h-10 w-full rounded-md border border-solid border-tailCall-border-light-500 bg-tailCall-light-100 px-SPACE_03 text-content-tiny text-tailCall-dark-500 outline-none focus:border-tailCall-dark-200 dark:border-tailCall-border-dark-200 dark:bg-tailCall-dark-400 dark:text-tailCall-light-100" + +const selectClasses = `${inputClasses} cursor-pointer` +const toolButtonClasses = + "inline-flex h-10 items-center justify-center gap-SPACE_02 rounded-md border border-solid border-tailCall-border-light-500 bg-tailCall-light-100 px-SPACE_03 text-content-tiny font-bold text-tailCall-dark-500 transition-colors hover:bg-tailCall-light-200 dark:border-tailCall-border-dark-200 dark:bg-tailCall-dark-400 dark:text-tailCall-light-100 dark:hover:bg-tailCall-dark-300" + +const sectionClasses = + "border border-solid border-tailCall-border-light-500 bg-tailCall-light-100 p-SPACE_05 dark:border-tailCall-border-dark-200 dark:bg-tailCall-dark-500" + +const ConfigGenerator = (): JSX.Element => { + const [state, setState] = useState(() => clonePreset("starter")) + const [preset, setPreset] = useState("starter") + const [format, setFormat] = useState("json") + const [schemaStatus, setSchemaStatus] = useState("loading") + const [fieldDescriptions, setFieldDescriptions] = useState({ + server: {}, + upstream: {}, + telemetry: {}, + }) + const [linkTypes, setLinkTypes] = useState(defaultLinkTypes) + const [copied, setCopied] = useState(false) + + const config = useMemo(() => buildRuntimeConfig(state), [state]) + const output = useMemo(() => { + if (format === "json") return `${JSON.stringify(config, null, 2)}\n` + if (format === "yaml") return `${toYaml(config)}\n` + return `${toGraphQLConfig(config)}\n` + }, [config, format]) + + useEffect(() => { + let active = true + + fetch(SCHEMA_URL) + .then((response) => { + if (!response.ok) throw new Error("Schema request failed") + return response.json() as Promise + }) + .then((schema) => { + if (!active) return + setFieldDescriptions(fieldDescriptionsFromSchema(schema)) + setLinkTypes(resolveLinkTypes(schema)) + setSchemaStatus("synced") + }) + .catch(() => { + if (!active) return + setSchemaStatus("fallback") + }) + + return () => { + active = false + } + }, []) + + const updateServer = (key: Key, value: ConfigState["server"][Key]): void => { + setState((current) => ({...current, server: {...current.server, [key]: value}})) + } + + const updateUpstream = ( + key: Key, + value: ConfigState["upstream"][Key], + ): void => { + setState((current) => ({...current, upstream: {...current.upstream, [key]: value}})) + } + + const updateTelemetry = ( + key: Key, + value: ConfigState["telemetry"][Key], + ): void => { + setState((current) => ({...current, telemetry: {...current.telemetry, [key]: value}})) + } + + const updateLink = (index: number, key: Key, value: LinkConfig[Key]): void => { + setState((current) => ({ + ...current, + links: current.links.map((link, linkIndex) => (linkIndex === index ? {...link, [key]: value} : link)), + })) + } + + const updateHeader = (linkIndex: number, headerIndex: number, key: keyof KeyValue, value: string): void => { + setState((current) => ({ + ...current, + links: current.links.map((link, currentLinkIndex) => { + if (currentLinkIndex !== linkIndex) return link + + return { + ...link, + headers: link.headers.map((header, currentHeaderIndex) => + currentHeaderIndex === headerIndex ? {...header, [key]: value} : header, + ), + } + }), + })) + } + + const applyPreset = (value: Preset): void => { + setPreset(value) + setState(clonePreset(value)) + } + + const addLink = (): void => { + setState((current) => ({ + ...current, + links: [...current.links, {id: "", type: "Config", src: "", protoPaths: "", headers: []}], + })) + } + + const removeLink = (index: number): void => { + setState((current) => ({...current, links: current.links.filter((_, linkIndex) => linkIndex !== index)})) + } + + const addHeader = (index: number): void => { + setState((current) => ({ + ...current, + links: current.links.map((link, linkIndex) => + linkIndex === index ? {...link, headers: [...link.headers, {key: "", value: ""}]} : link, + ), + })) + } + + const removeHeader = (linkIndex: number, headerIndex: number): void => { + setState((current) => ({ + ...current, + links: current.links.map((link, currentLinkIndex) => + currentLinkIndex === linkIndex + ? {...link, headers: link.headers.filter((_, currentHeaderIndex) => currentHeaderIndex !== headerIndex)} + : link, + ), + })) + } + + const copyOutput = async (): Promise => { + await navigator.clipboard.writeText(output) + setCopied(true) + window.setTimeout(() => setCopied(false), 1400) + } + + const filename = `tailcall.config.${format === "graphql" ? "graphql" : format === "yaml" ? "yml" : "json"}` + + return ( +
+
+
+
+

+ Tailcall Config +

+

+ Configuration generator +

+

+ Build runtime settings from the current Tailcall schema and export them as JSON, YAML, or GraphQL SDL. +

+
+ +
+ + + + +
+
+ +
+
+
+
+
+

Preset

+

+ Start with a common runtime profile, then tune each field. +

+
+
+ {(["starter", "production", "grpc"] as Preset[]).map((value) => ( + + ))} +
+
+
+ +
+ +
+ updateServer("port", value)} + type="number" + description={fieldDescriptions.server.port} + /> + updateServer("hostname", value)} + description={fieldDescriptions.server.hostname} + /> + + updateServer("graphQLRoute", value)} + /> + updateServer("statusRoute", value)} + /> + updateServer("globalResponseTimeout", value)} + type="number" + description={fieldDescriptions.server.globalResponseTimeout} + /> + updateServer("workers", value)} + type="number" + description={fieldDescriptions.server.workers} + /> +
+
+ updateServer("introspection", checked)} + description={fieldDescriptions.server.introspection} + /> + updateServer("queryValidation", checked)} + description={fieldDescriptions.server.queryValidation} + /> + updateServer("responseValidation", checked)} + description={fieldDescriptions.server.responseValidation} + /> + updateServer("enableFederation", checked)} + description={fieldDescriptions.server.enableFederation} + /> + updateServer("batchRequests", checked)} + description={fieldDescriptions.server.batchRequests} + /> +
+
+ +
+ +
+ updateUpstream("allowedHeaders", value)} + description="Comma-separated header names" + /> + updateUpstream("connectTimeout", value)} + type="number" + description={fieldDescriptions.upstream.connectTimeout} + /> + updateUpstream("poolIdleTimeout", value)} + type="number" + description={fieldDescriptions.upstream.poolIdleTimeout} + /> + updateUpstream("keepAliveInterval", value)} + type="number" + description={fieldDescriptions.upstream.keepAliveInterval} + /> + updateUpstream("keepAliveTimeout", value)} + type="number" + description={fieldDescriptions.upstream.keepAliveTimeout} + /> + updateUpstream("httpCache", value)} + type="number" + description={fieldDescriptions.upstream.httpCache} + /> + updateUpstream("batchMaxSize", value)} + type="number" + /> + updateUpstream("batchDelay", value)} + type="number" + /> + updateUpstream("batchHeaders", value)} + description="Comma-separated header names" + /> + updateUpstream("proxyUrl", value)} + type="url" + description={fieldDescriptions.upstream.proxy} + /> + updateUpstream("http2Only", checked)} + description={fieldDescriptions.upstream.http2Only} + /> +
+
+ +
+ +
+ + updateTelemetry("requestHeaders", value)} + description="Comma-separated header names" + /> + {state.telemetry.exporter === "stdout" && ( + updateTelemetry("stdoutPretty", checked)} + /> + )} + {state.telemetry.exporter === "prometheus" && ( + updateTelemetry("prometheusPath", value)} + /> + )} + {state.telemetry.exporter === "otlp" && ( + updateTelemetry("otlpUrl", value)} + type="url" + /> + )} +
+
+ +
+
+ + +
+ +
+ {state.links.map((link, index) => ( +
+
+ updateLink(index, "id", value)} /> + + updateLink(index, "src", value)} + /> + +
+ + {(link.type === "Protobuf" || link.type === "Grpc") && ( +
+ updateLink(index, "protoPaths", value)} + description="Comma-separated paths" + /> +
+ )} + +
+

Headers

+ +
+ + {link.headers.length > 0 && ( +
+ {link.headers.map((header, headerIndex) => ( +
+ updateHeader(index, headerIndex, "key", value)} + /> + updateHeader(index, headerIndex, "value", value)} + /> + +
+ ))} +
+ )} +
+ ))} +
+
+
+ + +
+
+
+ ) +} + +type TextFieldProps = { + label: string + value: string + onChange: (value: string) => void + description?: string + type?: "text" | "number" | "url" +} + +const TextField = ({label, value, onChange, description, type = "text"}: TextFieldProps): JSX.Element => ( + +) + +type ToggleFieldProps = { + label: string + checked: boolean + onChange: (checked: boolean) => void + description?: string +} + +const ToggleField = ({label, checked, onChange, description}: ToggleFieldProps): JSX.Element => ( + +) + +type FormSectionTitleProps = { + title: string + description?: string +} + +const FormSectionTitle = ({title, description}: FormSectionTitleProps): JSX.Element => ( +
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+) + +export default ConfigGenerator diff --git a/src/components/playground/Playground.tsx b/src/components/playground/Playground.tsx index d077327b4..63801f6eb 100644 --- a/src/components/playground/Playground.tsx +++ b/src/components/playground/Playground.tsx @@ -7,6 +7,9 @@ import "../../css/graphiql.css" import {type FetcherParams, FetcherOpts} from "@graphiql/toolkit" import {useCookieConsent} from "@site/src/utils/hooks/useCookieConsent" import {createGraphiQLFetcher} from "@graphiql/create-fetcher" +import Link from "@docusaurus/Link" +import {FileCode2} from "lucide-react" +import {pageLinks} from "@site/src/constants/routes" const useDebouncedValue = (inputValue: string, delay: number) => { const [debouncedValue, setDebouncedValue] = useState(inputValue) @@ -54,7 +57,10 @@ const Playground = () => { sendConversionEvent(playgroundAdsConversionId) const fetcher = createGraphiQLFetcher({url: apiEndpoint.toString()}) - return fetcher(graphQLParams, opts) + return fetcher( + graphQLParams as unknown as Parameters[0], + opts as unknown as Parameters[1], + ) } const emptyGraphiqlStorageObject = { @@ -82,6 +88,21 @@ const Playground = () => {
{typeof window !== "undefined" && (
+
+
+

Playground

+

+ Test a GraphQL endpoint or generate a Tailcall runtime config before deployment. +

+
+ +
{ + const location = useLocation() + + useEffect(() => { + ReactGA.send({hitType: "pageview", page: location.pathname, title: "Config Generator Page"}) + }, []) + + return ( + + + + ) +} + +export default ConfigGeneratorPage