mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
571 lines
21 KiB
TypeScript
571 lines
21 KiB
TypeScript
import React from 'react';
|
|
|
|
import type { AIAttestation, AIChainStats } from 'lib/api/achain';
|
|
import { cn } from 'lib/utils/cn';
|
|
import { useModels, useAttestations, useProviders, useInferenceResults } from 'lib/api/achain';
|
|
import { Skeleton } from '@luxfi/ui/skeleton';
|
|
import { Tag } from '@luxfi/ui/tag';
|
|
import PageTitle from 'ui/shared/Page/PageTitle';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TABS = {
|
|
dashboard: 'dashboard',
|
|
models: 'models',
|
|
providers: 'providers',
|
|
attestations: 'attestations',
|
|
} as const;
|
|
|
|
type TabId = typeof TABS[keyof typeof TABS];
|
|
|
|
const RECENT_ATTESTATIONS_COUNT = 5;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function truncateHash(hash: string): string {
|
|
if (hash.length <= 14) {
|
|
return hash;
|
|
}
|
|
return `${ hash.slice(0, 8) }...${ hash.slice(-4) }`;
|
|
}
|
|
|
|
function truncateAddress(address: string): string {
|
|
if (address.length <= 14) {
|
|
return address;
|
|
}
|
|
return `${ address.slice(0, 8) }...${ address.slice(-4) }`;
|
|
}
|
|
|
|
function formatParameters(params: number): string {
|
|
if (params >= 1_000_000_000_000) {
|
|
return `${ (params / 1_000_000_000_000).toFixed(1) }T`;
|
|
}
|
|
if (params >= 1_000_000_000) {
|
|
return `${ (params / 1_000_000_000).toFixed(1) }B`;
|
|
}
|
|
if (params >= 1_000_000) {
|
|
return `${ (params / 1_000_000).toFixed(1) }M`;
|
|
}
|
|
return params.toLocaleString();
|
|
}
|
|
|
|
function formatEarnings(weiStr: string): string {
|
|
const wei = BigInt(weiStr);
|
|
const ether = Number(wei) / 1e18;
|
|
return `${ ether.toFixed(2) } LUX`;
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
function formatTimestamp(iso: string): string {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stat card
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface StatCardProps {
|
|
readonly label: string;
|
|
readonly value: string;
|
|
readonly isLoading: boolean;
|
|
}
|
|
|
|
const StatCard = ({ label, value, isLoading }: StatCardProps) => (
|
|
<div className="border border-[var(--color-border-divider)] rounded-lg p-5 bg-gray-50 dark:bg-[rgba(255,255,255,0.04)]">
|
|
<div className="text-xs text-[var(--color-text-secondary)] font-semibold uppercase tracking-wider mb-1">
|
|
{ label }
|
|
</div>
|
|
<Skeleton loading={ isLoading }>
|
|
<div className="text-2xl font-bold text-[var(--color-text-primary)]">
|
|
{ value }
|
|
</div>
|
|
</Skeleton>
|
|
</div>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab button
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TabButtonProps {
|
|
readonly label: string;
|
|
readonly isActive: boolean;
|
|
readonly onClick: () => void;
|
|
}
|
|
|
|
const TabButton = ({ label, isActive, onClick }: TabButtonProps) => (
|
|
<button
|
|
className={ cn(
|
|
'px-4 py-2 text-sm bg-transparent cursor-pointer transition-all duration-150 border-b-2 hover:text-[var(--color-text-primary)]',
|
|
isActive ? 'font-semibold text-[var(--color-text-primary)] border-[var(--color-text-primary)]' : 'font-normal text-[var(--color-text-secondary)] border-transparent',
|
|
) }
|
|
onClick={ onClick }
|
|
>
|
|
{ label }
|
|
</button>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Attestation status tag
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function attestationStatusClassName(status: AIAttestation['status']): string {
|
|
switch (status) {
|
|
case 'verified': return 'bg-badge-green-bg text-badge-green-fg';
|
|
case 'pending': return 'bg-badge-yellow-bg text-badge-yellow-fg';
|
|
case 'rejected': return 'bg-badge-red-bg text-badge-red-fg';
|
|
}
|
|
}
|
|
|
|
function attestationTypeLabel(type: AIAttestation['type']): string {
|
|
switch (type) {
|
|
case 'compute': return 'Compute';
|
|
case 'training': return 'Training';
|
|
case 'inference': return 'Inference';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Table header component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TableHeaderCellProps {
|
|
readonly children: React.ReactNode;
|
|
readonly flex?: number;
|
|
readonly w?: string;
|
|
readonly minW?: string;
|
|
readonly textAlign?: 'left' | 'right' | 'center';
|
|
readonly ml?: string;
|
|
}
|
|
|
|
const TableHeaderCell = ({ children, flex, w, minW, textAlign, ml }: TableHeaderCellProps) => (
|
|
<div
|
|
className="shrink-0 text-[var(--color-text-secondary)] font-semibold text-xs uppercase tracking-wider"
|
|
style={{
|
|
flex: flex ?? undefined,
|
|
width: w,
|
|
minWidth: minW,
|
|
textAlign: textAlign ?? 'left',
|
|
marginLeft: ml,
|
|
}}
|
|
>
|
|
{ children }
|
|
</div>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dashboard tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface DashboardTabProps {
|
|
readonly stats: AIChainStats;
|
|
readonly attestations: ReadonlyArray<AIAttestation>;
|
|
readonly isLoading: boolean;
|
|
}
|
|
|
|
const DashboardTab = ({ stats, attestations, isLoading }: DashboardTabProps) => {
|
|
const recentAttestations = React.useMemo(
|
|
() => attestations.slice(0, RECENT_ATTESTATIONS_COUNT),
|
|
[ attestations ],
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
{ /* Stat cards */ }
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
|
<StatCard
|
|
label="Total Models"
|
|
value={ stats.totalModels.toLocaleString() }
|
|
isLoading={ isLoading }
|
|
/>
|
|
<StatCard
|
|
label="Active Providers"
|
|
value={ stats.activeProviders.toLocaleString() }
|
|
isLoading={ isLoading }
|
|
/>
|
|
<StatCard
|
|
label="Total Attestations"
|
|
value={ stats.totalAttestations.toLocaleString() }
|
|
isLoading={ isLoading }
|
|
/>
|
|
<StatCard
|
|
label="Avg Latency"
|
|
value={ `${ stats.avgLatencyMs }ms` }
|
|
isLoading={ isLoading }
|
|
/>
|
|
</div>
|
|
|
|
{ /* Recent attestations */ }
|
|
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
|
|
<div className="px-4 py-3 font-semibold text-sm text-[var(--color-text-primary)] border-b border-[var(--color-border-divider)]">
|
|
Recent Attestations
|
|
</div>
|
|
|
|
{ /* Header */ }
|
|
<div className="hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)]">
|
|
<TableHeaderCell minW="80px">ID</TableHeaderCell>
|
|
<TableHeaderCell minW="100px">Type</TableHeaderCell>
|
|
<TableHeaderCell flex={ 1 }>Provider</TableHeaderCell>
|
|
<TableHeaderCell minW="100px">Block</TableHeaderCell>
|
|
<TableHeaderCell minW="160px">Time</TableHeaderCell>
|
|
<TableHeaderCell minW="100px" textAlign="right">Status</TableHeaderCell>
|
|
</div>
|
|
|
|
{ /* Rows */ }
|
|
{ isLoading && (
|
|
<div className="px-4 py-6">
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px"/>
|
|
</div>
|
|
) }
|
|
|
|
{ !isLoading && recentAttestations.map((att) => (
|
|
<div key={ att.id } className="flex flex-wrap lg:flex-nowrap px-4 py-3 gap-4 border-b border-[var(--color-border-divider)] items-center hover:bg-gray-50 dark:hover:bg-[rgba(255,255,255,0.04)] transition-[background] duration-150">
|
|
<div className="min-w-[80px] shrink-0 font-mono text-sm text-[var(--color-text-primary)]">
|
|
{ att.id }
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0">
|
|
<Tag size="sm">{ attestationTypeLabel(att.type) }</Tag>
|
|
</div>
|
|
<div className="flex-1 font-mono text-sm text-[var(--color-text-secondary)]" title={ att.provider }>
|
|
{ truncateAddress(att.provider) }
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-sm text-[var(--color-text-secondary)]">
|
|
{ att.blockHeight.toLocaleString() }
|
|
</div>
|
|
<div className="min-w-[160px] shrink-0 text-sm text-[var(--color-text-secondary)]">
|
|
{ formatTimestamp(att.timestamp) }
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-left lg:text-right">
|
|
<Tag size="sm" className={ attestationStatusClassName(att.status) }>
|
|
{ att.status }
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
)) }
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Models tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ModelsTabProps {
|
|
readonly isLoading: boolean;
|
|
}
|
|
|
|
const ModelsTab = ({ isLoading }: ModelsTabProps) => {
|
|
const { models } = useModels();
|
|
|
|
return (
|
|
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
|
|
{ /* Header */ }
|
|
<div className="hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)]">
|
|
<TableHeaderCell minW="180px">Name</TableHeaderCell>
|
|
<TableHeaderCell minW="80px">Version</TableHeaderCell>
|
|
<TableHeaderCell minW="100px">Framework</TableHeaderCell>
|
|
<TableHeaderCell minW="100px" textAlign="right">Parameters</TableHeaderCell>
|
|
<TableHeaderCell flex={ 1 }>Provider</TableHeaderCell>
|
|
<TableHeaderCell minW="120px" textAlign="right">Registered</TableHeaderCell>
|
|
</div>
|
|
|
|
{ /* Rows */ }
|
|
{ isLoading && (
|
|
<div className="px-4 py-6">
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px"/>
|
|
</div>
|
|
) }
|
|
|
|
{ !isLoading && models.length === 0 && (
|
|
<div className="px-4 py-8 text-center text-[var(--color-text-secondary)] text-sm">
|
|
No models registered
|
|
</div>
|
|
) }
|
|
|
|
{ !isLoading && models.map((model) => (
|
|
<div key={ model.id } className="flex flex-wrap lg:flex-nowrap px-4 py-3 gap-4 border-b border-[var(--color-border-divider)] items-center hover:bg-gray-50 dark:hover:bg-[rgba(255,255,255,0.04)] transition-[background] duration-150">
|
|
<div className="min-w-[180px] shrink-0">
|
|
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{ model.name }</div>
|
|
<div className="text-xs font-mono text-[var(--color-text-secondary)]" title={ model.modelHash }>
|
|
{ truncateHash(model.modelHash) }
|
|
</div>
|
|
</div>
|
|
<div className="min-w-[80px] shrink-0">
|
|
<Tag size="sm">v{ model.version }</Tag>
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-sm text-[var(--color-text-secondary)]">
|
|
{ model.framework }
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-sm text-[var(--color-text-primary)] text-left lg:text-right">
|
|
{ formatParameters(model.parameters) }
|
|
</div>
|
|
<div className="flex-1 font-mono text-sm text-[var(--color-text-secondary)]" title={ model.provider }>
|
|
{ truncateAddress(model.provider) }
|
|
</div>
|
|
<div className="min-w-[120px] shrink-0 text-sm text-[var(--color-text-secondary)] text-left lg:text-right">
|
|
{ formatDate(model.registeredAt) }
|
|
</div>
|
|
</div>
|
|
)) }
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Providers tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ProvidersTabProps {
|
|
readonly isLoading: boolean;
|
|
}
|
|
|
|
const ProvidersTab = ({ isLoading }: ProvidersTabProps) => {
|
|
const { providers } = useProviders();
|
|
|
|
return (
|
|
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
|
|
{ /* Header */ }
|
|
<div className="hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)]">
|
|
<TableHeaderCell flex={ 1 }>Address</TableHeaderCell>
|
|
<TableHeaderCell minW="100px" textAlign="right">Reputation</TableHeaderCell>
|
|
<TableHeaderCell minW="100px" textAlign="right">Capacity</TableHeaderCell>
|
|
<TableHeaderCell minW="100px" textAlign="right">Total Jobs</TableHeaderCell>
|
|
<TableHeaderCell minW="140px" textAlign="right">Earnings</TableHeaderCell>
|
|
<TableHeaderCell minW="80px" textAlign="right">Status</TableHeaderCell>
|
|
</div>
|
|
|
|
{ /* Rows */ }
|
|
{ isLoading && (
|
|
<div className="px-4 py-6">
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px"/>
|
|
</div>
|
|
) }
|
|
|
|
{ !isLoading && providers.length === 0 && (
|
|
<div className="px-4 py-8 text-center text-[var(--color-text-secondary)] text-sm">
|
|
No compute providers found
|
|
</div>
|
|
) }
|
|
|
|
{ !isLoading && providers.map((provider) => (
|
|
<div key={ provider.address } className="flex flex-wrap lg:flex-nowrap px-4 py-3 gap-4 border-b border-[var(--color-border-divider)] items-center hover:bg-gray-50 dark:hover:bg-[rgba(255,255,255,0.04)] transition-[background] duration-150">
|
|
<div className="flex-1 font-mono text-sm text-[var(--color-text-primary)]" title={ provider.address }>
|
|
{ truncateAddress(provider.address) }
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-sm text-[var(--color-text-primary)] text-left lg:text-right">
|
|
{ provider.reputation }%
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-sm text-[var(--color-text-secondary)] text-left lg:text-right">
|
|
{ provider.capacity } GPUs
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-sm text-[var(--color-text-secondary)] text-left lg:text-right">
|
|
{ provider.totalJobs.toLocaleString() }
|
|
</div>
|
|
<div className="min-w-[140px] shrink-0 text-sm text-[var(--color-text-primary)] text-left lg:text-right">
|
|
{ formatEarnings(provider.totalEarnings) }
|
|
</div>
|
|
<div className="min-w-[80px] shrink-0 text-left lg:text-right">
|
|
<Tag size="sm" className={ provider.isActive ? 'bg-badge-green-bg text-badge-green-fg' : undefined }>
|
|
{ provider.isActive ? 'Active' : 'Offline' }
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
)) }
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Attestations tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface AttestationsTabProps {
|
|
readonly attestations: ReadonlyArray<AIAttestation>;
|
|
readonly isLoading: boolean;
|
|
}
|
|
|
|
const AttestationsTab = ({ attestations, isLoading }: AttestationsTabProps) => (
|
|
<div className="border border-[var(--color-border-divider)] rounded-lg overflow-hidden">
|
|
{ /* Header */ }
|
|
<div className="hidden lg:flex px-4 py-2 gap-4 border-b border-[var(--color-border-divider)]">
|
|
<TableHeaderCell minW="80px">ID</TableHeaderCell>
|
|
<TableHeaderCell minW="100px">Type</TableHeaderCell>
|
|
<TableHeaderCell flex={ 1 }>Provider</TableHeaderCell>
|
|
<TableHeaderCell flex={ 1 }>Model Hash</TableHeaderCell>
|
|
<TableHeaderCell minW="160px">Timestamp</TableHeaderCell>
|
|
<TableHeaderCell minW="100px" textAlign="right">Status</TableHeaderCell>
|
|
</div>
|
|
|
|
{ /* Rows */ }
|
|
{ isLoading && (
|
|
<div className="px-4 py-6">
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px" mb={ 3 }/>
|
|
<Skeleton loading={ true } h="16px"/>
|
|
</div>
|
|
) }
|
|
|
|
{ !isLoading && attestations.length === 0 && (
|
|
<div className="px-4 py-8 text-center text-[var(--color-text-secondary)] text-sm">
|
|
No attestations found
|
|
</div>
|
|
) }
|
|
|
|
{ !isLoading && attestations.map((att) => (
|
|
<div key={ att.id } className="flex flex-wrap lg:flex-nowrap px-4 py-3 gap-4 border-b border-[var(--color-border-divider)] items-center hover:bg-gray-50 dark:hover:bg-[rgba(255,255,255,0.04)] transition-[background] duration-150">
|
|
<div className="min-w-[80px] shrink-0 font-mono text-sm text-[var(--color-text-primary)]">
|
|
{ att.id }
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0">
|
|
<Tag size="sm">{ attestationTypeLabel(att.type) }</Tag>
|
|
</div>
|
|
<div className="flex-1 font-mono text-sm text-[var(--color-text-secondary)]" title={ att.provider }>
|
|
{ truncateAddress(att.provider) }
|
|
</div>
|
|
<div className="flex-1 font-mono text-sm text-[var(--color-text-secondary)]" title={ att.modelHash }>
|
|
{ truncateHash(att.modelHash) }
|
|
</div>
|
|
<div className="min-w-[160px] shrink-0 text-sm text-[var(--color-text-secondary)]">
|
|
{ formatTimestamp(att.timestamp) }
|
|
</div>
|
|
<div className="min-w-[100px] shrink-0 text-left lg:text-right">
|
|
<Tag size="sm" className={ attestationStatusClassName(att.status) }>
|
|
{ att.status }
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
)) }
|
|
</div>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AIPage = () => {
|
|
const [ activeTab, setActiveTab ] = React.useState<TabId>(TABS.dashboard);
|
|
const { models, isLoading: modelsLoading } = useModels();
|
|
const { attestations, isLoading: attestationsLoading } = useAttestations();
|
|
const { providers, isLoading: providersLoading } = useProviders();
|
|
const { results, isLoading: inferenceLoading } = useInferenceResults();
|
|
|
|
const isLoading = modelsLoading || attestationsLoading || providersLoading || inferenceLoading;
|
|
|
|
const stats = React.useMemo<AIChainStats>(() => {
|
|
const activeProviderCount = providers.filter((p) => p.isActive).length;
|
|
const totalLatency = results.reduce((sum, r) => sum + r.latencyMs, 0);
|
|
const avgLatency = results.length > 0 ? Math.round(totalLatency / results.length) : 0;
|
|
|
|
return {
|
|
totalModels: models.length,
|
|
activeProviders: activeProviderCount,
|
|
totalAttestations: attestations.length,
|
|
avgLatencyMs: avgLatency,
|
|
};
|
|
}, [ models, attestations, providers, results ]);
|
|
|
|
const handleDashboardClick = React.useCallback(() => {
|
|
setActiveTab(TABS.dashboard);
|
|
}, []);
|
|
|
|
const handleModelsClick = React.useCallback(() => {
|
|
setActiveTab(TABS.models);
|
|
}, []);
|
|
|
|
const handleProvidersClick = React.useCallback(() => {
|
|
setActiveTab(TABS.providers);
|
|
}, []);
|
|
|
|
const handleAttestationsClick = React.useCallback(() => {
|
|
setActiveTab(TABS.attestations);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
title="AI Compute"
|
|
secondRow={ (
|
|
<div className="text-sm text-[var(--color-text-secondary)]">
|
|
A-Chain AI models, compute providers, and attestations
|
|
</div>
|
|
) }
|
|
/>
|
|
|
|
{ /* Tabs */ }
|
|
<div className="flex border-b border-[var(--color-border-divider)] mb-6 gap-0">
|
|
<TabButton
|
|
label="Dashboard"
|
|
isActive={ activeTab === TABS.dashboard }
|
|
onClick={ handleDashboardClick }
|
|
/>
|
|
<TabButton
|
|
label={ `Models (${ models.length })` }
|
|
isActive={ activeTab === TABS.models }
|
|
onClick={ handleModelsClick }
|
|
/>
|
|
<TabButton
|
|
label={ `Providers (${ providers.length })` }
|
|
isActive={ activeTab === TABS.providers }
|
|
onClick={ handleProvidersClick }
|
|
/>
|
|
<TabButton
|
|
label={ `Attestations (${ attestations.length })` }
|
|
isActive={ activeTab === TABS.attestations }
|
|
onClick={ handleAttestationsClick }
|
|
/>
|
|
</div>
|
|
|
|
{ /* Tab content */ }
|
|
{ activeTab === TABS.dashboard && (
|
|
<DashboardTab
|
|
stats={ stats }
|
|
attestations={ attestations }
|
|
isLoading={ isLoading }
|
|
/>
|
|
) }
|
|
|
|
{ activeTab === TABS.models && (
|
|
<ModelsTab isLoading={ modelsLoading }/>
|
|
) }
|
|
|
|
{ activeTab === TABS.providers && (
|
|
<ProvidersTab isLoading={ providersLoading }/>
|
|
) }
|
|
|
|
{ activeTab === TABS.attestations && (
|
|
<AttestationsTab
|
|
attestations={ attestations }
|
|
isLoading={ attestationsLoading }
|
|
/>
|
|
) }
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default AIPage;
|