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`
|
- 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
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'}
|
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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -225,3 +225,6 @@ export const getStorageUploadUrls = () =>
|
|||||||
|
|
||||||
export const getStoragePhotoUrls = () =>
|
export const getStoragePhotoUrls = () =>
|
||||||
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
|
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
|
||||||
|
|
||||||
|
export const testStorageConnection = () =>
|
||||||
|
getStorageUrlsForPrefix();
|
||||||
|
|||||||
@ -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,
|
||||||
}} />
|
}} />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user