feat(admin): upgrade admin console UI widgets, add plan & status management, and redesign system status dashboard
This commit is contained in:
@@ -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