Files
LokiStack/frontend/src/components/Layout.tsx
T
opsx b49677f6a6 feat: 运维标杆化打磨 P0/P1/P2 —— 审计日志、真实指标、备份恢复、菜单收敛
审计发现 5 处硬伤(/metrics 空壳、零备份、prod 双副本数据分裂、零审计、资源偏紧),
本轮全部落地。

=== P0-1 真实 /metrics(原来是空壳)===
原实现只返回一行注释 `# LokiStack Metrics`,注释却写着"可被 Prometheus 抓取",
平台挂了/DB 写不进去/告警引擎卡死都只能靠用户反馈。
现接入 prometheus/client_golang,四层指标:
  HTTP    : lokistack_http_requests_total / _duration_seconds / _in_flight_requests
  存储    : lokistack_db_size_bytes / _db_save_duration_seconds / _db_save_total / _db_records
  业务    : lokistack_deployments{status,mode} / lokistack_collectors{status,type,run_mode}
  后台任务: lokistack_alert_evaluations_total / _duration_seconds / lokistack_loki_queries_total
  + go_* / process_*(GoCollector、ProcessCollector)

关键点:path 标签用 c.FullPath() 路由模板,不用原始 URL —— 否则 117 个 Deployment
会把 Prometheus 基数炸掉。指标定义下沉 pkg/metrics 叶子包,避开
services ↔ repository 循环依赖。

=== P0-2 备份与恢复(原来 0 处代码)===
平台全部状态在 /app/data/opsxloki.json(51KB)+ kubeconfigs/ + known_hosts/,
DB 丢了 = 失去"这些资源是谁建的"的映射,从此无法管理/卸载/升级在跑的 Loki。

  CLI : lokistack backup --output <path> / restore <pkg> / self-check / version
  API : GET /system/backup(下载)/ POST /system/restore(校验暂存)/
        POST /system/restore/apply(确认应用)/ GET /system/backups
  K8s : kubernetes/pdb-backup.yaml —— 每日 03:30 CronJob,保留最近 7 份

安全设计:
  - manifest.json 记录 ENCRYPTION_KEY 的 sha256 前 16 位**指纹**,用于恢复时
    校验"这把钥匙能不能解开这份备份",不泄露密钥本身
  - 恢复分两步(校验暂存 → 确认应用),应用前自动把当前 DB 备份为
    opsxloki.json.before-restore.<时间戳>
  - 包内不含明文密钥(kubeconfig 本就是加密态)

=== P0-3 prod compose 双副本 → 单副本 ===
lokistack-1 / lokistack-2 各挂独立数据卷 + nginx 轮询 = 数据分裂:
页面刷新两次可能看到不同结果,写操作只落一个副本。不是高可用。
已改单实例(nginx 仅做反代),文件顶部与 nginx.conf 都写明原因。

=== P0-4 操作审计日志(借鉴 /usr/local/src/aiops 的字段口径)===
aiops 是每个 controller 各写 recordXxxAudit();本平台有 60+ 写接口,
逐个埋点必然漏,漏掉的那个恰好最危险(删集群、改存储凭证)。
改为 **gin 中间件统一记录** + handler 通过 SetAudit 补充业务语义:
  models/audit.go     : 字段对齐 aiops,另加 Method/Path/Before/After
  services/audit.go   : 写入 + 90 天/2 万条自动裁剪 + 脱敏 + diff 生成
  middleware/audit.go : 自动识别 module/action/resource,跳过只读接口
  controllers/audit.go: 列表/详情/统计(仅 admin)
敏感字段(password/secretKey/privateKey/token)写入前统一替换为 ***。

=== P1 稳定性 ===
- 平台资源 1C/1Gi → 4C/4Gi 限(500m/512Mi 请求):JSON DB 全量读写 +
  每分钟告警评估 + 批量发布并发,1Gi 会 OOM
- terminationGracePeriodSeconds: 30 —— 收到 SIGTERM 要等 DB 完整落盘,
  否则 kubelet SIGKILL 会把 JSON 写一半(全库丢失)
- liveness timeoutSeconds 10s(默认 1s 写盘时会被误杀)
- PDB minAvailable: 0 —— 让 kubectl drain 正确等待重建,不卡维护
- DB schemaVersion + migrateSchema() 版本递进迁移(原先加字段全靠零值兜底)
- scripts/release.sh —— 不可变 tag <版本>-<日期>-<commit> + IfNotPresent,
  根治 latest 并发构建互相覆盖导致的静默回滚(本项目已实际踩过)
- PVC 保持 30Gi(按长期增长预留,云盘单价低,不做在线扩容)

=== P2 易用性 ===
- 一键自检 GET /system/self-check + 仪表盘自检卡:真实探活 Loki /ready、
  集群凭据解密、磁盘空间、主机状态、近 24h 失败操作
- 审计日志页 /audit:统计卡 + 用户名/模块/结果筛选 + 变更前后 diff 弹窗
- 菜单收敛 13 → 7(页内 tab 切换,URL 全部不变):
    资源接入 = 集群 + 主机 + 存储
    日志服务 = Loki 实例 + 采集器
    日志     = 查询 + 分析
    系统管理 = 用户 + 审计 + 设置
  新增 GroupTabs 组件 + meta.group/groupKey,侧栏按分组去重并正确高亮

测试:go vet 通过、go test 全绿、tsc 通过
2026-10-28 21:27:01 +00:00

277 lines
11 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 {
memo,
useState,
useEffect,
useCallback,
Suspense,
} from 'react'
import { Link, useLocation, useNavigate } from 'react-router'
import {
LogOut,
PanelLeftClose,
PanelLeftOpen,
Shield,
ChevronRight,
Activity,
Menu,
Sun,
Moon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { menuItems, findRouteMeta, findGroupKey } from '@/router/routes'
import { GroupTabs } from './GroupTabs'
import { getUser, clearAuth } from '@/lib/auth-storage'
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章)
const LAYOUT_COLLAPSED_KEY = 'lokistack.layout.collapsed'
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
const location = useLocation()
const { t } = useTranslation()
const isAdmin = getUser()?.role === 'admin'
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
return (
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label={t('layout.nav')}>
{visibleItems.map((item) => {
// 分组入口:当前路由属于该分组时高亮(进入 /hosts 也要让「资源接入」亮)
const currentGroup = findGroupKey(location.pathname)
const isActive = item.groupKey
? currentGroup === item.groupKey
: location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
onClick={onNavigate}
aria-label={t(item.label)}
className={cn(
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-accent-foreground/60'
: 'text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground'
)}
title={!collapsed ? t(item.label) : undefined}
>
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
{!collapsed && isActive && (
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
)}
</Link>
)
})}
</nav>
)
}
// 侧栏 Logo 区
function SidebarLogo({ collapsed }: { collapsed: boolean }) {
return (
<div className="h-16 flex items-center px-4 border-b border-sidebar-border flex-shrink-0">
{collapsed ? (
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center mx-auto">
<Activity className="w-5 h-5 text-sidebar-primary" />
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center">
<Activity className="w-5 h-5 text-sidebar-primary" />
</div>
<span className="font-bold text-xl tracking-tight">LokiStack</span>
</div>
)}
</div>
)
}
// 主题二态切换:浅色 ↔ 深色(key: lokistack.theme.mode;resolvedTheme 兼容"跟随系统/定时"偏好)
// 定时模式开启时点击 = 关闭定时并切到相反主题(用户手动覆盖调度)。
function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
const { t } = useTranslation()
const isDark = resolvedTheme === 'dark'
const label = isDark ? t('layout.toLight') : t('layout.toDark')
return (
<button
onClick={() => {
if (isThemeAuto()) {
setThemeAuto(false)
setTheme(isDark ? 'light' : 'dark')
return
}
setTheme(isDark ? 'light' : 'dark')
}}
aria-label={label}
title={label}
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
>
{isDark ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
</button>
)
}
// 页面级加载骨架(挂在 main 内,切页时布局不闪)
function PageSkeleton() {
const { t } = useTranslation()
return (
<div className="space-y-6" aria-busy="true" aria-label={t('layout.loading')}>
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40 rounded-xl" />
))}
</div>
</div>
)
}
/**
* Layout 组件 — 布局骨架,业务页只挂在内容区
* - 菜单/顶栏标题来自路由 meta 单源(router/routes.tsx)
* - 桌面端折叠状态持久化;移动端(<768px)侧栏为抽屉 + 遮罩
* - 页面懒加载的 Suspense 边界在 main 内部
*/
const Layout = memo(function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile()
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(LAYOUT_COLLAPSED_KEY) === '1')
const [mobileOpen, setMobileOpen] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const { t } = useTranslation()
const user = getUser()
// 主题定时调度(「定时」模式开启时每分钟按时区解析浅/深色)
useScheduledTheme()
// 顶栏标题来自路由 meta
// meta.title 为 i18n key(common 命名空间),展示前翻译
const pageTitleKey = findRouteMeta(location.pathname)?.title
const pageTitle = pageTitleKey ? t(pageTitleKey) : 'LokiStack'
// 折叠状态持久化(仅桌面端语义)
useEffect(() => {
localStorage.setItem(LAYOUT_COLLAPSED_KEY, collapsed ? '1' : '0')
}, [collapsed])
const toggleSidebar = useCallback(() => {
setCollapsed((prev) => !prev)
}, [])
const handleLogout = useCallback(() => {
clearAuth()
navigate('/login', { replace: true })
}, [navigate])
return (
<div className="flex h-screen bg-background">
{/* 桌面侧栏 */}
{!isMobile && (
<aside
className={cn(
'bg-sidebar text-sidebar-accent-foreground transition-all duration-300 flex flex-col flex-shrink-0',
collapsed ? 'w-16' : 'w-64'
)}
>
<SidebarLogo collapsed={collapsed} />
<SidebarNav collapsed={collapsed} />
<div className="p-2 border-t border-sidebar-border flex-shrink-0">
<button
onClick={toggleSidebar}
aria-label={collapsed ? t('layout.expand') : t('layout.collapse')}
title={collapsed ? t('layout.expand') : t('layout.collapse')}
className="flex items-center justify-center w-full py-2.5 rounded-lg text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
{collapsed ? (
<PanelLeftOpen className="w-5 h-5" />
) : (
<PanelLeftClose className="w-5 h-5" />
)}
</button>
</div>
</aside>
)}
{/* 移动端抽屉侧栏 */}
{isMobile && (
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
<SheetHeader className="sr-only">
<SheetTitle className="text-sidebar-accent-foreground">{t('layout.sheetTitle')}</SheetTitle>
</SheetHeader>
<div className="flex flex-col h-full">
<SidebarLogo collapsed={false} />
<SidebarNav collapsed={false} onNavigate={() => setMobileOpen(false)} />
</div>
</SheetContent>
</Sheet>
)}
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Top Bar */}
<header className="h-16 bg-card border-b border-border flex items-center justify-between px-4 md:px-6 flex-shrink-0 gap-3">
<div className="flex items-center gap-3 min-w-0">
{isMobile && (
<button
onClick={() => setMobileOpen(true)}
aria-label={t('layout.openNav')}
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
>
<Menu className="w-5 h-5" />
</button>
)}
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">
{pageTitle}
</h1>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<ThemeToggle />
{user && (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>{user.username}</span>
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium">
{user.role === 'admin' ? t('layout.admin') : user.role}
</span>
</div>
<button
onClick={handleLogout}
title={t('layout.logout')}
aria-label={t('layout.logout')}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-all"
>
<LogOut className="w-4 h-4" />
<span className="hidden md:inline">{t('layout.logoutShort')}</span>
</button>
</div>
)}
</div>
</header>
{/* 分组 tab(资源接入 / 日志服务 / 日志 / 系统管理);不属于分组时不渲染 */}
<GroupTabs />
{/* Page Content — 页面级 Suspense 边界,懒加载时不闪布局 */}
<main className="flex-1 overflow-auto p-4 md:p-6">
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
</main>
</div>
</div>
)
})
export default Layout