审计发现 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 通过
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
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
|