merge: 集成V2.6 性能增强与小宝后台化
# Conflicts: # apps/server/prisma/schema.prisma # apps/server/src/app.module.ts # apps/web/components/layout/Sidebar.tsx # docs/architecture.md # docs/decisions.md # docs/roadmap.md
This commit is contained in:
391
apps/web/app/admin/ops/page.tsx
Normal file
391
apps/web/app/admin/ops/page.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Database,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ServerCog,
|
||||
} from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
interface OpsRuntimeSnapshot {
|
||||
collectedAt: string;
|
||||
thresholds: {
|
||||
apiSlowRequestMs: number;
|
||||
prismaSlowQueryMs: number;
|
||||
};
|
||||
database: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
slowRequests: Array<{
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
slowQueries: Array<{
|
||||
id: string;
|
||||
queryPreview: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
jobQueue: {
|
||||
totals: Record<JobStatus, number> & { total: number };
|
||||
byType: Array<Record<JobStatus, number> & {
|
||||
type: string;
|
||||
total: number;
|
||||
oldestQueuedAt?: string;
|
||||
nextLeaseExpiresAt?: string;
|
||||
}>;
|
||||
recentFailures: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
lastError: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
};
|
||||
dirtySummaryCount: number;
|
||||
access: {
|
||||
requiredPermission: string;
|
||||
backendEnforced: boolean;
|
||||
adapter: string;
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<JobStatus, string> = {
|
||||
queued: '排队',
|
||||
running: '运行',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
export default function OpsPage() {
|
||||
return (
|
||||
<RouteGuard permission="ops:view">
|
||||
<OpsPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function OpsPageContent() {
|
||||
const [snapshot, setSnapshot] = useState<OpsRuntimeSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSnapshot = async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
try {
|
||||
const next = await api.get<OpsRuntimeSnapshot>('/ops/runtime');
|
||||
setSnapshot(next);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '读取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSnapshot(true);
|
||||
const timer = window.setInterval(() => { void fetchSnapshot(); }, 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Activity className="h-4 w-4 text-blue-600" strokeWidth={2} />
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">运维看板</h1>
|
||||
{snapshot && (
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||
{formatClock(snapshot.collectedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchSnapshot()}
|
||||
disabled={refreshing}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{refreshing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
刷新
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">{error}</div>
|
||||
) : snapshot ? (
|
||||
<div className="space-y-4">
|
||||
<OverviewStrip snapshot={snapshot} />
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div className="space-y-4">
|
||||
<SlowRequestsPanel snapshot={snapshot} />
|
||||
<SlowQueriesPanel snapshot={snapshot} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<JobQueuePanel snapshot={snapshot} />
|
||||
<FailuresPanel snapshot={snapshot} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewStrip({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
const cards = [
|
||||
{
|
||||
label: '慢请求',
|
||||
value: snapshot.slowRequests.length,
|
||||
sub: `阈值 ${snapshot.thresholds.apiSlowRequestMs}ms`,
|
||||
icon: Clock3,
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
label: '慢查询',
|
||||
value: snapshot.slowQueries.length,
|
||||
sub: `阈值 ${snapshot.thresholds.prismaSlowQueryMs}ms`,
|
||||
icon: Database,
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: '后台任务',
|
||||
value: snapshot.jobQueue.totals.total,
|
||||
sub: `${snapshot.jobQueue.totals.queued} 排队 / ${snapshot.jobQueue.totals.running} 运行`,
|
||||
icon: ServerCog,
|
||||
tone: 'zinc',
|
||||
},
|
||||
{
|
||||
label: '脏 Summary',
|
||||
value: snapshot.dirtySummaryCount,
|
||||
sub: 'xiaobao_risk_summaries',
|
||||
icon: AlertTriangle,
|
||||
tone: snapshot.dirtySummaryCount > 0 ? 'red' : 'emerald',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<section key={card.label} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 shadow-[var(--shadow-sm)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium text-[var(--ink-muted)]">{card.label}</p>
|
||||
<p className="mt-1 text-[24px] font-semibold leading-none tabular-nums text-[var(--ink)]">{card.value}</p>
|
||||
<p className="mt-1 truncate text-[11px] text-[var(--ink-soft)]">{card.sub}</p>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${toneClass(card.tone)}`}>
|
||||
<Icon className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlowRequestsPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="慢请求" right={`${snapshot.slowRequests.length}`} />
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{snapshot.slowRequests.length === 0 ? (
|
||||
<EmptyRow label="暂无慢请求" />
|
||||
) : snapshot.slowRequests.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[76px_minmax(0,1fr)_92px_96px] items-center gap-3 px-4 py-2.5 text-[12px]">
|
||||
<span className="font-mono font-medium text-blue-700">{item.method}</span>
|
||||
<span className="truncate font-mono text-[var(--ink)]">{item.path}</span>
|
||||
<span className="text-right font-mono tabular-nums text-red-600">{item.durationMs}ms</span>
|
||||
<span className="text-right text-[var(--ink-muted)]">{formatClock(item.occurredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SlowQueriesPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="慢查询" right={`${snapshot.slowQueries.length}`} />
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{snapshot.slowQueries.length === 0 ? (
|
||||
<EmptyRow label="暂无慢查询" />
|
||||
) : snapshot.slowQueries.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[minmax(0,1fr)_92px_96px] items-start gap-3 px-4 py-2.5 text-[12px]">
|
||||
<code className="min-w-0 break-words font-mono text-[11px] leading-5 text-[var(--ink)]">{item.queryPreview}</code>
|
||||
<span className="text-right font-mono tabular-nums text-amber-700">{item.durationMs}ms</span>
|
||||
<span className="text-right text-[var(--ink-muted)]">{formatClock(item.occurredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function JobQueuePanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
const rows = snapshot.jobQueue.byType;
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="后台任务" right={`${snapshot.jobQueue.totals.total}`} />
|
||||
<div className="border-b border-[var(--line)] px-4 py-3">
|
||||
<StatusBars totals={snapshot.jobQueue.totals} />
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{rows.length === 0 ? (
|
||||
<EmptyRow label="暂无任务" />
|
||||
) : rows.map((row) => (
|
||||
<div key={row.type} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate font-mono text-[12px] font-medium text-[var(--ink)]">{row.type}</span>
|
||||
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-muted)]">{row.total}</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
||||
{(['queued', 'running', 'succeeded', 'failed'] as JobStatus[]).map((status) => (
|
||||
<StatusPill key={status} status={status} count={row[status]} />
|
||||
))}
|
||||
</div>
|
||||
{(row.oldestQueuedAt || row.nextLeaseExpiresAt) && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-[var(--ink-muted)]">
|
||||
{row.oldestQueuedAt && <span>最早排队 {formatClock(row.oldestQueuedAt)}</span>}
|
||||
{row.nextLeaseExpiresAt && <span>Lease {formatClock(row.nextLeaseExpiresAt)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FailuresPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
const failures = snapshot.jobQueue.recentFailures;
|
||||
const dbOk = snapshot.database.ok;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="运行状态" right={dbOk ? 'OK' : 'DB'} />
|
||||
<div className="border-b border-[var(--line)] px-4 py-3">
|
||||
<div className={`flex items-center gap-2 rounded-md border px-3 py-2 text-[12px] ${dbOk ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
|
||||
{dbOk ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
|
||||
<span className="min-w-0 truncate">{dbOk ? '数据库可读' : snapshot.database.error || '数据库不可读'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{failures.length === 0 ? (
|
||||
<EmptyRow label="暂无失败任务" />
|
||||
) : failures.map((item) => (
|
||||
<div key={item.id} className="px-4 py-3 text-[12px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate font-mono font-medium text-[var(--ink)]">{item.type}</span>
|
||||
<span className="shrink-0 font-mono tabular-nums text-red-600">{item.attempts}/{item.maxAttempts}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-5 text-[var(--ink-soft)]">{item.lastError || '-'}</p>
|
||||
{item.updatedAt && <p className="mt-1 text-[11px] text-[var(--ink-muted)]">{formatClock(item.updatedAt)}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBars({ totals }: { totals: OpsRuntimeSnapshot['jobQueue']['totals'] }) {
|
||||
const statuses: JobStatus[] = ['queued', 'running', 'succeeded', 'failed'];
|
||||
const total = Math.max(1, totals.total);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
|
||||
{statuses.map((status) => (
|
||||
<span
|
||||
key={status}
|
||||
className={statusBarClass(status)}
|
||||
style={{ width: `${(totals[status] / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{statuses.map((status) => <StatusPill key={status} status={status} count={totals[status]} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status, count }: { status: JobStatus; count: number }) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-1 rounded-md px-2 py-1 text-[11px] ${statusPillClass(status)}`}>
|
||||
<span>{STATUS_LABEL[status]}</span>
|
||||
<span className="font-mono tabular-nums">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ title, right }: { title: string; right: string }) {
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--line)] px-4">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--ink)]">{title}</h2>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{right}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ label }: { label: string }) {
|
||||
return <div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">{label}</div>;
|
||||
}
|
||||
|
||||
function formatClock(value?: string) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return '-';
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function toneClass(tone: string) {
|
||||
if (tone === 'blue') return 'bg-blue-50 text-blue-700';
|
||||
if (tone === 'amber') return 'bg-amber-50 text-amber-700';
|
||||
if (tone === 'red') return 'bg-red-50 text-red-700';
|
||||
if (tone === 'emerald') return 'bg-emerald-50 text-emerald-700';
|
||||
return 'bg-zinc-100 text-zinc-700';
|
||||
}
|
||||
|
||||
function statusBarClass(status: JobStatus) {
|
||||
if (status === 'queued') return 'bg-blue-500';
|
||||
if (status === 'running') return 'bg-amber-500';
|
||||
if (status === 'succeeded') return 'bg-emerald-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function statusPillClass(status: JobStatus) {
|
||||
if (status === 'queued') return 'bg-blue-50 text-blue-700';
|
||||
if (status === 'running') return 'bg-amber-50 text-amber-700';
|
||||
if (status === 'succeeded') return 'bg-emerald-50 text-emerald-700';
|
||||
return 'bg-red-50 text-red-700';
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database } from 'lucide-react';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity } from 'lucide-react';
|
||||
import { useHasPermission } from '@/components/auth/Guard';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||
@@ -47,6 +47,7 @@ const NAV_GROUPS = [
|
||||
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
||||
{ label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' },
|
||||
{ label: '一致性', path: '/admin/consistency', icon: Database, permission: 'consistency:view' },
|
||||
{ label: '运维', path: '/admin/ops', icon: Activity, permission: 'ops:view' },
|
||||
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -52,6 +52,14 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ action: 'manage', label: '管理', permission: 'xiaobao.warning:manage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
module: 'ops',
|
||||
moduleLabel: '运维看板',
|
||||
category: 'main',
|
||||
actions: [
|
||||
{ action: 'view', label: '查看', permission: 'ops:view' },
|
||||
],
|
||||
},
|
||||
{ module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||
{ module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { RoleItem } from './members';
|
||||
import { DEFAULT_ROLE_PERMISSIONS } from './permissions';
|
||||
import { ALL_PERMISSIONS, DEFAULT_ROLE_PERMISSIONS } from './permissions';
|
||||
import { mergePresetRolePermissions } from './role-permission-migration';
|
||||
|
||||
test('default group leader role has business permissions without admin modules', () => {
|
||||
@@ -14,6 +14,13 @@ test('default group leader role has business permissions without admin modules',
|
||||
assert.equal(permissions.some((p) => p.startsWith('role:')), false);
|
||||
});
|
||||
|
||||
test('ops view permission is grantable but not part of non-admin preset roles', () => {
|
||||
assert.ok(ALL_PERMISSIONS.includes('ops:view'));
|
||||
assert.ok(DEFAULT_ROLE_PERMISSIONS['role-admin'].includes('*'));
|
||||
assert.equal(DEFAULT_ROLE_PERMISSIONS['role-pm'].includes('ops:view'), false);
|
||||
assert.equal(DEFAULT_ROLE_PERMISSIONS['role-lead'].includes('ops:view'), false);
|
||||
});
|
||||
|
||||
test('mergePresetRolePermissions adds the built-in group leader role to old role data', () => {
|
||||
const roles: RoleItem[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user