mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
186 lines
7.0 KiB
JavaScript
186 lines
7.0 KiB
JavaScript
"use client";
|
|
|
|
/**
|
|
* ModelAvailabilityBadge — compact inline status indicator
|
|
*
|
|
* Shows green when all models are operational, or amber/red when there are
|
|
* issues, with a hover popover for details and cooldown clearing.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Button } from "@/shared/components";
|
|
import { useNotificationStore } from "@/store/notificationStore";
|
|
|
|
const STATUS_CONFIG = {
|
|
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
|
|
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
|
|
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
|
|
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
|
|
};
|
|
|
|
export default function ModelAvailabilityBadge() {
|
|
const [data, setData] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [clearing, setClearing] = useState(null);
|
|
const ref = useRef(null);
|
|
const notify = useNotificationStore();
|
|
|
|
const fetchStatus = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/models/availability");
|
|
if (res.ok) {
|
|
const json = await res.json();
|
|
setData(json);
|
|
}
|
|
} catch {
|
|
// silent fail — will retry
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchStatus();
|
|
const interval = setInterval(fetchStatus, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchStatus]);
|
|
|
|
// Close popover on outside click
|
|
useEffect(() => {
|
|
const handleClick = (e) => {
|
|
if (ref.current && !ref.current.contains(e.target)) setExpanded(false);
|
|
};
|
|
if (expanded) document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [expanded]);
|
|
|
|
const handleClearCooldown = async (provider, model) => {
|
|
setClearing(`${provider}:${model}`);
|
|
try {
|
|
const res = await fetch("/api/models/availability", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action: "clearCooldown", provider, model }),
|
|
});
|
|
if (res.ok) {
|
|
notify.success(`Cooldown cleared for ${model}`);
|
|
await fetchStatus();
|
|
} else {
|
|
notify.error("Failed to clear cooldown");
|
|
}
|
|
} catch {
|
|
notify.error("Failed to clear cooldown");
|
|
} finally {
|
|
setClearing(null);
|
|
}
|
|
};
|
|
|
|
if (loading) return null;
|
|
|
|
const models = data?.models || [];
|
|
const unavailableCount = data?.unavailableCount || models.filter((m) => m.status !== "available").length;
|
|
const isHealthy = unavailableCount === 0;
|
|
|
|
// Group unhealthy models by provider
|
|
const byProvider = {};
|
|
models.forEach((m) => {
|
|
if (m.status === "available") return;
|
|
const key = m.provider || "unknown";
|
|
if (!byProvider[key]) byProvider[key] = [];
|
|
byProvider[key].push(m);
|
|
});
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
{/* <button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
|
isHealthy
|
|
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/15"
|
|
: "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">
|
|
{isHealthy ? "verified" : "warning"}
|
|
</span>
|
|
{isHealthy
|
|
? "All models operational"
|
|
: `${unavailableCount} model${unavailableCount !== 1 ? "s" : ""} with issues`}
|
|
</button> */}
|
|
|
|
{expanded && (
|
|
<div className="absolute top-full right-0 mt-2 w-80 bg-surface border border-border rounded-xl shadow-2xl z-50 overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-bg">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="material-symbols-outlined text-[16px]"
|
|
style={{ color: isHealthy ? "#22c55e" : "#f59e0b" }}
|
|
>
|
|
{isHealthy ? "verified" : "warning"}
|
|
</span>
|
|
<span className="text-sm font-semibold text-text-main">Model Status</span>
|
|
</div>
|
|
<button
|
|
onClick={fetchStatus}
|
|
className="p-1 rounded-lg hover:bg-surface text-text-muted hover:text-text-main transition-colors"
|
|
title="Refresh"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-4 py-3 max-h-60 overflow-y-auto">
|
|
{isHealthy ? (
|
|
<p className="text-sm text-text-muted text-center py-2">
|
|
All models are responding normally.
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2.5">
|
|
{Object.entries(byProvider).map(([provider, provModels]) => (
|
|
<div key={provider}>
|
|
<p className="text-xs font-semibold text-text-main mb-1.5 capitalize">{provider}</p>
|
|
<div className="flex flex-col gap-1">
|
|
{provModels.map((m) => {
|
|
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
|
|
const isClearing = clearing === `${m.provider}:${m.model}`;
|
|
return (
|
|
<div
|
|
key={`${m.provider}-${m.model}`}
|
|
className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-surface/30"
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span
|
|
className="material-symbols-outlined text-[14px] shrink-0"
|
|
style={{ color: status.color }}
|
|
>
|
|
{status.icon}
|
|
</span>
|
|
<span className="font-mono text-xs text-text-main truncate">{m.model}</span>
|
|
</div>
|
|
{m.status === "cooldown" && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleClearCooldown(m.provider, m.model)}
|
|
disabled={isClearing}
|
|
className="text-[10px] px-1.5! py-0.5! ml-2"
|
|
>
|
|
{isClearing ? "..." : "Clear"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|