Files
Cyrene_v2/frontend/web/src/admin/AdminApp.tsx
T
AskaEth c8053b1f36
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」)
- 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替
- 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0
- 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500)
- 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义)
- 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4)
- 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500
- 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
2026-10-05 14:38:37 +08:00

322 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken, getStoredRole, health } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import type { WsStatus } from "../lib/types";
import { UiModeSwitch } from "../design/UiModeSwitch";
import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
import { navigateAdmin, navigateUserShell, type AdminSectionId } from "./router";
import { Button, Tag, type TagTone } from "../components/primitives";
import { Icon } from "../components/Icon";
import { DashboardModule } from "./modules/Dashboard";
import { PersonasModule } from "./modules/Personas";
import { SessionsModule } from "./modules/Sessions";
import { MemoriesModule } from "./modules/Memories";
import { PluginsModule } from "./modules/Plugins";
import { ChannelsModule } from "./modules/Channels";
import { ChannelEventsModule } from "./modules/ChannelEvents";
import { IoTModule } from "./modules/IoT";
import { RagModule } from "./modules/Rag";
import { LlmModule } from "./modules/Llm";
import { AutomationsModule } from "./modules/Automations";
import { AgentsModule } from "./modules/Agents";
import { FlowModule } from "./modules/Flow";
import { BackthinkModule } from "./modules/Backthink";
import { NotificationsModule } from "./modules/Notifications";
import { ObservabilityModule } from "./modules/Observability";
import { UsersModule } from "./modules/Users";
import { SystemModule } from "./modules/System";
function renderSection(section: AdminSectionId): React.ReactNode {
switch (section) {
case "dashboard": return <DashboardModule />;
case "personas": return <PersonasModule />;
case "sessions": return <SessionsModule />;
case "memories": return <MemoriesModule />;
case "plugins": return <PluginsModule />;
case "channels": return <ChannelsModule />;
case "channel-events": return <ChannelEventsModule />;
case "iot": return <IoTModule />;
case "rag": return <RagModule />;
case "llm": return <LlmModule />;
case "automations": return <AutomationsModule />;
case "agents": return <AgentsModule />;
case "flow": return <FlowModule />;
case "backthink": return <BackthinkModule />;
case "notifications": return <NotificationsModule />;
case "observability": return <ObservabilityModule />;
case "users": return <UsersModule />;
case "system": return <SystemModule />;
default: return <DashboardModule />;
}
}
/**
* Sidebar content shared by the desktop rail and the mobile drawer (design/10).
* Both render ADMIN_GROUPS with the same `onNavigate` hook — the drawer passes
* its close callback (tap an item → close), the desktop rail passes a no-op — so
* desktop and mobile never diverge.
*/
function adminNavContent({ active, onNavigate }: { active: AdminSectionId; onNavigate: () => void }): React.ReactNode {
return (
<>
{ADMIN_GROUPS.map((group) => (
<div key={group} className="mb-1">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">{group}</div>
{ADMIN_SECTIONS.filter((s) => s.group === group).map((s) => (
<button
key={s.id}
onClick={() => { navigateAdmin(s.id); onNavigate(); }}
className={`flex w-full items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-left transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
s.id === active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<Icon name={s.icon} size={18} />
<span>{s.label}</span>
</button>
))}
</div>
))}
</>
);
}
/** Desktop left rail — fixed, `hidden md:flex`. */
function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
return (
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3 md:flex">
{adminNavContent({ active, onNavigate: () => {} })}
</nav>
);
}
/** Mobile drawer — scrim + spring slide-in panel (reuses mu-drawer-in). */
function AdminMobileNav({ open, active, onClose }: { open: boolean; active: AdminSectionId; onClose: () => void }): React.ReactNode {
if (!open) return null;
return (
<div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
onClick={onClose}
aria-label="关闭侧边栏"
/>
<nav
className="glass absolute inset-y-0 left-0 flex w-[var(--mu-sidebar-w)] max-w-[80vw] flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3"
style={{ animation: "mu-drawer-in 0.3s var(--mu-spring) both" }}
>
<div className="flex items-center justify-between px-2 pb-1">
<span className="text-xs font-medium text-[var(--mu-text-lo)]">菜单</span>
<button
onClick={onClose}
aria-label="关闭"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 hover:bg-[var(--mu-surface-4)] active:scale-95"
>
<Icon name="close" size={18} />
</button>
</div>
{adminNavContent({ active, onNavigate: onClose })}
</nav>
</div>
);
}
/** Poll interval for the backend liveness probe (GET /api/v1/health). */
const HEALTH_POLL_MS = 15_000;
type BackendState = "checking" | "online" | "offline";
const BACKEND_META: Record<BackendState, { label: string; tone: TagTone; dot: string }> = {
checking: { label: "后端检测中", tone: "neutral", dot: "mu-blink" },
online: { label: "后端在线", tone: "ok", dot: "bg-[var(--mu-ok)]" },
offline: { label: "后端失联", tone: "err", dot: "bg-[var(--mu-err)]" },
};
const WS_META: Record<WsStatus, { label: string; tone: TagTone; dot: string }> = {
idle: { label: "未连接", tone: "neutral", dot: "bg-[var(--mu-text-lo)]" },
connecting: { label: "连接中", tone: "warn", dot: "mu-blink" },
connected: { label: "实时", tone: "ok", dot: "bg-[var(--mu-ok)]" },
reconnecting: { label: "重连中", tone: "warn", dot: "mu-blink" },
disconnected: { label: "已断开", tone: "err", dot: "bg-[var(--mu-err)]" },
expired: { label: "登录过期", tone: "err", dot: "bg-[var(--mu-err)]" },
};
/** Real liveness probe — the tag must never claim 在线 before /health has actually
* answered, so it starts as "检测中" (which is a state of its own, not a guess).
* The realtime channel is a separate signal from the store: it can be down while
* REST is fine (and vice versa), so it is shown as its own tag. */
function useBackendHealth(): BackendState {
const [state, setState] = useState<BackendState>("checking");
useEffect(() => {
let cancelled = false;
const probe = () => {
health()
.then(() => {
if (!cancelled) setState("online");
})
.catch(() => {
if (!cancelled) setState("offline");
});
};
probe();
const id = window.setInterval(probe, HEALTH_POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
return state;
}
/** 后端在线 / 失联 + 实时通道状态, both from real signals. */
function BackendStatusTag(): React.ReactNode {
const backend = useBackendHealth();
const wsStatus = useAppStore((s) => s.wsStatus);
const meta = BACKEND_META[backend];
const ws = WS_META[wsStatus];
return (
<>
<span className="inline-flex shrink-0" title={`GET /api/v1/health · 每 ${HEALTH_POLL_MS / 1000}s 探测一次`}>
<Tag tone={meta.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${meta.dot}`} />
{meta.label}
</Tag>
</span>
<span className="hidden shrink-0 sm:inline-flex" title="实时通道(/ws)状态">
<Tag tone={ws.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${ws.dot}`} />
实时通道 · {ws.label}
</Tag>
</span>
</>
);
}
function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode {
const role = getStoredRole();
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const meta = ADMIN_SECTIONS.find((s) => s.id === section);
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
setTheme(next);
};
return (
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-2 border-b border-[var(--mu-glass-border)] px-3 sm:px-4">
{onMenu ? (
<button
onClick={onMenu}
aria-label="打开菜单"
title="菜单"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-95 md:hidden"
>
<Icon name="menu" size={20} />
</button>
) : null}
<div className="flex min-w-0 items-center gap-2">
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="Cyrene" className="h-7 w-7 shrink-0 rounded-full object-cover" />
<div className="min-w-0 leading-tight">
<div className="truncate whitespace-nowrap text-sm font-semibold">Cyrene · 管理面板</div>
<div className="truncate whitespace-nowrap text-[11px] text-[var(--mu-text-lo)]">{meta?.label ?? "仪表盘"}</div>
</div>
</div>
<div className="flex-1" />
{role ? (
<Tag tone={role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral"} className="hidden shrink-0 sm:inline-flex">
{role}
</Tag>
) : null}
<BackendStatusTag />
{/* 双档 UI 档位(裁决 16.4.4):面板顶栏也吃档位,胶囊形态对齐其余 h-8 按钮。
与用户端顶栏同理:窄屏收起(<sm),避免与「返回用户端」等既有控件争宽。 */}
<span className="hidden shrink-0 sm:inline-flex">
<UiModeSwitch variant="compact" />
</span>
<button
onClick={toggleTheme}
aria-label="切换主题"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
<Icon name={theme === "dark" ? "sun" : "moon"} size={18} />
</button>
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => { navigateUserShell(); }}>
返回用户端
</Button>
</header>
);
}
function LoginGate(): React.ReactNode {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-4)] text-[var(--mu-accent)]">
<Icon name="lock" size={32} />
</div>
<div className="text-lg font-semibold">需要登录</div>
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
管理面板需持有 JWT。请先在用户端 <span className="text-[var(--mu-text-hi)]">登录页</span> 完成登录后,再返回本页。
</p>
<Button variant="primary" onClick={() => { navigateUserShell(); }}>前往用户端登录</Button>
</div>
);
}
function Forbidden403(): React.ReactNode {
const role = getStoredRole();
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-err)]/15 text-[var(--mu-err)]">
<Icon name="block" size={32} />
</div>
<div className="text-lg font-semibold">403 · 权限不足</div>
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
当前角色 <Tag tone="neutral">{role ?? "?"}</Tag>(viewer 只读)。管理面板需 <Tag tone="accent">owner</Tag> 或 <Tag tone="ok">admin</Tag> 角色;管理端写操作仅 owner。
</p>
<div className="flex gap-2">
<Button variant="primary" onClick={() => { navigateUserShell(); }}>返回用户端</Button>
</div>
</div>
);
}
export function AdminApp({ section }: { section: AdminSectionId }): React.ReactNode {
// Mobile nav drawer — only surfaced on the admin shell (md+ keeps the fixed
// sidebar); the login/403 guards never render it but still hold the hook.
const [adminMenuOpen, setAdminMenuOpen] = useState(false);
// RBAC guard (design/10 §2 + design/11 §7). A logged-in viewer or an
// unauthenticated user never sees the admin module; the gateway would also
// reject them with 401/403.
if (!getAuthToken() || !getStoredRole()) {
return (
<div className="flex h-full flex-col">
<AdminTopBar section={section} />
<LoginGate />
</div>
);
}
if (getStoredRole() === "viewer") {
return (
<div className="flex h-full flex-col">
<AdminTopBar section={section} />
<Forbidden403 />
</div>
);
}
return (
<div className="flex h-full flex-col">
<AdminTopBar section={section} onMenu={() => setAdminMenuOpen(true)} />
<div className="flex min-h-0 flex-1">
<AdminSidebar active={section} />
<main className="relative flex-1 overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-6xl">{renderSection(section)}</div>
</main>
</div>
<AdminMobileNav open={adminMenuOpen} active={section} onClose={() => setAdminMenuOpen(false)} />
</div>
);
}