feat: implement AI usage dashboard and provider management features for tenant administration
This commit is contained in:
parent
c427e4aa51
commit
1d207d2dcb
@ -197,15 +197,25 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
|||||||
requiredPermission: { resource: "ai" },
|
requiredPermission: { resource: "ai" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Tenant Config",
|
label: "Tenant AI Providers",
|
||||||
path: "/tenant/ai/config",
|
path: "/tenant/ai/providers",
|
||||||
requiredPermission: { resource: "ai" },
|
requiredPermission: { resource: "ai" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Knowledge (RAG)",
|
label: "AI Usage & Cost Dashboard",
|
||||||
path: "/tenant/ai/knowledge",
|
path: "/tenant/ai/dashboard",
|
||||||
requiredPermission: { resource: "ai" },
|
requiredPermission: { resource: "ai" },
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// label: "Tenant Config",
|
||||||
|
// path: "/tenant/ai/config",
|
||||||
|
// requiredPermission: { resource: "ai" },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// label: "Knowledge (RAG)",
|
||||||
|
// path: "/tenant/ai/knowledge",
|
||||||
|
// requiredPermission: { resource: "ai" },
|
||||||
|
// },
|
||||||
],
|
],
|
||||||
requiredPermission: { resource: "ai" },
|
requiredPermission: { resource: "ai" },
|
||||||
},
|
},
|
||||||
|
|||||||
140
src/components/tenant/ViewAIProviderModal.tsx
Normal file
140
src/components/tenant/ViewAIProviderModal.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { Modal } from "@/components/shared";
|
||||||
|
import type { TenantAIConfig } from "@/types/ai";
|
||||||
|
|
||||||
|
interface ViewAIProviderModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
config: TenantAIConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewAIProviderModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
config,
|
||||||
|
}: ViewAIProviderModalProps): ReactElement => {
|
||||||
|
if (!config) return <></>;
|
||||||
|
|
||||||
|
const parseArray = (val: any): string[] => {
|
||||||
|
if (!val) return [];
|
||||||
|
if (Array.isArray(val)) return val;
|
||||||
|
if (typeof val === "string") {
|
||||||
|
return val.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="AI Provider Details"
|
||||||
|
description="View detailed settings for this AI Provider configuration."
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 text-sm select-none p-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||||
|
Provider
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-800 font-medium">
|
||||||
|
{config.provider}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||||
|
Display Name
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-800 font-medium">
|
||||||
|
{config.display_name || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||||
|
Config Type
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-bold uppercase tracking-wider bg-blue-50 text-blue-600 border border-blue-100 mt-1">
|
||||||
|
{config.config_type || "direct"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||||
|
Default Model
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-800 font-medium">
|
||||||
|
{config.default_model || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||||
|
Default Embedding Model
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-800 font-medium">
|
||||||
|
{(config as any).default_embedding_model || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.endpoint && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||||
|
Endpoint URL
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-800 font-mono break-all select-all">
|
||||||
|
{config.endpoint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
||||||
|
Custom Models
|
||||||
|
</span>
|
||||||
|
{parseArray(config.custom_models).length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{parseArray(config.custom_models).map((m: any, idx: any) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="px-2 py-0.5 bg-slate-50 text-slate-700 rounded text-xs font-medium border border-slate-200"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-800 font-medium">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
||||||
|
Custom Embedding Models
|
||||||
|
</span>
|
||||||
|
{parseArray((config as any).custom_embedding_models).length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{parseArray((config as any).custom_embedding_models).map((m: any, idx: any) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="px-2 py-0.5 bg-slate-50 text-slate-700 rounded text-xs font-medium border border-slate-200"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-800 font-medium">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="flex justify-end pt-4 border-t border-slate-100 mt-4">
|
||||||
|
<PrimaryButton onClick={onClose} className="h-10 px-5">
|
||||||
|
Close
|
||||||
|
</PrimaryButton>
|
||||||
|
</div> */}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
342
src/pages/tenant/TenantAIDashboard.tsx
Normal file
342
src/pages/tenant/TenantAIDashboard.tsx
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { useEffect, useState, type ReactElement } from "react";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { DataTable, type Column, FilterDropdown } from "@/components/shared";
|
||||||
|
import { aiService } from "@/services/ai-service";
|
||||||
|
import type { AICostSummary } from "@/types/ai";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
// RefreshCw,
|
||||||
|
MessageSquare,
|
||||||
|
Coins,
|
||||||
|
DollarSign,
|
||||||
|
Timer,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
|
||||||
|
type GroupBy = "day" | "week" | "month";
|
||||||
|
|
||||||
|
export const TenantAIDashboard = (): ReactElement => {
|
||||||
|
const [groupBy, setGroupBy] = useState<GroupBy | null>(null);
|
||||||
|
const [startDate, setStartDate] = useState<string>("");
|
||||||
|
const [endDate, setEndDate] = useState<string>("");
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [costs, setCosts] = useState<AICostSummary | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupBy) {
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date();
|
||||||
|
if (groupBy === "day") {
|
||||||
|
start.setDate(now.getDate() - 1);
|
||||||
|
} else if (groupBy === "week") {
|
||||||
|
start.setDate(now.getDate() - 7);
|
||||||
|
} else if (groupBy === "month") {
|
||||||
|
start.setDate(now.getDate() - 30);
|
||||||
|
}
|
||||||
|
setStartDate(start.toISOString().split("T")[0]);
|
||||||
|
setEndDate(now.toISOString().split("T")[0]);
|
||||||
|
}, [groupBy]);
|
||||||
|
|
||||||
|
const fetchCostSummary = async (
|
||||||
|
group: GroupBy | null = groupBy,
|
||||||
|
start: string = startDate,
|
||||||
|
end: string = endDate,
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await aiService.getCostSummary({
|
||||||
|
group_by: group || undefined,
|
||||||
|
start_date: group && start ? `${start}T00:00:00.000Z` : undefined,
|
||||||
|
end_date: group && end ? `${end}T23:59:59.999Z` : undefined,
|
||||||
|
});
|
||||||
|
setCosts(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(
|
||||||
|
err?.response?.data?.message || "Failed to fetch cost summary",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupBy || (startDate && endDate)) {
|
||||||
|
void fetchCostSummary(groupBy, startDate, endDate);
|
||||||
|
}
|
||||||
|
}, [groupBy, startDate, endDate]);
|
||||||
|
|
||||||
|
const providerColumns: Column<any>[] = [
|
||||||
|
{
|
||||||
|
key: "provider",
|
||||||
|
label: "Provider",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-semibold text-slate-900 select-all">
|
||||||
|
{row.provider}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completions",
|
||||||
|
label: "Requests",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-medium text-slate-700">{row.completions}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tokens",
|
||||||
|
label: "Tokens",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-mono text-xs text-slate-600">{row.tokens}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cost",
|
||||||
|
label: "Cost (USD)",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-mono text-xs text-emerald-600 font-bold">
|
||||||
|
${(row.cost || 0).toFixed(6)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const modelColumns: Column<any>[] = [
|
||||||
|
{
|
||||||
|
key: "model",
|
||||||
|
label: "Model",
|
||||||
|
render: (row) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-slate-900 select-all">
|
||||||
|
{row.model}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-wider">
|
||||||
|
{row.provider}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completions",
|
||||||
|
label: "Requests",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-medium text-slate-700">{row.completions}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tokens",
|
||||||
|
label: "Tokens",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-mono text-xs text-slate-600">{row.tokens}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cost",
|
||||||
|
label: "Cost (USD)",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-mono text-xs text-emerald-600 font-bold">
|
||||||
|
${(row.cost || 0).toFixed(6)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Formatting helpers
|
||||||
|
const formatTokens = (t: number) => {
|
||||||
|
if (!t) return "0";
|
||||||
|
if (t >= 1000000) return `${(t / 1000000).toFixed(2)}M`;
|
||||||
|
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`;
|
||||||
|
return t.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLatency = (ms: number) => {
|
||||||
|
if (!ms) return "0ms";
|
||||||
|
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
||||||
|
return `${Math.round(ms)}ms`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="AI Services"
|
||||||
|
pageHeader={{
|
||||||
|
title: "AI Usage & Cost Dashboard",
|
||||||
|
description:
|
||||||
|
"Monitor AI completions, tokens, and billing analytics across all providers.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top Controls Row */}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start gap-3">
|
||||||
|
{/* Date Range */}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={startDate && endDate ? `${formatDate(startDate)} - ${formatDate(endDate)}` : "All time"}
|
||||||
|
className="w-full sm:w-[220px] h-11 px-4 pr-10 rounded-[8px] border border-[#D1D5DB] bg-white text-[14px] font-medium text-[#334155] outline-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Calendar className="w-4 h-4 text-[#475569] absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group By */}
|
||||||
|
<div className="w-full sm:w-[240px]">
|
||||||
|
<FilterDropdown
|
||||||
|
label="Group by"
|
||||||
|
options={[
|
||||||
|
{ value: "day", label: "Day" },
|
||||||
|
{ value: "week", label: "Week" },
|
||||||
|
{ value: "month", label: "Month" },
|
||||||
|
]}
|
||||||
|
value={groupBy}
|
||||||
|
onChange={(val) => {
|
||||||
|
setGroupBy(val as GroupBy | null);
|
||||||
|
}}
|
||||||
|
placeholder="All"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 gap-4 select-none">
|
||||||
|
{/* Card 1 */}
|
||||||
|
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||||
|
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||||
|
<div className="text-[#94A3B8]">
|
||||||
|
<MessageSquare className="w-4 h-4 stroke-[1.8]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||||
|
{costs?.summary?.total_completions || 0}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||||
|
Total Completions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2 */}
|
||||||
|
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||||
|
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||||
|
<div className="text-[#94A3B8]">
|
||||||
|
<Coins className="w-4 h-4 stroke-[1.8]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||||
|
{formatTokens(costs?.summary?.total_tokens || 0)}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||||
|
Total Tokens
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3 */}
|
||||||
|
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||||
|
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||||
|
<div className="text-[#94A3B8]">
|
||||||
|
<DollarSign className="w-4 h-4 stroke-[1.8]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||||
|
${(costs?.summary?.total_cost || 0).toFixed(2)}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||||
|
Total Cost (USD)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 4 */}
|
||||||
|
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||||
|
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||||
|
<div className="text-[#94A3B8]">
|
||||||
|
<Timer className="w-4 h-4 stroke-[1.8]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||||
|
{formatLatency(costs?.summary?.avg_latency_ms || 0)}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||||
|
Avg Latency (ms)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts and Tables */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
{/* Usage by Provider Section */}
|
||||||
|
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-slate-800 select-none">
|
||||||
|
Usage by Provider
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 select-none">
|
||||||
|
Breakdown of requests, tokens and usage costs for each distinct
|
||||||
|
provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-slate-50 rounded-xl overflow-hidden">
|
||||||
|
<DataTable
|
||||||
|
data={costs?.by_provider || []}
|
||||||
|
columns={providerColumns}
|
||||||
|
keyExtractor={(item) =>
|
||||||
|
item.provider || Math.random().toString()
|
||||||
|
}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No provider metrics available for this period."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost by Model Section */}
|
||||||
|
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-slate-800 select-none">
|
||||||
|
Cost by Model
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 select-none">
|
||||||
|
Breakdown of requests and costs grouped at the AI model level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-slate-50 rounded-xl overflow-hidden">
|
||||||
|
<DataTable
|
||||||
|
data={costs?.by_model || []}
|
||||||
|
columns={modelColumns}
|
||||||
|
keyExtractor={(item) =>
|
||||||
|
`${item.model}-${item.provider}` || Math.random().toString()
|
||||||
|
}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No model metrics available for this period."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TenantAIDashboard;
|
||||||
377
src/pages/tenant/TenantAIProviderCreate.tsx
Normal file
377
src/pages/tenant/TenantAIProviderCreate.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import { type ReactElement, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
PrimaryButton,
|
||||||
|
SecondaryButton,
|
||||||
|
FormTagInput,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { aiService } from "@/services/ai-service";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
|
||||||
|
const createConfigSchema = z.object({
|
||||||
|
provider: z.string().min(1, "Provider vendor is required"),
|
||||||
|
display_name: z.string().optional(),
|
||||||
|
config_type: z.enum(["direct", "azure"]),
|
||||||
|
is_active: z.boolean().default(true),
|
||||||
|
api_key: z.string().min(1, "API Key is required"),
|
||||||
|
endpoint: z.string().url().max(500).optional(),
|
||||||
|
deployment: z.string().optional(),
|
||||||
|
api_version: z.string().optional(),
|
||||||
|
default_model: z.string().min(1, "Default model is required"),
|
||||||
|
custom_models: z.array(z.string()).default([]),
|
||||||
|
default_embedding_model: z.string().optional(),
|
||||||
|
custom_embedding_models: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TenantAIProviderCreate = (): ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setError,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<any>({
|
||||||
|
resolver: zodResolver(createConfigSchema),
|
||||||
|
defaultValues: {
|
||||||
|
provider: "",
|
||||||
|
display_name: "",
|
||||||
|
config_type: "direct",
|
||||||
|
is_active: true,
|
||||||
|
api_key: "",
|
||||||
|
endpoint: "",
|
||||||
|
deployment: "",
|
||||||
|
api_version: "",
|
||||||
|
default_model: "",
|
||||||
|
custom_models: [],
|
||||||
|
default_embedding_model: "",
|
||||||
|
custom_embedding_models: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedConfigType = watch("config_type");
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: any) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await aiService.upsertConfig({
|
||||||
|
provider: data.provider,
|
||||||
|
display_name: data.display_name || undefined,
|
||||||
|
config_type: data.config_type,
|
||||||
|
is_active: data.is_active,
|
||||||
|
api_key: data.api_key,
|
||||||
|
endpoint: data.endpoint || undefined,
|
||||||
|
deployment: data.deployment || undefined,
|
||||||
|
api_version: data.api_version || undefined,
|
||||||
|
default_model: data.default_model,
|
||||||
|
custom_models: data.custom_models || [],
|
||||||
|
default_embedding_model: data.default_embedding_model || undefined,
|
||||||
|
custom_embedding_models: data.custom_embedding_models || [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
showToast.success("AI Provider configuration created successfully!");
|
||||||
|
navigate("/tenant/ai/providers");
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||||
|
err.response.data.details.forEach((detail: any) => {
|
||||||
|
if (detail.path) {
|
||||||
|
setError(detail.path, { type: "server", message: detail.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const msg =
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.response?.data?.error ||
|
||||||
|
"Failed to save AI Provider configuration.";
|
||||||
|
showToast.error(typeof msg === "string" ? msg : "Validation failed");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="AI Gateway Services"
|
||||||
|
pageHeader={{
|
||||||
|
title: "AI Provider Configuration",
|
||||||
|
description: "Manage and reuse prompts for different use cases.",
|
||||||
|
action: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={() => navigate("/tenant/ai/providers")}
|
||||||
|
className="h-10 px-5 min-w-[120px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleSubmit(onFormSubmit)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-10 px-5 min-w-[120px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Saving..." : "Save Configuration"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/tenant/ai/providers")}
|
||||||
|
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800 transition-colors font-medium select-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Back to AI Providers List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6 max-w-4xl select-none">
|
||||||
|
{/* General Settings Section */}
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-6 space-y-4">
|
||||||
|
<h2 className="text-base font-semibold text-slate-800 select-none border-b border-slate-100 pb-2">
|
||||||
|
General Settings
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="provider"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="Provider"
|
||||||
|
required
|
||||||
|
placeholder="e.g., Mistral7b"
|
||||||
|
error={errors.provider?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="display_name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="Display Name"
|
||||||
|
placeholder="e.g., Mistral small 7b"
|
||||||
|
error={errors.display_name?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-50 p-4 rounded-xl">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 block">
|
||||||
|
Config Type <span className="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 block">
|
||||||
|
Select between Direct and Azure API configurations.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="config_type"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={field.value === "azure"}
|
||||||
|
onChange={() => field.onChange("azure")}
|
||||||
|
className="w-4 h-4 text-[#112868] focus:ring-[#112868] border-slate-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-slate-700">
|
||||||
|
Azure
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={field.value === "direct"}
|
||||||
|
onChange={() => field.onChange("direct")}
|
||||||
|
className="w-4 h-4 text-[#112868] focus:ring-[#112868] border-slate-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-slate-700">
|
||||||
|
Direct
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-slate-800 block">
|
||||||
|
Enable Configuration
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 block">
|
||||||
|
Activate this configuration to allow processing completions via this provider.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="is_active"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div
|
||||||
|
onClick={() => field.onChange(!field.value)}
|
||||||
|
className={`relative w-11 h-6 flex items-center rounded-full transition-colors cursor-pointer select-none ${
|
||||||
|
field.value ? "bg-[#112868]" : "bg-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute top-[2px] left-[2px] w-5 h-5 bg-white rounded-full transition-transform shadow-sm ${
|
||||||
|
field.value ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Credentials Section */}
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-6 space-y-4">
|
||||||
|
<h2 className="text-base font-semibold text-slate-800 border-b border-slate-100 pb-2">
|
||||||
|
Connection Credentials
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="api_key"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="API Key"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="Enter secret API key..."
|
||||||
|
error={errors.api_key?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="endpoint"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="Endpoint URL"
|
||||||
|
placeholder="e.g. https://my-tenant.openai.azure.com/"
|
||||||
|
error={errors.endpoint?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedConfigType === "azure" && (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="deployment"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="Deployment Name"
|
||||||
|
placeholder="e.g., gpt-4-deployment"
|
||||||
|
error={errors.deployment?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="api_version"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="API Version"
|
||||||
|
placeholder="e.g., 2023-05-15"
|
||||||
|
error={errors.api_version?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Models */}
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-6 space-y-4">
|
||||||
|
<h2 className="text-base font-semibold text-slate-800 border-b border-slate-100 pb-2">
|
||||||
|
Models
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="default_model"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="Default Model"
|
||||||
|
required
|
||||||
|
placeholder="e.g. gpt-4"
|
||||||
|
error={errors.default_model?.message as any}
|
||||||
|
helperText="Fallback model for this provider."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="custom_models"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormTagInput
|
||||||
|
{...field}
|
||||||
|
label="Custom Models (Optional)"
|
||||||
|
placeholder="Type a model ID and press enter..."
|
||||||
|
error={errors.custom_models?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="default_embedding_model"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormField
|
||||||
|
{...field}
|
||||||
|
label="Default Embedding Model (Optional)"
|
||||||
|
placeholder="e.g. text-embedding-ada-002"
|
||||||
|
error={errors.default_embedding_model?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="custom_embedding_models"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormTagInput
|
||||||
|
{...field}
|
||||||
|
label="Custom Embedding Models (Optional)"
|
||||||
|
placeholder="Type a model ID and press enter..."
|
||||||
|
error={errors.custom_embedding_models?.message as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TenantAIProviderCreate;
|
||||||
307
src/pages/tenant/TenantAIProviders.tsx
Normal file
307
src/pages/tenant/TenantAIProviders.tsx
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
type Column,
|
||||||
|
SearchBox,
|
||||||
|
FilterDropdown,
|
||||||
|
ActionDropdown,
|
||||||
|
PrimaryButton,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { Plus, Play, Loader2, Eye, Trash2 } from "lucide-react";
|
||||||
|
import { aiService } from "@/services/ai-service";
|
||||||
|
import type { TenantAIConfig } from "@/types/ai";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
import { ViewAIProviderModal } from "@/components/tenant/ViewAIProviderModal";
|
||||||
|
|
||||||
|
export const TenantAIProviders = (): ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [configs, setConfigs] = useState<TenantAIConfig[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
|
|
||||||
|
const [testingProviders, setTestingProviders] = useState<Record<string, boolean>>({});
|
||||||
|
const [selectedConfig, setSelectedConfig] = useState<TenantAIConfig | null>(null);
|
||||||
|
const [isViewModalOpen, setIsViewModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const fetchConfigs = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await aiService.listConfigs();
|
||||||
|
setConfigs(data || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.error?.message || "Failed to load configs";
|
||||||
|
setError(msg);
|
||||||
|
showToast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchConfigs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTestConnection = async (provider: string) => {
|
||||||
|
setTestingProviders((prev) => ({ ...prev, [provider]: true }));
|
||||||
|
try {
|
||||||
|
const resp = await aiService.testConfig(provider);
|
||||||
|
if (resp && resp.healthy) {
|
||||||
|
showToast.success(
|
||||||
|
`Connection healthy for ${provider}! Latency: ${resp.latency_ms || "N/A"} ms`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast.error(`Connection test failed for ${provider}.`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.error?.message || "Failed to test connection.";
|
||||||
|
showToast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setTestingProviders((prev) => ({ ...prev, [provider]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewConfig = async (provider: string) => {
|
||||||
|
try {
|
||||||
|
const cfg = await aiService.getConfig(provider);
|
||||||
|
setSelectedConfig(cfg);
|
||||||
|
setIsViewModalOpen(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.error?.message || "Failed to fetch AI Provider config details.";
|
||||||
|
showToast.error(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfig = async (provider: string) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete the AI provider configuration for ${provider}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await aiService.deleteConfig(provider);
|
||||||
|
showToast.success(`${provider} config removed successfully`);
|
||||||
|
void fetchConfigs();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.error?.message || "Failed to delete AI Provider configuration.";
|
||||||
|
showToast.error(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setStatusFilter("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredConfigs = useMemo(() => {
|
||||||
|
return configs.filter((cfg) => {
|
||||||
|
const searchMatches =
|
||||||
|
!searchQuery.trim() ||
|
||||||
|
(cfg.provider || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(cfg.display_name || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(cfg.default_model || "").toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const statusMatches =
|
||||||
|
!statusFilter ||
|
||||||
|
(statusFilter === "active" ? cfg.is_active : !cfg.is_active);
|
||||||
|
|
||||||
|
return searchMatches && statusMatches;
|
||||||
|
});
|
||||||
|
}, [configs, searchQuery, statusFilter]);
|
||||||
|
|
||||||
|
const columns: Column<TenantAIConfig>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "provider",
|
||||||
|
label: "Provider",
|
||||||
|
render: (row) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-[#112868] select-none">
|
||||||
|
{row.display_name || row.provider}
|
||||||
|
</p>
|
||||||
|
{row.endpoint && (
|
||||||
|
<p className="text-xs text-[#64748b] mt-0.5 truncate max-w-[280px]">
|
||||||
|
{row.endpoint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "config_type",
|
||||||
|
label: "Config Type",
|
||||||
|
render: (row) => {
|
||||||
|
const type = row.config_type || "direct";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-bold uppercase tracking-wider ${
|
||||||
|
type.toLowerCase() === "azure"
|
||||||
|
? "bg-purple-50 text-purple-600 border border-purple-100"
|
||||||
|
: "bg-blue-50 text-blue-600 border border-blue-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "default_model",
|
||||||
|
label: "Default Model",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="text-xs text-slate-800 font-medium select-none">
|
||||||
|
{row.default_model || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "is_active",
|
||||||
|
label: "Status",
|
||||||
|
render: (row) => {
|
||||||
|
const active = row.is_active;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium w-fit ${
|
||||||
|
active
|
||||||
|
? "text-green-700 bg-green-50 border border-green-100"
|
||||||
|
: "text-gray-500 bg-gray-50 border border-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
active ? "bg-green-500" : "bg-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{active ? "Active" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "last_verified_at",
|
||||||
|
label: "Last Verified",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="text-xs text-[#64748b]">
|
||||||
|
{row.last_verified_at ? formatDate(row.last_verified_at) : "Never"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => {
|
||||||
|
const isTesting = testingProviders[row.provider] || false;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-2 pr-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestConnection(row.provider)}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 border border-[#112868]/20 rounded-md text-xs font-medium bg-white text-[#112868] hover:bg-[#112868]/5 transition-colors cursor-pointer disabled:opacity-50 select-none h-8"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#112868]" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-3.5 h-3.5 fill-[#112868] text-[#112868]" />
|
||||||
|
)}
|
||||||
|
{isTesting ? "Testing..." : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ActionDropdown
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
icon: <Eye className="w-3.5 h-3.5 shrink-0" />,
|
||||||
|
label: "View Config",
|
||||||
|
onClick: () => handleViewConfig(row.provider),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Trash2 className="w-3.5 h-3.5 text-red-500 shrink-0" />,
|
||||||
|
label: "Delete Config",
|
||||||
|
onClick: () => handleDeleteConfig(row.provider),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[testingProviders]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="AI Gateway Services"
|
||||||
|
pageHeader={{
|
||||||
|
title: "Tenant AI Providers List",
|
||||||
|
description: "Manage tenant API keys and models for AI integrations.",
|
||||||
|
action: (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => navigate("/tenant/ai/providers/create")}
|
||||||
|
className="h-10 px-4 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create AI Provider
|
||||||
|
</PrimaryButton>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Subhead Toolbar matching Screenshot filter design */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SearchBox
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
placeholder="Search Here"
|
||||||
|
containerClassName="relative w-full sm:w-[280px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterDropdown
|
||||||
|
label="Status"
|
||||||
|
value={statusFilter || null}
|
||||||
|
onChange={(v) => setStatusFilter(typeof v === "string" ? v : "")}
|
||||||
|
placeholder="All"
|
||||||
|
options={[
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "disabled", label: "Disabled" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(searchQuery || statusFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="text-xs text-slate-500 hover:text-red-500 font-medium transition-colors cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table list */}
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden select-none">
|
||||||
|
<DataTable
|
||||||
|
data={filteredConfigs}
|
||||||
|
columns={columns}
|
||||||
|
keyExtractor={(item) => item.id || item.provider}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
emptyMessage="No tenant AI providers configured."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ViewAIProviderModal
|
||||||
|
isOpen={isViewModalOpen}
|
||||||
|
onClose={() => setIsViewModalOpen(false)}
|
||||||
|
config={selectedConfig}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TenantAIProviders;
|
||||||
@ -38,7 +38,7 @@ const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
|||||||
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||||
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
||||||
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
|
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
|
||||||
const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
|
// const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
|
||||||
const CompletionHistory = lazy(
|
const CompletionHistory = lazy(
|
||||||
() => import("@/pages/tenant/CompletionHistory"),
|
() => import("@/pages/tenant/CompletionHistory"),
|
||||||
);
|
);
|
||||||
@ -51,6 +51,13 @@ const PromptTestCases = lazy(() => import("@/pages/tenant/PromptTestCases"));
|
|||||||
const PromptTestCaseCreate = lazy(
|
const PromptTestCaseCreate = lazy(
|
||||||
() => import("@/pages/tenant/PromptTestCaseCreate"),
|
() => import("@/pages/tenant/PromptTestCaseCreate"),
|
||||||
);
|
);
|
||||||
|
const TenantAIProviders = lazy(
|
||||||
|
() => import("@/pages/tenant/TenantAIProviders"),
|
||||||
|
);
|
||||||
|
const TenantAIProviderCreate = lazy(
|
||||||
|
() => import("@/pages/tenant/TenantAIProviderCreate"),
|
||||||
|
);
|
||||||
|
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -197,9 +204,17 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/ai/completions/:completionId",
|
path: "/tenant/ai/completions/:completionId",
|
||||||
element: <LazyRoute component={CompletionDetail} />,
|
element: <LazyRoute component={CompletionDetail} />,
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// path: "/tenant/ai/config",
|
||||||
|
// element: <LazyRoute component={AIGateway} />,
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
path: "/tenant/ai/config",
|
path: "/tenant/ai/providers",
|
||||||
element: <LazyRoute component={AIGateway} />,
|
element: <LazyRoute component={TenantAIProviders} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/ai/providers/create",
|
||||||
|
element: <LazyRoute component={TenantAIProviderCreate} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/tenant/ai/prompts",
|
path: "/tenant/ai/prompts",
|
||||||
@ -226,7 +241,11 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
element: <LazyRoute component={PromptTestCases} />,
|
element: <LazyRoute component={PromptTestCases} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/tenant/ai/knowledge",
|
path: "/tenant/ai/dashboard",
|
||||||
element: <LazyRoute component={AIGateway} />,
|
element: <LazyRoute component={TenantAIDashboard} />,
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// path: "/tenant/ai/knowledge",
|
||||||
|
// element: <LazyRoute component={AIGateway} />,
|
||||||
|
// },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -114,6 +114,11 @@ class AIService {
|
|||||||
return unwrap<TenantAIConfig[]>(response);
|
return unwrap<TenantAIConfig[]>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConfig(provider: string): Promise<TenantAIConfig> {
|
||||||
|
const response = await apiClient.get(`/ai/config/${encodeURIComponent(provider)}`);
|
||||||
|
return unwrap<TenantAIConfig>(response);
|
||||||
|
}
|
||||||
|
|
||||||
async testConfig(provider: string): Promise<AIHealthResponse> {
|
async testConfig(provider: string): Promise<AIHealthResponse> {
|
||||||
const response = await apiClient.post(`/ai/config/${encodeURIComponent(provider)}/test`, {});
|
const response = await apiClient.post(`/ai/config/${encodeURIComponent(provider)}/test`, {});
|
||||||
return unwrap<AIHealthResponse>(response);
|
return unwrap<AIHealthResponse>(response);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user