Add connection errors to /admin/configuration
This commit is contained in:
parent
a80a8713c4
commit
5e39e42c97
@ -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:
|
||||
|
||||
40
src/admin/actions.ts
Normal file
40
src/admin/actions.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@ -27,7 +27,7 @@ export default function ChecklistRow({
|
||||
type={status ? 'checked' : optional ? 'optional' : 'missing'}
|
||||
loading={isPending}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex flex-col min-w-0 flex-grow">
|
||||
<div className={clsx(
|
||||
'flex flex-wrap items-center gap-2 pb-0.5',
|
||||
'font-bold dark:text-gray-300',
|
||||
|
||||
@ -5,14 +5,18 @@ import { BiErrorAlt } from 'react-icons/bi';
|
||||
export default function ErrorNote({
|
||||
className,
|
||||
children,
|
||||
size = 'medium',
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
size?: 'small' | 'medium'
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex w-full items-center gap-3',
|
||||
'px-3 py-2 border',
|
||||
'flex w-full items-center gap-3 border',
|
||||
size === 'medium'
|
||||
? 'px-3 py-2'
|
||||
: 'px-1.5 py-1',
|
||||
'text-red-600 dark:text-red-500/90',
|
||||
'bg-red-50/50 dark:bg-red-950/50',
|
||||
'border-red-100 dark:border-red-950',
|
||||
|
||||
@ -88,7 +88,7 @@ export const generateOpenAiImageQuery = async (
|
||||
|
||||
if (openai) {
|
||||
return generateText({
|
||||
model: openai('gpt-4-vision-preview'),
|
||||
model: openai('gpt-4o'),
|
||||
messages: [{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
@ -104,3 +104,33 @@ export const generateOpenAiImageQuery = async (
|
||||
}).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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -10,7 +10,7 @@ export type Primitive = string | number | boolean | undefined | null;
|
||||
|
||||
export const query = async <T extends QueryResultRow = any>(
|
||||
queryString: string,
|
||||
values: Primitive[],
|
||||
values: Primitive[] = [],
|
||||
) => {
|
||||
const client = await pool.connect();
|
||||
let response: QueryResult<T>;
|
||||
@ -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');
|
||||
|
||||
@ -225,3 +225,6 @@ export const getStorageUploadUrls = () =>
|
||||
|
||||
export const getStoragePhotoUrls = () =>
|
||||
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
|
||||
|
||||
export const testStorageConnection = () =>
|
||||
getStorageUrlsForPrefix();
|
||||
|
||||
@ -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 (
|
||||
<SiteChecklistClient {...{
|
||||
...CONFIG_CHECKLIST_STATUS,
|
||||
...connectionErrors,
|
||||
simplifiedView,
|
||||
secret,
|
||||
}} />
|
||||
|
||||
@ -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<Awaited<ReturnType<typeof testConnectionsAction>>> & {
|
||||
simplifiedView?: boolean
|
||||
showRefreshButton?: boolean
|
||||
secret: string
|
||||
@ -124,7 +137,7 @@ export default function SiteChecklistClient({
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={clsx(
|
||||
'text-[11px] font-medium tracking-wide',
|
||||
'text-[11px] font-medium tracking-wider',
|
||||
'px-0.5 py-[0.5px]',
|
||||
'rounded-[5px]',
|
||||
'bg-gray-100 dark:bg-gray-800',
|
||||
@ -145,7 +158,7 @@ export default function SiteChecklistClient({
|
||||
label: ReactNode,
|
||||
iconClassName?: string,
|
||||
) =>
|
||||
<div className="flex gap-1 -translate-x-1">
|
||||
<div className="flex gap-2 translate-x-[-3px]">
|
||||
<span className={iconClassName}>
|
||||
<StatusIcon {...{ type }} />
|
||||
</span>
|
||||
@ -154,6 +167,11 @@ export default function SiteChecklistClient({
|
||||
</span>
|
||||
</div>;
|
||||
|
||||
const renderConnectionError = (provider: string, error: string) =>
|
||||
<ErrorNote size="small" className="mt-2 mb-3">
|
||||
{provider} connection error: {`"${error}"`}
|
||||
</ErrorNote>;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6 w-full">
|
||||
<Checklist
|
||||
@ -165,6 +183,8 @@ export default function SiteChecklistClient({
|
||||
status={hasDatabase}
|
||||
isPending={isPendingPage}
|
||||
>
|
||||
{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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user