From 15653b5b351e3000b69fd27cf961dc5f56791eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 17:47:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E5=A2=9E=E5=8A=A0=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E6=80=A7=E8=83=BD=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 2 + .../interceptors/api-timing.interceptor.ts | 2 + .../src/modules/ops/ops-permission.adapter.ts | 11 + .../src/modules/ops/ops-runtime.store.spec.ts | 53 +++ .../src/modules/ops/ops-runtime.store.ts | 116 ++++++ .../src/modules/ops/ops.controller.spec.ts | 19 + apps/server/src/modules/ops/ops.controller.ts | 17 + apps/server/src/modules/ops/ops.module.ts | 16 + .../src/modules/ops/ops.service.spec.ts | 89 ++++ apps/server/src/modules/ops/ops.service.ts | 148 +++++++ apps/server/src/prisma/prisma.service.ts | 6 + apps/web/app/admin/ops/page.tsx | 391 ++++++++++++++++++ apps/web/components/layout/Sidebar.tsx | 3 +- apps/web/lib/permissions.ts | 8 + .../web/lib/role-permission-migration.test.ts | 9 +- docs/architecture.md | 9 + docs/decisions.md | 14 + docs/performance.md | 20 + docs/roadmap.md | 2 + 19 files changed, 933 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/modules/ops/ops-permission.adapter.ts create mode 100644 apps/server/src/modules/ops/ops-runtime.store.spec.ts create mode 100644 apps/server/src/modules/ops/ops-runtime.store.ts create mode 100644 apps/server/src/modules/ops/ops.controller.spec.ts create mode 100644 apps/server/src/modules/ops/ops.controller.ts create mode 100644 apps/server/src/modules/ops/ops.module.ts create mode 100644 apps/server/src/modules/ops/ops.service.spec.ts create mode 100644 apps/server/src/modules/ops/ops.service.ts create mode 100644 apps/web/app/admin/ops/page.tsx diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index c81d874..9aebdce 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -22,6 +22,7 @@ import { V22QueryModule } from './modules/v22-query/v22-query.module'; import { HealthModule } from './modules/health/health.module'; import { JobsModule } from './modules/jobs/jobs.module'; import { XiaobaoModule } from './modules/xiaobao/xiaobao.module'; +import { OpsModule } from './modules/ops/ops.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { XiaobaoModule } from './modules/xiaobao/xiaobao.module'; HealthModule, JobsModule, XiaobaoModule, + OpsModule, AiModule, ], controllers: [], diff --git a/apps/server/src/common/interceptors/api-timing.interceptor.ts b/apps/server/src/common/interceptors/api-timing.interceptor.ts index d925438..75500b4 100644 --- a/apps/server/src/common/interceptors/api-timing.interceptor.ts +++ b/apps/server/src/common/interceptors/api-timing.interceptor.ts @@ -1,5 +1,6 @@ import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; import { finalize, Observable } from 'rxjs'; +import { recordSlowRequest } from '../../modules/ops/ops-runtime.store'; const DEFAULT_API_SLOW_REQUEST_MS = 1000; @@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor { const method = request.method ?? 'UNKNOWN'; const url = request.originalUrl ?? request.url ?? 'unknown-url'; this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`); + recordSlowRequest({ method, url, durationMs, thresholdMs: this.thresholdMs }); }), ); } diff --git a/apps/server/src/modules/ops/ops-permission.adapter.ts b/apps/server/src/modules/ops/ops-permission.adapter.ts new file mode 100644 index 0000000..62320f0 --- /dev/null +++ b/apps/server/src/modules/ops/ops-permission.adapter.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { OPS_VIEW_PERMISSION } from './ops.service'; + +@Injectable() +export class OpsPermissionAdapter { + assertCanViewOps(_request: unknown) { + // V2.5 backend RBAC is not landed yet. Keep this adapter as the single + // replacement point for a real guard instead of coupling Ops to a temporary shape. + return { requiredPermission: OPS_VIEW_PERMISSION, enforced: false }; + } +} diff --git a/apps/server/src/modules/ops/ops-runtime.store.spec.ts b/apps/server/src/modules/ops/ops-runtime.store.spec.ts new file mode 100644 index 0000000..b960af0 --- /dev/null +++ b/apps/server/src/modules/ops/ops-runtime.store.spec.ts @@ -0,0 +1,53 @@ +import { + clearOpsRuntimeEventsForTests, + getOpsRuntimeEvents, + recordSlowPrismaQuery, + recordSlowRequest, +} from './ops-runtime.store'; + +describe('ops runtime store', () => { + afterEach(() => clearOpsRuntimeEventsForTests()); + + it('records recent slow requests without leaking query-string secrets', () => { + recordSlowRequest({ + method: 'GET', + url: '/api/v1/config/ai?apiKey=sk-secret&visible=1', + durationMs: 1300, + thresholdMs: 1000, + occurredAt: new Date('2026-07-08T08:00:00.000Z'), + }); + + const events = getOpsRuntimeEvents(); + + expect(events.slowRequests).toEqual([ + expect.objectContaining({ + method: 'GET', + path: '/api/v1/config/ai', + durationMs: 1300, + thresholdMs: 1000, + occurredAt: '2026-07-08T08:00:00.000Z', + }), + ]); + expect(JSON.stringify(events)).not.toContain('sk-secret'); + }); + + it('records recent slow Prisma queries with redacted and bounded previews', () => { + recordSlowPrismaQuery({ + query: `SELECT * FROM ai_logs WHERE metadata::text LIKE '%sk-secret-token%' ${'x'.repeat(400)}`, + durationMs: 450, + thresholdMs: 300, + occurredAt: new Date('2026-07-08T08:01:00.000Z'), + }); + + const [event] = getOpsRuntimeEvents().slowQueries; + + expect(event).toEqual(expect.objectContaining({ + durationMs: 450, + thresholdMs: 300, + occurredAt: '2026-07-08T08:01:00.000Z', + })); + expect(event.queryPreview).toContain('[redacted]'); + expect(event.queryPreview.length).toBeLessThanOrEqual(240); + expect(event.queryPreview).not.toContain('sk-secret-token'); + }); +}); diff --git a/apps/server/src/modules/ops/ops-runtime.store.ts b/apps/server/src/modules/ops/ops-runtime.store.ts new file mode 100644 index 0000000..2617c45 --- /dev/null +++ b/apps/server/src/modules/ops/ops-runtime.store.ts @@ -0,0 +1,116 @@ +const MAX_EVENTS = 50; +const MAX_PREVIEW_LENGTH = 240; + +export interface SlowRequestEvent { + id: string; + method: string; + path: string; + durationMs: number; + thresholdMs: number; + occurredAt: string; +} + +export interface SlowPrismaQueryEvent { + id: string; + queryPreview: string; + durationMs: number; + thresholdMs: number; + occurredAt: string; +} + +export interface OpsRuntimeEvents { + slowRequests: SlowRequestEvent[]; + slowQueries: SlowPrismaQueryEvent[]; +} + +interface RecordSlowRequestInput { + method: string; + url: string; + durationMs: number; + thresholdMs: number; + occurredAt?: Date; +} + +interface RecordSlowPrismaQueryInput { + query: string; + durationMs: number; + thresholdMs: number; + occurredAt?: Date; +} + +const slowRequests: SlowRequestEvent[] = []; +const slowQueries: SlowPrismaQueryEvent[] = []; +let nextId = 1; + +export function recordSlowRequest(input: RecordSlowRequestInput) { + slowRequests.unshift({ + id: makeId('req'), + method: normalizeMethod(input.method), + path: sanitizeRequestPath(input.url), + durationMs: Math.round(input.durationMs), + thresholdMs: Math.round(input.thresholdMs), + occurredAt: (input.occurredAt ?? new Date()).toISOString(), + }); + trim(slowRequests); +} + +export function recordSlowPrismaQuery(input: RecordSlowPrismaQueryInput) { + slowQueries.unshift({ + id: makeId('qry'), + queryPreview: truncate(redactText(input.query.replace(/\s+/g, ' ').trim()), MAX_PREVIEW_LENGTH), + durationMs: Math.round(input.durationMs), + thresholdMs: Math.round(input.thresholdMs), + occurredAt: (input.occurredAt ?? new Date()).toISOString(), + }); + trim(slowQueries); +} + +export function getOpsRuntimeEvents(): OpsRuntimeEvents { + return { + slowRequests: slowRequests.map((event) => ({ ...event })), + slowQueries: slowQueries.map((event) => ({ ...event })), + }; +} + +export function clearOpsRuntimeEventsForTests() { + slowRequests.splice(0, slowRequests.length); + slowQueries.splice(0, slowQueries.length); + nextId = 1; +} + +export function redactText(value: string, maxLength = 500): string { + const redacted = value + .replace(/(^|[^A-Za-z0-9])sk-[A-Za-z0-9_-]+/g, '$1[redacted]') + .replace(/(api[_-]?key|token|secret|password|authorization)(\s*[=:]\s*)(["']?)[^&\s"']+/gi, '$1$2$3[redacted]'); + return truncate(redacted, maxLength); +} + +function sanitizeRequestPath(url: string): string { + const raw = String(url || 'unknown-url'); + try { + const parsed = new URL(raw, 'http://local.invalid'); + return redactText(parsed.pathname || '/'); + } catch { + return redactText(raw.split('?')[0] || 'unknown-url'); + } +} + +function normalizeMethod(method: string): string { + const value = String(method || 'UNKNOWN').toUpperCase(); + return /^[A-Z]+$/.test(value) ? value : 'UNKNOWN'; +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 3))}...`; +} + +function trim(items: T[]) { + if (items.length > MAX_EVENTS) items.splice(MAX_EVENTS); +} + +function makeId(prefix: string): string { + const id = `${prefix}-${Date.now().toString(36)}-${nextId.toString(36)}`; + nextId += 1; + return id; +} diff --git a/apps/server/src/modules/ops/ops.controller.spec.ts b/apps/server/src/modules/ops/ops.controller.spec.ts new file mode 100644 index 0000000..87e6c8e --- /dev/null +++ b/apps/server/src/modules/ops/ops.controller.spec.ts @@ -0,0 +1,19 @@ +import { OpsController } from './ops.controller'; + +describe('OpsController', () => { + it('checks the ops:view adapter before returning runtime snapshot', async () => { + const service = { + getRuntimeSnapshot: jest.fn().mockResolvedValue({ ok: true }), + }; + const permissions = { + assertCanViewOps: jest.fn(), + }; + const controller = new OpsController(service as any, permissions as any); + const request = { headers: {} }; + + await expect(controller.getRuntime(request as any)).resolves.toEqual({ ok: true }); + + expect(permissions.assertCanViewOps).toHaveBeenCalledWith(request); + expect(service.getRuntimeSnapshot).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/ops/ops.controller.ts b/apps/server/src/modules/ops/ops.controller.ts new file mode 100644 index 0000000..d3e8f93 --- /dev/null +++ b/apps/server/src/modules/ops/ops.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Req } from '@nestjs/common'; +import { OpsPermissionAdapter } from './ops-permission.adapter'; +import { OpsService } from './ops.service'; + +@Controller('ops') +export class OpsController { + constructor( + private readonly ops: OpsService, + private readonly permissions: OpsPermissionAdapter, + ) {} + + @Get('runtime') + getRuntime(@Req() request: unknown) { + this.permissions.assertCanViewOps(request); + return this.ops.getRuntimeSnapshot(); + } +} diff --git a/apps/server/src/modules/ops/ops.module.ts b/apps/server/src/modules/ops/ops.module.ts new file mode 100644 index 0000000..6689fea --- /dev/null +++ b/apps/server/src/modules/ops/ops.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { OpsController } from './ops.controller'; +import { OpsPermissionAdapter } from './ops-permission.adapter'; +import { OPS_PRISMA, OpsService } from './ops.service'; + +@Module({ + controllers: [OpsController], + providers: [ + { provide: OPS_PRISMA, useExisting: PrismaService }, + OpsService, + OpsPermissionAdapter, + ], + exports: [OpsService, OpsPermissionAdapter], +}) +export class OpsModule {} diff --git a/apps/server/src/modules/ops/ops.service.spec.ts b/apps/server/src/modules/ops/ops.service.spec.ts new file mode 100644 index 0000000..039c94d --- /dev/null +++ b/apps/server/src/modules/ops/ops.service.spec.ts @@ -0,0 +1,89 @@ +import { recordSlowPrismaQuery, recordSlowRequest, clearOpsRuntimeEventsForTests } from './ops-runtime.store'; +import { OpsService } from './ops.service'; + +describe('OpsService', () => { + afterEach(() => clearOpsRuntimeEventsForTests()); + + it('returns runtime performance counters without exposing secrets', async () => { + const prisma = { + backgroundJob: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'job-queued', + type: 'xiaobao.summary.refresh', + status: 'queued', + attempts: 0, + maxAttempts: 5, + availableAt: new Date('2026-07-08T08:00:00.000Z'), + lockedUntil: null, + lastError: null, + updatedAt: new Date('2026-07-08T08:00:00.000Z'), + }, + { + id: 'job-running', + type: 'xiaobao.ai.interpret', + status: 'running', + attempts: 1, + maxAttempts: 3, + availableAt: new Date('2026-07-08T08:01:00.000Z'), + lockedUntil: new Date('2026-07-08T08:05:00.000Z'), + lastError: null, + updatedAt: new Date('2026-07-08T08:02:00.000Z'), + }, + { + id: 'job-failed', + type: 'xiaobao.ai.interpret', + status: 'failed', + attempts: 3, + maxAttempts: 3, + availableAt: new Date('2026-07-08T08:03:00.000Z'), + lockedUntil: null, + lastError: 'provider failed with sk-secret-token', + updatedAt: new Date('2026-07-08T08:04:00.000Z'), + }, + ]), + }, + xiaobaoRiskSummary: { + count: jest.fn().mockResolvedValue(7), + }, + }; + recordSlowRequest({ + method: 'POST', + url: '/api/v1/ai/risk-interpret?token=sk-secret-token', + durationMs: 1500, + thresholdMs: 1000, + occurredAt: new Date('2026-07-08T08:05:00.000Z'), + }); + recordSlowPrismaQuery({ + query: 'SELECT * FROM background_jobs WHERE last_error = "sk-secret-token"', + durationMs: 420, + thresholdMs: 300, + occurredAt: new Date('2026-07-08T08:06:00.000Z'), + }); + const service = new OpsService(prisma as any); + + const snapshot = await service.getRuntimeSnapshot(new Date('2026-07-08T08:07:00.000Z')); + + expect(snapshot.dirtySummaryCount).toBe(7); + expect(snapshot.jobQueue.totals).toEqual({ queued: 1, running: 1, succeeded: 0, failed: 1, total: 3 }); + expect(snapshot.jobQueue.byType).toEqual([ + expect.objectContaining({ type: 'xiaobao.ai.interpret', running: 1, failed: 1, total: 2 }), + expect.objectContaining({ type: 'xiaobao.summary.refresh', queued: 1, total: 1 }), + ]); + expect(snapshot.jobQueue.recentFailures).toEqual([ + expect.objectContaining({ + id: 'job-failed', + type: 'xiaobao.ai.interpret', + lastError: expect.stringContaining('[redacted]'), + }), + ]); + expect(snapshot.slowRequests[0].path).toBe('/api/v1/ai/risk-interpret'); + expect(snapshot.slowQueries[0].queryPreview).toContain('[redacted]'); + expect(JSON.stringify(snapshot)).not.toContain('sk-secret-token'); + expect(snapshot.access).toEqual({ + requiredPermission: 'ops:view', + backendEnforced: false, + adapter: 'OpsPermissionAdapter', + }); + }); +}); diff --git a/apps/server/src/modules/ops/ops.service.ts b/apps/server/src/modules/ops/ops.service.ts new file mode 100644 index 0000000..8b7b9da --- /dev/null +++ b/apps/server/src/modules/ops/ops.service.ts @@ -0,0 +1,148 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { resolveApiSlowRequestThreshold } from '../../common/interceptors/api-timing.interceptor'; +import { resolvePrismaSlowQueryThreshold } from '../../prisma/prisma-monitoring'; +import { getOpsRuntimeEvents, redactText } from './ops-runtime.store'; + +export const OPS_PRISMA = 'OPS_PRISMA'; +export const OPS_VIEW_PERMISSION = 'ops:view'; + +type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'; + +interface BackgroundJobRow { + id: string; + type: string; + status: string; + attempts?: number | null; + maxAttempts?: number | null; + availableAt?: Date | string | null; + lockedUntil?: Date | string | null; + lastError?: string | null; + updatedAt?: Date | string | null; +} + +@Injectable() +export class OpsService { + constructor(@Inject(OPS_PRISMA) private readonly prisma: any) {} + + async getRuntimeSnapshot(now = new Date()) { + const events = getOpsRuntimeEvents(); + const database = { ok: true, error: undefined as string | undefined }; + let jobRows: BackgroundJobRow[] = []; + let dirtySummaryCount = 0; + + try { + [jobRows, dirtySummaryCount] = await Promise.all([ + this.prisma.backgroundJob.findMany({ + select: { + id: true, + type: true, + status: true, + attempts: true, + maxAttempts: true, + availableAt: true, + lockedUntil: true, + lastError: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + take: 200, + }), + this.prisma.xiaobaoRiskSummary.count({ where: { dirty: true } }), + ]); + } catch (error) { + database.ok = false; + database.error = redactText(error instanceof Error ? error.message : String(error), 300); + } + + return { + collectedAt: now.toISOString(), + thresholds: { + apiSlowRequestMs: resolveApiSlowRequestThreshold(), + prismaSlowQueryMs: resolvePrismaSlowQueryThreshold(), + }, + database, + slowRequests: events.slowRequests, + slowQueries: events.slowQueries, + jobQueue: buildJobQueue(jobRows), + dirtySummaryCount, + access: { + requiredPermission: OPS_VIEW_PERMISSION, + backendEnforced: false, + adapter: 'OpsPermissionAdapter', + }, + }; + } +} + +function buildJobQueue(rows: BackgroundJobRow[]) { + const totals = emptyStatusCounts(); + const byType = new Map>(); + const recentFailures = rows + .filter((row) => normalizeStatus(row.status) === 'failed') + .slice(0, 10) + .map((row) => ({ + id: row.id, + type: row.type, + attempts: row.attempts ?? 0, + maxAttempts: row.maxAttempts ?? 0, + lastError: redactText(row.lastError ?? '', 300), + updatedAt: toIso(row.updatedAt), + })); + + for (const row of rows) { + const status = normalizeStatus(row.status); + totals[status] += 1; + totals.total += 1; + + const current = byType.get(row.type) ?? emptyTypeCounts(row.type); + current[status] += 1; + current.total += 1; + if (status === 'queued') { + current.oldestQueuedAt = minIso(current.oldestQueuedAt, toIso(row.availableAt)); + } + if (status === 'running') { + current.nextLeaseExpiresAt = minIso(current.nextLeaseExpiresAt, toIso(row.lockedUntil)); + } + byType.set(row.type, current); + } + + return { + totals, + byType: Array.from(byType.values()).sort((a, b) => b.total - a.total || a.type.localeCompare(b.type)), + recentFailures, + }; +} + +function emptyStatusCounts(): Record & { total: number } { + return { queued: 0, running: 0, succeeded: 0, failed: 0, total: 0 }; +} + +function emptyTypeCounts(type: string) { + return { + type, + queued: 0, + running: 0, + succeeded: 0, + failed: 0, + total: 0, + oldestQueuedAt: undefined as string | undefined, + nextLeaseExpiresAt: undefined as string | undefined, + }; +} + +function normalizeStatus(status: string): JobStatus { + return status === 'running' || status === 'succeeded' || status === 'failed' ? status : 'queued'; +} + +function toIso(value: Date | string | null | undefined): string | undefined { + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : undefined; + if (typeof value !== 'string') return undefined; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; +} + +function minIso(current: string | undefined, next: string | undefined): string | undefined { + if (!next) return current; + if (!current) return next; + return next < current ? next : current; +} diff --git a/apps/server/src/prisma/prisma.service.ts b/apps/server/src/prisma/prisma.service.ts index 6c2a82f..195784b 100644 --- a/apps/server/src/prisma/prisma.service.ts +++ b/apps/server/src/prisma/prisma.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +import { recordSlowPrismaQuery } from '../modules/ops/ops-runtime.store'; import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring'; @Injectable() @@ -16,6 +17,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul (this as any).$on('query', (event: { duration: number; query: string }) => { if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return; this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`); + recordSlowPrismaQuery({ + query: event.query, + durationMs: event.duration, + thresholdMs: this.slowQueryThresholdMs, + }); }); } diff --git a/apps/web/app/admin/ops/page.tsx b/apps/web/app/admin/ops/page.tsx new file mode 100644 index 0000000..a89aa22 --- /dev/null +++ b/apps/web/app/admin/ops/page.tsx @@ -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 & { total: number }; + byType: Array & { + 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 = { + queued: '排队', + running: '运行', + succeeded: '成功', + failed: '失败', +}; + +export default function OpsPage() { + return ( + + + + ); +} + +function OpsPageContent() { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const fetchSnapshot = async (initial = false) => { + if (initial) setLoading(true); + else setRefreshing(true); + try { + const next = await api.get('/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 ( +
+
+
+ +

运维看板

+ {snapshot && ( + + {formatClock(snapshot.collectedAt)} + + )} +
+ +
+ +
+ {loading ? ( +
+ + 加载中 +
+ ) : error ? ( +
{error}
+ ) : snapshot ? ( +
+ +
+
+ + +
+
+ + +
+
+
+ ) : null} +
+
+ ); +} + +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 ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
+
+
+

{card.label}

+

{card.value}

+

{card.sub}

+
+ + + +
+
+ ); + })} +
+ ); +} + +function SlowRequestsPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + return ( +
+ +
+ {snapshot.slowRequests.length === 0 ? ( + + ) : snapshot.slowRequests.map((item) => ( +
+ {item.method} + {item.path} + {item.durationMs}ms + {formatClock(item.occurredAt)} +
+ ))} +
+
+ ); +} + +function SlowQueriesPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + return ( +
+ +
+ {snapshot.slowQueries.length === 0 ? ( + + ) : snapshot.slowQueries.map((item) => ( +
+ {item.queryPreview} + {item.durationMs}ms + {formatClock(item.occurredAt)} +
+ ))} +
+
+ ); +} + +function JobQueuePanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + const rows = snapshot.jobQueue.byType; + return ( +
+ +
+ +
+
+ {rows.length === 0 ? ( + + ) : rows.map((row) => ( +
+
+ {row.type} + {row.total} +
+
+ {(['queued', 'running', 'succeeded', 'failed'] as JobStatus[]).map((status) => ( + + ))} +
+ {(row.oldestQueuedAt || row.nextLeaseExpiresAt) && ( +
+ {row.oldestQueuedAt && 最早排队 {formatClock(row.oldestQueuedAt)}} + {row.nextLeaseExpiresAt && Lease {formatClock(row.nextLeaseExpiresAt)}} +
+ )} +
+ ))} +
+
+ ); +} + +function FailuresPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) { + const failures = snapshot.jobQueue.recentFailures; + const dbOk = snapshot.database.ok; + + return ( +
+ +
+
+ {dbOk ? : } + {dbOk ? '数据库可读' : snapshot.database.error || '数据库不可读'} +
+
+
+ {failures.length === 0 ? ( + + ) : failures.map((item) => ( +
+
+ {item.type} + {item.attempts}/{item.maxAttempts} +
+

{item.lastError || '-'}

+ {item.updatedAt &&

{formatClock(item.updatedAt)}

} +
+ ))} +
+
+ ); +} + +function StatusBars({ totals }: { totals: OpsRuntimeSnapshot['jobQueue']['totals'] }) { + const statuses: JobStatus[] = ['queued', 'running', 'succeeded', 'failed']; + const total = Math.max(1, totals.total); + return ( +
+
+ {statuses.map((status) => ( + + ))} +
+
+ {statuses.map((status) => )} +
+
+ ); +} + +function StatusPill({ status, count }: { status: JobStatus; count: number }) { + return ( +
+ {STATUS_LABEL[status]} + {count} +
+ ); +} + +function PanelHeader({ title, right }: { title: string; right: string }) { + return ( +
+

{title}

+ {right} +
+ ); +} + +function EmptyRow({ label }: { label: string }) { + return
{label}
; +} + +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'; +} diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 4f052d1..7d5a807 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -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 } from 'lucide-react'; +import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, Activity } from 'lucide-react'; import { useHasPermission } from '@/components/auth/Guard'; import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems'; @@ -45,6 +45,7 @@ const NAV_GROUPS = [ items: [ { label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' }, { label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' }, + { label: '运维', path: '/admin/ops', icon: Activity, permission: 'ops:view' }, { label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' }, ], }, diff --git a/apps/web/lib/permissions.ts b/apps/web/lib/permissions.ts index 9bf95af..da9efad 100644 --- a/apps/web/lib/permissions.ts +++ b/apps/web/lib/permissions.ts @@ -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: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') }, diff --git a/apps/web/lib/role-permission-migration.test.ts b/apps/web/lib/role-permission-migration.test.ts index 66e36dd..70f072c 100644 --- a/apps/web/lib/role-permission-migration.test.ts +++ b/apps/web/lib/role-permission-migration.test.ts @@ -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[] = [ { diff --git a/docs/architecture.md b/docs/architecture.md index 6648448..aa53bc9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -147,6 +147,15 @@ V2.6 introduces a database-backed background job runtime for server-side refresh This runtime is intentionally DB-backed first. Redis is already available in deployment, but V2.6 jobs need transactional dedupe with domain writes more than high-throughput queue semantics. +## Runtime Ops Dashboard (V2.6) + +V2.6 adds an Ops runtime surface for local operators and future production admins: + +- Backend: `OpsModule` exposes `GET /api/v1/ops/runtime`, reading recent in-memory slow API request events, recent in-memory slow Prisma query events, `background_jobs` queue rows, and dirty `xiaobao_risk_summaries` count. +- Capture: `ApiTimingInterceptor` and `PrismaService` still log slow events, and also append redacted bounded previews into the Ops runtime buffer. SQL query parameters are not exposed; request query strings and key-like values are stripped or redacted. +- Jobs: the dashboard summarizes queued/running/succeeded/failed jobs by type and shows recent terminal failures with redacted `lastError`. +- Permissions: the frontend route is guarded by `ops:view`. Backend RBAC is represented by `OpsPermissionAdapter` until the V2.5 RBAC contract lands, so the replacement point is explicit instead of hard-coupled to temporary role data. + ## 生产部署层(2026-07-01) 当前仓库已补齐云服务器生产部署基线: diff --git a/docs/decisions.md b/docs/decisions.md index 30463e0..6bcd093 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -621,3 +621,17 @@ - Worker 不修改 Version、Requirement、DevTask、TestCase、Bug、Member 等业务实体,也不写通知。V2.7 通知如需消费结果,应通过 insight cache 或 adapter 读取。 **理由**:AI 解读是对确定性规则结果的解释层,不是业务事实源。把它做成 summary 后置队列,能让无人打开页面时也生成解释,同时通过 signature/cooldown/escalation 控制成本和重复调用。只写 cache 能保持 AI 与业务实体解耦,后续通知和审计可以复用缓存,而不是让 AI worker 直接参与业务状态流转。 + +## 48. V2.6 运维看板先做轻量运行时快照,RBAC 通过 adapter 衔接 + +**问题**:V2.6 增加了性能 harness、后台 job runtime、小宝 summary refresh 和 AI 解读队列。如果没有一个运行时入口,慢请求、慢查询、job 堆积和 dirty summary 数只能从日志或数据库手工排查。与此同时,V2.5 后端 RBAC/audit 合同尚未落地,不能为了看板临时硬编码一套后端权限结构。 + +**决策**: +- 新增 `OpsModule`,提供 `GET /api/v1/ops/runtime`,返回慢请求、慢 Prisma 查询、后台任务队列、失败任务和 dirty summary 数。 +- 慢请求继续由 `ApiTimingInterceptor` 识别;慢查询继续由 `PrismaService` query event 识别。二者额外写入进程内 ring buffer,作为轻量 dashboard 数据源。 +- 看板只保留最近事件,不做长期审计。长期审计和多实例聚合等待 V2.5 audit 或后续 observability 方案。 +- 请求 URL 去掉 query string;SQL 只展示截断后的 query preview;`sk-*`、token、secret、password、authorization 等 key-like 文本统一 redacted;不展示 AI provider apiKey、请求参数或环境变量。 +- Job 队列从 `background_jobs` 最近 200 行聚合,按 type 展示 queued/running/succeeded/failed,并展示最近 failed job 的脱敏 `lastError`。 +- 前端 `/admin/ops` 使用 `RouteGuard permission="ops:view"`;权限字典新增 `ops:view`,但不默认授给非管理员 preset。后端通过 `OpsPermissionAdapter` 保留 `ops:view` 校验入口,待 V2.5 RBAC guard 落地后替换。 + +**理由**:当前目标是让 V2.6 的性能和后台化能力可观察,而不是建设完整监控平台。进程内 ring buffer 成本低、对生产数据无额外写放大;结合脱敏规则可避免把 secrets 带进管理端。权限 adapter 明确了未来替换点,避免 Ops 看板和未定型 RBAC/audit 合同互相绑死。 diff --git a/docs/performance.md b/docs/performance.md index 8e03367..d7e31f9 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -77,6 +77,26 @@ The medium fixture is the V2.6 acceptance target. Large fixture runs are for ind See `docs/performance-hot-queries.md` for query budgets, partition-key contracts, and required indexes. +## Runtime Ops Dashboard + +`/admin/ops` provides the V2.6 runtime view for checking whether the hot paths and background workers stay healthy after fixture/perf runs. + +It reads `GET /api/v1/ops/runtime` and shows: + +- recent slow API requests captured by `ApiTimingInterceptor` +- recent slow Prisma query previews captured by `PrismaService` +- `background_jobs` totals and per-type queued/running/succeeded/failed counts +- recent failed jobs with redacted `lastError` +- dirty `xiaobao_risk_summaries` count + +Access is guarded in the frontend by `ops:view`. Backend RBAC is currently an explicit `OpsPermissionAdapter` placeholder until the V2.5 RBAC contract lands. + +Secret handling: + +- request query strings are stripped before display +- SQL is shown only as a bounded query preview, without Prisma parameters +- key-like text such as `sk-*`, token, secret, password, and authorization is redacted + ## Notes - `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6841eab..9479876 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,10 +19,12 @@ V2.4 已完成高增长和核心业务领域从“AppData 主写 + 关系表同 - V2.6.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。 - V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。 - V2.6.5 已新增服务端小宝 AI 解读队列,summary 刷新后按 signature/cooldown/escalation policy 入队,只写 `xiaobao_risk_insights` 缓存。 +- V2.6.6 已新增 `/admin/ops` 运行时看板和 `GET /api/v1/ops/runtime`,展示慢请求、慢查询、job 队列和 dirty summary 数。 ### 已完成(按时间倒序) **2026-07-08** +- V2.6.6 added the Ops runtime dashboard with `ops:view`, redacted slow request/query buffers, background job queue summary, failed job list, and dirty Xiaobao summary count. - V2.6.5 moved Xiaobao AI interpretation behind the background job runtime, reusing `AiService.interpretRisk()` and writing only insight cache rows. - V2.6.4 moved deterministic Xiaobao risk summary refresh into the server, registered the `xiaobao.summary.refresh` background job handler, and enqueue refresh jobs from dirty domain writes. - V2.6.3 added DB-backed background jobs with active dedupe keys, lease-based claiming, expired lock recovery, retry/terminal-failure handling, and a small handler worker.