feat(ops): 增加运行时性能看板
This commit is contained in:
@@ -22,6 +22,7 @@ import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
|||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { JobsModule } from './modules/jobs/jobs.module';
|
import { JobsModule } from './modules/jobs/jobs.module';
|
||||||
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||||
|
import { OpsModule } from './modules/ops/ops.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -45,6 +46,7 @@ import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
XiaobaoModule,
|
XiaobaoModule,
|
||||||
|
OpsModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
import { finalize, Observable } from 'rxjs';
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
import { recordSlowRequest } from '../../modules/ops/ops-runtime.store';
|
||||||
|
|
||||||
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor {
|
|||||||
const method = request.method ?? 'UNKNOWN';
|
const method = request.method ?? 'UNKNOWN';
|
||||||
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
||||||
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
||||||
|
recordSlowRequest({ method, url, durationMs, thresholdMs: this.thresholdMs });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/server/src/modules/ops/ops-permission.adapter.ts
Normal file
11
apps/server/src/modules/ops/ops-permission.adapter.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/server/src/modules/ops/ops-runtime.store.spec.ts
Normal file
53
apps/server/src/modules/ops/ops-runtime.store.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
116
apps/server/src/modules/ops/ops-runtime.store.ts
Normal file
116
apps/server/src/modules/ops/ops-runtime.store.ts
Normal file
@@ -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<T>(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;
|
||||||
|
}
|
||||||
19
apps/server/src/modules/ops/ops.controller.spec.ts
Normal file
19
apps/server/src/modules/ops/ops.controller.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/server/src/modules/ops/ops.controller.ts
Normal file
17
apps/server/src/modules/ops/ops.controller.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/server/src/modules/ops/ops.module.ts
Normal file
16
apps/server/src/modules/ops/ops.module.ts
Normal file
@@ -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 {}
|
||||||
89
apps/server/src/modules/ops/ops.service.spec.ts
Normal file
89
apps/server/src/modules/ops/ops.service.spec.ts
Normal file
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
148
apps/server/src/modules/ops/ops.service.ts
Normal file
148
apps/server/src/modules/ops/ops.service.ts
Normal file
@@ -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<string, ReturnType<typeof emptyTypeCounts>>();
|
||||||
|
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<JobStatus, number> & { 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { recordSlowPrismaQuery } from '../modules/ops/ops-runtime.store';
|
||||||
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,6 +17,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
||||||
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
||||||
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
|
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
|
||||||
|
recordSlowPrismaQuery({
|
||||||
|
query: event.query,
|
||||||
|
durationMs: event.duration,
|
||||||
|
thresholdMs: this.slowQueryThresholdMs,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
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 { useHasPermission } from '@/components/auth/Guard';
|
||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||||
@@ -45,6 +45,7 @@ const NAV_GROUPS = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
||||||
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role: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: '*' },
|
{ 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' },
|
{ 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: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
||||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||||
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { RoleItem } from './members';
|
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';
|
import { mergePresetRolePermissions } from './role-permission-migration';
|
||||||
|
|
||||||
test('default group leader role has business permissions without admin modules', () => {
|
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);
|
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', () => {
|
test('mergePresetRolePermissions adds the built-in group leader role to old role data', () => {
|
||||||
const roles: RoleItem[] = [
|
const roles: RoleItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
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)
|
## 生产部署层(2026-07-01)
|
||||||
|
|
||||||
当前仓库已补齐云服务器生产部署基线:
|
当前仓库已补齐云服务器生产部署基线:
|
||||||
|
|||||||
@@ -621,3 +621,17 @@
|
|||||||
- Worker 不修改 Version、Requirement、DevTask、TestCase、Bug、Member 等业务实体,也不写通知。V2.7 通知如需消费结果,应通过 insight cache 或 adapter 读取。
|
- Worker 不修改 Version、Requirement、DevTask、TestCase、Bug、Member 等业务实体,也不写通知。V2.7 通知如需消费结果,应通过 insight cache 或 adapter 读取。
|
||||||
|
|
||||||
**理由**:AI 解读是对确定性规则结果的解释层,不是业务事实源。把它做成 summary 后置队列,能让无人打开页面时也生成解释,同时通过 signature/cooldown/escalation 控制成本和重复调用。只写 cache 能保持 AI 与业务实体解耦,后续通知和审计可以复用缓存,而不是让 AI worker 直接参与业务状态流转。
|
**理由**: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 合同互相绑死。
|
||||||
|
|||||||
@@ -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.
|
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
|
## Notes
|
||||||
|
|
||||||
- `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable.
|
- `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable.
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ V2.4 已完成高增长和核心业务领域从“AppData 主写 + 关系表同
|
|||||||
- V2.6.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。
|
- V2.6.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。
|
||||||
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
||||||
- V2.6.5 已新增服务端小宝 AI 解读队列,summary 刷新后按 signature/cooldown/escalation policy 入队,只写 `xiaobao_risk_insights` 缓存。
|
- 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**
|
**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.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.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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user