=== 1. 修复 /analysis 与 /logs 直方图 404(我上一轮 patch 的误伤)===
现象:分析页直接报 "API endpoint not found";日志查询页直方图不显示。
根因:37e2f0f 里一次模糊匹配的 patch 把
logs.POST("/histogram", logsCtrl.Histogram)
改成了
logs.GET("/histogram", logsCtrl.Histogram)
前端 logsApi.histogram 一直用 POST(body 带 sourceId/query/start/end),
于是 POST /logs/histogram 全部 404。访问日志实证:
POST /api/v1/logs/histogram → 404 ×7
这类"方法写错"编译期查不出来,go build / tsc 都是绿的。
修法:同时注册 POST(前端用)与 GET(curl 调试用)。
新增 backend/routers/router_test.go 守门:
TestCriticalRoutes 断言 19 条关键路由的 method+path 组合都存在
TestHistogramAcceptsPost 单独断言 histogram 必须同时有 POST 与 GET
以后再把 POST 写成 GET,测试会直接红。
=== 2. 系统设置放回侧栏,改成 5 个顶层 tab ===
按使用反馈调整:
系统设置(侧栏一个入口)
├ 通用配置 /settings ← tab 起点
├ 用户管理 /users
├ 审计日志 /audit
├ LDAP 接入 /settings/ldap ← 新拆分
└ 默认镜像源 /settings/registry ← 新拆分
为什么拆:原来 /settings 页面内部已有 4 个 tab(通用/告警通知/LDAP/镜像源),
如果侧栏再加一层 tab 就是"两层 tab",点两下才知道自己在哪。现在扁平化成单层。
配套改动:
- 新增 pages/settings/ldap.tsx、registry.tsx(各自只渲染一块内容)
- settings 主页去掉内层 Tabs,只留通用配置(外观/语言/改密码/关于)
- 上一版把设置放到了右上角齿轮,现在移除齿轮入口
- 菜单文案:settings 页标题 系统设置 → 通用配置;新增 LDAP 接入 / 默认镜像源
- 删除设置页副标题「通用配置、告警通知、LDAP 接入与默认镜像源」(用户要求去掉)
- 删除 settings 里已不存在的 tabs.notify 文案
(注意 settings.notify.* 这些 key 仍被告警页的 NotifySection 用着,保留)
=== 3. 告警通知已不在系统设置里 ===
它在上一轮就搬到了「日志告警 → 通知通道」tab,
本次顺带把系统设置里遗留的 tab 文案一并清掉,避免出现"看似有两处配置入口"。
=== 实测 ===
POST /api/v1/logs/histogram → 200(修复前 404)
go test ./... 全绿(含新增的 routers 包)
tsc -b --force 通过
166 lines
7.6 KiB
TypeScript
166 lines
7.6 KiB
TypeScript
// 路由配置 — 全站唯一路由声明处。
|
||
// 页面组件一律 React.lazy 懒加载;菜单(menuItems)与页面标题(findRouteMeta)
|
||
// 均由本文件派生,新增页面只需在这里加一条配置。
|
||
import { lazy } from 'react';
|
||
import {
|
||
LayoutDashboard,
|
||
Server,
|
||
Monitor,
|
||
HardDrive,
|
||
Rocket,
|
||
RadioTower,
|
||
Settings,
|
||
UsersRound,
|
||
Wand2,
|
||
ScrollText,
|
||
BellRing,
|
||
BarChart3,
|
||
History,
|
||
Boxes,
|
||
ShieldCheck,
|
||
Container,
|
||
} from 'lucide-react';
|
||
import type { AppRoute, MenuItem, RouteMeta } from '@/types/router';
|
||
|
||
const Login = lazy(() => import('@/pages/login'));
|
||
const Dashboard = lazy(() => import('@/pages/dashboard'));
|
||
const Clusters = lazy(() => import('@/pages/clusters'));
|
||
const Hosts = lazy(() => import('@/pages/hosts'));
|
||
const Storage = lazy(() => import('@/pages/storage'));
|
||
const Deployments = lazy(() => import('@/pages/deployments'));
|
||
const Collectors = lazy(() => import('@/pages/collectors'));
|
||
const SettingsPage = lazy(() => import('@/pages/settings'));
|
||
const LdapPage = lazy(() => import('@/pages/settings/ldap'));
|
||
const RegistryPage = lazy(() => import('@/pages/settings/registry'));
|
||
const NotFound = lazy(() => import('@/pages/not-found'));
|
||
const WizardPage = lazy(() => import('@/pages/wizard'));
|
||
const LogsPage = lazy(() => import('@/pages/logs'));
|
||
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
||
const UsersPage = lazy(() => import('@/pages/users'));
|
||
const AnalysisPage = lazy(() => import('@/pages/analysis'));
|
||
const AuditPage = lazy(() => import('@/pages/audit'));
|
||
|
||
export const routes: AppRoute[] = [
|
||
{ path: '/login', component: Login, auth: false, meta: { title: 'menu.login', hideInMenu: true } },
|
||
{ path: '/', component: Dashboard, auth: true, meta: { title: 'menu.dashboard', icon: LayoutDashboard, order: 1, adminOnly: true } },
|
||
{ path: '/wizard', component: WizardPage, auth: true, meta: { title: 'menu.wizard', icon: Wand2, order: 2, adminOnly: true } },
|
||
// ── 资源接入(集群 / 主机 / 存储):装完基本不动的配置,合并为一个入口 ──
|
||
{
|
||
path: '/clusters', component: Clusters, auth: true,
|
||
meta: {
|
||
title: 'menu.clusters', icon: Server, order: 3, adminOnly: true,
|
||
group: { key: 'resources', label: 'menu.groups.resources', icon: Boxes, order: 3 },
|
||
},
|
||
},
|
||
{ path: '/hosts', component: Hosts, auth: true, meta: { title: 'menu.hosts', icon: Monitor, order: 4, adminOnly: true, groupKey: 'resources' } },
|
||
{ path: '/storage', component: Storage, auth: true, meta: { title: 'menu.storage', icon: HardDrive, order: 5, adminOnly: true, groupKey: 'resources' } },
|
||
|
||
// ── 日志服务(Loki 实例 / 采集器):部署与采集是同一件事的两半 ──
|
||
{
|
||
path: '/deployments', component: Deployments, auth: true,
|
||
meta: {
|
||
// 组内 tab 名用「组件部署」:部署对象是基础设施组件(Loki 实例),
|
||
// 与业务侧大量 cex-* 业务服务明确区分,避免误以为这里是发布业务服务。
|
||
title: 'menu.deployments', icon: Rocket, order: 4, adminOnly: true,
|
||
group: { key: 'services', label: 'menu.groups.services', icon: Rocket, order: 4 },
|
||
},
|
||
},
|
||
{ path: '/collectors', component: Collectors, auth: true, meta: { title: 'menu.collectors', icon: RadioTower, order: 7, adminOnly: true, groupKey: 'services' } },
|
||
|
||
// ── 日志查询 / 日志分析:高频入口,保持独立菜单项(不折叠)──
|
||
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 5 } },
|
||
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true } },
|
||
|
||
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
||
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
||
// 告警规则涉及阈值/通知通道等平台级配置,不属于日志查询者的职责。
|
||
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 7, adminOnly: true } },
|
||
|
||
// ── 系统管理(用户 / 审计 / 设置):低频管理动作 ──
|
||
{
|
||
path: '/users', component: UsersPage, auth: true,
|
||
meta: {
|
||
// 系统设置(tab 起点)/ 用户管理 / 审计日志 同属「系统设置」分组,
|
||
// 侧栏只占一个入口,页面内用 tab 切换。
|
||
title: 'menu.users', icon: UsersRound, order: 9, adminOnly: true,
|
||
groupKey: 'system',
|
||
},
|
||
},
|
||
{ path: '/audit', component: AuditPage, auth: true, meta: { title: 'menu.audit', icon: History, order: 10, adminOnly: true, groupKey: 'system' } },
|
||
{ path: '/settings/ldap', component: LdapPage, auth: true, meta: { title: 'menu.ldap', icon: ShieldCheck, order: 11, adminOnly: true, groupKey: 'system' } },
|
||
{ path: '/settings/registry', component: RegistryPage, auth: true, meta: { title: 'menu.registry', icon: Container, order: 12, adminOnly: true, groupKey: 'system' } },
|
||
// 系统设置放回侧栏,并作为「系统设置」分组的 tab 起点(order 最小 = 排在第一个)
|
||
{
|
||
path: '/settings', component: SettingsPage, auth: true,
|
||
meta: {
|
||
title: 'menu.settings', icon: Settings, order: 8, adminOnly: true,
|
||
group: { key: 'system', label: 'menu.groups.system', icon: Settings, order: 8 },
|
||
},
|
||
},
|
||
{ path: '*', component: NotFound, auth: false, meta: { title: 'menu.notFound', hideInMenu: true } },
|
||
];
|
||
|
||
/**
|
||
* 侧栏菜单:登录后可见的路由,按 order 排序。
|
||
*
|
||
* 分组规则(解决"菜单太多"的观感问题):
|
||
* - 带 meta.group 的路由是一个分组的**代表入口**,菜单显示分组的 label/icon
|
||
* - 带 meta.groupKey 的路由说明它属于某个已有分组 → 不进菜单,只在页内 tab 出现
|
||
* 这样 13 个路由收敛成 7 个菜单项,且 URL 全部保持不变(书签不会失效)。
|
||
*/
|
||
export const menuItems: MenuItem[] = (() => {
|
||
const grouped = new Set<string>();
|
||
const items: MenuItem[] = [];
|
||
|
||
const sorted = routes
|
||
.filter((route) => route.auth && !route.meta.hideInMenu)
|
||
.sort((a, b) => {
|
||
const orderA = a.meta.group?.order ?? a.meta.order ?? 0;
|
||
const orderB = b.meta.group?.order ?? b.meta.order ?? 0;
|
||
return orderA - orderB;
|
||
});
|
||
|
||
for (const route of sorted) {
|
||
const { group, groupKey } = route.meta;
|
||
// 属于某个分组的非代表路由:不占菜单位
|
||
if (groupKey && !group) continue;
|
||
if (group) {
|
||
if (grouped.has(group.key)) continue;
|
||
grouped.add(group.key);
|
||
items.push({
|
||
path: route.path,
|
||
icon: group.icon ?? route.meta.icon,
|
||
label: group.label,
|
||
adminOnly: route.meta.adminOnly,
|
||
groupKey: group.key,
|
||
});
|
||
continue;
|
||
}
|
||
items.push({
|
||
path: route.path,
|
||
icon: route.meta.icon,
|
||
label: route.meta.title,
|
||
adminOnly: route.meta.adminOnly,
|
||
});
|
||
}
|
||
return items;
|
||
})();
|
||
|
||
/** 取某个分组下的所有页面(用于页内 tab 切换),按 order 排序 */
|
||
export function findGroupRoutes(groupKey: string): AppRoute[] {
|
||
return routes
|
||
.filter((route) => route.meta.groupKey === groupKey || route.meta.group?.key === groupKey)
|
||
.sort((a, b) => (a.meta.order ?? 0) - (b.meta.order ?? 0));
|
||
}
|
||
|
||
/** 当前路径所属的分组标识(不在分组内返回 undefined) */
|
||
export function findGroupKey(pathname: string): string | undefined {
|
||
const meta = findRouteMeta(pathname);
|
||
return meta?.group?.key ?? meta?.groupKey;
|
||
}
|
||
|
||
/** 按路径查 meta(通配/未匹配返回 undefined,调用方自行兜底) */
|
||
export function findRouteMeta(pathname: string): RouteMeta | undefined {
|
||
return routes.find((route) => route.path === pathname)?.meta;
|
||
}
|