feat: implement AI usage dashboard and provider management features for tenant administration

This commit is contained in:
Yashwin 2026-05-04 19:01:08 +05:30
parent c427e4aa51
commit 1d207d2dcb
8 changed files with 2113 additions and 913 deletions

View File

@ -197,15 +197,25 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
requiredPermission: { resource: "ai" },
},
{
label: "Tenant Config",
path: "/tenant/ai/config",
label: "Tenant AI Providers",
path: "/tenant/ai/providers",
requiredPermission: { resource: "ai" },
},
{
label: "Knowledge (RAG)",
path: "/tenant/ai/knowledge",
label: "AI Usage & Cost Dashboard",
path: "/tenant/ai/dashboard",
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" },
},

View 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

View 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;

View 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;

View 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;

View File

@ -38,7 +38,7 @@ const FileView = lazy(() => import("@/pages/tenant/FileView"));
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
// const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
const CompletionHistory = lazy(
() => import("@/pages/tenant/CompletionHistory"),
);
@ -51,6 +51,13 @@ const PromptTestCases = lazy(() => import("@/pages/tenant/PromptTestCases"));
const PromptTestCaseCreate = lazy(
() => 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
const RouteLoader = (): ReactElement => (
@ -197,9 +204,17 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/ai/completions/:completionId",
element: <LazyRoute component={CompletionDetail} />,
},
// {
// path: "/tenant/ai/config",
// element: <LazyRoute component={AIGateway} />,
// },
{
path: "/tenant/ai/config",
element: <LazyRoute component={AIGateway} />,
path: "/tenant/ai/providers",
element: <LazyRoute component={TenantAIProviders} />,
},
{
path: "/tenant/ai/providers/create",
element: <LazyRoute component={TenantAIProviderCreate} />,
},
{
path: "/tenant/ai/prompts",
@ -226,7 +241,11 @@ export const tenantAdminRoutes: RouteConfig[] = [
element: <LazyRoute component={PromptTestCases} />,
},
{
path: "/tenant/ai/knowledge",
element: <LazyRoute component={AIGateway} />,
path: "/tenant/ai/dashboard",
element: <LazyRoute component={TenantAIDashboard} />,
},
// {
// path: "/tenant/ai/knowledge",
// element: <LazyRoute component={AIGateway} />,
// },
];

View File

@ -114,6 +114,11 @@ class AIService {
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> {
const response = await apiClient.post(`/ai/config/${encodeURIComponent(provider)}/test`, {});
return unwrap<AIHealthResponse>(response);