Add connection errors to /admin/configuration

This commit is contained in:
Sam Becker 2024-06-11 17:17:27 -05:00
parent a80a8713c4
commit 5e39e42c97
9 changed files with 117 additions and 10 deletions

View File

@ -71,7 +71,7 @@ _⚠ READ BEFORE PROCEEDING_
- Generate an API key and store in environment variable `OPENAI_SECRET_KEY` - Generate an API key and store in environment variable `OPENAI_SECRET_KEY`
- Setup usage limits to avoid unexpected charges (_recommended_) - Setup usage limits to avoid unexpected charges (_recommended_)
2. Add rate limiting (_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) 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` - 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: - Accepted values:

40
src/admin/actions.ts Normal file
View File

@ -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<any>
): Promise<string> =>
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,
};
});

View File

@ -27,7 +27,7 @@ export default function ChecklistRow({
type={status ? 'checked' : optional ? 'optional' : 'missing'} type={status ? 'checked' : optional ? 'optional' : 'missing'}
loading={isPending} loading={isPending}
/> />
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0 flex-grow">
<div className={clsx( <div className={clsx(
'flex flex-wrap items-center gap-2 pb-0.5', 'flex flex-wrap items-center gap-2 pb-0.5',
'font-bold dark:text-gray-300', 'font-bold dark:text-gray-300',

View File

@ -5,14 +5,18 @@ import { BiErrorAlt } from 'react-icons/bi';
export default function ErrorNote({ export default function ErrorNote({
className, className,
children, children,
size = 'medium',
}: { }: {
className?: string className?: string
children: ReactNode children: ReactNode
size?: 'small' | 'medium'
}) { }) {
return ( return (
<div className={clsx( <div className={clsx(
'flex w-full items-center gap-3', 'flex w-full items-center gap-3 border',
'px-3 py-2 border', size === 'medium'
? 'px-3 py-2'
: 'px-1.5 py-1',
'text-red-600 dark:text-red-500/90', 'text-red-600 dark:text-red-500/90',
'bg-red-50/50 dark:bg-red-950/50', 'bg-red-50/50 dark:bg-red-950/50',
'border-red-100 dark:border-red-950', 'border-red-100 dark:border-red-950',

View File

@ -88,7 +88,7 @@ export const generateOpenAiImageQuery = async (
if (openai) { if (openai) {
return generateText({ return generateText({
model: openai('gpt-4-vision-preview'), model: openai('gpt-4o'),
messages: [{ messages: [{
'role': 'user', 'role': 'user',
'content': [ 'content': [
@ -104,3 +104,33 @@ export const generateOpenAiImageQuery = async (
}).then(({ text }) => text); }).then(({ text }) => 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);
}
};

View File

@ -10,7 +10,7 @@ export type Primitive = string | number | boolean | undefined | null;
export const query = async <T extends QueryResultRow = any>( export const query = async <T extends QueryResultRow = any>(
queryString: string, queryString: string,
values: Primitive[], values: Primitive[] = [],
) => { ) => {
const client = await pool.connect(); const client = await pool.connect();
let response: QueryResult<T>; let response: QueryResult<T>;
@ -52,3 +52,6 @@ const isTemplateStringsArray = (
Array.isArray(strings) && 'raw' in strings && Array.isArray(strings.raw) Array.isArray(strings) && 'raw' in strings && Array.isArray(strings.raw)
); );
}; };
export const testDatabaseConnection = async () =>
query('SELECt COUNT(*) FROM pg_stat_user_tables');

View File

@ -225,3 +225,6 @@ export const getStorageUploadUrls = () =>
export const getStoragePhotoUrls = () => export const getStoragePhotoUrls = () =>
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`); getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
export const testStorageConnection = () =>
getStorageUrlsForPrefix();

View File

@ -1,6 +1,7 @@
import { generateAuthSecret } from '@/auth'; import { generateAuthSecret } from '@/auth';
import SiteChecklistClient from './SiteChecklistClient'; import SiteChecklistClient from './SiteChecklistClient';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config'; import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
import { testConnectionsAction } from '@/admin/actions';
export default async function SiteChecklist({ export default async function SiteChecklist({
simplifiedView, simplifiedView,
@ -8,9 +9,11 @@ export default async function SiteChecklist({
simplifiedView?: boolean simplifiedView?: boolean
}) { }) {
const secret = await generateAuthSecret(); const secret = await generateAuthSecret();
const connectionErrors = await testConnectionsAction().catch(() => ({}));
return ( return (
<SiteChecklistClient {...{ <SiteChecklistClient {...{
...CONFIG_CHECKLIST_STATUS, ...CONFIG_CHECKLIST_STATUS,
...connectionErrors,
simplifiedView, simplifiedView,
secret, secret,
}} /> }} />

View File

@ -1,6 +1,10 @@
'use client'; 'use client';
import { ComponentProps, ReactNode, useTransition } from 'react'; import {
ComponentProps,
ReactNode,
useTransition,
} from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import ChecklistRow from '../components/ChecklistRow'; import ChecklistRow from '../components/ChecklistRow';
@ -21,8 +25,11 @@ import StatusIcon from '@/components/StatusIcon';
import { labelForStorage } from '@/services/storage'; import { labelForStorage } from '@/services/storage';
import { HiSparkles } from 'react-icons/hi'; import { HiSparkles } from 'react-icons/hi';
import LoaderButton from '@/components/primitives/LoaderButton'; import LoaderButton from '@/components/primitives/LoaderButton';
import { testConnectionsAction } from '@/admin/actions';
import ErrorNote from '@/components/ErrorNote';
export default function SiteChecklistClient({ export default function SiteChecklistClient({
// Config checklist
hasDatabase, hasDatabase,
isPostgresSSLEnabled, isPostgresSSLEnabled,
hasVercelPostgres, hasVercelPostgres,
@ -56,10 +63,16 @@ export default function SiteChecklistClient({
isOgTextBottomAligned, isOgTextBottomAligned,
gridAspectRatio, gridAspectRatio,
hasGridAspectRatio, hasGridAspectRatio,
// Connection status
databaseError,
storageError,
aiError,
// Component props
simplifiedView, simplifiedView,
showRefreshButton, showRefreshButton,
secret, secret,
}: ConfigChecklistStatus & { }: ConfigChecklistStatus &
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
simplifiedView?: boolean simplifiedView?: boolean
showRefreshButton?: boolean showRefreshButton?: boolean
secret: string secret: string
@ -124,7 +137,7 @@ export default function SiteChecklistClient({
> >
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<span className={clsx( <span className={clsx(
'text-[11px] font-medium tracking-wide', 'text-[11px] font-medium tracking-wider',
'px-0.5 py-[0.5px]', 'px-0.5 py-[0.5px]',
'rounded-[5px]', 'rounded-[5px]',
'bg-gray-100 dark:bg-gray-800', 'bg-gray-100 dark:bg-gray-800',
@ -145,7 +158,7 @@ export default function SiteChecklistClient({
label: ReactNode, label: ReactNode,
iconClassName?: string, iconClassName?: string,
) => ) =>
<div className="flex gap-1 -translate-x-1"> <div className="flex gap-2 translate-x-[-3px]">
<span className={iconClassName}> <span className={iconClassName}>
<StatusIcon {...{ type }} /> <StatusIcon {...{ type }} />
</span> </span>
@ -154,6 +167,11 @@ export default function SiteChecklistClient({
</span> </span>
</div>; </div>;
const renderConnectionError = (provider: string, error: string) =>
<ErrorNote size="small" className="mt-2 mb-3">
{provider} connection error: {`"${error}"`}
</ErrorNote>;
return ( return (
<div className="max-w-xl space-y-6 w-full"> <div className="max-w-xl space-y-6 w-full">
<Checklist <Checklist
@ -165,6 +183,8 @@ export default function SiteChecklistClient({
status={hasDatabase} status={hasDatabase}
isPending={isPendingPage} isPending={isPendingPage}
> >
{databaseError &&
renderConnectionError('Database', databaseError)}
{hasVercelPostgres {hasVercelPostgres
? renderSubStatus('checked', 'Vercel Postgres: connected') ? renderSubStatus('checked', 'Vercel Postgres: connected')
: renderSubStatus('optional', <> : renderSubStatus('optional', <>
@ -195,6 +215,8 @@ export default function SiteChecklistClient({
status={hasStorageProvider} status={hasStorageProvider}
isPending={isPendingPage} isPending={isPendingPage}
> >
{storageError &&
renderConnectionError('Storage', storageError)}
{hasVercelBlobStorage {hasVercelBlobStorage
? renderSubStatus('checked', 'Vercel Blob: connected') ? renderSubStatus('checked', 'Vercel Blob: connected')
: renderSubStatus('optional', <> : renderSubStatus('optional', <>
@ -314,6 +336,8 @@ export default function SiteChecklistClient({
isPending={isPendingPage} isPending={isPendingPage}
optional optional
> >
{aiError &&
renderConnectionError('OpenAI', aiError)}
Store your OpenAI secret key in order to add experimental support Store your OpenAI secret key in order to add experimental support
for AI-generated text descriptions and enable an invisible field for AI-generated text descriptions and enable an invisible field
called {'"Semantic Description"'} used to support CMD-K search called {'"Semantic Description"'} used to support CMD-K search