From 5e39e42c9761922aa92c4abcda0162296803e4b3 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 11 Jun 2024 17:17:27 -0500 Subject: [PATCH] Add connection errors to /admin/configuration --- README.md | 2 +- src/admin/actions.ts | 40 ++++++++++++++++++++++++++++++++ src/components/ChecklistRow.tsx | 2 +- src/components/ErrorNote.tsx | 8 +++++-- src/services/openai.ts | 32 ++++++++++++++++++++++++- src/services/postgres.ts | 5 +++- src/services/storage/index.ts | 3 +++ src/site/SiteChecklist.tsx | 3 +++ src/site/SiteChecklistClient.tsx | 32 +++++++++++++++++++++---- 9 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 src/admin/actions.ts diff --git a/README.md b/README.md index 77d11edf..a576928f 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ _⚠️ READ BEFORE PROCEEDING_ - Generate an API key and store in environment variable `OPENAI_SECRET_KEY` - Setup usage limits to avoid unexpected charges (_recommended_) 2. Add rate limiting (_recommended_) - - As an additional precaution, create a [Vercel KV](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) store and link it to your project in order to enable rate limiting + - As an additional precaution, create a [Vercel KV](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) store and link it to your project in order to enable rate limiting—no further configuration necessary 3. Configure auto-generated fields (optional) - Set which text fields auto-generate when uploading a photo by storing a comma-separated list, e.g., `AI_TEXT_AUTO_GENERATED_FIELDS = title, semantic` - Accepted values: diff --git a/src/admin/actions.ts b/src/admin/actions.ts new file mode 100644 index 00000000..dbd6f86c --- /dev/null +++ b/src/admin/actions.ts @@ -0,0 +1,40 @@ +'use server'; + +import { runAuthenticatedAdminServerAction } from '@/auth'; +import { testOpenAiConnection } from '@/services/openai'; +import { testDatabaseConnection } from '@/services/postgres'; +import { testStorageConnection } from '@/services/storage'; +import { CONFIG_CHECKLIST_STATUS } from '@/site/config'; + +const scanForError = ( + shouldCheck: boolean, + promise: () => Promise +): Promise => + shouldCheck + ? promise().then(() => '').catch(error => error.message) + : Promise.resolve(''); + +export const testConnectionsAction = async () => + runAuthenticatedAdminServerAction(async () => { + const { + hasDatabase, + hasStorageProvider, + isAiTextGenerationEnabled, + } = CONFIG_CHECKLIST_STATUS; + + const [ + databaseError, + storageError, + aiError, + ] = await Promise.all([ + scanForError(hasDatabase, testDatabaseConnection), + scanForError(hasStorageProvider, testStorageConnection), + scanForError(isAiTextGenerationEnabled, testOpenAiConnection), + ]); + + return { + databaseError, + storageError, + aiError, + }; + }); diff --git a/src/components/ChecklistRow.tsx b/src/components/ChecklistRow.tsx index 4b4c68e4..9680d3cf 100644 --- a/src/components/ChecklistRow.tsx +++ b/src/components/ChecklistRow.tsx @@ -27,7 +27,7 @@ export default function ChecklistRow({ type={status ? 'checked' : optional ? 'optional' : 'missing'} loading={isPending} /> -
+
text); } }; + +export const testOpenAiConnection = async () => { + if (ratelimit) { + let success = false; + try { + success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success; + } catch (e: any) { + console.error('Failed to rate limit OpenAI', e); + throw new Error('Failed to rate limit OpenAI'); + } + if (!success) { + console.error('OpenAI rate limit exceeded'); + throw new Error('OpenAI rate limit exceeded'); + } + } + if (openai) { + return generateText({ + model: openai('gpt-4o'), + messages: [{ + 'role': 'user', + 'content': [ + { + 'type': 'text', + 'text': 'Test connection', + }, + ], + }], + }).then(({ text }) => text); + } +}; diff --git a/src/services/postgres.ts b/src/services/postgres.ts index d1169d94..e2a239de 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -10,7 +10,7 @@ export type Primitive = string | number | boolean | undefined | null; export const query = async ( queryString: string, - values: Primitive[], + values: Primitive[] = [], ) => { const client = await pool.connect(); let response: QueryResult; @@ -52,3 +52,6 @@ const isTemplateStringsArray = ( Array.isArray(strings) && 'raw' in strings && Array.isArray(strings.raw) ); }; + +export const testDatabaseConnection = async () => + query('SELECt COUNT(*) FROM pg_stat_user_tables'); diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 5303f603..7fb3123f 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -225,3 +225,6 @@ export const getStorageUploadUrls = () => export const getStoragePhotoUrls = () => getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`); + +export const testStorageConnection = () => + getStorageUrlsForPrefix(); diff --git a/src/site/SiteChecklist.tsx b/src/site/SiteChecklist.tsx index 5ff9344b..891a74b5 100644 --- a/src/site/SiteChecklist.tsx +++ b/src/site/SiteChecklist.tsx @@ -1,6 +1,7 @@ import { generateAuthSecret } from '@/auth'; import SiteChecklistClient from './SiteChecklistClient'; import { CONFIG_CHECKLIST_STATUS } from '@/site/config'; +import { testConnectionsAction } from '@/admin/actions'; export default async function SiteChecklist({ simplifiedView, @@ -8,9 +9,11 @@ export default async function SiteChecklist({ simplifiedView?: boolean }) { const secret = await generateAuthSecret(); + const connectionErrors = await testConnectionsAction().catch(() => ({})); return ( diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 0d4e0a07..ae19e82d 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -1,6 +1,10 @@ 'use client'; -import { ComponentProps, ReactNode, useTransition } from 'react'; +import { + ComponentProps, + ReactNode, + useTransition, +} from 'react'; import { useRouter } from 'next/navigation'; import { clsx } from 'clsx/lite'; import ChecklistRow from '../components/ChecklistRow'; @@ -21,8 +25,11 @@ import StatusIcon from '@/components/StatusIcon'; import { labelForStorage } from '@/services/storage'; import { HiSparkles } from 'react-icons/hi'; import LoaderButton from '@/components/primitives/LoaderButton'; +import { testConnectionsAction } from '@/admin/actions'; +import ErrorNote from '@/components/ErrorNote'; export default function SiteChecklistClient({ + // Config checklist hasDatabase, isPostgresSSLEnabled, hasVercelPostgres, @@ -56,10 +63,16 @@ export default function SiteChecklistClient({ isOgTextBottomAligned, gridAspectRatio, hasGridAspectRatio, + // Connection status + databaseError, + storageError, + aiError, + // Component props simplifiedView, showRefreshButton, secret, -}: ConfigChecklistStatus & { +}: ConfigChecklistStatus & + Partial>> & { simplifiedView?: boolean showRefreshButton?: boolean secret: string @@ -124,7 +137,7 @@ export default function SiteChecklistClient({ > -
+
@@ -154,6 +167,11 @@ export default function SiteChecklistClient({
; + const renderConnectionError = (provider: string, error: string) => + + {provider} connection error: {`"${error}"`} + ; + return (
+ {databaseError && + renderConnectionError('Database', databaseError)} {hasVercelPostgres ? renderSubStatus('checked', 'Vercel Postgres: connected') : renderSubStatus('optional', <> @@ -195,6 +215,8 @@ export default function SiteChecklistClient({ status={hasStorageProvider} isPending={isPendingPage} > + {storageError && + renderConnectionError('Storage', storageError)} {hasVercelBlobStorage ? renderSubStatus('checked', 'Vercel Blob: connected') : renderSubStatus('optional', <> @@ -314,6 +336,8 @@ export default function SiteChecklistClient({ isPending={isPendingPage} optional > + {aiError && + renderConnectionError('OpenAI', aiError)} Store your OpenAI secret key in order to add experimental support for AI-generated text descriptions and enable an invisible field called {'"Semantic Description"'} used to support CMD-K search