feat(admin): 补全平台任务观测 + 空间管理 + 基建加 MinIO/实时探针

对照已实现系统功能补 admin 缺失模块(feat/site):

- 全平台任务/运行观测:GET /admin/tasks(跨租户查 sundynix_task,join 租户名/提交人邮箱/评测,
  按状态/租户筛 + 状态计数;含 HITL 待审批=筛 waiting)+ TasksPage(状态卡+筛+表)。
- 空间(Space)管理:GET /admin/spaces(跨租户列 Space + 成员数子查询 + kind/归档态)+ SpacesPage。
- 基建观测:infra 加 MinIO(126 对象存储);postgres/redis/minio 从启动标志升级为实时 ping
  (blob.Ping/Postgres.Ping/Redis.Ping),能反映中途掉线。StatusPage 加 minio 标签、6 个依赖。
- 模型健康/熔断:确认 DashboardPage 已有「运行时链路态」渲染 m.health,无需重做。

验证:admin tsc + vitest 41 过、gateway build/vet/test 过;两新页浏览器实测渲染+优雅错误处理;
两新端点起临时 gateway 打真 PG 实测——tasks(counts+3路join)、spaces(成员数子查询)均返真数据。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 17:06:25 +08:00
parent de315450a4
commit 373167b705
13 changed files with 544 additions and 5 deletions
+122
View File
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { adminSpaces, type AdminSpace } from "../api";
// 全平台空间(Space)观测:跨租户看所有空间(类型/租户/建者/成员数/归档态)。
// Space 是租户与成员之间的中间容器(Tenant > Space > 成员),数据来自 sundynix_space。
const KIND: Record<string, { label: string; badge: string }> = {
personal: { label: "个人", badge: "bg-gray-100 text-gray-500" },
project: { label: "项目", badge: "bg-cyan-50 text-cyan-600" },
tenant: { label: "全员", badge: "bg-violet-50 text-violet-600" },
};
const kind = (k: string) => KIND[k] ?? { label: k, badge: "bg-gray-100 text-gray-500" };
export function SpacesPage() {
const [spaces, setSpaces] = useState<AdminSpace[]>([]);
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const [hideArchived, setHideArchived] = useState(true);
const load = useCallback(() => {
setLoading(true);
adminSpaces(300)
.then((s) => {
setSpaces(s);
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
const shown = useMemo(() => (hideArchived ? spaces.filter((s) => !s.archived) : spaces), [spaces, hideArchived]);
const stats = useMemo(() => {
const byKind: Record<string, number> = {};
let archived = 0;
for (const s of spaces) {
byKind[s.kind] = (byKind[s.kind] ?? 0) + 1;
if (s.archived) archived++;
}
return { total: spaces.length, byKind, archived };
}, [spaces]);
return (
<div className="space-y-5">
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700">(Space)</h3>
<span className="text-[11px] text-gray-400">Agent / </span>
</div>
{/* 计数卡片 */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Stat label="空间总数" value={stats.total} tone="text-gray-800" />
<Stat label="项目空间" value={stats.byKind.project ?? 0} tone="text-cyan-600" />
<Stat label="全员空间" value={stats.byKind.tenant ?? 0} tone="text-violet-600" />
<Stat label="已归档" value={stats.archived} tone="text-gray-400" />
</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 ? "加载中…" : `${shown.length} 个空间`}</span>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-500">
<input type="checkbox" checked={hideArchived} onChange={(e) => setHideArchived(e.target.checked)} />
</label>
<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>
</div>
</div>
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
<div className="max-h-[32rem] 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 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{shown.map((s) => (
<tr key={s.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{s.name}</td>
<td className="py-2 pr-3">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${kind(s.kind).badge}`}>{kind(s.kind).label}</span>
</td>
<td className="py-2 pr-3 text-gray-600">{s.tenant_name || <span className="text-gray-300">{s.tenant_id || "—"}</span>}</td>
<td className="py-2 pr-3 text-xs text-gray-500">{s.creator_email || s.creator || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{s.members}</td>
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(s.created_at).toLocaleDateString("zh-CN")}</td>
<td className="py-2 text-xs">
{s.archived ? <span className="text-gray-400"></span> : <span className="text-emerald-600"></span>}
</td>
</tr>
))}
{shown.length === 0 && !loading && (
<tr>
<td colSpan={7} className="py-8 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>{value}</div>
</div>
);
}