feat(awooop): surface ai provider route status
This commit is contained in:
@@ -305,6 +305,42 @@ interface CallbackRepliesResponse {
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
interface AiRoutePolicyItem {
|
||||
priority: number;
|
||||
provider_name: string;
|
||||
url?: string | null;
|
||||
workload_type?: string | null;
|
||||
reason?: string | null;
|
||||
role?: string | null;
|
||||
runtime?: string | null;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
interface AiRouteHealthItem {
|
||||
status?: string | null;
|
||||
host?: string | null;
|
||||
latency_ms?: number | null;
|
||||
reason?: string | null;
|
||||
checked_at?: number | null;
|
||||
from_cache?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
interface AiRouteStatusResponse {
|
||||
schema_version: string;
|
||||
workload_type: string;
|
||||
policy_order: AiRoutePolicyItem[];
|
||||
selected_provider?: string | null;
|
||||
selected_url?: string | null;
|
||||
selected_model?: string | null;
|
||||
fallback_chain: AiRoutePolicyItem[];
|
||||
route_reason: string;
|
||||
route_source: string;
|
||||
route_error?: string | null;
|
||||
health: Record<string, AiRouteHealthItem>;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 常數
|
||||
// =============================================================================
|
||||
@@ -1384,6 +1420,161 @@ function CallbackReplyEvidencePanel({
|
||||
);
|
||||
}
|
||||
|
||||
function aiRouteHealthLabelKey(status?: string | null) {
|
||||
if (
|
||||
status === "healthy" ||
|
||||
status === "slow" ||
|
||||
status === "degraded" ||
|
||||
status === "offline" ||
|
||||
status === "not_checked"
|
||||
) {
|
||||
return `health.${status}`;
|
||||
}
|
||||
return "health.unknown";
|
||||
}
|
||||
|
||||
function aiRouteRoleLabelKey(role?: string | null) {
|
||||
if (
|
||||
role === "primary" ||
|
||||
role === "secondary" ||
|
||||
role === "local_fallback" ||
|
||||
role === "final_fallback"
|
||||
) {
|
||||
return `roles.${role}`;
|
||||
}
|
||||
return "roles.ollama";
|
||||
}
|
||||
|
||||
function AiRouteStatusPanel({
|
||||
status,
|
||||
error,
|
||||
}: {
|
||||
status: AiRouteStatusResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const t = useTranslations("awooop.aiRouteStatus");
|
||||
const policy = status?.policy_order ?? [];
|
||||
const selectedProvider = status?.selected_provider ?? null;
|
||||
const selectedModel = status?.selected_model ?? null;
|
||||
const checkedAt = status?.checked_at
|
||||
? new Date(status.checked_at).toLocaleTimeString("zh-TW", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--";
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-[#1f5b9b]" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
|
||||
<p className="text-xs text-[#77736a]">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="border border-[#9bb6d9] bg-[#eef5ff] px-2 py-0.5 text-xs font-semibold text-[#1f5b9b]">
|
||||
{selectedProvider
|
||||
? t("selected", { provider: selectedProvider })
|
||||
: t("selectedEmpty")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="px-4 py-4 text-sm text-[#9f2f25]">
|
||||
{t("error", { error })}
|
||||
</div>
|
||||
) : !status ? (
|
||||
<div className="px-4 py-4 text-sm text-[#5f5b52]">
|
||||
{t("empty")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-3">
|
||||
<div className="bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("fields.workload")}</p>
|
||||
<p className="mt-2 font-mono text-sm font-semibold text-[#141413]">
|
||||
{status.workload_type || "--"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
|
||||
{t("fields.checkedAt", { time: checkedAt })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("fields.primary")}</p>
|
||||
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]">
|
||||
{selectedProvider ?? "--"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
|
||||
{selectedModel
|
||||
? t("fields.model", { model: selectedModel })
|
||||
: t("fields.modelEmpty")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("fields.reason")}</p>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-[#5f5b52]">
|
||||
{status.route_error
|
||||
? t("fields.routeError", { error: status.route_error })
|
||||
: status.route_reason || "--"}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs text-[#77736a]">
|
||||
{status.route_source}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2 xl:grid-cols-4">
|
||||
{policy.map((item) => {
|
||||
const health = status.health?.[item.provider_name];
|
||||
const healthKey = aiRouteHealthLabelKey(health?.status);
|
||||
const roleKey = aiRouteRoleLabelKey(item.role);
|
||||
const isSelected = selectedProvider === item.provider_name;
|
||||
const latency = typeof health?.latency_ms === "number"
|
||||
? `${health.latency_ms.toFixed(1)}ms`
|
||||
: "--";
|
||||
|
||||
return (
|
||||
<article key={`${item.priority}-${item.provider_name}`} className="bg-white px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-xs font-semibold text-[#141413]">
|
||||
{item.provider_name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#77736a]">
|
||||
{t(roleKey as never)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 border px-2 py-0.5 text-xs font-semibold",
|
||||
isSelected
|
||||
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
|
||||
: item.runtime === "cloud"
|
||||
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
|
||||
: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]"
|
||||
)}
|
||||
>
|
||||
{isSelected ? t("badges.active") : t("badges.standby")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-xs leading-5 text-[#5f5b52]">
|
||||
<p>{t("fields.health", { status: t(healthKey as never) })}</p>
|
||||
<p>{t("fields.latency", { latency })}</p>
|
||||
<p className="truncate font-mono text-[#77736a]">
|
||||
{item.url || t("fields.noUrl")}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
@@ -1400,6 +1591,8 @@ export default function RunsPage() {
|
||||
const [callbackEvents, setCallbackEvents] = useState<CallbackReplyEvent[]>([]);
|
||||
const [callbackEventsTotal, setCallbackEventsTotal] = useState(0);
|
||||
const [callbackEventsError, setCallbackEventsError] = useState<string | null>(null);
|
||||
const [aiRouteStatus, setAiRouteStatus] = useState<AiRouteStatusResponse | null>(null);
|
||||
const [aiRouteStatusError, setAiRouteStatusError] = useState<string | null>(null);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -1532,6 +1725,25 @@ export default function RunsPage() {
|
||||
setCallbackEventsError(`HTTP ${callbackRes.status}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const routeStatusRes = await fetch(
|
||||
`${API_BASE}/api/v1/platform/ai-route-status?workload_type=deep_rca`
|
||||
);
|
||||
if (routeStatusRes.ok) {
|
||||
const routeStatusData: AiRouteStatusResponse = await routeStatusRes.json();
|
||||
setAiRouteStatus(routeStatusData);
|
||||
setAiRouteStatusError(null);
|
||||
} else {
|
||||
setAiRouteStatus(null);
|
||||
setAiRouteStatusError(`HTTP ${routeStatusRes.status}`);
|
||||
}
|
||||
} catch (routeStatusError) {
|
||||
setAiRouteStatus(null);
|
||||
setAiRouteStatusError(
|
||||
routeStatusError instanceof Error ? routeStatusError.message : "route status failed"
|
||||
);
|
||||
}
|
||||
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
@@ -1721,6 +1933,11 @@ export default function RunsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<AiRouteStatusPanel
|
||||
status={aiRouteStatus}
|
||||
error={aiRouteStatusError}
|
||||
/>
|
||||
|
||||
<SourceDossierCoveragePanel
|
||||
coverage={dossierCoverage}
|
||||
error={dossierCoverageError}
|
||||
|
||||
Reference in New Issue
Block a user