diff --git a/sundynix-admin/index.html b/sundynix-admin/index.html index 9e63cd1..1d7db00 100644 --- a/sundynix-admin/index.html +++ b/sundynix-admin/index.html @@ -3,7 +3,9 @@ - sundynix-agentix · 运维控制台 + + + sundynix agentix · 事件驱动的 AI Agent 工作台
diff --git a/sundynix-admin/package-lock.json b/sundynix-admin/package-lock.json index 4236ccb..6e761d8 100644 --- a/sundynix-admin/package-lock.json +++ b/sundynix-admin/package-lock.json @@ -8,9 +8,12 @@ "name": "sundynix-admin", "version": "0.1.0", "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^1.25.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.0" + "react-router-dom": "^7.1.0", + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -2357,6 +2360,15 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3220,6 +3232,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -4040,6 +4061,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", diff --git a/sundynix-admin/package.json b/sundynix-admin/package.json index 9b3e1cf..c2b4646 100644 --- a/sundynix-admin/package.json +++ b/sundynix-admin/package.json @@ -11,9 +11,12 @@ "test:watch": "vitest" }, "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^1.25.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.0" + "react-router-dom": "^7.1.0", + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/sundynix-admin/public/apple-touch-icon.png b/sundynix-admin/public/apple-touch-icon.png new file mode 100644 index 0000000..11be7af Binary files /dev/null and b/sundynix-admin/public/apple-touch-icon.png differ diff --git a/sundynix-admin/public/brand/logo-full.jpg b/sundynix-admin/public/brand/logo-full.jpg new file mode 100644 index 0000000..02e39e6 Binary files /dev/null and b/sundynix-admin/public/brand/logo-full.jpg differ diff --git a/sundynix-admin/public/brand/logo-mark.webp b/sundynix-admin/public/brand/logo-mark.webp new file mode 100644 index 0000000..4c26031 Binary files /dev/null and b/sundynix-admin/public/brand/logo-mark.webp differ diff --git a/sundynix-admin/public/favicon-32.png b/sundynix-admin/public/favicon-32.png new file mode 100644 index 0000000..d0825f0 Binary files /dev/null and b/sundynix-admin/public/favicon-32.png differ diff --git a/sundynix-admin/public/favicon.png b/sundynix-admin/public/favicon.png new file mode 100644 index 0000000..776e242 Binary files /dev/null and b/sundynix-admin/public/favicon.png differ diff --git a/sundynix-admin/src/App.tsx b/sundynix-admin/src/App.tsx index dfff3f9..f47b0e6 100644 --- a/sundynix-admin/src/App.tsx +++ b/sundynix-admin/src/App.tsx @@ -1,11 +1,18 @@ import { useEffect, useState } from "react"; -import { HashRouter } from "react-router-dom"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; import { AppShell } from "./shell/AppShell"; import { Login } from "./Login"; import { me, clearToken, type AuthUser } from "./api"; -// 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置。 -export default function App() { +// 官网落地页(融进本工程,src/site/),挂根 /。 +import { SiteLayout } from "./site/components/layout/site-layout"; +import HomePage from "./site/pages/home"; +import DownloadPage from "./site/pages/download"; +import NotFoundPage from "./site/pages/not-found"; + +// 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。 +// 未登录 → Login;登录后 → AppShell(其内部再渲染 /admin/* 子路由)。 +function AdminGate() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -26,14 +33,31 @@ export default function App() { return ; } return ( - - { - clearToken(); - setUser(null); - }} - /> - + { + clearToken(); + setUser(null); + }} + /> + ); +} + +// 用 BrowserRouter(路径式):/ = 官网、/admin = 运维后台。 +// gateway 的 NoRoute 对非 /api、非文件路径回退 index.html,故深链/刷新都能工作。 +export default function App() { + return ( + + + {/* 后台:/admin 前缀,最具体,优先匹配 */} + } /> + {/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer) */} + }> + } /> + } /> + } /> + + + ); } diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 10c32af..fe056d0 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -314,6 +314,58 @@ export async function adminRefundOrder(id: string, memo: string): Promise<{ stat return { status: d.status ?? "", detail: d.detail }; } +// ---- 全平台任务/运行观测(管理端,来自 sundynix_task 跨租户)---- +export interface AdminTask { + task_id: string; + tenant_id: string; + tenant_name: string; + owner: string; + owner_email: string; + status: string; + detail: string; + topic: string; + at: string; // RFC3339 + eval_level: string; + eval_overall: number; +} + +// adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。 +export async function adminTasks( + status = "", + tenant = "", + limit = 50, +): Promise<{ tasks: AdminTask[]; counts: Record }> { + const q = new URLSearchParams(); + if (status) q.set("status", status); + if (tenant) q.set("tenant", tenant); + q.set("limit", String(limit)); + const res = guard(await fetch(`${ADMIN}/tasks?${q.toString()}`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as { tasks?: AdminTask[]; counts?: Record; error?: string }; + if (!res.ok) throw new Error(d.error ?? `tasks failed: ${res.status}`); + return { tasks: d.tasks ?? [], counts: d.counts ?? {} }; +} + +// ---- 全平台空间(Space)观测(管理端,跨租户 sundynix_space)---- +export interface AdminSpace { + id: string; + tenant_id: string; + tenant_name: string; + name: string; + kind: string; // personal / project / tenant + creator: string; + creator_email: string; + archived: boolean; + members: number; + created_at: string; +} + +export async function adminSpaces(limit = 200): Promise { + const res = guard(await fetch(`${ADMIN}/spaces?limit=${limit}`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as { spaces?: AdminSpace[]; error?: string }; + if (!res.ok) throw new Error(d.error ?? `spaces failed: ${res.status}`); + return d.spaces ?? []; +} + // ---- 自动评测观测(真数据,来自 sundynix_eval)---- export interface EvalDay { day: string; // YYYYMMDD diff --git a/sundynix-admin/src/index.css b/sundynix-admin/src/index.css index ad8a360..b9e5d9d 100644 --- a/sundynix-admin/src/index.css +++ b/sundynix-admin/src/index.css @@ -8,3 +8,111 @@ body, height: 100%; margin: 0; } + +/* ══ 官网落地页设计 token(跟随 logo:青→蓝→紫)══ + 仅 src/site/ 的落地页组件用这些;admin 控制台用默认 gray/violet 调色板,互不影响。 + bg-ground/text-ink 等只作用在 SiteLayout 的容器上(见 site-layout.tsx),不改 body。 */ +:root { + --ground: #f5f7f9; + --surface: #ffffff; + --ink: #151c24; + --ink-2: #526069; + --ink-3: #8595a0; + --hairline: #e0e7ec; + --hairline-strong: #c6d2da; + --accent: #0284c7; + --accent-ink: #075985; + --accent-soft: #e0f2fe; + --code-bg: #ebf1f5; + --term-bg: #0e1620; + --term-ink: #c9e4f5; + --brand-g1: #0891b2; + --brand-g2: #2563eb; + --brand-g3: #9333ea; +} + +.dark { + --ground: #0b1117; + --surface: #111925; + --ink: #e6edf3; + --ink-2: #93a6b4; + --ink-3: #62737f; + --hairline: #1f2c38; + --hairline-strong: #32434f; + --accent: #38bdf8; + --accent-ink: #7dd3fc; + --accent-soft: #0b3049; + --code-bg: #14202b; + --term-bg: #0a121b; + --term-ink: #bfe3f7; + --brand-g1: #22d3ee; + --brand-g2: #60a5fa; + --brand-g3: #c084fc; +} + +/* v3 对 var() 色不支持 /opacity 修饰,用 color-mix 还原 bg-ground/85(sticky 头透底 + 模糊)。 */ +.bg-ground-85 { + background-color: color-mix(in srgb, var(--ground) 85%, transparent); +} + +/* 品牌渐变文字(logo 同源:青→蓝→紫),落地页克制使用 */ +.text-brand-gradient { + background: linear-gradient(92deg, var(--brand-g1), var(--brand-g2) 55%, var(--brand-g3)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* 终端光标 */ +@keyframes caret-blink { + 50% { + opacity: 0; + } +} +.caret-blink { + animation: caret-blink 1.1s steps(1) infinite; +} + +/* 滚动进场(配合 Reveal 组件) */ +.reveal { + opacity: 0; + transform: translateY(16px); + transition: + opacity 0.6s ease, + transform 0.6s ease; +} +.reveal-in { + opacity: 1; + transform: none; +} + +/* 终端事件流:逐行浮现 */ +@keyframes term-line-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } +} +.term-line { + opacity: 0; + animation: term-line-in 0.45s ease forwards; +} + +@media (prefers-reduced-motion: reduce) { + .caret-blink, + .term-line { + animation: none; + } + .term-line { + opacity: 1; + } + .reveal { + opacity: 1; + transform: none; + transition: none; + } +} diff --git a/sundynix-admin/src/pages/SpacesPage.tsx b/sundynix-admin/src/pages/SpacesPage.tsx new file mode 100644 index 0000000..cc580b4 --- /dev/null +++ b/sundynix-admin/src/pages/SpacesPage.tsx @@ -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 = { + 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([]); + 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 = {}; + 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 ( +
+
+

全平台空间(Space)

+ 租户与成员之间的中间容器:Agent / 知识库按空间共享 +
+ + {/* 计数卡片 */} +
+ + + + +
+ +
+
+ {loading ? "加载中…" : `共 ${shown.length} 个空间`} +
+ + +
+
+ {err &&

{err}

} + +
+ + + + + + + + + + + + + + {shown.map((s) => ( + + + + + + + + + + ))} + {shown.length === 0 && !loading && ( + + + + )} + +
空间类型租户建者成员创建时间状态
{s.name} + {kind(s.kind).label} + {s.tenant_name || {s.tenant_id || "—"}}{s.creator_email || s.creator || "—"}{s.members}{new Date(s.created_at).toLocaleDateString("zh-CN")} + {s.archived ? 已归档 : 活跃} +
暂无空间
+
+
+
+ ); +} + +function Stat({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/sundynix-admin/src/pages/StatusPage.tsx b/sundynix-admin/src/pages/StatusPage.tsx index 4c31e63..7904abb 100644 --- a/sundynix-admin/src/pages/StatusPage.tsx +++ b/sundynix-admin/src/pages/StatusPage.tsx @@ -15,6 +15,7 @@ const INFRA_META: Record 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" }, }; function toolCategory(t: string): string { @@ -187,7 +188,7 @@ export function StatusPage() {
- +
{data.infra.map((s) => ( diff --git a/sundynix-admin/src/pages/TasksPage.tsx b/sundynix-admin/src/pages/TasksPage.tsx new file mode 100644 index 0000000..744e9bd --- /dev/null +++ b/sundynix-admin/src/pages/TasksPage.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useState } from "react"; +import { adminTasks, type AdminTask } from "../api"; + +// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。 +// 含 HITL 待审批(筛 waiting)。数据来自 sundynix_task,后端 GET /admin/tasks。 + +const STATUS: Record = { + submitted: { label: "已提交", badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" }, + running: { label: "运行中", badge: "bg-cyan-50 text-cyan-600", dot: "bg-cyan-500" }, + done: { label: "完成", badge: "bg-emerald-50 text-emerald-600", dot: "bg-emerald-500" }, + failed: { label: "失败", badge: "bg-rose-50 text-rose-500", dot: "bg-rose-500" }, + timeout: { label: "超时", badge: "bg-amber-50 text-amber-600", dot: "bg-amber-500" }, + waiting: { label: "待审批", badge: "bg-yellow-50 text-yellow-700", dot: "bg-yellow-500" }, + rejected: { label: "已拒绝", badge: "bg-violet-50 text-violet-600", dot: "bg-violet-500" }, +}; +const st = (s: string) => STATUS[s] ?? { label: s, badge: "bg-gray-100 text-gray-500", dot: "bg-gray-400" }; + +// 筛选标签顺序(waiting 提前,运维最关心待审批 + 失败)。 +const FILTERS = ["", "waiting", "running", "failed", "timeout", "done", "rejected"] as const; + +const EVAL_LEVEL: Record = { + excellent: "text-emerald-600", + good: "text-emerald-500", + fair: "text-amber-600", + poor: "text-rose-500", +}; + +export function TasksPage() { + const [tasks, setTasks] = useState([]); + const [counts, setCounts] = useState>({}); + const [filter, setFilter] = useState(""); + const [err, setErr] = useState(""); + const [loading, setLoading] = useState(false); + + const load = useCallback(() => { + setLoading(true); + adminTasks(filter, "", 100) + .then((r) => { + setTasks(r.tasks); + setCounts(r.counts); + setErr(""); + }) + .catch((e) => setErr((e as Error).message)) + .finally(() => setLoading(false)); + }, [filter]); + useEffect(load, [load]); + + const total = Object.values(counts).reduce((a, b) => a + b, 0); + + return ( +
+
+

全平台任务 / 运行观测

+ 跨租户查看所有任务,含 HITL 待审批(筛「待审批」) +
+ + {/* 状态计数卡片 */} +
+ setFilter("")} /> + {FILTERS.filter((f) => f).map((f) => ( + setFilter(f)} /> + ))} +
+ +
+
+ {loading ? "加载中…" : `共 ${tasks.length} 条${filter ? `(${st(filter).label})` : ""}`} + +
+ {err &&

{err}

} + +
+ + + + + + + + + + + + + + {tasks.map((t) => ( + + + + + + + + + + ))} + {tasks.length === 0 && !loading && ( + + + + )} + +
时间任务 / 主题租户提交人状态评测详情
{new Date(t.at).toLocaleString("zh-CN")} +
{t.topic || {t.task_id}}
+ {t.topic && {t.task_id}} +
{t.tenant_name || {t.tenant_id || "—"}}{t.owner_email || t.owner || "—"} + + + {st(t.status).label} + + + {t.eval_level ? ( + + {t.eval_level} · {(t.eval_overall * 100).toFixed(0)} + + ) : ( + + )} + + {t.detail || "—"} +
暂无任务
+
+
+
+ ); +} + +function Stat({ + label, + value, + tone, + active, + onClick, +}: { + label: string; + value: number; + tone?: string; + active: boolean; + onClick: () => void; +}) { + const dot = tone ? st(tone).dot : "bg-gray-400"; + return ( + + ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index cd76ce5..8337e7b 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -9,11 +9,13 @@ const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage }))); const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage }))); const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage }))); +const TasksPage = lazy(() => import("./pages/TasksPage").then((m) => ({ default: m.TasksPage }))); const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default: m.EvalsPage }))); const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage }))); const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage }))); const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage }))); const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage }))); +const SpacesPage = lazy(() => import("./pages/SpacesPage").then((m) => ({ default: m.SpacesPage }))); export interface RouteDef { path: string; @@ -25,70 +27,84 @@ export interface RouteDef { export const routes: RouteDef[] = [ { - path: "/dashboard", + path: "dashboard", label: "概览", group: "分析", ready: true, element: , }, { - path: "/usage", + path: "usage", label: "计费 & 用量", group: "分析", ready: true, element: , }, { - path: "/models", + path: "models", label: "模型", group: "配置", ready: true, element: , }, { - path: "/datasources", + path: "datasources", label: "数据源 & RAG", group: "配置", ready: true, element: , }, { - path: "/prompts", + path: "prompts", label: "提示词", group: "配置", ready: true, element: , }, { - path: "/status", + path: "status", label: "服务状态", group: "运维", ready: true, element: , }, { - path: "/evals", + path: "tasks", + label: "任务观测", + group: "运维", + ready: true, + element: , + }, + { + path: "evals", label: "自动评测", group: "运维", ready: true, element: , }, { - path: "/audit", + path: "audit", label: "审计 & 安全", group: "运维", ready: true, element: , }, { - path: "/tenants", + path: "tenants", label: "租户 & 用户", group: "平台", ready: true, element: , }, { - path: "/guardrails", + path: "spaces", + label: "空间", + group: "平台", + ready: true, + element: , + }, + { + path: "guardrails", label: "安全护栏", group: "平台", ready: true, @@ -96,7 +112,7 @@ export const routes: RouteDef[] = [ }, ]; -export const defaultPath = "/dashboard"; +export const defaultPath = "dashboard"; // 派生分组导航(保持注册顺序)。 export function navGroups(): Array<{ group: string; items: RouteDef[] }> { diff --git a/sundynix-admin/src/shell/AppShell.tsx b/sundynix-admin/src/shell/AppShell.tsx index b612211..33d90cf 100644 --- a/sundynix-admin/src/shell/AppShell.tsx +++ b/sundynix-admin/src/shell/AppShell.tsx @@ -4,11 +4,15 @@ import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom" import { routes, navGroups, defaultPath } from "../routes"; import { gatewayOnline, type AuthUser } from "../api"; +// 控制台挂在 /admin 前缀下(根 / 是官网)。路由注册表里 path 为相对(dashboard 等), +// 链接/匹配时统一加 /admin 前缀。 +const ADMIN_BASE = "/admin"; + // 控制台外壳:导航与内容均由路由注册表派生(动态路由)。 export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) { const [online, setOnline] = useState(false); const loc = useLocation(); - const current = routes.find((r) => r.path === loc.pathname); + const current = routes.find((r) => `${ADMIN_BASE}/${r.path}` === loc.pathname); useEffect(() => { const ping = () => gatewayOnline().then(setOnline); @@ -31,7 +35,7 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v {g.items.map((r) => ( `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" diff --git a/sundynix-admin/src/site/components/download-grid.tsx b/sundynix-admin/src/site/components/download-grid.tsx new file mode 100644 index 0000000..fe2a316 --- /dev/null +++ b/sundynix-admin/src/site/components/download-grid.tsx @@ -0,0 +1,31 @@ +import { DOWNLOADS } from '@/content/site' +import { cn } from '@/lib/utils' + +export function DownloadGrid() { + return ( +
+ {DOWNLOADS.map((d) => ( +
+
{d.os}
+
+ {d.meta} +
+ + {d.action} + +
+ ))} +
+ ) +} diff --git a/sundynix-admin/src/site/components/layout/site-footer.tsx b/sundynix-admin/src/site/components/layout/site-footer.tsx new file mode 100644 index 0000000..7aee537 --- /dev/null +++ b/sundynix-admin/src/site/components/layout/site-footer.tsx @@ -0,0 +1,85 @@ +import { Link } from 'react-router-dom' +import { SITE } from '@/content/site' +import { LogoMark } from '@/components/logo' + +const COLUMNS = [ + { + title: 'PRODUCT', + links: [ + { label: '功能', href: '/#features' }, + { label: '架构', href: '/#architecture' }, + { label: '下载', href: '/download', internal: true }, + ], + }, + { + title: 'RESOURCES', + links: [ + { label: '下载桌面版', href: '/download', internal: true }, + { label: '源码仓库', href: SITE.repo }, + ], + }, +] as const + +export function SiteFooter() { + return ( +
+
+
+

+ + sundynix agentix +

+

{SITE.tagline}

+

+ {SITE.version} · MACOS / WINDOWS / SELF-HOSTED +

+
+ + {COLUMNS.map((col) => ( + + ))} +
+ +
+
+ © 2026 SUNDYNIX AGENTIX + + {SITE.icp} + +
+
+
+ ) +} diff --git a/sundynix-admin/src/site/components/layout/site-header.tsx b/sundynix-admin/src/site/components/layout/site-header.tsx new file mode 100644 index 0000000..dcaa2c5 --- /dev/null +++ b/sundynix-admin/src/site/components/layout/site-header.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react' +import { Link, NavLink } from 'react-router-dom' +import { Menu, X } from 'lucide-react' +import { LogoMark } from '@/components/logo' +import { cn } from '@/lib/utils' + +// 官网默认浅色,不搬深浅切换(ThemeProvider 未挂载)。 + +const NAV_ITEMS = [ + { label: '产品', to: '/', end: true }, + { label: '功能', to: '/#features' }, + { label: '架构', to: '/#architecture' }, +] + +export function SiteHeader() { + const [open, setOpen] = useState(false) + + const links = NAV_ITEMS.map((item) => + item.to.includes('#') ? ( + setOpen(false)} + className="text-sm text-ink-2 transition-colors hover:text-ink" + > + {item.label} + + ) : ( + setOpen(false)} + className={({ isActive }) => + cn( + 'text-sm transition-colors hover:text-ink', + isActive ? 'text-ink' : 'text-ink-2', + ) + } + > + {item.label} + + ), + ) + + return ( +
+
+ + + sundynix agentix + + + + +
+ +
+
+ + {open && ( + + )} +
+ ) +} diff --git a/sundynix-admin/src/site/components/layout/site-layout.tsx b/sundynix-admin/src/site/components/layout/site-layout.tsx new file mode 100644 index 0000000..c029b8b --- /dev/null +++ b/sundynix-admin/src/site/components/layout/site-layout.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react' +import { Outlet, useLocation } from 'react-router-dom' +import { SiteHeader } from '@/components/layout/site-header' +import { SiteFooter } from '@/components/layout/site-footer' + +export function SiteLayout() { + const { pathname } = useLocation() + + useEffect(() => { + window.scrollTo(0, 0) + }, [pathname]) + + return ( +
+ +
+ +
+ +
+ ) +} diff --git a/sundynix-admin/src/site/components/logo.tsx b/sundynix-admin/src/site/components/logo.tsx new file mode 100644 index 0000000..f7d9e55 --- /dev/null +++ b/sundynix-admin/src/site/components/logo.tsx @@ -0,0 +1,19 @@ +interface LogoMarkProps { + size?: number + className?: string +} + +/** 品牌 Logo:霓虹数据流 S(用户原版位图裁切,源图在 design-assets/) */ +export function LogoMark({ size = 28, className }: LogoMarkProps) { + return ( + + ) +} diff --git a/sundynix-admin/src/site/components/reveal.tsx b/sundynix-admin/src/site/components/reveal.tsx new file mode 100644 index 0000000..53ebac5 --- /dev/null +++ b/sundynix-admin/src/site/components/reveal.tsx @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface RevealProps { + children: ReactNode + className?: string + /** 进场延迟(毫秒),用于同屏元素错峰 */ + delay?: number +} + +/** 滚动进场:进入视口后淡入上移一次;prefers-reduced-motion 时直接显示 */ +export function Reveal({ children, className, delay = 0 }: RevealProps) { + const ref = useRef(null) + const [visible, setVisible] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setVisible(true) + return + } + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true) + io.disconnect() + } + }, + { threshold: 0.15, rootMargin: '0px 0px -40px 0px' }, + ) + io.observe(el) + return () => io.disconnect() + }, []) + + return ( +
+ {children} +
+ ) +} diff --git a/sundynix-admin/src/site/components/section-label.tsx b/sundynix-admin/src/site/components/section-label.tsx new file mode 100644 index 0000000..bcd4339 --- /dev/null +++ b/sundynix-admin/src/site/components/section-label.tsx @@ -0,0 +1,7 @@ +export function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ) +} diff --git a/sundynix-admin/src/site/components/terminal-demo.tsx b/sundynix-admin/src/site/components/terminal-demo.tsx new file mode 100644 index 0000000..bccfd2f --- /dev/null +++ b/sundynix-admin/src/site/components/terminal-demo.tsx @@ -0,0 +1,41 @@ +import { TERMINAL_DEMO } from '@/content/site' + +/** Hero 事件流终端窗 — 双主题共用深色 */ +export function TerminalDemo() { + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} + + {TERMINAL_DEMO.title} + +
+
+
+ + {TERMINAL_DEMO.prompt} +
+
+ ────────────────────────────────── +
+ {TERMINAL_DEMO.events.map((ev, i) => ( +
+ + [{ev.tag}] + {' '} + {ev.text} + {i === TERMINAL_DEMO.events.length - 1 && ( + + )} +
+ ))} +
+
+ ) +} diff --git a/sundynix-admin/src/site/content/site.ts b/sundynix-admin/src/site/content/site.ts new file mode 100644 index 0000000..2adaf30 --- /dev/null +++ b/sundynix-admin/src/site/content/site.ts @@ -0,0 +1,139 @@ +/** 站点文案与数据,改这里即可,不用动组件 */ + +export const SITE = { + /** 静态回退版本号;线上以 /api/releases/latest 为准 */ + version: 'v0.1.2', + tagline: '事件驱动的 AI Agent 工作台', + repo: 'https://git.sundynix.cn/sundynix/sundynix-agentix', + icp: '滇ICP备2025056308号-1', +} + +export const TERMINAL_DEMO = { + title: 'SUNDYNIX.STREAMS.RT-7F2A', + prompt: '帮我调研「2026 国产大模型推理成本」,出一份带图表的报告', + events: [ + { + tag: 'coordinator', + text: '已拆解为 3 个子任务,派发 researcher × 2 · writer × 1', + }, + { + tag: 'knowledge', + text: '三路混合检索 · vector + fulltext + graph · RRF 融合', + }, + { tag: 'harness', text: '输出守卫通过 · 预算 ¥2.00 剩余 ¥1.37' }, + { + tag: 'report', + text: '大纲 5 章已确认,并行撰写中 → 推理成本分析.docx', + ok: true, + }, + ], +} + +export const FEATURES = [ + { + label: 'STUDIO', + title: '可视化编排画布', + desc: 'React Flow 画布拖出工作流,导出 JSON DSL 直接执行;分支路由、map 并行扇出、⌘K 命令面板。', + }, + { + label: 'MULTI-AGENT', + title: '多智能体协作', + desc: 'Orchestrator–Worker 模式:主脑撰写任务简报,并行派发专家 Agent,团队视图实时看它们「上班」。', + }, + { + label: 'KNOWLEDGE', + title: '三路混合检索知识库', + desc: '向量 + 全文 + 知识图谱三路召回,RRF 融合重排;双链笔记、反向链接与关系图谱,检索过程可调试。', + }, + { + label: 'REPORT', + title: '真 · Word 报告生成', + desc: '选题 → 大纲规划 → 分章并行研究撰写,产出原生 .docx(零依赖 OOXML 渲染),另有 PDF / Markdown。', + }, + { + label: 'MEMORY', + title: '长期记忆', + desc: '异步批量固化(ADD/UPDATE/DELETE),近因 × 重要性衰减打分,记忆面板随时可查可改。', + }, + { + label: 'HARNESS', + title: '可靠性套件', + desc: '提示注入拦截、流式密钥脱敏、熔断器、LLM 评审自动纠偏、成本预算上限——生产级守卫全内置。', + }, + { + label: 'SANDBOX', + title: '代码解释器沙箱', + desc: 'AST 静态审查 + Docker 隔离:无网络、非 root、只读根文件系统,让 Agent 放心跑代码。', + }, + { + label: 'STREAMING', + title: '一切皆流式', + desc: 'NATS 零拷贝骨干网直推 SSE/WS,节点级执行轨迹实时可见,Prometheus + OTel 全链路观测。', + }, +] + +export const ARCH_LAYERS = [ + { + id: 'L1 · CLIENT', + name: '桌面工作台', + detail: '· Web / 管理控制台', + tech: 'wails3 · react19', + }, + { + id: 'L2 · GATEWAY', + name: 'API 网关', + detail: '· 鉴权 / DSL 解析 / 计费 / 守卫', + tech: 'gin · postgres · redis', + }, + { + id: 'L4 · DISPATCHER', + name: '图执行引擎', + detail: '· Agent Loop / LLM Pool', + tech: 'eino · openai-compat', + }, + { + id: 'L5 · TOOLS', + name: 'MCP 工具层', + detail: '· 检索 / 解析 / 沙箱', + tech: 'milvus · bleve · neo4j · py3.11', + }, +] + +export const QUICKSTART_STEPS = [ + { + title: '下载或克隆。', + desc: '桌面版开箱即用;自部署走 docker compose。', + }, + { + title: '接入模型。', + desc: '任何 OpenAI 兼容端点:DeepSeek、百炼……填 Key 即可。', + }, + { + title: '喂知识,派任务。', + desc: '拖入文档建库,画布编排或直接对话,看团队开工。', + }, +] + +export const DOWNLOADS = [ + { + os: 'macOS', + meta: 'universal · .app · v0.1.2', + action: '下载 .dmg', + href: `${SITE.repo}/releases`, + highlight: true, + }, + { + os: 'Windows', + meta: 'x64 · .exe · v0.1.2', + action: '下载 .exe', + href: `${SITE.repo}/releases`, + highlight: false, + }, + { + os: '自托管', + meta: 'docker compose · 全平台', + action: '部署文档', + href: SITE.repo, + highlight: false, + }, +] diff --git a/sundynix-admin/src/site/hooks/use-page-title.ts b/sundynix-admin/src/site/hooks/use-page-title.ts new file mode 100644 index 0000000..f696ee8 --- /dev/null +++ b/sundynix-admin/src/site/hooks/use-page-title.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react' + +const BASE = 'sundynix agentix' +const DEFAULT = `${BASE} — 事件驱动的 AI Agent 工作台` + +/** 设置页面标题;不传参恢复默认 */ +export function usePageTitle(title?: string) { + useEffect(() => { + document.title = title ? `${title} — ${BASE}` : DEFAULT + return () => { + document.title = DEFAULT + } + }, [title]) +} diff --git a/sundynix-admin/src/site/hooks/use-release.ts b/sundynix-admin/src/site/hooks/use-release.ts new file mode 100644 index 0000000..0194a85 --- /dev/null +++ b/sundynix-admin/src/site/hooks/use-release.ts @@ -0,0 +1,22 @@ +import { SITE } from '@/content/site' + +export interface ReleaseInfo { + version: string + page_url: string + macos_url?: string + windows_url?: string + source: 'gitea' | 'fallback' +} + +// 官网融进 admin 后不接 posts/releases 后端(营销落地页 only):直接用静态版本号, +// 去掉原来那次必然失败的 fetch('/api/releases/latest')。 +const STATIC: ReleaseInfo = { + version: SITE.version, + page_url: `${SITE.repo}/releases`, + source: 'fallback', +} + +/** 最新版本信息(静态,来自 content/site.ts)。 */ +export function useRelease(): ReleaseInfo { + return STATIC +} diff --git a/sundynix-admin/src/site/lib/utils.ts b/sundynix-admin/src/site/lib/utils.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/sundynix-admin/src/site/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/sundynix-admin/src/site/pages/download.tsx b/sundynix-admin/src/site/pages/download.tsx new file mode 100644 index 0000000..a1b3e78 --- /dev/null +++ b/sundynix-admin/src/site/pages/download.tsx @@ -0,0 +1,23 @@ +import { SectionLabel } from '@/components/section-label' +import { DownloadGrid } from '@/components/download-grid' +import { usePageTitle } from '@/hooks/use-page-title' + +export default function DownloadPage() { + usePageTitle('下载') + return ( +
+
+
+ DOWNLOAD +

+ 下载 sundynix agentix +

+

+ macOS 与 Windows 桌面版,或用 docker compose 私有化部署。 +

+
+ +
+
+ ) +} diff --git a/sundynix-admin/src/site/pages/home/architecture.tsx b/sundynix-admin/src/site/pages/home/architecture.tsx new file mode 100644 index 0000000..b5d126c --- /dev/null +++ b/sundynix-admin/src/site/pages/home/architecture.tsx @@ -0,0 +1,54 @@ +import { Fragment } from 'react' +import { ARCH_LAYERS } from '@/content/site' +import { SectionLabel } from '@/components/section-label' +import { Reveal } from '@/components/reveal' + +export function Architecture() { + return ( +
+
+ +
+ ARCHITECTURE +

+ 五层架构,一条零拷贝总线 +

+

+ Monolith First,为微服务演进预留缝隙(Morph B)。 +

+
+
+ + + {ARCH_LAYERS.map((layer, i) => ( + + {/* NATS 总线插在 L2 和 L4 之间 */} + {i === 2 && ( +
+ ⇅ NATS JETSTREAM — 零拷贝消息骨干网 · sundynix.* ⇅ +
+ )} +
+ + {layer.id} + + + {layer.name}{' '} + + {layer.detail} + + + + {layer.tech} + +
+
+ ))} +
+
+
+ ) +} diff --git a/sundynix-admin/src/site/pages/home/download-cta.tsx b/sundynix-admin/src/site/pages/home/download-cta.tsx new file mode 100644 index 0000000..b561238 --- /dev/null +++ b/sundynix-admin/src/site/pages/home/download-cta.tsx @@ -0,0 +1,26 @@ +import { SectionLabel } from '@/components/section-label' +import { DownloadGrid } from '@/components/download-grid' +import { Reveal } from '@/components/reveal' + +export function DownloadCta() { + return ( +
+
+ +
+ DOWNLOAD +

+ 把 AI 团队装进桌面 +

+

+ 内置应用内更新,发布流水线自动出包。 +

+
+
+ + + +
+
+ ) +} diff --git a/sundynix-admin/src/site/pages/home/features.tsx b/sundynix-admin/src/site/pages/home/features.tsx new file mode 100644 index 0000000..7bec4f2 --- /dev/null +++ b/sundynix-admin/src/site/pages/home/features.tsx @@ -0,0 +1,40 @@ +import { FEATURES } from '@/content/site' +import { SectionLabel } from '@/components/section-label' +import { Reveal } from '@/components/reveal' + +export function Features() { + return ( +
+
+ +
+ CAPABILITIES +

+ 一个工作台,一支 AI 团队 +

+

+ 从编排到交付,每个环节都是流式的、可观测的、有守卫的。 +

+
+
+ + +
+ {FEATURES.map((f) => ( +
+ + {f.label} + +

{f.title}

+

{f.desc}

+
+ ))} +
+
+
+
+ ) +} diff --git a/sundynix-admin/src/site/pages/home/hero.tsx b/sundynix-admin/src/site/pages/home/hero.tsx new file mode 100644 index 0000000..86ee401 --- /dev/null +++ b/sundynix-admin/src/site/pages/home/hero.tsx @@ -0,0 +1,58 @@ +import { Link } from 'react-router-dom' +import { Download } from 'lucide-react' +import { SITE } from '@/content/site' +import { TerminalDemo } from '@/components/terminal-demo' +import { Reveal } from '@/components/reveal' + +export function Hero() { + return ( +
+ + + + {SITE.version} 已发布 · macOS / Windows + + + + +

+ 事件驱动的 +
+ AI Agent 工作台 + +

+
+ + +

+ 在画布上编排智能体,让多 Agent + 团队替你研究、检索、写出真正的 Word + 报告。桌面开箱即用,也可一条命令私有化部署。 +

+
+ + +
+ + 下载桌面版 + + + $ make demo + +
+
+ + + + +
+ ) +} diff --git a/sundynix-admin/src/site/pages/home/index.tsx b/sundynix-admin/src/site/pages/home/index.tsx new file mode 100644 index 0000000..10e8ab0 --- /dev/null +++ b/sundynix-admin/src/site/pages/home/index.tsx @@ -0,0 +1,17 @@ +import { Hero } from '@/pages/home/hero' +import { Features } from '@/pages/home/features' +import { Architecture } from '@/pages/home/architecture' +import { Quickstart } from '@/pages/home/quickstart' +import { DownloadCta } from '@/pages/home/download-cta' + +export default function HomePage() { + return ( + <> + + + + + + + ) +} diff --git a/sundynix-admin/src/site/pages/home/quickstart.tsx b/sundynix-admin/src/site/pages/home/quickstart.tsx new file mode 100644 index 0000000..45b3f18 --- /dev/null +++ b/sundynix-admin/src/site/pages/home/quickstart.tsx @@ -0,0 +1,56 @@ +import { QUICKSTART_STEPS } from '@/content/site' +import { SectionLabel } from '@/components/section-label' +import { Reveal } from '@/components/reveal' + +export function Quickstart() { + return ( +
+
+ +
+ QUICKSTART +

+ 五分钟跑起来 +

+
+
+ + +
+            
+              # 内嵌 NATS,零配置体验
+              {'\n'}
+              $ git clone
+              https://git.sundynix.cn/sundynix/sundynix-agentix{'\n'}
+              $ make demo{'\n'}
+              {'\n'}
+              # 或一条命令私有化部署
+              {'\n'}
+              $ docker compose up -d
+            
+          
+ +
    + {QUICKSTART_STEPS.map((step, i) => ( +
  • + + 0{i + 1} + + + {step.title} + {step.desc} + +
  • + ))} +
+
+
+
+ ) +} diff --git a/sundynix-admin/src/site/pages/not-found.tsx b/sundynix-admin/src/site/pages/not-found.tsx new file mode 100644 index 0000000..41b64a8 --- /dev/null +++ b/sundynix-admin/src/site/pages/not-found.tsx @@ -0,0 +1,25 @@ +import { Link } from 'react-router-dom' +import { usePageTitle } from '@/hooks/use-page-title' + +export default function NotFoundPage() { + usePageTitle('页面不存在') + return ( +
+

+ 404 · NOT FOUND +

+

+ 这条事件流不存在 +

+

+ 地址可能已变更,回首页重新出发。 +

+ + 回首页 + +
+ ) +} diff --git a/sundynix-admin/tailwind.config.js b/sundynix-admin/tailwind.config.js index a440ac6..9da054c 100644 --- a/sundynix-admin/tailwind.config.js +++ b/sundynix-admin/tailwind.config.js @@ -1,6 +1,38 @@ /** @type {import('tailwindcss').Config} */ export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], - theme: { extend: {} }, + darkMode: "class", + theme: { + extend: { + // 官网落地页设计 token(跟随 logo:青→蓝→紫)。值引用 CSS 变量(见 src/index.css 的 + // :root/.dark),admin 自身页面用默认 gray/violet 调色板、不碰这些 token,故不冲突。 + colors: { + ground: "var(--ground)", + surface: "var(--surface)", + ink: "var(--ink)", + "ink-2": "var(--ink-2)", + "ink-3": "var(--ink-3)", + hairline: "var(--hairline)", + "hairline-strong": "var(--hairline-strong)", + accent: "var(--accent)", + "accent-ink": "var(--accent-ink)", + "accent-soft": "var(--accent-soft)", + code: "var(--code-bg)", + term: "var(--term-bg)", + "term-ink": "var(--term-ink)", + }, + fontFamily: { + sans: [ + "-apple-system", + "PingFang SC", + "Hiragino Sans GB", + "Microsoft YaHei", + "Noto Sans SC", + "sans-serif", + ], + mono: ["ui-monospace", "SF Mono", "JetBrains Mono", "Menlo", "Consolas", "monospace"], + }, + }, + }, plugins: [], }; diff --git a/sundynix-admin/tsconfig.json b/sundynix-admin/tsconfig.json index 75411a3..a949670 100644 --- a/sundynix-admin/tsconfig.json +++ b/sundynix-admin/tsconfig.json @@ -10,7 +10,9 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "strict": true + "strict": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/site/*"] } }, "include": ["src"] } diff --git a/sundynix-admin/vite.config.ts b/sundynix-admin/vite.config.ts index f67cd1d..d54dc93 100644 --- a/sundynix-admin/vite.config.ts +++ b/sundynix-admin/vite.config.ts @@ -1,9 +1,12 @@ /// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import path from "node:path"; export default defineConfig({ plugins: [react()], + // 官网落地页融进 admin,代码在 src/site/ 下且沿用 @ 别名导入(@/content/site 等)。 + resolve: { alias: { "@": path.resolve(__dirname, "./src/site") } }, server: { port: 5174 }, // 单元/组件测试:纯逻辑 + 关键控制面(jsdom 环境)。运行:npm test test: { diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index 42715c2..fc5b73d 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -3,6 +3,7 @@ package handler import ( "context" "net/http" + "strconv" "strings" "time" @@ -305,6 +306,31 @@ func (h *Handler) AdminReconcile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0}) } +// AdminTasks: GET /api/v1/admin/tasks?status=&tenant=&limit= —— 全平台任务/运行观测。 +// 跨租户看所有任务(状态/租户/提交人/评测),含 HITL 待审批(status=waiting)。返回列表 + 状态计数。 +func (h *Handler) AdminTasks(c *gin.Context) { + ctx := c.Request.Context() + limit := 50 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + rows := h.db.AllTasks(ctx, c.Query("status"), c.Query("tenant"), limit) + c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)}) +} + +// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。 +func (h *Handler) AdminSpaces(c *gin.Context) { + limit := 200 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + c.JSON(http.StatusOK, gin.H{"spaces": h.db.AllSpaces(c.Request.Context(), limit)}) +} + // AdminRefundOrder: POST /api/v1/admin/orders/:id/refund —— 人工退款(PAYMENT_DESIGN §5)。 // 只退 paid 单:订单置 refunded + 记 adjust 负分录 + 回退余额(幂等,可能扣成负余额)。 // 真渠道(微信)退款仅冲销本地积分与订单态,钱的原路退回由 admin 在微信商户后台线下操作 diff --git a/sundynix-gateway/internal/handler/status_handler.go b/sundynix-gateway/internal/handler/status_handler.go index 6c437a8..3e75ba6 100644 --- a/sundynix-gateway/internal/handler/status_handler.go +++ b/sundynix-gateway/internal/handler/status_handler.go @@ -65,9 +65,11 @@ func (h *Handler) AdminStatus(c *gin.Context) { dispUp bool // dispatcher 在线 dispDetail string // dispatcher 详情(模型/运行时长) dispLatency int // dispatcher 探针耗时 + + pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志) ) - wg.Add(4) + wg.Add(5) // 1) mcp-go health → milvus / neo4j 基建灯 go func() { @@ -115,16 +117,27 @@ func (h *Handler) AdminStatus(c *gin.Context) { } }() + // 5) 基建活性探针:PG / Redis / MinIO 实时 ping(非仅启动标志,能反映中途掉线)。 + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(parent, probeTimeout) + defer cancel() + pgUp = h.db.Ping(ctx) + redisUp = h.cache.Ping(ctx) + minioUp = h.blob != nil && h.blob.Ping(ctx) + }() + wg.Wait() c.JSON(http.StatusOK, systemStatus{ CheckedAt: time.Now().Format(time.RFC3339), Infra: []statusItem{ - {Name: "postgres", Up: h.db.Enabled()}, - {Name: "redis", Up: h.cache.Enabled()}, + {Name: "postgres", Up: pgUp}, + {Name: "redis", Up: redisUp}, {Name: "nats", Up: true}, // 网关连不上 NATS 即 fatal,能应答即在线 {Name: "milvus", Up: milvus}, {Name: "neo4j", Up: neo4j}, + {Name: "minio", Up: minioUp}, // 对象存储(报告/KB 正文/blob,126) }, Services: []statusItem{ {Name: "gateway", Up: true, Detail: "在线"}, diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 7d55ff5..2e1b7be 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -158,6 +158,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.DELETE("/tenants/:id/members/:uid", h.AdminRemoveMember) // 移除成员 admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 + admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批) + admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数) admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据) diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 61e2661..cf4dac6 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -126,6 +126,18 @@ func migrateDocLinkToID(db *gorm.DB) { // Enabled 报告是否处于真实持久化模式。 func (p *Postgres) Enabled() bool { return p.db != nil } +// Ping 活性探测:底层连接池发一次 PingContext,验证 PG 此刻真的可达(非仅启动时连过)。 +func (p *Postgres) Ping(ctx context.Context) bool { + if p.db == nil { + return false + } + sqlDB, err := p.db.DB() + if err != nil { + return false + } + return sqlDB.PingContext(ctx) == nil +} + // SaveTask 持久化一次任务提交(best-effort:降级模式下静默跳过)。 func (p *Postgres) SaveTask(ctx context.Context, owner, id, graph string) error { if p.db == nil { diff --git a/sundynix-gateway/internal/store/redis.go b/sundynix-gateway/internal/store/redis.go index 6fc4d2a..51144aa 100644 --- a/sundynix-gateway/internal/store/redis.go +++ b/sundynix-gateway/internal/store/redis.go @@ -31,6 +31,14 @@ func OpenRedis(addr string) *Redis { // Enabled 报告是否处于真实限流模式。 func (r *Redis) Enabled() bool { return r.rdb != nil } +// Ping 活性探测:发一次 PING,验证 Redis 此刻真的可达(非仅启动时连过)。 +func (r *Redis) Ping(ctx context.Context) bool { + if r.rdb == nil { + return false + } + return r.rdb.Ping(ctx).Err() == nil +} + // Allow 滑动窗口计数限流:在 window 内对 key 累加,超过 limit 即拒绝。 // 降级模式(rdb==nil)始终放行。 func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.Duration) (bool, error) { diff --git a/sundynix-gateway/internal/store/space_query.go b/sundynix-gateway/internal/store/space_query.go new file mode 100644 index 0000000..8babf92 --- /dev/null +++ b/sundynix-gateway/internal/store/space_query.go @@ -0,0 +1,44 @@ +package store + +import ( + "context" + "time" +) + +// 全平台空间观测(管理端「空间管理」页)。系统级口径:跨租户看所有 Space, +// 走 WithoutTenant 旁路租户插件。 + +// AdminSpaceRow 是管理端空间一行:空间 + 租户名 + 建者邮箱 + 成员数。 +type AdminSpaceRow struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name"` + Name string `json:"name"` + Kind string `json:"kind"` // personal / project / tenant + Creator string `json:"creator"` + CreatorEmail string `json:"creator_email"` + Archived bool `json:"archived"` + Members int64 `json:"members"` + CreatedAt time.Time `json:"created_at"` +} + +// AllSpaces 全平台空间流(管理端观测;跨租户)。倒序,翻页。 +func (p *Postgres) AllSpaces(ctx context.Context, limit int) []AdminSpaceRow { + if p.db == nil { + return nil + } + if limit <= 0 || limit > 500 { + limit = 200 + } + ctx = WithoutTenant(ctx) // 系统级:看所有租户的空间 + var out []AdminSpaceRow + p.db.WithContext(ctx).Table("sundynix_space as s"). + Select("s.id, s.tenant_id, s.name, s.kind, s.creator, s.archived, s.created_at, " + + "coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as creator_email, " + + "(select count(*) from sundynix_space_member m where m.space_id = s.id and m.deleted_at is null) as members"). + Joins("left join sundynix_tenant tn on tn.id = s.tenant_id"). + Joins("left join sundynix_user u on u.id = s.creator"). + Where("s.deleted_at is null"). + Order("s.created_at desc").Limit(limit).Scan(&out) + return out +} diff --git a/sundynix-gateway/internal/store/task_query.go b/sundynix-gateway/internal/store/task_query.go new file mode 100644 index 0000000..1cf77f4 --- /dev/null +++ b/sundynix-gateway/internal/store/task_query.go @@ -0,0 +1,75 @@ +package store + +import ( + "context" + "time" +) + +// 全平台任务观测(管理端「运行观测」页)。系统级口径:跨租户看所有任务, +// 故走 WithoutTenant 旁路租户插件 + raw Table 查询(同 RecentRuns 手动控制过滤)。 + +// AdminTaskRow 是管理端任务流一行:任务 + 租户名 + 提交人邮箱 + 评测分级(LEFT JOIN)。 +type AdminTaskRow struct { + TaskID string `json:"task_id"` + TenantID string `json:"tenant_id"` + TenantName string `json:"tenant_name"` + Owner string `json:"owner"` + OwnerEmail string `json:"owner_email"` + Status string `json:"status"` + Detail string `json:"detail"` + Topic string `json:"topic"` + At time.Time `json:"at"` + EvalLevel string `json:"eval_level"` + EvalOverall float64 `json:"eval_overall"` +} + +// AllTasks 全平台任务流(管理端观测;可按状态/租户过滤)。倒序,翻页。 +// status 空=全部;tenantID 空=全租户。 +func (p *Postgres) AllTasks(ctx context.Context, status, tenantID string, limit int) []AdminTaskRow { + if p.db == nil { + return nil + } + if limit <= 0 || limit > 200 { + limit = 50 + } + ctx = WithoutTenant(ctx) // 系统级:看所有租户的任务 + q := p.db.WithContext(ctx).Table("sundynix_task as t"). + Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, " + + "coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, " + + "coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " + + "coalesce(t.graph->>'topic','') as topic"). + Joins("left join sundynix_tenant tn on tn.id = t.tenant_id"). + Joins("left join sundynix_user u on u.id = t.owner"). + Joins("left join sundynix_eval e on e.task_id = t.task_id"). + Where("t.deleted_at is null") + if status != "" { + q = q.Where("t.status = ?", status) + } + if tenantID != "" { + q = q.Where("t.tenant_id = ?", tenantID) + } + var out []AdminTaskRow + q.Order("t.created_at desc").Limit(limit).Scan(&out) + return out +} + +// TaskStatusCounts 全平台任务按状态计数(观测页的筛选标签 + 分布)。 +func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 { + out := map[string]int64{} + if p.db == nil { + return out + } + ctx = WithoutTenant(ctx) + var rows []struct { + Status string + N int64 + } + p.db.WithContext(ctx).Table("sundynix_task"). + Select("status, count(*) as n"). + Where("deleted_at is null"). + Group("status").Scan(&rows) + for _, r := range rows { + out[r.Status] = r.N + } + return out +} diff --git a/sundynix-shared/blob/minio.go b/sundynix-shared/blob/minio.go index aca3f0b..f37e173 100644 --- a/sundynix-shared/blob/minio.go +++ b/sundynix-shared/blob/minio.go @@ -46,9 +46,19 @@ func Open(endpoint, accessKey, secretKey, bucket string) *Store { return &Store{cli: cli, bucket: bucket} } -// Ready 报告对象存储是否可用。 +// Ready 报告对象存储是否可用(启动时连接状态)。 func (s *Store) Ready() bool { return s != nil && s.cli != nil } +// Ping 活性探测:向 MinIO 发一次 BucketExists,验证连接此刻真的可用(非仅启动时连过)。 +// 供服务状态页做实时健康灯。 +func (s *Store) Ping(ctx context.Context) bool { + if s == nil || s.cli == nil { + return false + } + _, err := s.cli.BucketExists(ctx, s.bucket) + return err == nil +} + // Put 写入一段文本到对象键 key。 func (s *Store) Put(ctx context.Context, key, content string) error { r := bytes.NewReader([]byte(content))