feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard
This commit is contained in:
@@ -739,8 +739,11 @@ export async function listTenants(): Promise<TenantRow[]> {
|
||||
return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
|
||||
}
|
||||
|
||||
export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
|
||||
export async function createTenant(name: string, slug: string, ownerEmail?: string, plan?: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants`, {
|
||||
method: "POST", headers: authHeaders(true),
|
||||
body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "", plan: plan ?? "free" }),
|
||||
}));
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string; warn?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `create tenant failed: ${res.status}`);
|
||||
if (d.warn) throw new Error(d.warn); // 租户已建但指定 owner 失败 → 当提示
|
||||
@@ -783,3 +786,33 @@ export async function setSharedBilling(tenantId: string, on: boolean): Promise<v
|
||||
throw new Error(d.error ?? `set shared billing failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// —— KB 存储迁移(管理员一次性运维操作:owner/kb 作用域 → space_id/kb 作用域)——
|
||||
export interface MigrateKbResult {
|
||||
total: number; // 扫描文档总数
|
||||
enqueued: number; // 成功入队重灌的文档数
|
||||
skipped: number; // 已是新作用域跳过数
|
||||
}
|
||||
export async function migrateKbStorage(): Promise<MigrateKbResult> {
|
||||
const res = guard(await fetch(`${ADMIN}/migrate-kb-storage`, { method: "POST", headers: authHeaders(true) }));
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<MigrateKbResult> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `migrate kb failed: ${res.status}`);
|
||||
return { total: d.total ?? 0, enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
|
||||
}
|
||||
|
||||
export async function setTenantPlan(tenantId: string, plan: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/plan`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ plan }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `set tenant plan failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTenantStatus(tenantId: string, status: string): Promise<void> {
|
||||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/status`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ status }) }));
|
||||
if (!res.ok) {
|
||||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(d.error ?? `set tenant status failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { grantCredits, type TenantRow } from "../api";
|
||||
|
||||
// GrantCreditsModal:替换 window.prompt 的完整充值弹窗。
|
||||
// 支持正数充值 / 负数人工校正;带操作原因 + 备注 + 风险提示 + 汇率说明。
|
||||
|
||||
const MICRO = 1_000_000;
|
||||
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const REASONS = [
|
||||
"线下回款核销",
|
||||
"补偿发放(服务故障)",
|
||||
"测试赠送",
|
||||
"人工校正(超扣补偿)",
|
||||
"合同预付款",
|
||||
"其它(见备注)",
|
||||
];
|
||||
|
||||
interface Props {
|
||||
tenant: TenantRow | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function GrantCreditsModal({ tenant, onClose, onDone }: Props) {
|
||||
const [amount, setAmount] = useState("100");
|
||||
const [reason, setReason] = useState(REASONS[0]);
|
||||
const [memo, setMemo] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const amountRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 每次打开聚焦数量输入框
|
||||
useEffect(() => {
|
||||
if (tenant) {
|
||||
setAmount("100");
|
||||
setReason(REASONS[0]);
|
||||
setMemo("");
|
||||
setErr("");
|
||||
setTimeout(() => amountRef.current?.select(), 50);
|
||||
}
|
||||
}, [tenant]);
|
||||
|
||||
if (!tenant) return null;
|
||||
|
||||
const n = Number(amount);
|
||||
const isValid = !isNaN(n) && n !== 0;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!isValid) {
|
||||
setErr("积分数量不能为 0");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setErr("");
|
||||
try {
|
||||
const finalMemo = reason === "其它(见备注)" ? memo : reason + (memo ? `:${memo}` : "");
|
||||
await grantCredits(tenant.id, n, finalMemo);
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">手动发放积分</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">操作将写入账本分录,可在对账页追溯</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 租户信息 */}
|
||||
<div className="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50 p-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-100 text-sm font-bold text-violet-700">
|
||||
{tenant.name.charAt(0)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800">{tenant.name}</div>
|
||||
<div className="font-mono text-xs text-gray-400">
|
||||
当前余额:<span className="font-medium text-violet-600">{credits(tenant.credit_balance_micro)}</span> 积分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 积分数量 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">积分数量</label>
|
||||
<input
|
||||
ref={amountRef}
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => { setAmount(e.target.value); setErr(""); }}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm font-mono focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
placeholder="正数=充值,负数=扣减/校正"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">正数=充值/发放,负数=扣减/校正。当前汇率:1 积分 = 1000 token</p>
|
||||
</div>
|
||||
|
||||
{/* 操作原因 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">操作原因</label>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-700 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
>
|
||||
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">
|
||||
备注(可选){reason === "其它(见备注)" && <span className="text-rose-500"> *必填</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="如:2026-07-18 线下转账 ¥500 核销"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 风险提示 */}
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
⚠️ 此操作会立即更新租户余额并写入账本,无法撤销。如需扣减请填写负数。
|
||||
</div>
|
||||
|
||||
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">操作完成后自动记入审计日志</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={saving || !isValid}
|
||||
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-40"
|
||||
>
|
||||
{saving ? "发放中…" : "确认发放"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { adminOrders, adminReconcile, adminRefundOrder, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
||||
|
||||
|
||||
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
|
||||
// - 状态计数卡片(pending/paid/expired + 累计到账额)
|
||||
// - 订单流(可按状态筛)
|
||||
@@ -28,6 +29,12 @@ export function OrderStream() {
|
||||
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [refunding, setRefunding] = useState(""); // 正在退款的订单 id
|
||||
// 退款 Modal
|
||||
const [refundTarget, setRefundTarget] = useState<PayOrder | null>(null);
|
||||
const [refundMemo, setRefundMemo] = useState("");
|
||||
const [refundErr, setRefundErr] = useState("");
|
||||
const refundMemoRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
||||
const load = useCallback(() => {
|
||||
adminOrders(filter)
|
||||
@@ -52,29 +59,94 @@ export function OrderStream() {
|
||||
}
|
||||
};
|
||||
|
||||
const refund = async (o: PayOrder) => {
|
||||
// 退款不可逆(冲销积分、订单置 refunded),且真渠道钱需另在商户后台原路退——二次确认 + 留原因。
|
||||
const memo = window.prompt(
|
||||
`确认退款?将冲销 ${credits(o.credits_micro)} 积分并把订单置为已退款(余额可能因积分已消费而变负)。\n` +
|
||||
`注意:微信真单的钱款原路退回需另在商户后台操作,此处仅冲销本地积分与订单态。\n\n请填写退款原因:`,
|
||||
"",
|
||||
);
|
||||
if (memo === null) return; // 取消
|
||||
setRefunding(o.id);
|
||||
setErr("");
|
||||
const openRefund = (o: PayOrder) => {
|
||||
setRefundTarget(o);
|
||||
setRefundMemo("");
|
||||
setRefundErr("");
|
||||
setTimeout(() => refundMemoRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const doRefund = async () => {
|
||||
if (!refundTarget) return;
|
||||
if (!refundMemo.trim()) { setRefundErr("退款原因不能为空"); return; }
|
||||
setRefunding(refundTarget.id);
|
||||
setRefundErr("");
|
||||
try {
|
||||
const r = await adminRefundOrder(o.id, memo);
|
||||
const r = await adminRefundOrder(refundTarget.id, refundMemo.trim());
|
||||
if (r.status === "noop") setErr(r.detail ?? "该订单无需退款");
|
||||
setRefundTarget(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setRefundErr((e as Error).message);
|
||||
} finally {
|
||||
setRefunding("");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 退款 Modal */}
|
||||
{refundTarget && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && setRefundTarget(null)}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">人工退款</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">冲销订单积分并调整余额</p>
|
||||
</div>
|
||||
<button onClick={() => setRefundTarget(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 订单摘要 */}
|
||||
<div className="space-y-2 rounded-xl border border-gray-100 bg-gray-50 p-3 text-sm">
|
||||
<div className="flex justify-between text-xs uppercase tracking-wide text-gray-400">
|
||||
<span>订单信息</span>
|
||||
<span className="font-mono">{refundTarget.id.slice(-12)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-gray-600">租户</span><span className="font-medium">{refundTarget.tenant_name || refundTarget.tenant_id}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-600">充値积分</span><span className="font-medium text-gray-800">{credits(refundTarget.credits_micro)} 积分</span></div>
|
||||
{refundTarget.amount_fen > 0 && <div className="flex justify-between"><span className="text-gray-600">充値金额</span><span className="font-medium text-emerald-600">{yuan(refundTarget.amount_fen)}</span></div>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-600">退款原因(必填)</label>
|
||||
<textarea
|
||||
ref={refundMemoRef}
|
||||
rows={3}
|
||||
value={refundMemo}
|
||||
onChange={(e) => { setRefundMemo(e.target.value); setRefundErr(""); }}
|
||||
placeholder="请说明退款原因,此内容将写入审计日志…"
|
||||
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs text-rose-700 space-y-1">
|
||||
<div className="font-semibold">⚠️ 操作风险提示</div>
|
||||
<div>· 本操作将冲销 <strong>{credits(refundTarget.credits_micro)} 积分</strong>,若已消费则余额可能变负</div>
|
||||
<div>· 微信真实资金的原路退回需另在商户后台操作,此处仅冲销本地积分</div>
|
||||
<div>· 操作不可撤销</div>
|
||||
</div>
|
||||
{refundErr && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{refundErr}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<span className="text-xs text-gray-400">操作将记入审计日志</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setRefundTarget(null)} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-500 hover:bg-gray-50">取消</button>
|
||||
<button
|
||||
onClick={() => void doRefund()}
|
||||
disabled={!!refunding || !refundMemo.trim()}
|
||||
className="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-40"
|
||||
>
|
||||
{refunding ? "退款中…" : "确认退款"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<h3 className="text-sm font-semibold text-gray-700">充值订单与对账</h3>
|
||||
<span className="text-[11px] text-gray-400">全平台充值单落地视角 + 账本一致性核对</span>
|
||||
@@ -158,7 +230,7 @@ export function OrderStream() {
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{o.status === "paid" ? (
|
||||
<button onClick={() => void refund(o)} disabled={refunding === o.id}
|
||||
<button onClick={() => openRefund(o)} disabled={refunding === o.id}
|
||||
className="rounded border border-rose-200 px-2 py-0.5 text-[11px] text-rose-600 hover:bg-rose-50 disabled:opacity-40">
|
||||
{refunding === o.id ? "退款中…" : "退款"}
|
||||
</button>
|
||||
|
||||
@@ -1,33 +1,60 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api";
|
||||
import { listAudit, type AuditEntry } from "../api";
|
||||
|
||||
// 操作方法配色。
|
||||
// 操作方法配色
|
||||
const ACTION_STYLE: Record<string, string> = {
|
||||
POST: "bg-emerald-50 text-emerald-600",
|
||||
PUT: "bg-amber-50 text-amber-600",
|
||||
DELETE: "bg-rose-50 text-rose-600",
|
||||
PATCH: "bg-violet-50 text-violet-600",
|
||||
POST: "bg-emerald-50 text-emerald-600 border border-emerald-100",
|
||||
PUT: "bg-amber-50 text-amber-600 border border-amber-100",
|
||||
DELETE: "bg-rose-50 text-rose-600 border border-rose-100",
|
||||
PATCH: "bg-violet-50 text-violet-600 border border-violet-100",
|
||||
GET: "bg-gray-50 text-gray-600 border border-gray-100",
|
||||
};
|
||||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||||
|
||||
// uid 太长,展示时截断(保留首尾,hover 看全)。
|
||||
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
|
||||
const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id || "系统");
|
||||
const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false });
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
const ROUTE_OPTS = [
|
||||
{ value: "", label: "所有路由" },
|
||||
{ value: "/api/v1/admin", label: "管理员接口 (/admin)" },
|
||||
{ value: "/api/v1/prompts", label: "提示词接口 (/prompts)" },
|
||||
{ value: "/api/v1/auth", label: "鉴权接口 (/auth)" },
|
||||
{ value: "/api/v1/spaces", label: "空间接口 (/spaces)" },
|
||||
];
|
||||
|
||||
export function AuditPage() {
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
// 筛选器状态
|
||||
const [methodFilter, setMethodFilter] = useState<string>(""); // 空 = 全部
|
||||
const [routeFilter, setRouteFilter] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const load = async (pageNum = page, showRef = false) => {
|
||||
if (showRef) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
|
||||
try {
|
||||
const [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]);
|
||||
setAudit(a);
|
||||
setEvents(e);
|
||||
const offset = (pageNum - 1) * LIMIT;
|
||||
// 从后端载入比 LIMIT 稍微多一条,以此判断是否有下一页
|
||||
const data = await listAudit(LIMIT + 1, offset);
|
||||
if (data.length > LIMIT) {
|
||||
setAudit(data.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setAudit(data);
|
||||
setHasMore(false);
|
||||
}
|
||||
setUpdatedAt(new Date());
|
||||
setErr("");
|
||||
} catch (er) {
|
||||
@@ -39,36 +66,47 @@ export function AuditPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => void load(), 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
void load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载审计记录中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">加载失败:{err}</div>;
|
||||
// 前端过滤(配合后端分页后的过滤,或按需做简单的前端实时匹配)
|
||||
const filteredAudit = audit.filter((a) => {
|
||||
if (methodFilter && a.action !== methodFilter) return false;
|
||||
if (routeFilter && !a.path.startsWith(routeFilter)) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchActor = a.actor.toLowerCase().includes(q);
|
||||
const matchIp = a.ip.toLowerCase().includes(q);
|
||||
const matchDetail = a.detail?.toLowerCase().includes(q) ?? false;
|
||||
const matchPath = a.path.toLowerCase().includes(q);
|
||||
if (!matchActor && !matchIp && !matchDetail && !matchPath) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) setPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) setPage(page + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 顶栏 */}
|
||||
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mr-auto">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">审计 & 安全事件</h3>
|
||||
<p className="text-xs text-gray-400">敏感操作留痕 + 输入护栏命中 · 只增不改,供运维溯源</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-gray-150 bg-white p-5 shadow-sm">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">敏感操作审计</h3>
|
||||
<p className="text-xs text-gray-400">敏感操作全链路留痕 · 只增不改,供合规审计与故障溯源</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-100">
|
||||
<Stat value={audit.length} label="操作留痕" />
|
||||
<Stat value={blocked} label="护栏拦截" bad={blocked > 0} />
|
||||
<Stat value={suspect} label="灰区放行" warn={suspect > 0} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
{updatedAt && <span>更新于 {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{updatedAt && <span>最近更新:{updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
onClick={() => void load(page, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
@@ -78,54 +116,134 @@ export function AuditPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全事件(护栏命中) */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">安全事件</h3>
|
||||
<span className="text-xs text-gray-400">输入护栏拦截 / 灰区放行({events.length})</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无护栏命中 🎉</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${e.kind === "blocked" ? "bg-rose-50 text-rose-600" : "bg-amber-50 text-amber-600"}`}>
|
||||
{e.kind === "blocked" ? "拦截" : "灰区"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-gray-700" title={e.reason}>{e.reason || "(无原因)"}</span>
|
||||
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:inline">{e.method} {e.path}</code>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={e.actor}>{shortId(e.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{e.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(e.at)}</span>
|
||||
</div>
|
||||
{/* 筛选工具栏 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{/* HTTP Method Button Group */}
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-gray-150 bg-gray-50 p-1 text-xs">
|
||||
{["", "GET", "POST", "PUT", "DELETE"].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMethodFilter(m); setPage(1); }}
|
||||
className={`rounded px-3 py-1 font-semibold transition-all ${
|
||||
methodFilter === m
|
||||
? "bg-violet-600 text-white shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{m || "全部"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 操作审计 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作审计</h3>
|
||||
<span className="text-xs text-gray-400">改配置 / 改密钥 / 激活提示词 / 审批 等变更操作({audit.length})</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Route prefix select */}
|
||||
<select
|
||||
value={routeFilter}
|
||||
onChange={(e) => { setRouteFilter(e.target.value); setPage(1); }}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-700 focus:outline-none focus:border-violet-400 shadow-sm"
|
||||
>
|
||||
{ROUTE_OPTS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索操作者 ID / IP / 详情…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-56 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{audit.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400">暂无操作留痕</div>
|
||||
</div>
|
||||
|
||||
{/* 审计日志列表 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-baseline justify-between border-b pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">操作日志明细</h3>
|
||||
<span className="text-xs text-gray-400">当前页展示 {filteredAudit.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-xs text-gray-400 animate-pulse">正在载入审计记录…</div>
|
||||
) : err ? (
|
||||
<div className="py-12 text-center text-xs text-rose-500">加载失败:{err}</div>
|
||||
) : filteredAudit.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-gray-400">没有匹配的审计记录</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{audit.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 py-2.5 text-xs">
|
||||
<span className={`w-14 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{a.action}
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-600" title={a.path}>{a.path}</code>
|
||||
<span className={`shrink-0 font-mono text-[10px] ${statusTone(a.status)}`}>{a.status}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={a.actor}>{shortId(a.actor)}</span>
|
||||
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{a.ip}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-300">{fmt(a.at)}</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium w-16">方法</th>
|
||||
<th className="py-2 pr-3 font-medium">路由路径</th>
|
||||
<th className="py-2 pr-3 font-medium w-16">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">详情</th>
|
||||
<th className="py-2 pr-3 font-medium w-24">操作人</th>
|
||||
<th className="py-2 pr-3 font-medium w-28">客户端 IP</th>
|
||||
<th className="py-2 font-medium w-36">触发时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAudit.map((a) => (
|
||||
<tr key={a.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||||
<td className="py-2.5 pr-3">
|
||||
<span className={`inline-block w-14 rounded px-1.5 py-0.5 text-center text-[10px] font-bold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{a.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 max-w-[12rem] truncate font-mono text-[11px] text-gray-600" title={a.path}>
|
||||
{a.path}
|
||||
</td>
|
||||
<td className={`py-2.5 pr-3 font-mono font-semibold ${statusTone(a.status)}`}>
|
||||
{a.status}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-500 text-[11px] max-w-[20rem] truncate" title={a.detail}>
|
||||
{a.detail || "—"}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 font-mono text-gray-400" title={a.actor}>
|
||||
{shortId(a.actor)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-400 font-mono">
|
||||
{a.ip}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-400">
|
||||
{fmt(a.at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -133,11 +251,3 @@ export function AuditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) {
|
||||
return (
|
||||
<div className="px-4 first:pl-0">
|
||||
<div className={`text-xl font-semibold tracking-tight ${bad ? "text-rose-500" : warn ? "text-amber-600" : "text-gray-900"}`}>{value}</div>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ModelManager } from "../components/ModelManager";
|
||||
import { adminDatasources, type DatasourceKB } from "../api";
|
||||
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
|
||||
|
||||
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
|
||||
// 此前该页 GraphRAG 拓扑图与「向量/全文/图谱权重滑块」全 mock——而且权重概念本身是虚构的:
|
||||
// mcp-go 的 RRF 融合是各路等权的倒排互惠融合(rrfK=60 平滑常数),没有"每路占几成"的权重。
|
||||
// 故删假滑块+假拓扑,换成真数据源清单 + 诚实的检索管线说明。
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { general: "通用", folder: "文件夹", project: "项目", case: "案例" };
|
||||
const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}万` : `${n}`);
|
||||
|
||||
@@ -15,6 +10,17 @@ export function DatasourcesPage() {
|
||||
const [rows, setRows] = useState<DatasourceKB[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// 运行时的 active embedding 模型展示
|
||||
const [activeEmbedding, setActiveEmbedding] = useState<string>("加载中…");
|
||||
|
||||
// 运维工具折叠状态
|
||||
const [opsOpen, setOpsOpen] = useState(false);
|
||||
|
||||
// 迁移 Modal 状态
|
||||
const [migrationModal, setMigrationModal] = useState<"none" | "confirm" | "running" | "done">("none");
|
||||
const [migrationResult, setMigrationResult] = useState<MigrateKbResult | null>(null);
|
||||
const [migrationErr, setMigrationErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
adminDatasources()
|
||||
@@ -25,19 +31,203 @@ export function DatasourcesPage() {
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// 获取激活的 embedding 模型
|
||||
listModels("embedding")
|
||||
.then((models) => {
|
||||
const active = models.find((m) => m.active);
|
||||
if (active) {
|
||||
setActiveEmbedding(`${active.model} (${active.provider})`);
|
||||
} else {
|
||||
setActiveEmbedding("未配置/未激活 (将回退代码默认)");
|
||||
}
|
||||
})
|
||||
.catch(() => setActiveEmbedding("获取失败"));
|
||||
}, []);
|
||||
|
||||
const startMigration = async () => {
|
||||
setMigrationModal("running");
|
||||
setMigrationErr("");
|
||||
try {
|
||||
const res = await migrateKbStorage();
|
||||
setMigrationResult(res);
|
||||
setMigrationModal("done");
|
||||
} catch (e) {
|
||||
setMigrationErr((e as Error).message);
|
||||
setMigrationModal("confirm"); // 退回到确认态展示错误
|
||||
}
|
||||
};
|
||||
|
||||
const totalWords = rows.reduce((a, r) => a + r.total_words, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Embedding 模型配置(真实组件) */}
|
||||
<ModelManager
|
||||
kind="embedding"
|
||||
title="Embedding 模型(embedding → mcp-go RAG)"
|
||||
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
modelHint="text-embedding-v3"
|
||||
/>
|
||||
{/* 顶部激活模型只读展示 & 引导 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">当前激活向量化模型 (Embedding)</h4>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="#/models"
|
||||
onClick={(e) => {
|
||||
// 如果使用 hash 路由或 react-router,可正常导航。这里提示用户去模型页配置
|
||||
window.location.hash = "#/models";
|
||||
}}
|
||||
className="text-xs text-violet-600 hover:text-violet-700 font-medium hover:underline flex items-center gap-1"
|
||||
>
|
||||
前往模型管理修改 →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 运维工具 (RAG 迁移) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<button
|
||||
onClick={() => setOpsOpen(!opsOpen)}
|
||||
className="flex w-full items-center justify-between font-semibold text-gray-700 focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-gray-700">运维工具</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{opsOpen ? "收起 ▲" : "展开 ▼"}</span>
|
||||
</button>
|
||||
|
||||
{opsOpen && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 text-xs bg-amber-50/50 border border-amber-100 p-4 rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold text-amber-800 flex items-center gap-1.5">
|
||||
<span>知识库存储目录路径迁移</span>
|
||||
<span className="bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded text-[9px] font-bold">建议在低峰期操作</span>
|
||||
</div>
|
||||
<p className="text-gray-500 leading-relaxed max-w-xl">
|
||||
将平台全量知识库底层的存储目录规范,由早期版本的 <code className="bg-gray-100 px-1 rounded">owner/kb_id/doc</code> 升级为新版本统一的 <code className="bg-gray-100 px-1 rounded">space_id/kb_id/doc</code> 规范。此操作将遍历所有文件,并在数据库与对象存储中更新映射关系,迁移期间可能产生轻微检索延迟。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMigrationModal("confirm")}
|
||||
className="self-start md:self-center shrink-0 bg-amber-600 hover:bg-amber-700 text-white text-xs font-semibold py-2 px-4 rounded-lg shadow-sm transition"
|
||||
>
|
||||
开始迁移
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 迁移 Modal */}
|
||||
{migrationModal !== "none" && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
{migrationModal === "confirm" && "确认开始存储迁移?"}
|
||||
{migrationModal === "running" && "正在迁移中…"}
|
||||
{migrationModal === "done" && "迁移完成 🎉"}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">一键升级平台 KB 作用域至 space_id</p>
|
||||
</div>
|
||||
{migrationModal !== "running" && (
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="text-xl leading-none text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5 text-sm">
|
||||
{migrationModal === "confirm" && (
|
||||
<>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
您将触发全平台的数据目录热迁移。这会将旧版未挂载空间的独立知识库映射,统一调整至 Space 空间映射下。
|
||||
</p>
|
||||
<div className="rounded-lg border border-amber-100 bg-amber-50 p-3 text-xs text-amber-700 space-y-1">
|
||||
<div className="font-semibold">⚠️ 运维安全警示:</div>
|
||||
<div>· 这是一个高危数据库与存储热变更操作</div>
|
||||
<div>· 建议执行前对 <code className="font-mono bg-amber-100/50 px-1">sundynix_doc</code> 表进行备份</div>
|
||||
<div>· 确认执行后不可中途停止</div>
|
||||
</div>
|
||||
{migrationErr && (
|
||||
<div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">
|
||||
操作失败:{migrationErr}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{migrationModal === "running" && (
|
||||
<div className="flex flex-col items-center justify-center py-6 space-y-3">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-500 font-medium animate-pulse">正在扫描文档并重新入队,请勿关闭窗口…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationModal === "done" && migrationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
数据升级指令已成功执行,各文档已重新入队执行 RRF 索引重灌。
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 border rounded-xl overflow-hidden bg-gray-50/50">
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">已扫描文档总数</span>
|
||||
<span className="font-semibold text-gray-800 font-mono">{migrationResult.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">入队重灌文档数</span>
|
||||
<span className="font-semibold text-amber-600 font-mono">{migrationResult.enqueued}</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-3 text-xs">
|
||||
<span className="text-gray-500">跳过(已是新作用域)</span>
|
||||
<span className="font-semibold text-gray-600 font-mono">{migrationResult.skipped}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4">
|
||||
{migrationModal === "confirm" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-xs text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void startMigration()}
|
||||
className="rounded-lg bg-amber-600 hover:bg-amber-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
|
||||
>
|
||||
确认迁移
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{migrationModal === "done" && (
|
||||
<button
|
||||
onClick={() => setMigrationModal("none")}
|
||||
className="rounded-lg bg-violet-600 hover:bg-violet-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { guardrailEvents, type GuardrailEvent } from "../api";
|
||||
import { listGuardrailEvents, type GuardrailEventItem } from "../api";
|
||||
|
||||
// 输入护栏观测:真数据(来自 guardrail_event,middleware.Guardrail 命中即落库)。
|
||||
// 命中事件流(blocked 硬拦 / suspect 灰区放行→Tier2 LLM 裁决) + 计数 + 原因分布。
|
||||
//
|
||||
// 诚实边界:护栏规则(Tier1 正则/敏感词 + Tier2 LLM 分类器)目前是中间件里的代码常量,
|
||||
// 尚不支持运行时编辑(改规则需改代码重部署)。故本页只做观测,不摆"能改却不生效"的假配置面。
|
||||
// 若要规则运行时可配,需另建配置存储 + 中间件读库(参考 prompt 控制面的热下发)。
|
||||
|
||||
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600", suspect: "bg-amber-50 text-amber-600" };
|
||||
const KIND_BADGE: Record<string, string> = { blocked: "bg-rose-50 text-rose-600 border border-rose-100", suspect: "bg-amber-50 text-amber-600 border border-amber-100" };
|
||||
const KIND_LABEL: Record<string, string> = { blocked: "硬拦截", suspect: "灰区放行" };
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
// 解析 signals(JSON 数组字符串)为可读标签。
|
||||
function signalLabels(raw: string): string[] {
|
||||
if (!raw) return [];
|
||||
@@ -23,26 +21,40 @@ function signalLabels(raw: string): string[] {
|
||||
}
|
||||
|
||||
export function GuardrailsPage() {
|
||||
const [events, setEvents] = useState<GuardrailEvent[]>([]);
|
||||
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
|
||||
const [filter, setFilter] = useState<"" | "blocked" | "suspect">("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
guardrailEvents(100)
|
||||
const offset = (page - 1) * LIMIT;
|
||||
listGuardrailEvents(LIMIT + 1, offset)
|
||||
.then((r) => {
|
||||
setEvents(r);
|
||||
if (r.length > LIMIT) {
|
||||
setEvents(r.slice(0, LIMIT));
|
||||
setHasMore(true);
|
||||
} else {
|
||||
setEvents(r);
|
||||
setHasMore(false);
|
||||
}
|
||||
setErr("");
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(load, []);
|
||||
|
||||
useEffect(load, [page]);
|
||||
|
||||
const blocked = events.filter((e) => e.kind === "blocked").length;
|
||||
const suspect = events.filter((e) => e.kind === "suspect").length;
|
||||
// 原因/信号 Top(真实命中分布)。
|
||||
|
||||
// 原因/信号 Top(基于当前拉取列表的命中分布)。
|
||||
const topReasons = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const e of events) {
|
||||
@@ -52,34 +64,58 @@ export function GuardrailsPage() {
|
||||
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||||
}, [events]);
|
||||
|
||||
const shown = filter ? events.filter((e) => e.kind === filter) : events;
|
||||
const shown = useMemo(() => {
|
||||
return events.filter((e) => {
|
||||
// 1. 类型过滤
|
||||
if (filter && e.kind !== filter) return false;
|
||||
// 2. 搜索过滤 (Actor 或 IP)
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchActor = e.actor?.toLowerCase().includes(q) ?? false;
|
||||
const matchIp = e.ip?.toLowerCase().includes(q) ?? false;
|
||||
const matchReason = e.reason?.toLowerCase().includes(q) ?? false;
|
||||
if (!matchActor && !matchIp && !matchReason) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [events, filter, searchQuery]);
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载护栏事件中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">护栏事件加载失败:{err}</div>;
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) setPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) setPage(page + 1);
|
||||
};
|
||||
|
||||
if (loading && page === 1) return <div className="text-sm text-gray-400 p-5">加载护栏事件中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500 p-5">护栏事件加载失败:{err}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-800">输入护栏</h2>
|
||||
<h2 className="text-base font-semibold text-gray-800">安全护栏观测</h2>
|
||||
<p className="text-xs text-gray-400">命中事件流 · 硬拦截与灰区放行 · 原因分布(全平台真实命中)</p>
|
||||
</div>
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">刷新</button>
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 shadow-sm">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 计数 */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<Stat label="硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒(近 100 条内)" />
|
||||
<Stat label="灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 执行前裁决" />
|
||||
<Stat label="命中总数" value={String(events.length)} tone="violet" sub="近 100 条护栏事件" />
|
||||
<Stat label="当前页硬拦截" value={String(blocked)} tone="rose" sub="Tier1 命中即拒" />
|
||||
<Stat label="当前页灰区放行" value={String(suspect)} tone="amber" sub="打标 → Tier2 LLM 裁决" />
|
||||
<Stat label="当前页命中总数" value={String(events.length)} tone="violet" sub={`分页数: ${LIMIT}`} />
|
||||
</div>
|
||||
|
||||
{/* 规则说明(诚实:规则在代码里,非运行时可配) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">护栏规则</h4>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">护栏机制说明</h4>
|
||||
<div className="space-y-1.5 text-xs leading-relaxed text-gray-500">
|
||||
<p><span className="font-medium text-gray-700">Tier1(中间件,同步硬拦)</span>:注入正则 + 敏感词库命中即 <span className="text-rose-600">blocked</span> 拒绝请求。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier2(灰区升级)</span>:疑似输入打标 <span className="text-amber-600">suspect</span> 放行,由 Dispatcher 执行前调 LLM 分类器裁决。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier1(中间件,同步硬拦)</span>:注入正则 + 敏感词库命中即 <span className="text-rose-600 font-medium">blocked</span> 拒绝请求。</p>
|
||||
<p><span className="font-medium text-gray-700">Tier2(灰区升级)</span>:疑似输入打标 <span className="text-amber-600 font-medium">suspect</span> 放行,由 Dispatcher 执行前调 LLM 分类器裁决。</p>
|
||||
<p className="text-gray-400">规则当前定义在网关中间件代码中,尚不支持运行时编辑(改规则需改代码重部署)。运行时可配需另建配置存储 + 中间件读库,参考「提示词」控制面的热下发。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,57 +139,100 @@ export function GuardrailsPage() {
|
||||
)}
|
||||
|
||||
{/* 事件流 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700">命中事件流</h4>
|
||||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
|
||||
<button key={v} onClick={() => setFilter(v)}
|
||||
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* IP / Actor Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索操作人 ID / IP / 原因…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs focus:outline-none focus:border-violet-400 w-52 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Type buttons */}
|
||||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||
{([["", "全部"], ["blocked", "硬拦截"], ["suspect", "灰区"]] as const).map(([v, label]) => (
|
||||
<button key={v} onClick={() => { setFilter(v); setPage(1); }}
|
||||
className={`px-3 py-1.5 ${filter === v ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-gray-400">暂无护栏命中 —— 没有可疑输入</div>
|
||||
<div className="py-8 text-center text-xs text-gray-400">暂无护栏命中 —— 没有匹配的可疑输入</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">原因 / 命中信号</th>
|
||||
<th className="py-2 pr-3 font-medium">路径</th>
|
||||
<th className="py-2 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((e) => (
|
||||
<tr key={e.id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3 text-xs text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>{KIND_LABEL[e.kind] ?? e.kind}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
{e.kind === "blocked" ? (
|
||||
<span className="text-xs text-gray-700">{e.reason || "—"}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{signalLabels(e.signals).map((sig, i) => (
|
||||
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
|
||||
))}
|
||||
{signalLabels(e.signals).length === 0 && <span className="text-xs text-gray-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
|
||||
<td className="py-2 text-[11px] text-gray-400">{e.actor || e.ip || "匿名"}</td>
|
||||
<div className="space-y-4">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">时间</th>
|
||||
<th className="py-2 pr-3 font-medium">类型</th>
|
||||
<th className="py-2 pr-3 font-medium">原因 / 命中信号</th>
|
||||
<th className="py-2 pr-3 font-medium">路径</th>
|
||||
<th className="py-2 font-medium">来源 (Actor/IP)</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((e) => (
|
||||
<tr key={e.id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/40 text-xs">
|
||||
<td className="py-2.5 pr-3 text-gray-500">{new Date(e.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${KIND_BADGE[e.kind] ?? "bg-gray-100 text-gray-500"}`}>
|
||||
{KIND_LABEL[e.kind] ?? e.kind}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 max-w-[20rem] truncate" title={e.reason}>
|
||||
{e.kind === "blocked" ? (
|
||||
<span className="text-gray-700">{e.reason || "—"}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{signalLabels(e.signals).map((sig, i) => (
|
||||
<span key={i} className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">{sig}</span>
|
||||
))}
|
||||
{signalLabels(e.signals).length === 0 && <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 font-mono text-[11px] text-gray-500">{e.method} {e.path}</td>
|
||||
<td className="py-2.5 text-[11px] text-gray-400" title={`Actor: ${e.actor}, IP: ${e.ip}`}>
|
||||
{e.actor ? `${e.actor.slice(0, 8)}…` : ""} {e.ip ? `(${e.ip})` : "匿名"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 mt-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
当前第 <span className="font-semibold text-gray-700">{page}</span> 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 shadow-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,3 +250,4 @@ function Stat({ label, value, sub, tone }: { label: string; value: string; sub?:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { ModelManager } from "../components/ModelManager";
|
||||
|
||||
// 对话模型(chat)配置页 → Dispatcher 经 NATS 热更新。
|
||||
// 模型配置页:Chat / Embedding 双 Tab 统一管理。
|
||||
export function ModelsPage() {
|
||||
const [tab, setTab] = useState<"chat" | "embedding">("chat");
|
||||
|
||||
return (
|
||||
<ModelManager
|
||||
kind="chat"
|
||||
title="对话模型(chat → Dispatcher)"
|
||||
baseUrlHint="https://api.deepseek.com"
|
||||
modelHint="deepseek-chat"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* 头部标题与 Tab 切换 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-800">模型管理</h3>
|
||||
<p className="text-xs text-gray-400">配置全平台的对话模型(LLM)与向量化模型(Embedding)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
|
||||
<button
|
||||
onClick={() => setTab("chat")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
tab === "chat"
|
||||
? "bg-white text-violet-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
对话模型 (Chat)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("embedding")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
tab === "embedding"
|
||||
? "bg-white text-violet-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
向量化模型 (Embedding)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型管理组件 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
{tab === "chat" ? (
|
||||
<ModelManager
|
||||
kind="chat"
|
||||
title="对话模型配置 (chat → Dispatcher)"
|
||||
baseUrlHint="https://api.deepseek.com"
|
||||
modelHint="deepseek-chat"
|
||||
/>
|
||||
) : (
|
||||
<ModelManager
|
||||
kind="embedding"
|
||||
title="向量化模型配置 (embedding → mcp-go RAG)"
|
||||
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
modelHint="text-embedding-v3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { getStatus, type StatusItem, type SystemStatus, type ToolInfo } from "../api";
|
||||
|
||||
const REFRESH_SEC = 5;
|
||||
|
||||
const SERVICE_META: Record<string, { role: string; icon: IconName }> = {
|
||||
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway" },
|
||||
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu" },
|
||||
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool" },
|
||||
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box" },
|
||||
const SERVICE_META: Record<string, { role: string; icon: IconName; colorClass: string }> = {
|
||||
gateway: { role: "HTTP 接入层 · 鉴权 / 限流 / SSE", icon: "gateway", colorClass: "bg-violet-50 text-violet-600" },
|
||||
dispatcher: { role: "编排执行 · Eino 图引擎", icon: "cpu", colorClass: "bg-amber-50 text-amber-600" },
|
||||
"mcp-go": { role: "Go I/O 工具 · RAG / 记忆 / 报告", icon: "tool", colorClass: "bg-cyan-50 text-cyan-600" },
|
||||
"mcp-py": { role: "Python 算法工具 · 沙箱 / 解析", icon: "box", colorClass: "bg-rose-50 text-rose-600" },
|
||||
};
|
||||
|
||||
const INFRA_META: Record<string, { role: string; port: string; icon: IconName }> = {
|
||||
postgres: { role: "关系库", port: "5432", icon: "db" },
|
||||
redis: { role: "缓存 / 限流", port: "6379", icon: "db" },
|
||||
nats: { role: "消息总线 · JetStream", port: "4222", icon: "bus" },
|
||||
milvus: { role: "向量库", port: "19530", icon: "db" },
|
||||
neo4j: { role: "图数据库", port: "7687", icon: "bus" },
|
||||
minio: { role: "对象存储 · 报告/正文/blob", port: "9000", icon: "db" },
|
||||
postgres: { role: "关系主库", port: "5432", icon: "db" },
|
||||
redis: { role: "限流 / 缓存", port: "6379", icon: "db" },
|
||||
nats: { role: "JetStream 总线", port: "4222", icon: "bus" },
|
||||
milvus: { role: "向量检索库", port: "19530", icon: "db" },
|
||||
neo4j: { role: "图关系库", port: "7687", icon: "bus" },
|
||||
minio: { role: "对象存储 (OSS)", port: "9000", icon: "db" },
|
||||
};
|
||||
|
||||
function toolCategory(t: string): string {
|
||||
@@ -27,23 +28,22 @@ function toolCategory(t: string): string {
|
||||
if (["run_code", "secure_sandbox", "parse_document"].includes(t)) return "算法 / 沙箱";
|
||||
return "系统";
|
||||
}
|
||||
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
|
||||
// 能力域配色(工具小卡片的圆点 + 角标)。
|
||||
const CAT_STYLE: Record<string, { dot: string; chip: string }> = {
|
||||
"知识库 / 检索": { dot: "bg-violet-500", chip: "bg-violet-50 text-violet-600" },
|
||||
记忆: { dot: "bg-cyan-500", chip: "bg-cyan-50 text-cyan-600" },
|
||||
报告: { dot: "bg-amber-500", chip: "bg-amber-50 text-amber-600" },
|
||||
会话历史: { dot: "bg-emerald-500", chip: "bg-emerald-50 text-emerald-600" },
|
||||
外部接入: { dot: "bg-rose-500", chip: "bg-rose-50 text-rose-600" },
|
||||
"算法 / 沙箱": { dot: "bg-indigo-500", chip: "bg-indigo-50 text-indigo-600" },
|
||||
系统: { dot: "bg-gray-400", chip: "bg-gray-100 text-gray-500" },
|
||||
};
|
||||
const latTone = (ms?: number) => (ms == null ? "text-gray-400" : ms < 50 ? "text-emerald-600" : ms < 200 ? "text-amber-600" : "text-rose-500");
|
||||
|
||||
// 数据流动光点 + 节点辉光的关键帧(注入一次)。
|
||||
const CAT_ORDER = ["知识库 / 检索", "记忆", "报告", "会话历史", "外部接入", "算法 / 沙箱", "系统"];
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
"知识库 / 检索": "border-violet-200 hover:border-violet-400 text-violet-700 bg-violet-50/30 hover:bg-violet-50/50",
|
||||
"记忆": "border-cyan-200 hover:border-cyan-400 text-cyan-700 bg-cyan-50/30 hover:bg-cyan-50/50",
|
||||
"报告": "border-amber-200 hover:border-amber-400 text-amber-700 bg-amber-50/30 hover:bg-amber-50/50",
|
||||
"会话历史": "border-emerald-200 hover:border-emerald-400 text-emerald-700 bg-emerald-50/30 hover:bg-emerald-50/50",
|
||||
"外部接入": "border-rose-200 hover:border-rose-400 text-rose-700 bg-rose-50/30 hover:bg-rose-50/50",
|
||||
"算法 / 沙箱": "border-indigo-200 hover:border-indigo-400 text-indigo-700 bg-indigo-50/30 hover:bg-indigo-50/50",
|
||||
"系统": "border-blue-200 hover:border-blue-400 text-blue-700 bg-blue-50/30 hover:bg-blue-50/50",
|
||||
};
|
||||
|
||||
const FLOW_CSS = `
|
||||
@keyframes sdxFlow { 0%{left:-6%;opacity:0} 12%{opacity:1} 88%{opacity:1} 100%{left:106%;opacity:0} }
|
||||
@keyframes sdxGlow { 0%,100%{opacity:.55} 50%{opacity:1} }
|
||||
@keyframes sdxFlow { 0%{left:-8%;opacity:0} 15%{opacity:1} 85%{opacity:1} 100%{left:108%;opacity:0} }
|
||||
@keyframes sdxGlow { 0%,100%{transform: scale(1); opacity: 0.8;} 50%{transform: scale(1.15); opacity: 1;} }
|
||||
`;
|
||||
|
||||
export function StatusPage() {
|
||||
@@ -53,13 +53,20 @@ export function StatusPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [auto, setAuto] = useState(true);
|
||||
const [countdown, setCountdown] = useState(REFRESH_SEC);
|
||||
|
||||
// MCP 工具浏览器状态
|
||||
const [activeServer, setActiveServer] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
|
||||
const autoRef = useRef(auto);
|
||||
autoRef.current = auto;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await getStatus());
|
||||
const res = await getStatus();
|
||||
setData(res);
|
||||
setErr("");
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -88,11 +95,53 @@ export function StatusPage() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// 当 data 加载后,默认选中第一个 server
|
||||
useEffect(() => {
|
||||
if (data?.tools?.length && !activeServer) {
|
||||
const active = data.tools.find((t) => t.up);
|
||||
if (active) {
|
||||
setActiveServer(active.server);
|
||||
} else {
|
||||
setActiveServer(data.tools[0].server);
|
||||
}
|
||||
}
|
||||
}, [data, activeServer]);
|
||||
|
||||
const svc = (n: string) => data?.services.find((s) => s.name === n);
|
||||
const infra = (n: string) => data?.infra.find((s) => s.name === n);
|
||||
|
||||
if (loading && !data) return <div className="text-sm text-gray-400">加载中…</div>;
|
||||
if (!data) return <div className="text-sm text-rose-500">拉取失败:{err}</div>;
|
||||
// 获取当前选中的 server 及其下的工具
|
||||
const currentServerGroup = useMemo(() => {
|
||||
if (!data?.tools) return null;
|
||||
return data.tools.find((g) => g.server === activeServer) ?? data.tools[0] ?? null;
|
||||
}, [data, activeServer]);
|
||||
|
||||
const serverTools = useMemo(() => currentServerGroup?.tools ?? [], [currentServerGroup]);
|
||||
|
||||
// 当前选中的 server 下所有工具的分类以及数量计数
|
||||
const serverCategories = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of serverTools) {
|
||||
const cat = toolCategory(t.name);
|
||||
counts[cat] = (counts[cat] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [serverTools]);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
return serverTools.filter((t) => {
|
||||
const cat = toolCategory(t.name);
|
||||
if (selectedCategory !== "All" && cat !== selectedCategory) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return t.name.toLowerCase().includes(q) || t.cn.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [serverTools, selectedCategory, searchQuery]);
|
||||
|
||||
if (loading && !data) return <div className="text-sm text-gray-400 p-6">加载中…</div>;
|
||||
if (!data) return <div className="text-sm text-rose-500 p-6">拉取失败:{err}</div>;
|
||||
|
||||
const servicesUp = data.services.filter((s) => s.up).length;
|
||||
const infraUp = data.infra.filter((s) => s.up).length;
|
||||
@@ -103,60 +152,92 @@ export function StatusPage() {
|
||||
const avgLat = lats.length ? Math.round(lats.reduce((a, b) => a + b, 0) / lats.length) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 概览:状态 + 数字层次 + 控制(中性底,绿色只作状态点) */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className={`relative flex h-2.5 w-2.5 ${allUp ? "" : ""}`}>
|
||||
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${allUp ? "bg-emerald-400" : "bg-amber-400"}`} />
|
||||
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"}`} />
|
||||
</span>
|
||||
<div className="mr-auto">
|
||||
<div className="text-lg font-semibold tracking-tight text-gray-900">{allUp ? "系统运行正常" : `${downCount} 项异常`}</div>
|
||||
<div className="text-xs text-gray-400">{allUp ? "所有应用服务与基建均已就绪" : "部分组件未就绪,见下方明细"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => setAuto((a) => !a)}
|
||||
className={`rounded-full px-3 py-1.5 font-medium transition ${auto ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-400"}`}
|
||||
>
|
||||
{auto ? `自动刷新 · ${countdown}s` : "已暂停"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
className="flex items-center gap-1.5 rounded-full bg-gray-900 px-3.5 py-1.5 font-medium text-white transition hover:bg-gray-700"
|
||||
>
|
||||
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<style>{FLOW_CSS}</style>
|
||||
|
||||
{/* 头部控制栏 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-800 flex items-center gap-2.5">
|
||||
服务监控控制台
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${
|
||||
allUp ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${allUp ? "bg-emerald-500" : "bg-amber-500"} breath-dot`} style={{ animation: "subtle-breath 2.5s ease-in-out infinite" }} />
|
||||
{allUp ? "运行正常" : `${downCount} 项异常`}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-gray-400 mt-1">系统全量核心微服务探针与基础架构数据库实时监控</p>
|
||||
</div>
|
||||
|
||||
{/* 数字层次:大号数字 + 小标签,竖线分隔,不再用边框盒子 */}
|
||||
<div className="mt-6 grid grid-cols-2 divide-x divide-gray-100 sm:grid-cols-5">
|
||||
<BigStat value={`${servicesUp}/${data.services.length}`} label="应用服务" bad={servicesUp !== data.services.length} />
|
||||
<BigStat value={`${infraUp}/${data.infra.length}`} label="基建环境" bad={infraUp !== data.infra.length} />
|
||||
<BigStat value={toolCount} label="注册工具" />
|
||||
<BigStat value={avgLat != null ? `${avgLat}ms` : "—"} label="平均探针延迟" />
|
||||
<BigStat value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })} label="最后检查" muted />
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setAuto((a) => !a)}
|
||||
className={`flex items-center gap-2 text-xs px-3.5 py-2 rounded-xl border border-gray-200 bg-white shadow-sm transition ${
|
||||
auto ? "text-emerald-700 border-emerald-200 bg-emerald-50/10 font-bold" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${auto ? "bg-emerald-500 animate-ping" : "bg-gray-400"}`} />
|
||||
{auto ? `自动刷新 · ${countdown}s` : "已暂停自动刷新"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-600 hover:bg-violet-700 text-xs font-semibold text-white transition shadow-sm hover:shadow-violet-200 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="refresh" className={`h-3.5 w-3.5 ${busy ? "animate-spin" : ""}`} />
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 请求链路:实时数据管道(深色科技底 + 节点辉光 + 流动光点) */}
|
||||
<section
|
||||
className="relative overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 p-6"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, rgba(148,163,184,0.10) 1px, transparent 0), radial-gradient(60% 120% at 50% 0%, rgba(124,58,237,0.18), transparent 70%)",
|
||||
backgroundSize: "22px 22px, 100% 100%",
|
||||
}}
|
||||
>
|
||||
<style>{FLOW_CSS}</style>
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-slate-100">请求链路</h3>
|
||||
<span className="text-xs text-slate-500">桌面端 / 管理端 → 网关 → 总线 → 调度 → 工具层 · 实时</span>
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<BigStatCard
|
||||
label="应用服务"
|
||||
value={`${servicesUp}/${data.services.length}`}
|
||||
subText={servicesUp === data.services.length ? "100% 存活在线" : `${data.services.length - servicesUp} 个节点离线`}
|
||||
barPercent={(servicesUp / data.services.length) * 100}
|
||||
barColor={servicesUp === data.services.length ? "bg-emerald-500" : "bg-amber-500"}
|
||||
/>
|
||||
<BigStatCard
|
||||
label="基建依赖"
|
||||
value={`${infraUp}/${data.infra.length}`}
|
||||
subText={infraUp === data.infra.length ? "关系/缓存/总线均就绪" : `${data.infra.length - infraUp} 个组件受损`}
|
||||
barPercent={(infraUp / data.infra.length) * 100}
|
||||
barColor={infraUp === data.infra.length ? "bg-emerald-500" : "bg-amber-500"}
|
||||
/>
|
||||
<BigStatCard
|
||||
label="MCP注册工具"
|
||||
value={`${toolCount} 个`}
|
||||
subText="大模型挂载能力集合"
|
||||
/>
|
||||
<BigStatCard
|
||||
label="平均探针延迟"
|
||||
value={avgLat != null ? `${avgLat} ms` : "—"}
|
||||
subText={avgLat != null && avgLat < 20 ? "通信链路极其通畅" : "存在部分网络阻塞"}
|
||||
valueColor="text-cyan-600"
|
||||
/>
|
||||
<BigStatCard
|
||||
label="最后检查"
|
||||
value={new Date(data.checked_at).toLocaleTimeString("zh-CN", { hour12: false })}
|
||||
subText="轮询频率每 5 秒"
|
||||
valueColor="text-gray-600 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topology Pipeline (请求链路) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-gradient-to-br from-white to-slate-50/50 p-6">
|
||||
<div className="border-b border-slate-100 pb-3 mb-6">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 01.553-.894L9 2l6 3 5.447-2.724A1 1 0 0121 3.118v10.764a1 1 0 01-.553.894L15 18l-6 2z" />
|
||||
</svg>
|
||||
实时网络通信链路拓扑
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center overflow-x-auto pb-1">
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 md:gap-2 px-2">
|
||||
<FlowNode icon="monitor" label="客户端" sub="桌面端 / 管理端" up />
|
||||
<Link up={svc("gateway")?.up} />
|
||||
<FlowNode icon="gateway" label="网关" sub=":8080" up={svc("gateway")?.up} latency={svc("gateway")?.latency_ms} />
|
||||
@@ -171,70 +252,232 @@ export function StatusPage() {
|
||||
sub="mcp-go · mcp-py"
|
||||
up={Boolean(svc("mcp-go")?.up && svc("mcp-py")?.up)}
|
||||
partial={Boolean((svc("mcp-go")?.up || svc("mcp-py")?.up) && !(svc("mcp-go")?.up && svc("mcp-py")?.up))}
|
||||
latency={Math.max(svc("mcp-go")?.latency_ms ?? 0, svc("mcp-py")?.latency_ms ?? 0) || undefined}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 应用服务 ‖ 基建环境 */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-5">
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-3">
|
||||
<SectionHead title="应用服务" hint="进程存活 · 探针耗时" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-gray-100 bg-gray-100 sm:grid-cols-2">
|
||||
{data.services.map((s) => (
|
||||
<ServiceRow key={s.name} item={s} />
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
{/* 应用服务 (左) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-3">
|
||||
<div className="border-b border-slate-100 pb-3.5 mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-violet-600"></span>
|
||||
微应用服务组件
|
||||
</h3>
|
||||
<p class="text-[10px] text-slate-400 mt-0.5">业务核心接入层与后端逻辑</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.services.map((s) => {
|
||||
const meta = SERVICE_META[s.name];
|
||||
return (
|
||||
<div key={s.name} className="border border-slate-200 bg-slate-50/30 rounded-2xl p-4 flex flex-col justify-between hover:border-violet-300 transition duration-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-800 block">{s.name}</span>
|
||||
<span className="text-[10px] text-slate-400 mt-1 block leading-relaxed">{meta?.role || s.detail}</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full border text-[9px] font-bold ${
|
||||
s.up ? "bg-emerald-50 text-emerald-700 border-emerald-100" : "bg-rose-50 text-rose-700 border-rose-100"
|
||||
}`}>
|
||||
{s.up ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-semibold">
|
||||
{s.name === "mcp-go" || s.name === "mcp-py" ? "已注册工具数" : "网络延迟"}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-700 font-mono">
|
||||
{s.name === "mcp-go" ? "23 个" : s.name === "mcp-py" ? "4 个" : `${s.latency_ms ?? 0} ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6 lg:col-span-2">
|
||||
<SectionHead title="基建环境" hint="6 个依赖" />
|
||||
<div className="mt-4 divide-y divide-gray-100">
|
||||
{data.infra.map((s) => (
|
||||
<InfraRow key={s.name} item={s} />
|
||||
))}
|
||||
{/* 基建依赖 (右) */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 lg:col-span-2">
|
||||
<div className="border-b border-slate-100 pb-3.5 mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-cyan-500"></span>
|
||||
平台基础架构依赖
|
||||
</h3>
|
||||
<p class="text-[10px] text-slate-400 mt-0.5">数据库、中间件及第三方存储服务</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
{data.infra.map((item) => {
|
||||
const meta = INFRA_META[item.name];
|
||||
return (
|
||||
<div key={item.name} className="flex items-center justify-between text-xs pb-3 border-b border-slate-100 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-slate-800">{item.name}</span>
|
||||
<span className="text-[10px] text-slate-400">{meta?.role || "后端组件"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="text-[10px] text-slate-400 font-mono bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
:{meta?.port || "—"}
|
||||
</code>
|
||||
<span className={`font-bold ${item.up ? "text-emerald-600" : "text-rose-500"}`}>
|
||||
{item.up ? "就绪" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* MCP 工具注册 */}
|
||||
<section className="rounded-2xl border border-gray-200/70 bg-white p-6">
|
||||
<SectionHead title="MCP 工具注册" hint="各 MCP 服务在线时上报、按能力域分组" />
|
||||
<div className="mt-4 space-y-5">
|
||||
{data.tools.map((g) => (
|
||||
<ToolServer key={g.server} server={g.server} up={g.up} tools={g.tools ?? []} />
|
||||
{/* MCP 工具注册箱 */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-6 space-y-4">
|
||||
<div className="border-b border-slate-100 pb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
MCP 资源注册工具箱
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">查看上报给大模型分发器的运行时 MCP 能力函数</p>
|
||||
</div>
|
||||
|
||||
{/* Tab & Search */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Tab Buttons */}
|
||||
<div className="flex rounded-xl border border-slate-200 p-0.5 bg-slate-100/50 text-xs">
|
||||
{data.tools.map((g) => (
|
||||
<button
|
||||
key={g.server}
|
||||
onClick={() => {
|
||||
setActiveServer(g.server);
|
||||
setSelectedCategory("All");
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg font-bold transition-all duration-300 ${
|
||||
activeServer === g.server
|
||||
? "text-violet-700 bg-white shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{g.server} ({g.tools?.length ?? 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索函数别名 / 物理名称 / 作用..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-violet-200 focus:border-violet-400 w-56 shadow-sm transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Pills Filter */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-slate-400 border-b border-slate-100 pb-3.5">
|
||||
<span className="mr-2 font-bold text-slate-500 uppercase tracking-wider text-[10px]">能力标签筛选:</span>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("All")}
|
||||
className={`px-3 py-1 rounded-full font-semibold transition ${
|
||||
selectedCategory === "All"
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{Object.keys(serverCategories).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setSelectedCategory(c)}
|
||||
className={`px-3 py-1 rounded-full font-semibold transition ${
|
||||
selectedCategory === c
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "bg-slate-100 hover:bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{c} ({serverCategories[c]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tools Grid */}
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-slate-400">没有找到任何匹配的函数工具 🔍</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTools.map((t) => {
|
||||
const cat = toolCategory(t.name);
|
||||
const colorClass = CATEGORY_COLORS[cat] || CATEGORY_COLORS["系统"];
|
||||
return (
|
||||
<div key={t.name} className={`p-4 rounded-2xl flex flex-col justify-between transition-all duration-300 hover:-translate-y-0.5 border ${colorClass}`}>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{t.cn}
|
||||
</span>
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded bg-white border border-slate-200 uppercase font-mono">
|
||||
{cat}
|
||||
</span>
|
||||
</div>
|
||||
<code className="text-[10px] text-slate-400 font-mono block">{t.name}</code>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed mt-2 line-clamp-2" title={t.desc}>
|
||||
{t.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 子组件 ----
|
||||
// ---- 子子组件 ----
|
||||
|
||||
function SectionHead({ title, hint }: { title: string; hint: string }) {
|
||||
function BigStatCard({
|
||||
label,
|
||||
value,
|
||||
subText,
|
||||
barPercent,
|
||||
barColor,
|
||||
valueColor = "text-slate-800",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subText: string;
|
||||
barPercent?: number;
|
||||
barColor?: string;
|
||||
valueColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h3 className="text-sm font-semibold tracking-tight text-gray-900">{title}</h3>
|
||||
<span className="text-xs text-gray-400">{hint}</span>
|
||||
<div className="premium-card p-5 bg-white border border-gray-200 rounded-2xl shadow-sm hover:shadow-md transition">
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">{label}</span>
|
||||
<div className="flex items-baseline gap-1 mt-2">
|
||||
<span className={`text-2xl font-bold ${valueColor}`}>{value}</span>
|
||||
</div>
|
||||
{barPercent != null ? (
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor || "bg-emerald-500"}`} style={{ width: `${barPercent}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400 block mt-3 font-medium">{subText}</span>
|
||||
)}
|
||||
{barPercent != null && (
|
||||
<span className="text-[10px] text-slate-400 block mt-1.5 font-medium">{subText}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigStat({ value, label, bad, muted }: { value: string | number; label: string; bad?: boolean; muted?: boolean }) {
|
||||
return (
|
||||
<div className="px-4 first:pl-0">
|
||||
<div className={`text-2xl font-semibold tracking-tight ${bad ? "text-rose-500" : muted ? "text-gray-500" : "text-gray-900"}`}>{value}</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ up, partial }: { up?: boolean; partial?: boolean }) {
|
||||
const c = up ? "bg-emerald-500" : partial ? "bg-amber-500" : "bg-rose-500";
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${c}`} />;
|
||||
}
|
||||
|
||||
function FlowNode({
|
||||
icon,
|
||||
label,
|
||||
@@ -251,159 +494,72 @@ function FlowNode({
|
||||
latency?: number;
|
||||
}) {
|
||||
const down = !up && !partial;
|
||||
const accent = down ? "text-rose-400" : partial ? "text-amber-300" : "text-emerald-300";
|
||||
const ring = down ? "ring-rose-500/40" : partial ? "ring-amber-400/40" : "ring-emerald-400/30";
|
||||
const glow = down
|
||||
? "shadow-[0_0_18px_-4px_rgba(244,63,94,0.5)]"
|
||||
: partial
|
||||
? "shadow-[0_0_18px_-4px_rgba(245,158,11,0.45)]"
|
||||
: "shadow-[0_0_22px_-6px_rgba(16,185,129,0.55)]";
|
||||
|
||||
// 决定 icon 背景和文字颜色配色
|
||||
const iconColorClass = useMemo(() => {
|
||||
if (icon === "monitor") return "bg-slate-100 text-slate-600";
|
||||
if (icon === "gateway") return "bg-violet-50 text-violet-600";
|
||||
if (icon === "bus") return "bg-cyan-50 text-cyan-600";
|
||||
if (icon === "cpu") return "bg-amber-50 text-amber-600";
|
||||
return "bg-rose-50 text-rose-600";
|
||||
}, [icon]);
|
||||
|
||||
return (
|
||||
<div className={`relative flex min-w-[116px] flex-1 flex-col items-center rounded-xl bg-white/[0.04] px-3 py-3.5 text-center ring-1 ${ring} ${glow} backdrop-blur-sm`}>
|
||||
<span className="absolute right-2.5 top-2.5 flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`}
|
||||
style={{ animation: "sdxGlow 1.6s ease-in-out infinite" }}
|
||||
/>
|
||||
<span className={`relative inline-flex h-2 w-2 rounded-full ${down ? "bg-rose-500" : partial ? "bg-amber-400" : "bg-emerald-400"}`} />
|
||||
</span>
|
||||
<Icon name={icon} className={`mb-1.5 h-5 w-5 ${accent}`} />
|
||||
<div className="text-xs font-semibold text-slate-100">{label}</div>
|
||||
<div className="text-[10px] text-slate-400">{sub}</div>
|
||||
{latency != null && <div className={`mt-1 font-mono text-[10px] ${down ? "text-rose-300" : "text-emerald-300/90"}`}>{latency}ms</div>}
|
||||
<div className="flex items-center gap-3 p-4 rounded-2xl bg-white border border-slate-200 shadow-sm w-44 hover:shadow-md transition duration-300">
|
||||
<div className={`h-9 w-9 rounded-xl flex items-center justify-center ${iconColorClass}`}>
|
||||
<Icon name={icon} className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-800">{label}</div>
|
||||
<div className="text-[9px] text-slate-400 font-mono">
|
||||
{sub}
|
||||
{latency != null && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className={down ? "text-rose-500 font-bold" : "text-emerald-600 font-bold"}>
|
||||
{latency}ms
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Link:连接线 + 沿线流动的数据光点(up 才流动;down 显示红色静态虚线)。
|
||||
function Link({ up }: { up?: boolean }) {
|
||||
if (!up) {
|
||||
return (
|
||||
<div className="relative mx-1 h-px min-w-[28px] flex-1 self-center bg-gradient-to-r from-rose-500/30 via-rose-500/40 to-rose-500/30" />
|
||||
<div className="h-8 w-px md:h-px md:w-full bg-gradient-to-b md:bg-gradient-to-r from-rose-500/20 to-rose-500/20 relative" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative mx-1 h-5 min-w-[28px] flex-1 self-center">
|
||||
{/* 基线 */}
|
||||
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-gradient-to-r from-emerald-400/20 via-cyan-400/30 to-emerald-400/20" />
|
||||
{/* 流动光点(多个 + 错峰延迟 → 数据流动感) */}
|
||||
{[0, 0.53, 1.06].map((d, i) => (
|
||||
<div className="h-8 w-px md:h-px md:w-full bg-slate-200 relative flex items-center justify-center">
|
||||
{/* 流动光点 */}
|
||||
{[0, 0.8, 1.6].map((delay, idx) => (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-cyan-300 shadow-[0_0_8px_2px_rgba(34,211,238,0.7)]"
|
||||
style={{ animation: `sdxFlow 1.6s linear ${d}s infinite` }}
|
||||
key={idx}
|
||||
className="absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-violet-500 shadow-[0_0_8px_rgba(139,92,246,0.6)]"
|
||||
style={{ animation: `sdxFlow 2.4s linear ${delay}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceRow({ item }: { item: StatusItem }) {
|
||||
const meta = SERVICE_META[item.name];
|
||||
return (
|
||||
<div className="bg-white p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${item.up ? "bg-gray-50 text-gray-500" : "bg-rose-50 text-rose-500"}`}>
|
||||
<Icon name={meta?.icon ?? "tool"} className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">{item.name}</span>
|
||||
<Dot up={item.up} />
|
||||
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "运行中" : "离线"}</span>
|
||||
{item.up && item.latency_ms != null && <span className={`ml-auto font-mono text-[11px] ${latTone(item.latency_ms)}`}>{item.latency_ms}ms</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-gray-400" title={meta?.role}>{meta?.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-2 truncate text-[11px] ${item.up ? "text-gray-500" : "text-rose-500"}`} title={item.detail}>{item.detail || (item.up ? "在线" : "无响应")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfraRow({ item }: { item: StatusItem }) {
|
||||
const meta = INFRA_META[item.name];
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<Icon name={meta?.icon ?? "db"} className={`h-4 w-4 shrink-0 ${item.up ? "text-gray-300" : "text-rose-400"}`} />
|
||||
<span className="text-sm font-medium text-gray-800">{item.name}</span>
|
||||
<span className="text-[11px] text-gray-400">{meta?.role}</span>
|
||||
<code className="ml-auto font-mono text-[10px] text-gray-300">:{meta?.port}</code>
|
||||
<span className="flex w-12 items-center justify-end gap-1.5">
|
||||
<Dot up={item.up} />
|
||||
<span className={`text-[11px] ${item.up ? "text-emerald-600" : "text-rose-500"}`}>{item.up ? "就绪" : "离线"}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolServer({ server, up, tools }: { server: string; up: boolean; tools: ToolInfo[] }) {
|
||||
const groups: Record<string, ToolInfo[]> = {};
|
||||
for (const t of tools) (groups[toolCategory(t.name)] ??= []).push(t);
|
||||
const cats = Object.keys(groups).sort((a, b) => CAT_ORDER.indexOf(a) - CAT_ORDER.indexOf(b));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Dot up={up} />
|
||||
<span className="text-sm font-semibold text-gray-900">{server}</span>
|
||||
<span className="text-xs text-gray-400">{up ? `${tools.length} 个工具` : "无响应(未启动?)"}</span>
|
||||
{up &&
|
||||
cats.map((c) => (
|
||||
<span key={c} className="rounded-md bg-gray-50 px-2 py-0.5 text-[10px] text-gray-400">
|
||||
{c} <span className="font-semibold text-gray-600">{groups[c].length}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{up && cats.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{cats.map((c) => {
|
||||
const cs = CAT_STYLE[c] ?? CAT_STYLE["系统"];
|
||||
return (
|
||||
<div key={c}>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${cs.dot}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">{c}</span>
|
||||
<span className="text-[10px] text-gray-300">{groups[c].length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups[c].map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
className="group rounded-xl border border-gray-200/70 bg-white p-3 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_4px_16px_-6px_rgba(124,58,237,0.25)]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${cs.dot}`} />
|
||||
<span className="text-xs font-semibold text-gray-800">{t.cn}</span>
|
||||
<code className="ml-auto truncate font-mono text-[10px] text-gray-400">{t.name}</code>
|
||||
</div>
|
||||
<div className="mt-1.5 line-clamp-2 text-[10px] leading-relaxed text-gray-400" title={t.desc}>
|
||||
{t.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 内联图标 ----
|
||||
type IconName = "refresh" | "monitor" | "gateway" | "bus" | "cpu" | "tool" | "box" | "db";
|
||||
const PATHS: Record<IconName, string> = {
|
||||
refresh: "M21 12a9 9 0 1 1-3-6.7L21 8 M21 3v5h-5",
|
||||
monitor: "M3 4h18v12H3z M8 20h8 M12 16v4",
|
||||
gateway: "M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01",
|
||||
bus: "M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M18 16a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M8.6 13.5l6.8 4 M15.4 6.5l-6.8 4",
|
||||
cpu: "M6 6h12v12H6z M9 9h6v6H9z M9 1v3 M15 1v3 M9 20v3 M15 20v3 M1 9h3 M1 15h3 M20 9h3 M20 15h3",
|
||||
tool: "M14.7 6.3a4 4 0 0 1-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.7 2.7-2-2 2.7-2.7z",
|
||||
gateway: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||
bus: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4",
|
||||
cpu: "M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4",
|
||||
tool: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
box: "M21 8 12 3 3 8v8l9 5 9-5z M3 8l9 5 9-5 M12 13v8",
|
||||
db: "M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3z M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6 M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6",
|
||||
};
|
||||
|
||||
function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -411,3 +567,4 @@ function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adminTasks, type AdminTask } from "../api";
|
||||
import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api";
|
||||
|
||||
// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。
|
||||
// 含 HITL 待审批(筛 waiting)。数据来自 sundynix_task,后端 GET /admin/tasks。
|
||||
@@ -29,12 +29,22 @@ export function TasksPage() {
|
||||
const [tasks, setTasks] = useState<AdminTask[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [filter, setFilter] = useState("");
|
||||
const [tenantFilter, setTenantFilter] = useState("");
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [copiedId, setCopiedId] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 载入租户列表用于筛选
|
||||
useEffect(() => {
|
||||
listTenants()
|
||||
.then(setTenants)
|
||||
.catch((e) => console.error("Failed to load tenants:", e));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
adminTasks(filter, "", 100)
|
||||
adminTasks(filter, tenantFilter, 100)
|
||||
.then((r) => {
|
||||
setTasks(r.tasks);
|
||||
setCounts(r.counts);
|
||||
@@ -42,9 +52,19 @@ export function TasksPage() {
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filter]);
|
||||
}, [filter, tenantFilter]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const handleCopy = (id: string) => {
|
||||
navigator.clipboard.writeText(id)
|
||||
.then(() => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(""), 1500);
|
||||
})
|
||||
.catch((err) => console.error("Failed to copy:", err));
|
||||
};
|
||||
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
@@ -63,8 +83,26 @@ export function TasksPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">{loading ? "加载中…" : `共 ${tasks.length} 条${filter ? `(${st(filter).label})` : ""}`}</span>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400">
|
||||
{loading ? "加载中…" : `共 ${tasks.length} 条${filter ? `(${st(filter).label})` : ""}`}
|
||||
</span>
|
||||
{/* 租户过滤下拉 */}
|
||||
<select
|
||||
value={tenantFilter}
|
||||
onChange={(e) => setTenantFilter(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 focus:outline-none focus:border-violet-400"
|
||||
>
|
||||
<option value="">全部租户</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name || t.slug || t.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onClick={load} className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50">
|
||||
刷新
|
||||
</button>
|
||||
@@ -89,8 +127,21 @@ export function TasksPage() {
|
||||
<tr key={t.task_id} className="border-b border-gray-50 last:border-0 align-top">
|
||||
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(t.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="text-gray-800">{t.topic || <code className="text-[11px] text-gray-500">{t.task_id}</code>}</div>
|
||||
{t.topic && <code className="text-[10px] text-gray-300">{t.task_id}</code>}
|
||||
<div className="text-gray-800 flex items-center gap-1.5">
|
||||
<span>{t.topic || <code className="text-[11px] text-gray-500">{t.task_id.slice(0, 8)}…</code>}</span>
|
||||
<button
|
||||
onClick={() => handleCopy(t.task_id)}
|
||||
className="text-[10px] text-gray-400 hover:text-violet-600 transition"
|
||||
title="复制任务 ID"
|
||||
>
|
||||
{copiedId === t.task_id ? "✓ 已复制" : "📋"}
|
||||
</button>
|
||||
</div>
|
||||
{t.topic && (
|
||||
<div className="text-[10px] text-gray-300 font-mono flex items-center gap-1">
|
||||
<span>{t.task_id}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-gray-600">{t.tenant_name || <span className="text-gray-300">{t.tenant_id || "—"}</span>}</td>
|
||||
<td className="py-2 pr-3 text-xs text-gray-500">{t.owner_email || t.owner || "—"}</td>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listTenants, createTenant, listMembers, addMember, setMemberRole, removeMember, setSharedBilling, type TenantRow, type Member } from "../api";
|
||||
import {
|
||||
listTenants,
|
||||
createTenant,
|
||||
listMembers,
|
||||
addMember,
|
||||
setMemberRole,
|
||||
removeMember,
|
||||
setSharedBilling,
|
||||
setTenantPlan,
|
||||
setTenantStatus,
|
||||
type TenantRow,
|
||||
type Member
|
||||
} from "../api";
|
||||
import { GrantCreditsModal } from "../components/GrantCreditsModal";
|
||||
|
||||
// 管理端「租户 & 用户」= 多成员租户管理(平台运维口径):租户目录 + 每租户成员增改删。
|
||||
// 全真数据:GET/POST /admin/tenants、/admin/tenants/:id/members。
|
||||
@@ -8,6 +21,9 @@ const ROLES = ["owner", "admin", "member", "viewer", "billing_admin"];
|
||||
const ROLE_CN: Record<string, string> = { owner: "所有者", admin: "管理员", member: "成员", viewer: "只读", billing_admin: "计费管理" };
|
||||
const credits = (m: number) => (m / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const PLANS = ["free", "pro", "enterprise"];
|
||||
const PLAN_CN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
|
||||
|
||||
export function TenantsPage() {
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [selId, setSelId] = useState<string>("");
|
||||
@@ -19,6 +35,7 @@ export function TenantsPage() {
|
||||
const [tName, setTName] = useState("");
|
||||
const [tSlug, setTSlug] = useState("");
|
||||
const [tOwner, setTOwner] = useState("");
|
||||
const [tPlan, setTPlan] = useState("free");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// 加成员表单
|
||||
@@ -27,6 +44,11 @@ export function TenantsPage() {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [mErr, setMErr] = useState("");
|
||||
|
||||
// Modals state
|
||||
const [editPlanTenant, setEditPlanTenant] = useState<TenantRow | null>(null);
|
||||
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
|
||||
const [statusChanging, setStatusChanging] = useState(false);
|
||||
|
||||
const selected = useMemo(() => tenants.find((t) => t.id === selId) ?? null, [tenants, selId]);
|
||||
|
||||
const loadTenants = async (keepSel = true) => {
|
||||
@@ -67,10 +89,11 @@ export function TenantsPage() {
|
||||
if (!tName.trim() || !tSlug.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined);
|
||||
await createTenant(tName.trim(), tSlug.trim(), tOwner.trim() || undefined, tPlan);
|
||||
setTName("");
|
||||
setTSlug("");
|
||||
setTOwner("");
|
||||
setTPlan("free");
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -119,11 +142,89 @@ export function TenantsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onPlanChange = async (plan: string) => {
|
||||
if (!editPlanTenant) return;
|
||||
try {
|
||||
await setTenantPlan(editPlanTenant.id, plan);
|
||||
setEditPlanTenant(null);
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setErr(`修改 Plan 失败: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onStatusToggle = async () => {
|
||||
if (!selected) return;
|
||||
const nextStatus = selected.status === "suspended" ? "active" : "suspended";
|
||||
const actionText = nextStatus === "suspended" ? "暂停" : "恢复";
|
||||
if (!window.confirm(`确定要 ${actionText} 租户「${selected.name}」吗?`)) return;
|
||||
|
||||
setStatusChanging(true);
|
||||
try {
|
||||
await setTenantStatus(selected.id, nextStatus);
|
||||
await loadTenants();
|
||||
} catch (e) {
|
||||
setMErr(`${actionText}租户失败: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setStatusChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载租户中…</div>;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{err && <div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
|
||||
|
||||
{/* 充值 Modal */}
|
||||
<GrantCreditsModal
|
||||
tenant={grantTenant}
|
||||
onClose={() => setGrantTenant(null)}
|
||||
onDone={() => void loadTenants()}
|
||||
/>
|
||||
|
||||
{/* 修改方案 Modal */}
|
||||
{editPlanTenant && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => e.target === e.currentTarget && setEditPlanTenant(null)}
|
||||
>
|
||||
<div className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">更改方案等级</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-400">调整租户 {editPlanTenant.name} 的等级</p>
|
||||
</div>
|
||||
<button onClick={() => setEditPlanTenant(null)} className="text-xl leading-none text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
{PLANS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => void onPlanChange(p)}
|
||||
className={`w-full text-left p-3 rounded-lg border flex items-center justify-between transition-all ${
|
||||
editPlanTenant.plan === p
|
||||
? "border-violet-500 bg-violet-50/50 font-semibold text-violet-700"
|
||||
: "border-gray-100 hover:bg-gray-50 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span>{PLAN_CN[p]}</span>
|
||||
<span className="text-[11px] uppercase text-gray-400">{p}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-6 py-4 flex justify-end">
|
||||
<button onClick={() => setEditPlanTenant(null)} className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-xs text-gray-500 hover:bg-gray-50">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 左:租户目录 + 建租户 */}
|
||||
<div className="space-y-6">
|
||||
@@ -140,8 +241,22 @@ export function TenantsPage() {
|
||||
className={`w-full rounded-lg border p-3 text-left transition-all ${selId === t.id ? "border-violet-500 bg-violet-50/40 shadow-sm" : "border-gray-100 hover:bg-gray-50/60"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-800">{t.name}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-medium ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}>{t.plan}</span>
|
||||
<span className="text-xs font-bold text-gray-800 flex items-center gap-1.5">
|
||||
{t.name}
|
||||
{t.status === "suspended" && (
|
||||
<span className="rounded bg-rose-100 text-rose-700 px-1 py-0.2 text-[8px] font-bold">已暂停</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditPlanTenant(t);
|
||||
}}
|
||||
title="点击修改 Plan"
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${t.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
|
||||
>
|
||||
{t.plan} ✎
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[10px] text-gray-400">
|
||||
<span>{t.members} 成员 · <span className="font-mono">{t.slug}</span></span>
|
||||
@@ -158,6 +273,20 @@ export function TenantsPage() {
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="租户名称(如:华泰投行部)" value={tName} onChange={(e) => setTName(e.target.value)} />
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs font-mono focus:border-violet-500 focus:outline-none" placeholder="唯一标识 slug(如 ht-ib)" value={tSlug} onChange={(e) => setTSlug(e.target.value)} />
|
||||
<input className="w-full rounded border px-2 py-1.5 text-xs focus:border-violet-500 focus:outline-none" placeholder="owner 邮箱(可选,须已注册)" value={tOwner} onChange={(e) => setTOwner(e.target.value)} />
|
||||
|
||||
<label className="block text-xs text-gray-500 space-y-1">
|
||||
<span>方案等级</span>
|
||||
<select
|
||||
value={tPlan}
|
||||
onChange={(e) => setTPlan(e.target.value)}
|
||||
className="w-full rounded border bg-white px-2 py-1.5 text-xs text-gray-700 focus:border-violet-500 focus:outline-none"
|
||||
>
|
||||
{PLANS.map((p) => (
|
||||
<option key={p} value={p}>{PLAN_CN[p]} ({p})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button onClick={() => void onCreate()} disabled={creating || !tName.trim() || !tSlug.trim()} className="w-full rounded bg-violet-600 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-40">
|
||||
{creating ? "创建中…" : "创建租户"}
|
||||
</button>
|
||||
@@ -174,18 +303,49 @@ export function TenantsPage() {
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between border-b pb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · 成员</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-700">{selected.name} · 成员</h4>
|
||||
<span
|
||||
onClick={() => setEditPlanTenant(selected)}
|
||||
className={`rounded px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:opacity-85 ${selected.plan === "free" ? "bg-gray-100 text-gray-500" : "bg-violet-100 text-violet-700"}`}
|
||||
>
|
||||
{selected.plan} ✎
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400">成员共享该租户的积分池与用量口径 · slug {selected.slug}</p>
|
||||
</div>
|
||||
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} 积分</span>
|
||||
|
||||
<div className="flex items-center gap-2 text-right">
|
||||
<span className="tabular-nums text-xs text-gray-500">{credits(selected.credit_balance_micro)} 积分</span>
|
||||
<button
|
||||
onClick={() => setGrantTenant(selected)}
|
||||
className="rounded border border-violet-200 px-2 py-0.5 text-[10px] text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
充值/校正
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 共享计费开关 */}
|
||||
<label className="mb-3 flex cursor-pointer items-center gap-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
|
||||
<span className="font-medium text-gray-700">共享计费</span>
|
||||
<span className="text-gray-400">开:成员消耗计本租户积分池;关:成员各计个人池(owner 恒计本租户)</span>
|
||||
</label>
|
||||
{/* 状态控制 & 共享计费开关 */}
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 p-3 rounded-lg border border-gray-100 bg-gray-50/50">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-600">
|
||||
<input type="checkbox" checked={selected.shared_billing} onChange={(e) => void onToggleShared(e.target.checked)} className="h-4 w-4 accent-violet-600" />
|
||||
<span className="font-medium text-gray-700">共享计费</span>
|
||||
<span className="text-gray-400">开:成员消耗本租户池;关:成员消耗个人池</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void onStatusToggle()}
|
||||
disabled={statusChanging}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-semibold shadow-sm transition-all ${
|
||||
selected.status === "suspended"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
|
||||
: "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100"
|
||||
}`}
|
||||
>
|
||||
{selected.status === "suspended" ? "恢复租户账号" : "暂停租户账号"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 加成员 */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-violet-100 bg-violet-50/30 p-3">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||
import { BillingRules } from "../components/BillingRules";
|
||||
import { TopupChannels } from "../components/TopupChannels";
|
||||
import { OrderStream } from "../components/OrderStream";
|
||||
import { GrantCreditsModal } from "../components/GrantCreditsModal";
|
||||
|
||||
|
||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
||||
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
||||
@@ -46,6 +48,10 @@ export function UsagePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
// 发放积分 Modal
|
||||
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
|
||||
const [allTenants, setAllTenants] = useState<TenantRow[]>([]);
|
||||
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
@@ -63,30 +69,34 @@ export function UsagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 预载租户列表用于发放积分 Modal
|
||||
listTenants().then(setAllTenants).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, tenant]);
|
||||
|
||||
|
||||
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
|
||||
|
||||
const [granting, setGranting] = useState("");
|
||||
const doGrant = async (tenantId: string, name: string) => {
|
||||
const s = window.prompt(`给「${name}」充值积分(正=充值,负=扣减/校正)`, "100");
|
||||
if (s === null) return;
|
||||
const n = Number(s);
|
||||
if (!n) return;
|
||||
setGranting(tenantId);
|
||||
try {
|
||||
await grantCredits(tenantId, n, "admin 手动充值");
|
||||
await load();
|
||||
} catch (e) {
|
||||
window.alert((e as Error).message);
|
||||
} finally {
|
||||
setGranting("");
|
||||
const openGrant = (r: UsageTenantSum) => {
|
||||
const found = allTenants.find((t) => t.id === r.tenant_id);
|
||||
if (found) {
|
||||
setGrantTenant(found);
|
||||
} else {
|
||||
// 如果全载列表还没到,直接用 UsageTenantSum 构造一个临时对象
|
||||
setGrantTenant({
|
||||
id: r.tenant_id, name: r.name, slug: "", plan: "free",
|
||||
status: "active", credit_balance_micro: r.balance_micro,
|
||||
shared_billing: false, members: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载用量数据中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">用量加载失败:{err}</div>;
|
||||
if (!report) return null;
|
||||
@@ -96,6 +106,9 @@ export function UsagePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 发放积分 Modal */}
|
||||
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
|
||||
|
||||
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
||||
<BillingRules onSaved={() => void load()} />
|
||||
|
||||
@@ -214,11 +227,10 @@ export function UsagePage() {
|
||||
<td className={`py-2 pr-3 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => void doGrant(r.tenant_id, r.name || r.tenant_id)}
|
||||
disabled={granting === r.tenant_id}
|
||||
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50 disabled:opacity-40"
|
||||
onClick={() => openGrant(r)}
|
||||
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
{granting === r.tenant_id ? "…" : "充值"}
|
||||
充值
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,68 +2,202 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { routes, navGroups, defaultPath } from "../routes";
|
||||
import { gatewayOnline, type AuthUser } from "../api";
|
||||
import { getStatus, type AuthUser } from "../api";
|
||||
|
||||
// 控制台挂在 /admin 前缀下(根 / 是官网)。路由注册表里 path 为相对(dashboard 等),
|
||||
// 链接/匹配时统一加 /admin 前缀。
|
||||
const ADMIN_BASE = "/admin";
|
||||
|
||||
// 控制台外壳:导航与内容均由路由注册表派生(动态路由)。
|
||||
// 路线路径到 SVG 图标的映射
|
||||
const ICON_MAP: Record<string, JSX.Element> = {
|
||||
dashboard: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v10a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
),
|
||||
usage: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 8h6m-5 0a3 3 0 110 6m0-6V7a1 1 0 112 0v1m-1 5a1.5 1.5 0 100-3m0 3v1m0-1a1.5 1.5 0 100-3m-3-3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
models: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
),
|
||||
datasources: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
),
|
||||
prompts: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
),
|
||||
status: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z" />
|
||||
</svg>
|
||||
),
|
||||
tasks: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
evals: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
audit: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
),
|
||||
tenants: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
spaces: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
guardrails: (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
||||
const [online, setOnline] = useState(false);
|
||||
// 健康状态对象
|
||||
const [health, setHealth] = useState({
|
||||
gateway: false,
|
||||
dispatcher: false,
|
||||
postgres: false,
|
||||
redis: false,
|
||||
nats: false,
|
||||
});
|
||||
|
||||
const loc = useLocation();
|
||||
const current = routes.find((r) => `${ADMIN_BASE}/${r.path}` === loc.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const ping = () => gatewayOnline().then(setOnline);
|
||||
const ping = () => {
|
||||
getStatus()
|
||||
.then((res) => {
|
||||
const pgUp = res.infra?.find((x) => x.name === "postgres")?.up ?? false;
|
||||
const redisUp = res.infra?.find((x) => x.name === "redis")?.up ?? false;
|
||||
const natsUp = res.infra?.find((x) => x.name === "nats")?.up ?? false;
|
||||
const dispatcherUp = res.services?.find((x) => x.name === "dispatcher")?.up ?? false;
|
||||
|
||||
setHealth({
|
||||
gateway: true, // 能获取到 status 意味着 Gateway 必然在线
|
||||
dispatcher: dispatcherUp,
|
||||
postgres: pgUp,
|
||||
redis: redisUp,
|
||||
nats: natsUp,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 获取失败,全部下线
|
||||
setHealth({
|
||||
gateway: false,
|
||||
dispatcher: false,
|
||||
postgres: false,
|
||||
redis: false,
|
||||
nats: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
ping();
|
||||
const id = setInterval(ping, 4000);
|
||||
const id = setInterval(ping, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen text-gray-900">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r bg-gray-50">
|
||||
<div className="border-b p-4">
|
||||
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
|
||||
<div className="text-[11px] text-gray-400">运维控制台</div>
|
||||
<div className="flex h-screen w-screen text-gray-900 bg-gray-50/20">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-gray-150 bg-gray-55/40">
|
||||
<div className="border-b border-gray-100 p-5">
|
||||
<div className="text-sm font-bold text-gray-800 tracking-tight">sundynix-agentix</div>
|
||||
<div className="text-[10px] text-gray-400 font-medium">运维管理控制台</div>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-1.5 p-3 overflow-y-auto">
|
||||
{navGroups().map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="mt-2 px-3 text-[9px] font-semibold tracking-wider text-gray-300">{g.group}</div>
|
||||
<div key={g.group} className="space-y-1">
|
||||
<div className="px-3 py-1 text-[9px] font-bold uppercase tracking-wider text-gray-400">{g.group}</div>
|
||||
{g.items.map((r) => (
|
||||
<NavLink
|
||||
key={r.path}
|
||||
to={`${ADMIN_BASE}/${r.path}`}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center justify-between rounded px-3 py-2 text-sm ${
|
||||
isActive ? "bg-violet-50 font-medium text-violet-700" : "text-gray-600 hover:bg-gray-100"
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
|
||||
isActive
|
||||
? "bg-violet-600 text-white shadow-sm shadow-violet-100"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{r.label}
|
||||
{!r.ready && <span className="text-[9px] text-gray-300">规划</span>}
|
||||
<span className="shrink-0">{ICON_MAP[r.path]}</span>
|
||||
<span className="flex-1 truncate">{r.label}</span>
|
||||
{!r.ready && <span className="bg-gray-100 text-gray-400 px-1 py-0.2 rounded text-[8px]">规划</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto border-t p-4 text-[11px] text-gray-500">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="truncate text-gray-600" title={user.email}>{user.name || user.email}</span>
|
||||
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600">登出</button>
|
||||
|
||||
<div className="border-t border-gray-100 p-4 text-xs text-gray-500 bg-white/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate text-gray-700 font-semibold" title={user.email}>{user.name || user.email}</span>
|
||||
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600 font-semibold transition">登出</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
Gateway {online ? "在线" : "离线"}
|
||||
|
||||
{/* 健康度指示栏 */}
|
||||
<div className="pt-2 border-t border-gray-100/50">
|
||||
<div className="mb-1 text-[10px] font-bold text-gray-400 uppercase tracking-wide">系统服务健康度</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Gateway dot */}
|
||||
<span
|
||||
title={`Gateway: ${health.gateway ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.gateway ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Dispatcher dot */}
|
||||
<span
|
||||
title={`Dispatcher: ${health.dispatcher ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.dispatcher ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Postgres dot */}
|
||||
<span
|
||||
title={`Postgres: ${health.postgres ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.postgres ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* Redis dot */}
|
||||
<span
|
||||
title={`Redis: ${health.redis ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.redis ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
{/* NATS dot */}
|
||||
<span
|
||||
title={`NATS: ${health.nats ? "在线" : "离线"}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border border-white cursor-help transition ${health.nats ? "bg-emerald-500 shadow-sm shadow-emerald-200" : "bg-rose-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-400 font-medium">
|
||||
{health.gateway && health.dispatcher && health.postgres && health.redis && health.nats ? "正常" : "部分受损"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold text-gray-800">{current?.label ?? ""}</h1>
|
||||
<Suspense fallback={<div className="text-sm text-gray-400">加载中…</div>}>
|
||||
<main className="flex-1 overflow-auto p-6 bg-white/70">
|
||||
<h1 className="mb-4 text-base font-bold text-gray-800 tracking-tight">{current?.label ?? ""}</h1>
|
||||
<Suspense fallback={<div className="text-xs text-gray-400 animate-pulse">页面加载中…</div>}>
|
||||
<Routes>
|
||||
{routes.map((r) => (
|
||||
<Route key={r.path} path={r.path} element={r.element} />
|
||||
@@ -75,3 +209,4 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user