diff --git a/.github/workflows/precompile.yml b/.github/workflows/precompile.yml index 4a507b597..6364a7276 100644 --- a/.github/workflows/precompile.yml +++ b/.github/workflows/precompile.yml @@ -6,7 +6,62 @@ on: workflow_dispatch: jobs: - release: + release_dashboard: + name: Build Convex Dashboard + runs-on: [self-hosted, aws, x64, xlarge] + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Install just + uses: extractions/setup-just@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: NPM install globals + run: npm ci --prefix scripts + + - name: Rush install + run: | + just rush install + + - name: Build dashboard dependencies + run: | + just rush build -T dashboard-self-hosted + + - name: Build dashboard + run: | + cd npm-packages/dashboard-self-hosted && npm run build:export + + - name: Zip output + run: | + cd npm-packages/dashboard-self-hosted/out && zip -r ../../../dashboard.zip . + + - name: Precompute release name + id: release_name + shell: bash + run: | + echo "RELEASE_NAME=$(date +'%Y-%m-%d')-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Create Upload Precompiled Artifacts + id: create_release + uses: softprops/action-gh-release@v2 + with: + files: | + dashboard.zip + tag_name: precompiled-${{ steps.release_name.outputs.RELEASE_NAME }} + name: Precompiled ${{ steps.release_name.outputs.RELEASE_NAME }} + draft: false + prerelease: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + release_backend: strategy: fail-fast: false matrix: diff --git a/npm-packages/dashboard-self-hosted/next.config.js b/npm-packages/dashboard-self-hosted/next.config.js index 46c953b28..d7f6767ee 100644 --- a/npm-packages/dashboard-self-hosted/next.config.js +++ b/npm-packages/dashboard-self-hosted/next.config.js @@ -31,11 +31,13 @@ const securityHeaders = [ }, ]; -/** @type {import('next').NextConfig} */ -const nextConfig = { - swcMinify: true, - transpilePackages: [], - reactStrictMode: true, +const optionsForExport = { + output: "export", + images: { + unoptimized: true, + }, +}; +const optionsForBuild = { output: "standalone", async headers() { return [ @@ -46,6 +48,14 @@ const nextConfig = { }, ]; }, +}; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + swcMinify: true, + transpilePackages: [], + reactStrictMode: true, + ...(process.env.BUILD_TYPE === "export" ? optionsForExport : optionsForBuild), experimental: { webpackBuildWorker: true, }, @@ -103,4 +113,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; +module.exports = nextConfig; \ No newline at end of file diff --git a/npm-packages/dashboard-self-hosted/package.json b/npm-packages/dashboard-self-hosted/package.json index acb23f95b..ae1077a83 100644 --- a/npm-packages/dashboard-self-hosted/package.json +++ b/npm-packages/dashboard-self-hosted/package.json @@ -6,6 +6,7 @@ "dev": "npm run build:generated && next dev --port 6790", "build": "npm run build:generated && next build", "build:generated": "python3 ../dashboard-common/scripts/build-convexServerTypes.py", + "build:export": "BUILD_TYPE=export NEXT_PUBLIC_DEFAULT_LIST_DEPLOYMENTS_API_PORT=6791 npm run build", "start": "next start -p 6791", "lint": "next lint --max-warnings 0 --dir src/ && tsc", "lint:fix": "next lint --fix --max-warnings 0 --dir src/" diff --git a/npm-packages/dashboard-self-hosted/src/components/DeploymentCredentialsForm.tsx b/npm-packages/dashboard-self-hosted/src/components/DeploymentCredentialsForm.tsx new file mode 100644 index 000000000..98e4ac351 --- /dev/null +++ b/npm-packages/dashboard-self-hosted/src/components/DeploymentCredentialsForm.tsx @@ -0,0 +1,66 @@ +import { EnterIcon, EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons"; +import { Button } from "dashboard-common/elements/Button"; +import { TextInput } from "dashboard-common/elements/TextInput"; +import { useState } from "react"; + +export function DeploymentCredentialsForm({ + onSubmit, + initialAdminKey, + initialDeploymentUrl, +}: { + onSubmit: (adminKey: string, deploymentUrl: string) => Promise; + initialAdminKey: string | null; + initialDeploymentUrl: string | null; +}) { + const [draftAdminKey, setDraftAdminKey] = useState( + initialAdminKey ?? "", + ); + const [draftDeploymentUrl, setDraftDeploymentUrl] = useState( + initialDeploymentUrl ?? "", + ); + const [showKey, setShowKey] = useState(false); + return ( +
{ + e.preventDefault(); + void onSubmit(draftAdminKey, draftDeploymentUrl); + }} + > + { + setDraftDeploymentUrl(e.target.value); + }} + /> + { + setShowKey(!showKey); + }} + description="The admin key is required every time you open the dashboard." + onChange={(e) => { + setDraftAdminKey(e.target.value); + }} + /> + + + ); +} \ No newline at end of file diff --git a/npm-packages/dashboard-self-hosted/src/components/DeploymentList.tsx b/npm-packages/dashboard-self-hosted/src/components/DeploymentList.tsx new file mode 100644 index 000000000..d4af1b8ff --- /dev/null +++ b/npm-packages/dashboard-self-hosted/src/components/DeploymentList.tsx @@ -0,0 +1,73 @@ +import { Button } from "dashboard-common/elements/Button"; +import { useEffect, useState } from "react"; +import { useLocalStorage } from "react-use"; + +export type Deployment = { + name: string; + adminKey: string; + url: string; +}; + +export function DeploymentList({ + listDeploymentsApiUrl, + onError, + onSelect, +}: { + listDeploymentsApiUrl: string; + onError: (error: string) => void; + onSelect: (adminKey: string, deploymentUrl: string) => Promise; +}) { + const [lastStoredDeployment, setLastStoredDeployment] = useLocalStorage( + "lastDeployment", + "", + ); + const [deployments, setDeployments] = useState([]); + useEffect(() => { + const f = async () => { + let resp: Response; + try { + resp = await fetch(listDeploymentsApiUrl); + } catch (e) { + onError(`Failed to fetch deployments: ${e}`); + return; + } + if (!resp.ok) { + const text = await resp.text(); + onError(`Failed to fetch deployments: ${resp.statusText} ${text}`); + return; + } + let data: { deployments: Deployment[] }; + try { + data = await resp.json(); + } catch (e) { + onError(`Failed to parse deployments: ${e}`); + return; + } + setDeployments(data.deployments); + const lastDeployment = data.deployments.find( + (d: Deployment) => d.name === lastStoredDeployment, + ); + if (lastDeployment) { + void onSelect(lastDeployment.adminKey, lastDeployment.url); + } + }; + void f(); + }, [listDeploymentsApiUrl, onError, onSelect, lastStoredDeployment]); + return ( +
+

Select a deployment:

+ {deployments.map((d) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/npm-packages/dashboard-self-hosted/src/lib/checkDeploymentInfo.ts b/npm-packages/dashboard-self-hosted/src/lib/checkDeploymentInfo.ts new file mode 100644 index 000000000..3c84b175e --- /dev/null +++ b/npm-packages/dashboard-self-hosted/src/lib/checkDeploymentInfo.ts @@ -0,0 +1,38 @@ +async function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +const MAX_RETRIES = 3; +const MAX_RETRIES_DELAY_MS = 500; + +export async function checkDeploymentInfo( + adminKey: string, + deploymentUrl: string +): Promise { + let retries = 0; + while (retries < MAX_RETRIES) { + try { + const resp = await fetch(new URL("/api/check_admin_key", deploymentUrl), { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${adminKey}`, + "Convex-Client": "dashboard-0.0.0", + }, + }); + if (resp.ok) { + return true; + } + if (resp.status === 404) { + return null; + } + } catch (e) { + // Do nothing + } + await sleep(MAX_RETRIES_DELAY_MS); + retries++; + } + return false; +} diff --git a/npm-packages/dashboard-self-hosted/src/pages/_app.tsx b/npm-packages/dashboard-self-hosted/src/pages/_app.tsx index 6b74dd80d..0b2629375 100644 --- a/npm-packages/dashboard-self-hosted/src/pages/_app.tsx +++ b/npm-packages/dashboard-self-hosted/src/pages/_app.tsx @@ -5,23 +5,15 @@ import Head from "next/head"; import { useQuery } from "convex/react"; import udfs from "dashboard-common/udfs"; import { useSessionStorage } from "react-use"; -import { - EnterIcon, - ExitIcon, - EyeNoneIcon, - EyeOpenIcon, - GearIcon, -} from "@radix-ui/react-icons"; +import { ExitIcon, GearIcon } from "@radix-ui/react-icons"; import { ConvexLogo } from "dashboard-common/elements/ConvexLogo"; import { ToastContainer } from "dashboard-common/elements/ToastContainer"; import { ThemeConsumer } from "dashboard-common/elements/ThemeConsumer"; import { Favicon } from "dashboard-common/elements/Favicon"; import { ToggleTheme } from "dashboard-common/elements/ToggleTheme"; import { Menu, MenuItem } from "dashboard-common/elements/Menu"; -import { TextInput } from "dashboard-common/elements/TextInput"; -import { Button } from "dashboard-common/elements/Button"; import { ThemeProvider } from "next-themes"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ErrorBoundary } from "components/ErrorBoundary"; import { DeploymentDashboardLayout } from "dashboard-common/layouts/DeploymentDashboardLayout"; import { @@ -31,11 +23,20 @@ import { DeploymentInfoContext, } from "dashboard-common/lib/deploymentContext"; import { Tooltip } from "dashboard-common/elements/Tooltip"; +import { DeploymentCredentialsForm } from "components/DeploymentCredentialsForm"; +import { DeploymentList } from "components/DeploymentList"; +import { checkDeploymentInfo } from "lib/checkDeploymentInfo"; function App({ Component, - pageProps: { deploymentUrl, ...pageProps }, -}: AppProps & { pageProps: { deploymentUrl: string } }) { + pageProps: { deploymentUrl, adminKey, listDeploymentsApiUrl, ...pageProps }, +}: AppProps & { + pageProps: { + deploymentUrl: string | null; + adminKey: string | null; + listDeploymentsApiUrl: string | null; + }; +}) { return ( <> @@ -47,7 +48,11 @@ function App({
- + @@ -62,35 +67,61 @@ function App({ ); } +const LIST_DEPLOYMENTS_API_PORT_QUERY_PARAM = "a"; + +function normalizeUrl(url: string) { + try { + const parsedUrl = new URL(url); + // remove trailing slash + return parsedUrl.href.replace(/\/$/, ""); + } catch (e) { + return null; + } +} + App.getInitialProps = async ({ ctx }: { ctx: { req?: any } }) => { // On server-side, get from process.env if (ctx.req) { - // Tolerate a trailing slash on the url e.g. https://example.com/ should be valid and stripped to https://example.com - const deploymentUrl = process.env.NEXT_PUBLIC_DEPLOYMENT_URL?.replace( - /\/$/, - "", - ); - if (!deploymentUrl) { - throw new Error( - "NEXT_PUBLIC_DEPLOYMENT_URL environment variable is not set", - ); + // This is a relative URL, so add localhost as the origin so it can be parsed + const url = new URL(ctx.req.url, "http://127.0.0.1"); + + let deploymentUrl: string | null = null; + if (process.env.NEXT_PUBLIC_DEPLOYMENT_URL) { + deploymentUrl = normalizeUrl(process.env.NEXT_PUBLIC_DEPLOYMENT_URL); } + + const listDeploymentsApiPort = + url.searchParams.get(LIST_DEPLOYMENTS_API_PORT_QUERY_PARAM) ?? + process.env.NEXT_PUBLIC_DEFAULT_LIST_DEPLOYMENTS_API_PORT; + let listDeploymentsApiUrl: string | null = null; + if (listDeploymentsApiPort) { + const port = parseInt(listDeploymentsApiPort); + if (!Number.isNaN(port)) { + listDeploymentsApiUrl = normalizeUrl(`http://127.0.0.1:${port}`); + } + } + return { pageProps: { deploymentUrl, + adminKey: null, + listDeploymentsApiUrl, }, }; } // On client-side navigation, get from window.__NEXT_DATA__ - const deploymentUrl = window.__NEXT_DATA__?.props?.pageProps?.deploymentUrl; - if (!deploymentUrl) { - throw new Error("deploymentUrl not found in __NEXT_DATA__"); - } - + const clientSideDeploymentUrl = + window.__NEXT_DATA__?.props?.pageProps?.deploymentUrl ?? null; + const clientSideAdminKey = + window.__NEXT_DATA__?.props?.pageProps?.adminKey ?? null; + const clientSideListDeploymentsApiUrl = + window.__NEXT_DATA__?.props?.pageProps?.listDeploymentsApiUrl ?? null; return { pageProps: { - deploymentUrl, + deploymentUrl: clientSideDeploymentUrl ?? null, + adminKey: clientSideAdminKey ?? null, + listDeploymentsApiUrl: clientSideListDeploymentsApiUrl ?? null, }, }; }; @@ -105,10 +136,10 @@ const deploymentInfo: Omit = { reportHttpError: ( method: string, url: string, - error: { code: string; message: string }, + error: { code: string; message: string } ) => { console.error( - `failed to request ${method} ${url}: ${error.code} - ${error.message} `, + `failed to request ${method} ${url}: ${error.code} - ${error.message} ` ); }, useCurrentTeam: () => ({ @@ -167,72 +198,102 @@ const deploymentInfo: Omit = { function DeploymentInfoProvider({ children, deploymentUrl, + adminKey, + listDeploymentsApiUrl, }: { children: React.ReactNode; - deploymentUrl: string; + deploymentUrl: string | null; + adminKey: string | null; + listDeploymentsApiUrl: string | null; }) { - const [adminKey, setAdminKey] = useSessionStorage("adminKey", ""); - const [draftAdminKey, setDraftAdminKey] = useState(""); - - const [showKey, setShowKey] = useState(false); + const [shouldListDeployments, setShouldListDeployments] = useState( + listDeploymentsApiUrl !== null + ); + const [isValidDeploymentInfo, setIsValidDeploymentInfo] = useState< + boolean | null + >(null); + const [storedAdminKey, setStoredAdminKey] = useSessionStorage("adminKey", ""); + const [storedDeploymentUrl, setStoredDeploymentUrl] = useSessionStorage( + "deploymentUrl", + "" + ); + const onSubmit = useCallback( + async (submittedAdminKey: string, submittedDeploymentUrl: string) => { + const isValid = await checkDeploymentInfo( + submittedAdminKey, + submittedDeploymentUrl + ); + if (isValid === false) { + setIsValidDeploymentInfo(false); + return; + } + // For deployments that don't have the `/check_admin_key` endpoint, + // we set isValidDeploymentInfo to true so we can move on. The dashboard + // will just hit a less graceful error later if the credentials are invalid. + setIsValidDeploymentInfo(true); + setStoredAdminKey(submittedAdminKey); + setStoredDeploymentUrl(submittedDeploymentUrl); + }, + [setStoredAdminKey, setStoredDeploymentUrl] + ); const finalValue: DeploymentInfo = useMemo( () => ({ ...deploymentInfo, ok: true, - adminKey, - deploymentUrl, + adminKey: storedAdminKey, + deploymentUrl: storedDeploymentUrl, }) as DeploymentInfo, - [adminKey, deploymentUrl], + [storedAdminKey, storedDeploymentUrl] ); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); + useEffect(() => { + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.delete(LIST_DEPLOYMENTS_API_PORT_QUERY_PARAM); + window.history.replaceState({}, "", url.toString()); + } + }, []); if (!mounted) return null; - if (!adminKey) { + if (!isValidDeploymentInfo) { return (
-
{ - setDraftAdminKey(""); - setAdminKey(draftAdminKey); - }} - > - { - setShowKey(!showKey); - }} - description="The admin key is required every time you open the dashboard." - onChange={(e) => { - setDraftAdminKey(e.target.value); + {shouldListDeployments && listDeploymentsApiUrl !== null ? ( + { + setShouldListDeployments(false); }} + onSelect={onSubmit} /> - - + ) : ( + + )} + {isValidDeploymentInfo === false && ( +
+ The deployment URL or admin key is invalid. Please check that you + have entered the correct values. +
+ )}
); } return ( <> -
setAdminKey("")} /> +
{ + setStoredAdminKey(""); + setStoredDeploymentUrl(""); + }} + /> {children}