mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
698 lines
23 KiB
TypeScript
698 lines
23 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Loader2,
|
|
RotateCcw,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
|
|
import { AdminService } from '@/lib/api/services/admin';
|
|
import type {
|
|
AdminModel,
|
|
CertificationPath,
|
|
CertificationRow,
|
|
CertificationStatus,
|
|
ProviderCertification,
|
|
UpstreamProvider,
|
|
} from '@/lib/api/services/admin';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/ui/command';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface ProviderCertificationDialogProps {
|
|
provider: UpstreamProvider;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
interface ModelOption {
|
|
model: AdminModel;
|
|
source: 'configured' | 'discovered';
|
|
}
|
|
|
|
type ModelPathMode = 'default' | 'selected' | 'all';
|
|
|
|
interface ModelPathTarget {
|
|
path?: string;
|
|
label: string;
|
|
}
|
|
|
|
interface ModelRun {
|
|
modelId: string;
|
|
targets: ModelPathTarget[];
|
|
}
|
|
|
|
interface ModelCertificationResult {
|
|
resultKey: string;
|
|
modelId: string;
|
|
pathLabel: string;
|
|
report?: ProviderCertification;
|
|
error?: string;
|
|
}
|
|
|
|
const STATUS_STYLES: Record<
|
|
CertificationStatus,
|
|
{ label: string; icon: typeof CheckCircle2; className: string }
|
|
> = {
|
|
ok: {
|
|
label: 'OK',
|
|
icon: CheckCircle2,
|
|
className:
|
|
'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
|
|
},
|
|
warn: {
|
|
label: 'Warn',
|
|
icon: AlertTriangle,
|
|
className:
|
|
'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400',
|
|
},
|
|
fail: {
|
|
label: 'Fail',
|
|
icon: XCircle,
|
|
className: 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-400',
|
|
},
|
|
};
|
|
|
|
function StatusBadge({ status }: { status: CertificationStatus }) {
|
|
const style = STATUS_STYLES[status];
|
|
const Icon = style.icon;
|
|
return (
|
|
<Badge variant='outline' className={cn('gap-1', style.className)}>
|
|
<Icon className='h-3 w-3' />
|
|
{style.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function RowItem({ row }: { row: CertificationRow }) {
|
|
const [showEvidence, setShowEvidence] = useState(false);
|
|
const hasEvidence = Object.keys(row.evidence).length > 0;
|
|
return (
|
|
<li className='rounded-md border p-3 text-sm'>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div className='min-w-0'>
|
|
<div className='font-medium'>{row.title}</div>
|
|
<div className='text-muted-foreground mt-0.5 break-words'>
|
|
{row.detail}
|
|
</div>
|
|
<div className='text-muted-foreground mt-1 font-mono text-xs'>
|
|
{row.id}
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={row.status} />
|
|
</div>
|
|
{hasEvidence && (
|
|
<div className='mt-2'>
|
|
<button
|
|
type='button'
|
|
onClick={() => setShowEvidence((value) => !value)}
|
|
className='text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs'
|
|
>
|
|
{showEvidence ? (
|
|
<ChevronDown className='h-3 w-3' />
|
|
) : (
|
|
<ChevronRight className='h-3 w-3' />
|
|
)}
|
|
Evidence
|
|
</button>
|
|
{showEvidence && (
|
|
<pre className='bg-muted mt-2 max-h-64 overflow-auto rounded p-2 font-mono text-xs'>
|
|
{JSON.stringify(row.evidence, null, 2)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function ChecklistSummary({ report }: { report: ProviderCertification }) {
|
|
return (
|
|
<ul className='grid gap-2 sm:grid-cols-2'>
|
|
{report.checklist.map((goal) => {
|
|
const style = STATUS_STYLES[goal.status];
|
|
const Icon = style.icon;
|
|
return (
|
|
<li
|
|
key={goal.goal}
|
|
className={cn(
|
|
'flex items-start gap-2 rounded-md border p-2 text-sm',
|
|
style.className
|
|
)}
|
|
>
|
|
<Icon className='mt-0.5 h-4 w-4 shrink-0' />
|
|
<span>{goal.label}</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function CertificationReport({ report }: { report: ProviderCertification }) {
|
|
const failing = report.rows.filter((row) => row.status === 'fail').length;
|
|
const warning = report.rows.filter((row) => row.status === 'warn').length;
|
|
|
|
return (
|
|
<div className='space-y-4'>
|
|
<ChecklistSummary report={report} />
|
|
<div className='text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs'>
|
|
<span>
|
|
{report.rows.length} checks · {failing} failed · {warning} warnings
|
|
</span>
|
|
<span>Generated {new Date(report.generated_at).toLocaleString()}</span>
|
|
</div>
|
|
<ul className='space-y-2'>
|
|
{report.rows.map((row) => (
|
|
<RowItem key={row.id} row={row} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return 'Certification request failed';
|
|
}
|
|
|
|
function resultStatus(
|
|
result: ModelCertificationResult
|
|
): CertificationStatus | 'error' {
|
|
if (result.error || !result.report) return 'error';
|
|
if (result.report.rows.some((row) => row.status === 'fail')) return 'fail';
|
|
if (result.report.rows.some((row) => row.status === 'warn')) return 'warn';
|
|
return 'ok';
|
|
}
|
|
|
|
export function ProviderCertificationDialog({
|
|
provider,
|
|
open,
|
|
onOpenChange,
|
|
}: ProviderCertificationDialogProps) {
|
|
const [checkCache, setCheckCache] = useState(true);
|
|
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
|
|
const [pathModes, setPathModes] = useState<Record<string, ModelPathMode>>({});
|
|
const [selectedModelPaths, setSelectedModelPaths] = useState<
|
|
Record<string, string[]>
|
|
>({});
|
|
const [results, setResults] = useState<ModelCertificationResult[]>([]);
|
|
const [currentModel, setCurrentModel] = useState<{
|
|
id: string;
|
|
index: number;
|
|
total: number;
|
|
pathCount: number;
|
|
} | null>(null);
|
|
|
|
const models = useQuery({
|
|
queryKey: ['provider-models', provider.id],
|
|
queryFn: () => AdminService.getProviderModels(provider.id),
|
|
enabled: open,
|
|
});
|
|
|
|
const certify = useMutation({
|
|
mutationFn: async ({
|
|
modelRuns,
|
|
includeCache,
|
|
}: {
|
|
modelRuns: ModelRun[];
|
|
includeCache: boolean;
|
|
}) => {
|
|
const completed: ModelCertificationResult[] = [];
|
|
setResults([]);
|
|
|
|
for (const [index, run] of modelRuns.entries()) {
|
|
setCurrentModel({
|
|
id: run.modelId,
|
|
index: index + 1,
|
|
total: modelRuns.length,
|
|
pathCount: run.targets.length,
|
|
});
|
|
const batch = await Promise.all(
|
|
run.targets.map(async (target): Promise<ModelCertificationResult> => {
|
|
const resultKey = `${run.modelId}::${target.path ?? 'default'}`;
|
|
try {
|
|
const report = await AdminService.certifyProvider(provider.id, {
|
|
model_id: run.modelId,
|
|
model_path: target.path,
|
|
check_cache: includeCache,
|
|
});
|
|
return {
|
|
resultKey,
|
|
modelId: run.modelId,
|
|
pathLabel: target.label,
|
|
report,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
resultKey,
|
|
modelId: run.modelId,
|
|
pathLabel: target.label,
|
|
error: getErrorMessage(error),
|
|
};
|
|
}
|
|
})
|
|
);
|
|
completed.push(...batch);
|
|
setResults([...completed]);
|
|
}
|
|
|
|
return completed;
|
|
},
|
|
onSettled: () => setCurrentModel(null),
|
|
});
|
|
const { reset: resetCertification } = certify;
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
resetCertification();
|
|
setSelectedModelIds([]);
|
|
setPathModes({});
|
|
setSelectedModelPaths({});
|
|
setResults([]);
|
|
setCurrentModel(null);
|
|
}
|
|
}, [open, resetCertification]);
|
|
|
|
const configuredOptions: ModelOption[] =
|
|
models.data?.db_models.map((model) => ({
|
|
model,
|
|
source: 'configured',
|
|
})) ?? [];
|
|
const discoveredOptions: ModelOption[] =
|
|
models.data?.remote_models.map((model) => ({
|
|
model,
|
|
source: 'discovered',
|
|
})) ?? [];
|
|
const allOptions = [...configuredOptions, ...discoveredOptions];
|
|
const namesById = new Map(
|
|
allOptions.map(({ model }) => [model.id, model.name || model.id])
|
|
);
|
|
|
|
const pathsForModel = (modelId: string): CertificationPath[] =>
|
|
(models.data?.certification_paths[modelId] ?? []).filter(
|
|
(path) => path.endpoint_tag
|
|
);
|
|
|
|
const pathLabel = (path: CertificationPath): string =>
|
|
path.endpoint_name && path.endpoint_name !== path.endpoint_tag
|
|
? `${path.endpoint_name} (${path.endpoint_tag})`
|
|
: path.endpoint_tag || 'Provider default';
|
|
|
|
const toggleModel = (modelId: string) => {
|
|
const isSelected = selectedModelIds.includes(modelId);
|
|
setSelectedModelIds((current) =>
|
|
isSelected
|
|
? current.filter((id) => id !== modelId)
|
|
: [...current, modelId]
|
|
);
|
|
setPathModes((current) => {
|
|
const next = { ...current };
|
|
if (isSelected) delete next[modelId];
|
|
else next[modelId] = 'default';
|
|
return next;
|
|
});
|
|
setSelectedModelPaths((current) => {
|
|
const next = { ...current };
|
|
if (isSelected) delete next[modelId];
|
|
return next;
|
|
});
|
|
resetCertification();
|
|
setResults([]);
|
|
};
|
|
|
|
const modelsNeedingPath = selectedModelIds.filter(
|
|
(modelId) =>
|
|
pathModes[modelId] === 'selected' &&
|
|
(selectedModelPaths[modelId]?.length ?? 0) === 0
|
|
);
|
|
|
|
const buildModelRuns = (): ModelRun[] =>
|
|
selectedModelIds.map((modelId) => {
|
|
const paths = pathsForModel(modelId);
|
|
const mode = pathModes[modelId] ?? 'default';
|
|
if (mode === 'all') {
|
|
return {
|
|
modelId,
|
|
targets: paths.map((path) => ({
|
|
path: path.path,
|
|
label: pathLabel(path),
|
|
})),
|
|
};
|
|
}
|
|
if (mode === 'selected') {
|
|
const selected = new Set(selectedModelPaths[modelId] ?? []);
|
|
return {
|
|
modelId,
|
|
targets: paths
|
|
.filter((path) => selected.has(path.path))
|
|
.map((path) => ({ path: path.path, label: pathLabel(path) })),
|
|
};
|
|
}
|
|
return { modelId, targets: [{ label: 'Provider default' }] };
|
|
});
|
|
|
|
const modelRuns = buildModelRuns();
|
|
const targetCount = modelRuns.reduce(
|
|
(total, run) => total + run.targets.length,
|
|
0
|
|
);
|
|
|
|
const renderModelGroup = (label: string, options: ModelOption[]) => {
|
|
if (options.length === 0) return null;
|
|
return (
|
|
<CommandGroup heading={`${label} (${options.length})`}>
|
|
{options.map(({ model }) => (
|
|
<CommandItem
|
|
key={`${label}-${model.id}`}
|
|
value={`${model.name} ${model.id}`}
|
|
onSelect={() => toggleModel(model.id)}
|
|
disabled={certify.isPending}
|
|
>
|
|
<Checkbox
|
|
checked={selectedModelIds.includes(model.id)}
|
|
tabIndex={-1}
|
|
aria-hidden='true'
|
|
className='pointer-events-none'
|
|
/>
|
|
<span className='min-w-0 flex-1 truncate'>
|
|
{model.name || model.id}
|
|
</span>
|
|
<span className='text-muted-foreground max-w-56 truncate font-mono text-xs'>
|
|
{model.id}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[780px]'>
|
|
<DialogHeader>
|
|
<DialogTitle>Certify upstream models</DialogTitle>
|
|
<DialogDescription>
|
|
Select models, then use the provider default, choose specific
|
|
paths, or test every path. Models run one at a time; paths for the
|
|
same model run in parallel against {provider.base_url}.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className='space-y-2'>
|
|
<div className='flex items-center justify-between gap-3'>
|
|
<Label>Models</Label>
|
|
<div className='flex items-center gap-2'>
|
|
<span className='text-muted-foreground text-xs'>
|
|
{selectedModelIds.length} selected
|
|
</span>
|
|
{selectedModelIds.length > 0 && !certify.isPending && (
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => {
|
|
setSelectedModelIds([]);
|
|
setPathModes({});
|
|
setSelectedModelPaths({});
|
|
setResults([]);
|
|
resetCertification();
|
|
}}
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Command className='h-auto rounded-md border'>
|
|
<CommandInput
|
|
placeholder='Search models by name or ID…'
|
|
disabled={models.isLoading || certify.isPending}
|
|
/>
|
|
<CommandList className='max-h-[min(18rem,40dvh)]'>
|
|
<CommandEmpty>
|
|
{models.isLoading ? 'Loading models…' : 'No models found'}
|
|
</CommandEmpty>
|
|
{renderModelGroup('Configured models', configuredOptions)}
|
|
{renderModelGroup('Discovered models', discoveredOptions)}
|
|
</CommandList>
|
|
</Command>
|
|
{models.isError && (
|
|
<p className='text-destructive text-sm'>
|
|
{getErrorMessage(models.error)}
|
|
</p>
|
|
)}
|
|
{selectedModelIds.map((modelId) => {
|
|
const paths = pathsForModel(modelId);
|
|
const mode = pathModes[modelId] ?? 'default';
|
|
const selectedPaths = selectedModelPaths[modelId] ?? [];
|
|
return (
|
|
<div key={modelId} className='space-y-3 rounded-md border p-3'>
|
|
<div className='min-w-0'>
|
|
<div className='truncate text-sm font-medium'>
|
|
{namesById.get(modelId) ?? modelId}
|
|
</div>
|
|
<div className='text-muted-foreground truncate font-mono text-xs'>
|
|
{modelId}
|
|
</div>
|
|
</div>
|
|
{paths.length > 0 ? (
|
|
<>
|
|
<ToggleGroup
|
|
type='single'
|
|
variant='outline'
|
|
size='sm'
|
|
value={mode}
|
|
onValueChange={(value) => {
|
|
if (!value) return;
|
|
setPathModes((current) => ({
|
|
...current,
|
|
[modelId]: value as ModelPathMode,
|
|
}));
|
|
}}
|
|
disabled={certify.isPending}
|
|
className='w-full justify-start'
|
|
>
|
|
<ToggleGroupItem value='default'>Default</ToggleGroupItem>
|
|
<ToggleGroupItem value='selected'>Choose paths</ToggleGroupItem>
|
|
<ToggleGroupItem value='all'>All paths</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
{mode === 'default' && (
|
|
<p className='text-muted-foreground text-xs'>
|
|
Uses the upstream provider's normal model routing.
|
|
</p>
|
|
)}
|
|
{mode === 'selected' && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
className='w-full justify-between'
|
|
disabled={certify.isPending}
|
|
>
|
|
<span className='truncate'>
|
|
{selectedPaths.length === 0
|
|
? 'Select paths'
|
|
: `${selectedPaths.length} path${selectedPaths.length === 1 ? '' : 's'} selected`}
|
|
</span>
|
|
<ChevronDown className='h-4 w-4' />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align='start'
|
|
className='w-80 max-w-[calc(100vw-2rem)] p-2'
|
|
>
|
|
<div className='max-h-64 space-y-1 overflow-y-auto overscroll-contain'>
|
|
{paths.map((path) => {
|
|
const checked = selectedPaths.includes(path.path);
|
|
return (
|
|
<label
|
|
key={path.path}
|
|
className='hover:bg-muted flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm'
|
|
>
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) =>
|
|
setSelectedModelPaths((current) => {
|
|
const previous = current[modelId] ?? [];
|
|
return {
|
|
...current,
|
|
[modelId]:
|
|
value === true
|
|
? [...previous, path.path]
|
|
: previous.filter(
|
|
(item) => item !== path.path
|
|
),
|
|
};
|
|
})
|
|
}
|
|
/>
|
|
<span className='min-w-0 truncate'>
|
|
{pathLabel(path)}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
{mode === 'all' && (
|
|
<p className='text-muted-foreground text-xs'>
|
|
All {paths.length} paths will run in parallel.
|
|
</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className='text-muted-foreground text-xs'>
|
|
Only the provider default route is available.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{modelsNeedingPath.length > 0 && (
|
|
<p className='text-muted-foreground text-xs'>
|
|
Choose at least one path for each model using “Choose paths”.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className='flex flex-wrap items-center justify-between gap-3'>
|
|
<div className='flex items-center gap-2'>
|
|
<Checkbox
|
|
id={`certify-cache-${provider.id}`}
|
|
checked={checkCache}
|
|
onCheckedChange={(value) => setCheckCache(value === true)}
|
|
disabled={certify.isPending}
|
|
/>
|
|
<Label htmlFor={`certify-cache-${provider.id}`} className='text-sm'>
|
|
Probe prompt caching and margin
|
|
</Label>
|
|
</div>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() =>
|
|
certify.mutate({
|
|
modelRuns,
|
|
includeCache: checkCache,
|
|
})
|
|
}
|
|
disabled={
|
|
certify.isPending ||
|
|
selectedModelIds.length === 0 ||
|
|
modelsNeedingPath.length > 0
|
|
}
|
|
className='gap-1.5'
|
|
>
|
|
{certify.isPending ? (
|
|
<Loader2 className='h-4 w-4 animate-spin' />
|
|
) : (
|
|
<RotateCcw className='h-4 w-4' />
|
|
)}
|
|
{certify.isPending
|
|
? `Running ${currentModel?.index ?? 1} of ${currentModel?.total ?? selectedModelIds.length}`
|
|
: results.length > 0
|
|
? `Run ${targetCount} route${targetCount === 1 ? '' : 's'} again`
|
|
: `Certify ${targetCount || ''} route${targetCount === 1 ? '' : 's'}`}
|
|
</Button>
|
|
</div>
|
|
|
|
{currentModel && (
|
|
<div className='text-muted-foreground flex items-center gap-2 text-sm'>
|
|
<Loader2 className='h-4 w-4 animate-spin' />
|
|
Probing {namesById.get(currentModel.id) ?? currentModel.id}
|
|
{currentModel.pathCount > 1
|
|
? ` across ${currentModel.pathCount} paths in parallel`
|
|
: ''}{' '}
|
|
— model {currentModel.index} of {currentModel.total}
|
|
</div>
|
|
)}
|
|
|
|
{results.length > 0 && (
|
|
<Tabs
|
|
key={results.map((result) => result.resultKey).join('|')}
|
|
defaultValue={results[0].resultKey}
|
|
className='space-y-3'
|
|
>
|
|
<div className='overflow-x-auto'>
|
|
<TabsList variant='line' className='min-w-max'>
|
|
{results.map((result) => {
|
|
const status = resultStatus(result);
|
|
const Icon =
|
|
status === 'error' ? XCircle : STATUS_STYLES[status].icon;
|
|
return (
|
|
<TabsTrigger
|
|
key={result.resultKey}
|
|
value={result.resultKey}
|
|
title={`${namesById.get(result.modelId) ?? result.modelId} · ${result.pathLabel}`}
|
|
className='max-w-64'
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
status === 'ok' && 'text-emerald-600',
|
|
status === 'warn' && 'text-amber-600',
|
|
(status === 'fail' || status === 'error') &&
|
|
'text-red-600'
|
|
)}
|
|
/>
|
|
<span className='truncate'>
|
|
{namesById.get(result.modelId) ?? result.modelId} ·{' '}
|
|
{result.pathLabel}
|
|
</span>
|
|
</TabsTrigger>
|
|
);
|
|
})}
|
|
</TabsList>
|
|
</div>
|
|
{results.map((result) => (
|
|
<TabsContent key={result.resultKey} value={result.resultKey}>
|
|
{result.report ? (
|
|
<CertificationReport report={result.report} />
|
|
) : (
|
|
<div className='rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-400'>
|
|
{result.error ?? 'Certification failed'}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|