refactor: 优化代码结构和类型定义
feat(types): 添加express.d.ts类型引用 style: 格式化express.d.ts中的接口定义 refactor: 移除未使用的AntFC类型导入 chore: 删除自动生成的.umi-production文件 feat: 添加店铺管理相关表和初始化脚本 docs: 更新安全规则和交互指南文档 refactor: 统一使用FC类型替代React.FC perf: 优化图表组件导入方式 style: 添加.prettierrc配置文件 refactor: 调整组件导入顺序和结构 feat: 添加平台库存管理路由 fix: 修复订单同步时的库存检查逻辑 docs: 更新RBAC设计和租户管理文档 refactor: 优化部门控制器代码
This commit is contained in:
517
dashboard/src/services/behaviorAnalyticsService.ts
Normal file
517
dashboard/src/services/behaviorAnalyticsService.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* [FE-MON003] 用户行为分析服务
|
||||
* @description 事件追踪、页面访问追踪、用户行为分析
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
import { http } from './http';
|
||||
|
||||
export interface UserBehaviorEvent {
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
eventType: EventType;
|
||||
eventName: string;
|
||||
eventData: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
referrer: string;
|
||||
userAgent: string;
|
||||
viewport: { width: number; height: number };
|
||||
language: string;
|
||||
}
|
||||
|
||||
export type EventType =
|
||||
| 'page_view'
|
||||
| 'page_exit'
|
||||
| 'click'
|
||||
| 'scroll'
|
||||
| 'form_submit'
|
||||
| 'form_change'
|
||||
| 'search'
|
||||
| 'error'
|
||||
| 'custom'
|
||||
| 'api_call'
|
||||
| 'user_action';
|
||||
|
||||
export interface PageViewEvent {
|
||||
path: string;
|
||||
title: string;
|
||||
duration?: number;
|
||||
scrollDepth?: number;
|
||||
}
|
||||
|
||||
export interface ClickEvent {
|
||||
element: string;
|
||||
text?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface SearchEvent {
|
||||
query: string;
|
||||
results?: number;
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
pageViews: number;
|
||||
events: number;
|
||||
device: DeviceInfo;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
type: 'desktop' | 'tablet' | 'mobile';
|
||||
os: string;
|
||||
browser: string;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
}
|
||||
|
||||
interface BehaviorAnalyticsConfig {
|
||||
enabled: boolean;
|
||||
trackPageViews: boolean;
|
||||
trackClicks: boolean;
|
||||
trackScrolls: boolean;
|
||||
trackForms: boolean;
|
||||
trackSearch: boolean;
|
||||
trackApiCalls: boolean;
|
||||
scrollThreshold: number;
|
||||
reportInterval: number;
|
||||
batchSize: number;
|
||||
samplingRate: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: BehaviorAnalyticsConfig = {
|
||||
enabled: true,
|
||||
trackPageViews: true,
|
||||
trackClicks: true,
|
||||
trackScrolls: true,
|
||||
trackForms: true,
|
||||
trackSearch: true,
|
||||
trackApiCalls: true,
|
||||
scrollThreshold: 0.25,
|
||||
reportInterval: 15000,
|
||||
batchSize: 20,
|
||||
samplingRate: 1.0,
|
||||
};
|
||||
|
||||
class UserBehaviorAnalyticsService {
|
||||
private config: BehaviorAnalyticsConfig;
|
||||
private eventQueue: UserBehaviorEvent[] = [];
|
||||
private sessionId: string;
|
||||
private sessionStartTime: string;
|
||||
private currentPageViewStart: number = 0;
|
||||
private maxScrollDepth: number = 0;
|
||||
private isInitialized = false;
|
||||
private reportInterval: NodeJS.Timeout | null = null;
|
||||
private scrollThresholds: Set<number> = new Set();
|
||||
private lastPagePath: string = '';
|
||||
|
||||
constructor(config: Partial<BehaviorAnalyticsConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.sessionId = this.generateSessionId();
|
||||
this.sessionStartTime = new Date().toISOString();
|
||||
}
|
||||
|
||||
private generateSessionId(): string {
|
||||
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
if (this.isInitialized) return;
|
||||
this.isInitialized = true;
|
||||
|
||||
if (Math.random() > this.config.samplingRate) {
|
||||
this.config.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.trackPageViews) {
|
||||
this.trackPageViews();
|
||||
}
|
||||
|
||||
if (this.config.trackClicks) {
|
||||
this.trackClicks();
|
||||
}
|
||||
|
||||
if (this.config.trackScrolls) {
|
||||
this.trackScrolls();
|
||||
}
|
||||
|
||||
if (this.config.trackForms) {
|
||||
this.trackForms();
|
||||
}
|
||||
|
||||
if (this.config.trackSearch) {
|
||||
this.trackSearch();
|
||||
}
|
||||
|
||||
this.startPeriodicReport();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.trackPageExit();
|
||||
this.flushEvents();
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.trackPageExit();
|
||||
this.flushEvents();
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
this.currentPageViewStart = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[BehaviorAnalytics] Initialized');
|
||||
}
|
||||
|
||||
private trackPageViews(): void {
|
||||
this.currentPageViewStart = Date.now();
|
||||
this.lastPagePath = window.location.pathname;
|
||||
|
||||
this.trackEvent('page_view', 'page_load', {
|
||||
path: window.location.pathname,
|
||||
title: document.title,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
});
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
const self = this;
|
||||
|
||||
history.pushState = function(...args) {
|
||||
self.trackPageExit();
|
||||
originalPushState.apply(this, args);
|
||||
self.handleRouteChange();
|
||||
};
|
||||
|
||||
history.replaceState = function(...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
self.handleRouteChange();
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
this.trackPageExit();
|
||||
this.handleRouteChange();
|
||||
});
|
||||
}
|
||||
|
||||
private handleRouteChange(): void {
|
||||
this.maxScrollDepth = 0;
|
||||
this.scrollThresholds.clear();
|
||||
this.currentPageViewStart = Date.now();
|
||||
this.lastPagePath = window.location.pathname;
|
||||
|
||||
this.trackEvent('page_view', 'route_change', {
|
||||
path: window.location.pathname,
|
||||
title: document.title,
|
||||
search: window.location.search,
|
||||
});
|
||||
}
|
||||
|
||||
private trackPageExit(): void {
|
||||
const duration = Date.now() - this.currentPageViewStart;
|
||||
|
||||
this.trackEvent('page_exit', 'page_unload', {
|
||||
path: this.lastPagePath,
|
||||
duration,
|
||||
scrollDepth: this.maxScrollDepth,
|
||||
});
|
||||
}
|
||||
|
||||
private trackClicks(): void {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as Element;
|
||||
|
||||
const clickData = {
|
||||
element: this.getElementPath(target),
|
||||
tagName: target.tagName.toLowerCase(),
|
||||
text: target.textContent?.substring(0, 100),
|
||||
className: target.className,
|
||||
id: target.id,
|
||||
href: (target as HTMLAnchorElement).href,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.trackEvent('click', 'element_click', clickData);
|
||||
}, true);
|
||||
}
|
||||
|
||||
private trackScrolls(): void {
|
||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const scrollPercent = docHeight > 0 ? (scrollTop / docHeight) : 0;
|
||||
|
||||
if (scrollPercent > this.maxScrollDepth) {
|
||||
this.maxScrollDepth = scrollPercent;
|
||||
}
|
||||
|
||||
const threshold = Math.floor(scrollPercent / this.config.scrollThreshold) * this.config.scrollThreshold;
|
||||
|
||||
if (!this.scrollThresholds.has(threshold) && threshold > 0) {
|
||||
this.scrollThresholds.add(threshold);
|
||||
this.trackEvent('scroll', 'scroll_depth', {
|
||||
depth: threshold,
|
||||
scrollTop,
|
||||
docHeight,
|
||||
});
|
||||
}
|
||||
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
this.trackEvent('scroll', 'scroll_end', {
|
||||
maxScrollDepth: this.maxScrollDepth,
|
||||
finalScrollTop: scrollTop,
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
}
|
||||
|
||||
private trackForms(): void {
|
||||
document.addEventListener('submit', (event) => {
|
||||
const form = event.target as HTMLFormElement;
|
||||
|
||||
this.trackEvent('form_submit', 'form_submission', {
|
||||
formId: form.id,
|
||||
formName: form.name,
|
||||
formAction: form.action,
|
||||
formMethod: form.method,
|
||||
fieldCount: form.elements.length,
|
||||
});
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', (event) => {
|
||||
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'SELECT' || target.tagName === 'TEXTAREA') {
|
||||
this.trackEvent('form_change', 'field_change', {
|
||||
fieldType: target.type,
|
||||
fieldName: target.name,
|
||||
fieldId: target.id,
|
||||
formId: (target.form as HTMLFormElement)?.id,
|
||||
});
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
private trackSearch(): void {
|
||||
const searchInputs = document.querySelectorAll('input[type="search"], input[placeholder*="搜索"], input[placeholder*="search"]');
|
||||
|
||||
searchInputs.forEach((input) => {
|
||||
let searchTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
input.addEventListener('input', (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
if (target.value.length >= 2) {
|
||||
this.trackEvent('search', 'search_input', {
|
||||
query: target.value,
|
||||
inputId: target.id,
|
||||
inputName: target.name,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getElementPath(element: Element): string {
|
||||
const path: string[] = [];
|
||||
let current: Element | null = element;
|
||||
|
||||
while (current && current !== document.body) {
|
||||
let selector = current.tagName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
} else if (current.className) {
|
||||
const classes = current.className.split(' ').filter(Boolean).slice(0, 2);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
trackEvent(eventType: EventType, eventName: string, eventData: Record<string, unknown> = {}): void {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
const tenantId = localStorage.getItem('tenantId') || 'default';
|
||||
const shopId = localStorage.getItem('shopId') || 'default';
|
||||
const userId = localStorage.getItem('userId') || 'anonymous';
|
||||
|
||||
const event: UserBehaviorEvent = {
|
||||
tenantId,
|
||||
shopId,
|
||||
userId,
|
||||
sessionId: this.sessionId,
|
||||
eventType,
|
||||
eventName,
|
||||
eventData,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
referrer: document.referrer,
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
language: navigator.language,
|
||||
};
|
||||
|
||||
this.eventQueue.push(event);
|
||||
|
||||
if (this.eventQueue.length >= this.config.batchSize) {
|
||||
this.flushEvents();
|
||||
}
|
||||
}
|
||||
|
||||
trackCustomEvent(eventName: string, eventData: Record<string, unknown> = {}): void {
|
||||
this.trackEvent('custom', eventName, eventData);
|
||||
}
|
||||
|
||||
trackUserAction(action: string, details: Record<string, unknown> = {}): void {
|
||||
this.trackEvent('user_action', action, details);
|
||||
}
|
||||
|
||||
trackApiCall(endpoint: string, method: string, duration: number, status: number): void {
|
||||
if (!this.config.trackApiCalls) return;
|
||||
|
||||
this.trackEvent('api_call', 'api_request', {
|
||||
endpoint,
|
||||
method,
|
||||
duration,
|
||||
status,
|
||||
success: status >= 200 && status < 300,
|
||||
});
|
||||
}
|
||||
|
||||
private startPeriodicReport(): void {
|
||||
this.reportInterval = setInterval(() => {
|
||||
if (this.eventQueue.length > 0) {
|
||||
this.flushEvents();
|
||||
}
|
||||
}, this.config.reportInterval);
|
||||
}
|
||||
|
||||
private async flushEvents(): Promise<void> {
|
||||
if (this.eventQueue.length === 0) return;
|
||||
|
||||
const events = [...this.eventQueue];
|
||||
this.eventQueue = [];
|
||||
|
||||
try {
|
||||
await http.post('/api/telemetry/behavior/batch', {
|
||||
events,
|
||||
session: {
|
||||
sessionId: this.sessionId,
|
||||
startTime: this.sessionStartTime,
|
||||
pageViews: events.filter(e => e.eventType === 'page_view').length,
|
||||
events: events.length,
|
||||
},
|
||||
});
|
||||
console.log('[BehaviorAnalytics] Events reported successfully');
|
||||
} catch (error) {
|
||||
console.error('[BehaviorAnalytics] Failed to report events:', error);
|
||||
this.eventQueue.unshift(...events);
|
||||
}
|
||||
}
|
||||
|
||||
getSession(): UserSession {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
startTime: this.sessionStartTime,
|
||||
pageViews: this.eventQueue.filter(e => e.eventType === 'page_view').length,
|
||||
events: this.eventQueue.length,
|
||||
device: this.getDeviceInfo(),
|
||||
};
|
||||
}
|
||||
|
||||
private getDeviceInfo(): DeviceInfo {
|
||||
const ua = navigator.userAgent;
|
||||
let type: DeviceInfo['type'] = 'desktop';
|
||||
let os = 'unknown';
|
||||
let browser = 'unknown';
|
||||
|
||||
if (/mobile/i.test(ua)) {
|
||||
type = 'mobile';
|
||||
} else if (/tablet/i.test(ua)) {
|
||||
type = 'tablet';
|
||||
}
|
||||
|
||||
if (/windows/i.test(ua)) os = 'Windows';
|
||||
else if (/mac/i.test(ua)) os = 'MacOS';
|
||||
else if (/linux/i.test(ua)) os = 'Linux';
|
||||
else if (/android/i.test(ua)) os = 'Android';
|
||||
else if (/ios|iphone|ipad/i.test(ua)) os = 'iOS';
|
||||
|
||||
if (/chrome/i.test(ua)) browser = 'Chrome';
|
||||
else if (/firefox/i.test(ua)) browser = 'Firefox';
|
||||
else if (/safari/i.test(ua)) browser = 'Safari';
|
||||
else if (/edge/i.test(ua)) browser = 'Edge';
|
||||
|
||||
return {
|
||||
type,
|
||||
os,
|
||||
browser,
|
||||
screenWidth: screen.width,
|
||||
screenHeight: screen.height,
|
||||
};
|
||||
}
|
||||
|
||||
getEventQueue(): UserBehaviorEvent[] {
|
||||
return [...this.eventQueue];
|
||||
}
|
||||
|
||||
setUserId(userId: string): void {
|
||||
localStorage.setItem('userId', userId);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
this.reportInterval = null;
|
||||
}
|
||||
|
||||
this.trackPageExit();
|
||||
this.flushEvents();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const behaviorAnalytics = new UserBehaviorAnalyticsService();
|
||||
|
||||
export const initBehaviorAnalytics = (config?: Partial<BehaviorAnalyticsConfig>): void => {
|
||||
if (config) {
|
||||
Object.assign(behaviorAnalytics['config'], config);
|
||||
}
|
||||
behaviorAnalytics.init();
|
||||
};
|
||||
|
||||
export default behaviorAnalytics;
|
||||
477
dashboard/src/services/errorMonitorService.ts
Normal file
477
dashboard/src/services/errorMonitorService.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* [FE-MON002] 错误监控和上报服务
|
||||
* @description 全局错误捕获、ErrorBoundary集成、错误上报
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
import { http } from './http';
|
||||
|
||||
export interface ErrorReport {
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
userId: string;
|
||||
traceId: string;
|
||||
errorType: 'javascript' | 'promise' | 'resource' | 'react' | 'api';
|
||||
message: string;
|
||||
stack?: string;
|
||||
componentStack?: string;
|
||||
url: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
fileName?: string;
|
||||
timestamp: string;
|
||||
userAgent: string;
|
||||
breadcrumbs: Breadcrumb[];
|
||||
context: Record<string, unknown>;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface Breadcrumb {
|
||||
type: 'navigation' | 'click' | 'xhr' | 'console' | 'error' | 'user';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ErrorMonitorConfig {
|
||||
maxBreadcrumbs: number;
|
||||
reportInterval: number;
|
||||
enableConsoleCapture: boolean;
|
||||
enableXhrCapture: boolean;
|
||||
enableClickCapture: boolean;
|
||||
enableNavigationCapture: boolean;
|
||||
ignoredErrors: RegExp[];
|
||||
samplingRate: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ErrorMonitorConfig = {
|
||||
maxBreadcrumbs: 50,
|
||||
reportInterval: 10000,
|
||||
enableConsoleCapture: true,
|
||||
enableXhrCapture: true,
|
||||
enableClickCapture: true,
|
||||
enableNavigationCapture: true,
|
||||
ignoredErrors: [
|
||||
/ResizeObserver loop/,
|
||||
/Network request failed/,
|
||||
/Loading chunk/,
|
||||
/Script error/,
|
||||
],
|
||||
samplingRate: 1.0,
|
||||
};
|
||||
|
||||
class ErrorMonitorService {
|
||||
private config: ErrorMonitorConfig;
|
||||
private breadcrumbs: Breadcrumb[] = [];
|
||||
private errorQueue: ErrorReport[] = [];
|
||||
private isInitialized = false;
|
||||
private reportInterval: NodeJS.Timeout | null = null;
|
||||
private originalConsole: Record<string, (...args: unknown[]) => void> = {};
|
||||
private originalXhr: {
|
||||
open: typeof XMLHttpRequest.prototype.open;
|
||||
send: typeof XMLHttpRequest.prototype.send;
|
||||
} | null = null;
|
||||
|
||||
constructor(config: Partial<ErrorMonitorConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
init(): void {
|
||||
if (this.isInitialized) return;
|
||||
this.isInitialized = true;
|
||||
|
||||
this.captureGlobalErrors();
|
||||
this.capturePromiseRejections();
|
||||
this.captureResourceErrors();
|
||||
|
||||
if (this.config.enableConsoleCapture) {
|
||||
this.captureConsoleErrors();
|
||||
}
|
||||
|
||||
if (this.config.enableXhrCapture) {
|
||||
this.captureXhrErrors();
|
||||
}
|
||||
|
||||
if (this.config.enableClickCapture) {
|
||||
this.captureClickEvents();
|
||||
}
|
||||
|
||||
if (this.config.enableNavigationCapture) {
|
||||
this.captureNavigationEvents();
|
||||
}
|
||||
|
||||
this.startPeriodicReport();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.flushReports();
|
||||
});
|
||||
|
||||
console.log('[ErrorMonitor] Initialized');
|
||||
}
|
||||
|
||||
private captureGlobalErrors(): void {
|
||||
window.addEventListener('error', (event) => {
|
||||
if (this.shouldIgnoreError(event.message)) return;
|
||||
|
||||
this.addBreadcrumb({
|
||||
type: 'error',
|
||||
message: event.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
},
|
||||
});
|
||||
|
||||
this.reportError({
|
||||
errorType: 'javascript',
|
||||
message: event.message,
|
||||
stack: event.error?.stack,
|
||||
fileName: event.filename,
|
||||
lineNumber: event.lineno,
|
||||
columnNumber: event.colno,
|
||||
severity: this.determineSeverity(event.message),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private capturePromiseRejections(): void {
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const message = event.reason?.message || String(event.reason);
|
||||
|
||||
if (this.shouldIgnoreError(message)) return;
|
||||
|
||||
this.addBreadcrumb({
|
||||
type: 'error',
|
||||
message: `Unhandled Promise Rejection: ${message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
reason: event.reason,
|
||||
},
|
||||
});
|
||||
|
||||
this.reportError({
|
||||
errorType: 'promise',
|
||||
message,
|
||||
stack: event.reason?.stack,
|
||||
severity: 'high',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private captureResourceErrors(): void {
|
||||
window.addEventListener('error', (event) => {
|
||||
if (event.target && (event.target as Element).tagName) {
|
||||
const target = event.target as Element;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
|
||||
if (['img', 'script', 'link', 'video', 'audio'].includes(tagName)) {
|
||||
const src = (target as HTMLImageElement).src || (target as HTMLLinkElement).href;
|
||||
|
||||
this.reportError({
|
||||
errorType: 'resource',
|
||||
message: `Failed to load resource: ${src}`,
|
||||
severity: 'medium',
|
||||
tags: ['resource', tagName],
|
||||
});
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
private captureConsoleErrors(): void {
|
||||
const methods = ['error', 'warn'] as const;
|
||||
|
||||
methods.forEach((method) => {
|
||||
this.originalConsole[method] = console[method];
|
||||
console[method] = (...args: unknown[]) => {
|
||||
this.originalConsole[method](...args);
|
||||
|
||||
const message = args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||
).join(' ');
|
||||
|
||||
this.addBreadcrumb({
|
||||
type: 'console',
|
||||
message: `[${method.toUpperCase()}] ${message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { args },
|
||||
});
|
||||
|
||||
if (method === 'error') {
|
||||
this.reportError({
|
||||
errorType: 'javascript',
|
||||
message,
|
||||
severity: 'low',
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private captureXhrErrors(): void {
|
||||
this.originalXhr = {
|
||||
open: XMLHttpRequest.prototype.open,
|
||||
send: XMLHttpRequest.prototype.send,
|
||||
};
|
||||
|
||||
const self = this;
|
||||
|
||||
XMLHttpRequest.prototype.open = function(method: string, url: string, ...args: unknown[]) {
|
||||
(this as any)._errorMonitorUrl = url;
|
||||
(this as any)._errorMonitorMethod = method;
|
||||
return self.originalXhr!.open.apply(this, [method, url, ...args] as [string, string, ...unknown[]]);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function(...args: unknown[]) {
|
||||
const xhr = this;
|
||||
const startTime = Date.now();
|
||||
|
||||
xhr.addEventListener('loadend', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
const status = xhr.status;
|
||||
|
||||
self.addBreadcrumb({
|
||||
type: 'xhr',
|
||||
message: `${(xhr as any)._errorMonitorMethod || 'GET'} ${(xhr as any)._errorMonitorUrl}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
status,
|
||||
duration,
|
||||
responseSize: xhr.responseText?.length,
|
||||
},
|
||||
});
|
||||
|
||||
if (status >= 400) {
|
||||
self.reportError({
|
||||
errorType: 'api',
|
||||
message: `API Error: ${(xhr as any)._errorMonitorUrl} returned ${status}`,
|
||||
severity: status >= 500 ? 'high' : 'medium',
|
||||
tags: ['api', `status-${status}`],
|
||||
context: {
|
||||
url: (xhr as any)._errorMonitorUrl,
|
||||
method: (xhr as any)._errorMonitorMethod,
|
||||
status,
|
||||
response: xhr.responseText?.substring(0, 500),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
self.reportError({
|
||||
errorType: 'api',
|
||||
message: `Network Error: ${(xhr as any)._errorMonitorUrl}`,
|
||||
severity: 'high',
|
||||
tags: ['api', 'network-error'],
|
||||
});
|
||||
});
|
||||
|
||||
return self.originalXhr!.send.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
private captureClickEvents(): void {
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as Element;
|
||||
const selector = this.getElementSelector(target);
|
||||
|
||||
this.addBreadcrumb({
|
||||
type: 'click',
|
||||
message: `Click on ${selector}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
tagName: target.tagName,
|
||||
className: target.className,
|
||||
id: target.id,
|
||||
text: target.textContent?.substring(0, 50),
|
||||
},
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
private captureNavigationEvents(): void {
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
const self = this;
|
||||
|
||||
history.pushState = function(...args) {
|
||||
originalPushState.apply(this, args);
|
||||
self.addNavigationBreadcrumb('pushState');
|
||||
};
|
||||
|
||||
history.replaceState = function(...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
self.addNavigationBreadcrumb('replaceState');
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
this.addNavigationBreadcrumb('popstate');
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
this.addNavigationBreadcrumb('hashchange');
|
||||
});
|
||||
}
|
||||
|
||||
private addNavigationBreadcrumb(type: string): void {
|
||||
this.addBreadcrumb({
|
||||
type: 'navigation',
|
||||
message: `Navigation: ${type} -> ${window.location.pathname}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
type,
|
||||
url: window.location.href,
|
||||
pathname: window.location.pathname,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getElementSelector(element: Element): string {
|
||||
if (element.id) return `#${element.id}`;
|
||||
if (element.className) {
|
||||
const classes = element.className.split(' ').filter(Boolean).join('.');
|
||||
return `${element.tagName.toLowerCase()}.${classes}`;
|
||||
}
|
||||
return element.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
addBreadcrumb(breadcrumb: Breadcrumb): void {
|
||||
this.breadcrumbs.push(breadcrumb);
|
||||
if (this.breadcrumbs.length > this.config.maxBreadcrumbs) {
|
||||
this.breadcrumbs.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private shouldIgnoreError(message: string): boolean {
|
||||
if (Math.random() > this.config.samplingRate) return true;
|
||||
return this.config.ignoredErrors.some(pattern => pattern.test(message));
|
||||
}
|
||||
|
||||
private determineSeverity(message: string): 'low' | 'medium' | 'high' | 'critical' {
|
||||
const criticalPatterns = [/fatal/i, /crash/i, /security/i];
|
||||
const highPatterns = [/error/i, /fail/i, /timeout/i];
|
||||
const mediumPatterns = [/warn/i, /deprecated/i];
|
||||
|
||||
if (criticalPatterns.some(p => p.test(message))) return 'critical';
|
||||
if (highPatterns.some(p => p.test(message))) return 'high';
|
||||
if (mediumPatterns.some(p => p.test(message))) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
reportError(params: {
|
||||
errorType: ErrorReport['errorType'];
|
||||
message: string;
|
||||
stack?: string;
|
||||
componentStack?: string;
|
||||
fileName?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
severity?: ErrorReport['severity'];
|
||||
tags?: string[];
|
||||
context?: Record<string, unknown>;
|
||||
}): void {
|
||||
const tenantId = localStorage.getItem('tenantId') || 'default';
|
||||
const shopId = localStorage.getItem('shopId') || 'default';
|
||||
const userId = localStorage.getItem('userId') || 'anonymous';
|
||||
|
||||
const errorReport: ErrorReport = {
|
||||
tenantId,
|
||||
shopId,
|
||||
userId,
|
||||
traceId: `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
errorType: params.errorType,
|
||||
message: params.message,
|
||||
stack: params.stack,
|
||||
componentStack: params.componentStack,
|
||||
url: window.location.href,
|
||||
lineNumber: params.lineNumber,
|
||||
columnNumber: params.columnNumber,
|
||||
fileName: params.fileName,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
breadcrumbs: [...this.breadcrumbs],
|
||||
context: params.context || {},
|
||||
severity: params.severity || 'medium',
|
||||
tags: params.tags || [],
|
||||
};
|
||||
|
||||
this.errorQueue.push(errorReport);
|
||||
console.error('[ErrorMonitor] Error captured:', errorReport);
|
||||
}
|
||||
|
||||
reportReactError(error: Error, componentStack: string): void {
|
||||
this.reportError({
|
||||
errorType: 'react',
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack,
|
||||
severity: 'high',
|
||||
tags: ['react', 'error-boundary'],
|
||||
});
|
||||
}
|
||||
|
||||
private startPeriodicReport(): void {
|
||||
this.reportInterval = setInterval(() => {
|
||||
if (this.errorQueue.length > 0) {
|
||||
this.flushReports();
|
||||
}
|
||||
}, this.config.reportInterval);
|
||||
}
|
||||
|
||||
private async flushReports(): Promise<void> {
|
||||
if (this.errorQueue.length === 0) return;
|
||||
|
||||
const errors = [...this.errorQueue];
|
||||
this.errorQueue = [];
|
||||
|
||||
try {
|
||||
await http.post('/api/telemetry/errors/batch', { errors });
|
||||
console.log('[ErrorMonitor] Errors reported successfully');
|
||||
} catch (error) {
|
||||
console.error('[ErrorMonitor] Failed to report errors:', error);
|
||||
this.errorQueue.unshift(...errors);
|
||||
}
|
||||
}
|
||||
|
||||
getBreadcrumbs(): Breadcrumb[] {
|
||||
return [...this.breadcrumbs];
|
||||
}
|
||||
|
||||
getErrorQueue(): ErrorReport[] {
|
||||
return [...this.errorQueue];
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
this.reportInterval = null;
|
||||
}
|
||||
|
||||
Object.entries(this.originalConsole).forEach(([method, fn]) => {
|
||||
(console as any)[method] = fn;
|
||||
});
|
||||
|
||||
if (this.originalXhr) {
|
||||
XMLHttpRequest.prototype.open = this.originalXhr.open;
|
||||
XMLHttpRequest.prototype.send = this.originalXhr.send;
|
||||
}
|
||||
|
||||
this.flushReports();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const errorMonitor = new ErrorMonitorService();
|
||||
|
||||
export const initErrorMonitor = (config?: Partial<ErrorMonitorConfig>): void => {
|
||||
if (config) {
|
||||
Object.assign(errorMonitor['config'], config);
|
||||
}
|
||||
errorMonitor.init();
|
||||
};
|
||||
|
||||
export default errorMonitor;
|
||||
141
dashboard/src/services/monitoringInit.ts
Normal file
141
dashboard/src/services/monitoringInit.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* [FE-MON] 前端监控初始化模块
|
||||
* @description 统一初始化所有前端监控服务
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
import { performanceMonitor, initPerformanceMonitor } from './performanceMonitorService';
|
||||
import { errorMonitor, initErrorMonitor } from './errorMonitorService';
|
||||
import { behaviorAnalytics, initBehaviorAnalytics } from './behaviorAnalyticsService';
|
||||
|
||||
export interface MonitoringConfig {
|
||||
enabled: boolean;
|
||||
performance: {
|
||||
enabled: boolean;
|
||||
reportInterval: number;
|
||||
};
|
||||
error: {
|
||||
enabled: boolean;
|
||||
reportInterval: number;
|
||||
samplingRate: number;
|
||||
};
|
||||
behavior: {
|
||||
enabled: boolean;
|
||||
reportInterval: number;
|
||||
samplingRate: number;
|
||||
trackPageViews: boolean;
|
||||
trackClicks: boolean;
|
||||
trackScrolls: boolean;
|
||||
trackForms: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: MonitoringConfig = {
|
||||
enabled: true,
|
||||
performance: {
|
||||
enabled: true,
|
||||
reportInterval: 30000,
|
||||
},
|
||||
error: {
|
||||
enabled: true,
|
||||
reportInterval: 10000,
|
||||
samplingRate: 1.0,
|
||||
},
|
||||
behavior: {
|
||||
enabled: true,
|
||||
reportInterval: 15000,
|
||||
samplingRate: 1.0,
|
||||
trackPageViews: true,
|
||||
trackClicks: true,
|
||||
trackScrolls: true,
|
||||
trackForms: true,
|
||||
},
|
||||
};
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
export function initMonitoring(config: Partial<MonitoringConfig> = {}): void {
|
||||
if (isInitialized) {
|
||||
console.warn('[Monitoring] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalConfig: MonitoringConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
performance: { ...DEFAULT_CONFIG.performance, ...config.performance },
|
||||
error: { ...DEFAULT_CONFIG.error, ...config.error },
|
||||
behavior: { ...DEFAULT_CONFIG.behavior, ...config.behavior },
|
||||
};
|
||||
|
||||
if (!finalConfig.enabled) {
|
||||
console.log('[Monitoring] Monitoring is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Monitoring] Initializing monitoring services...');
|
||||
|
||||
if (finalConfig.performance.enabled) {
|
||||
initPerformanceMonitor();
|
||||
console.log('[Monitoring] Performance monitoring initialized');
|
||||
}
|
||||
|
||||
if (finalConfig.error.enabled) {
|
||||
initErrorMonitor({
|
||||
reportInterval: finalConfig.error.reportInterval,
|
||||
samplingRate: finalConfig.error.samplingRate,
|
||||
});
|
||||
console.log('[Monitoring] Error monitoring initialized');
|
||||
}
|
||||
|
||||
if (finalConfig.behavior.enabled) {
|
||||
initBehaviorAnalytics({
|
||||
reportInterval: finalConfig.behavior.reportInterval,
|
||||
samplingRate: finalConfig.behavior.samplingRate,
|
||||
trackPageViews: finalConfig.behavior.trackPageViews,
|
||||
trackClicks: finalConfig.behavior.trackClicks,
|
||||
trackScrolls: finalConfig.behavior.trackScrolls,
|
||||
trackForms: finalConfig.behavior.trackForms,
|
||||
});
|
||||
console.log('[Monitoring] Behavior analytics initialized');
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[Monitoring] All monitoring services initialized');
|
||||
}
|
||||
|
||||
export function destroyMonitoring(): void {
|
||||
if (!isInitialized) return;
|
||||
|
||||
performanceMonitor.destroy();
|
||||
errorMonitor.destroy();
|
||||
behaviorAnalytics.destroy();
|
||||
|
||||
isInitialized = false;
|
||||
console.log('[Monitoring] All monitoring services destroyed');
|
||||
}
|
||||
|
||||
export function getMonitoringStatus(): {
|
||||
initialized: boolean;
|
||||
performance: boolean;
|
||||
error: boolean;
|
||||
behavior: boolean;
|
||||
} {
|
||||
return {
|
||||
initialized: isInitialized,
|
||||
performance: (performanceMonitor as any).isInitialized || false,
|
||||
error: (errorMonitor as any).isInitialized || false,
|
||||
behavior: (behaviorAnalytics as any).isInitialized || false,
|
||||
};
|
||||
}
|
||||
|
||||
export { performanceMonitor, errorMonitor, behaviorAnalytics };
|
||||
|
||||
export default {
|
||||
init: initMonitoring,
|
||||
destroy: destroyMonitoring,
|
||||
getStatus: getMonitoringStatus,
|
||||
performanceMonitor,
|
||||
errorMonitor,
|
||||
behaviorAnalytics,
|
||||
};
|
||||
215
dashboard/src/services/notificationService.ts
Normal file
215
dashboard/src/services/notificationService.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 通知服务
|
||||
* 支持多渠道通知发送(EMAIL、SMS、WEBHOOK)
|
||||
*/
|
||||
|
||||
import { notificationStorage } from './notificationStorage';
|
||||
|
||||
export type NotificationChannel = 'EMAIL' | 'SMS' | 'WEBHOOK';
|
||||
|
||||
export interface NotificationOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
channels?: NotificationChannel[];
|
||||
recipient?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface NotificationResponse {
|
||||
success: boolean;
|
||||
channel: NotificationChannel;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
/**
|
||||
* 发送通知
|
||||
* @param options 通知选项
|
||||
* @returns 通知发送结果
|
||||
*/
|
||||
async sendNotification(options: NotificationOptions): Promise<NotificationResponse[]> {
|
||||
const { title, message, channels = ['EMAIL'], recipient, data } = options;
|
||||
|
||||
const results: NotificationResponse[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
let result: NotificationResponse;
|
||||
|
||||
switch (channel) {
|
||||
case 'EMAIL':
|
||||
result = await this.sendEmail(title, message, recipient, data);
|
||||
break;
|
||||
case 'SMS':
|
||||
result = await this.sendSMS(title, message, recipient, data);
|
||||
break;
|
||||
case 'WEBHOOK':
|
||||
result = await this.sendWebhook(title, message, data);
|
||||
break;
|
||||
default:
|
||||
result = {
|
||||
success: false,
|
||||
channel,
|
||||
error: '不支持的通知渠道',
|
||||
};
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
|
||||
// 保存通知到本地存储
|
||||
if (result.success) {
|
||||
notificationStorage.saveNotification({
|
||||
title,
|
||||
message,
|
||||
type: 'info',
|
||||
channel,
|
||||
data,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
success: false,
|
||||
channel,
|
||||
error: error instanceof Error ? error.message : '发送失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件通知
|
||||
* @param title 标题
|
||||
* @param message 消息内容
|
||||
* @param recipient 收件人
|
||||
* @param data 附加数据
|
||||
* @returns 发送结果
|
||||
*/
|
||||
private async sendEmail(
|
||||
title: string,
|
||||
message: string,
|
||||
recipient?: string,
|
||||
data?: Record<string, any>
|
||||
): Promise<NotificationResponse> {
|
||||
// 模拟邮件发送
|
||||
console.log('发送邮件通知:', {
|
||||
title,
|
||||
message,
|
||||
recipient,
|
||||
data,
|
||||
});
|
||||
|
||||
// 实际项目中这里应该调用邮件服务API
|
||||
return {
|
||||
success: true,
|
||||
channel: 'EMAIL',
|
||||
message: '邮件发送成功',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信通知
|
||||
* @param title 标题
|
||||
* @param message 消息内容
|
||||
* @param recipient 收件人
|
||||
* @param data 附加数据
|
||||
* @returns 发送结果
|
||||
*/
|
||||
private async sendSMS(
|
||||
title: string,
|
||||
message: string,
|
||||
recipient?: string,
|
||||
data?: Record<string, any>
|
||||
): Promise<NotificationResponse> {
|
||||
// 模拟短信发送
|
||||
console.log('发送短信通知:', {
|
||||
title,
|
||||
message,
|
||||
recipient,
|
||||
data,
|
||||
});
|
||||
|
||||
// 实际项目中这里应该调用短信服务API
|
||||
return {
|
||||
success: true,
|
||||
channel: 'SMS',
|
||||
message: '短信发送成功',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送Webhook通知
|
||||
* @param title 标题
|
||||
* @param message 消息内容
|
||||
* @param data 附加数据
|
||||
* @returns 发送结果
|
||||
*/
|
||||
private async sendWebhook(
|
||||
title: string,
|
||||
message: string,
|
||||
data?: Record<string, any>
|
||||
): Promise<NotificationResponse> {
|
||||
// 模拟Webhook发送
|
||||
console.log('发送Webhook通知:', {
|
||||
title,
|
||||
message,
|
||||
data,
|
||||
});
|
||||
|
||||
// 实际项目中这里应该调用Webhook服务API
|
||||
return {
|
||||
success: true,
|
||||
channel: 'WEBHOOK',
|
||||
message: 'Webhook发送成功',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通知历史
|
||||
* @param limit 限制数量
|
||||
* @returns 通知历史列表
|
||||
*/
|
||||
async getNotificationHistory(limit: number = 50) {
|
||||
// 从本地存储获取通知历史
|
||||
console.log('获取通知历史:', { limit });
|
||||
|
||||
const notifications = notificationStorage.getNotifications();
|
||||
return notifications.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记通知为已读
|
||||
* @param notificationId 通知ID
|
||||
* @returns 操作结果
|
||||
*/
|
||||
async markAsRead(notificationId: string) {
|
||||
// 使用存储服务标记为已读
|
||||
console.log('标记通知为已读:', { notificationId });
|
||||
|
||||
const success = notificationStorage.markAsRead(notificationId);
|
||||
return {
|
||||
success,
|
||||
message: success ? '标记成功' : '标记失败',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除通知
|
||||
* @param notificationId 通知ID
|
||||
* @returns 操作结果
|
||||
*/
|
||||
async deleteNotification(notificationId: string) {
|
||||
// 使用存储服务删除通知
|
||||
console.log('删除通知:', { notificationId });
|
||||
|
||||
const success = notificationStorage.deleteNotification(notificationId);
|
||||
return {
|
||||
success,
|
||||
message: success ? '删除成功' : '删除失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
165
dashboard/src/services/notificationStorage.ts
Normal file
165
dashboard/src/services/notificationStorage.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 通知存储服务
|
||||
* 提供通知的本地存储和持久化功能
|
||||
*/
|
||||
|
||||
interface StoredNotification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
channel?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
class NotificationStorage {
|
||||
private readonly STORAGE_KEY = 'makemd_notifications';
|
||||
|
||||
/**
|
||||
* 保存通知到本地存储
|
||||
* @param notification 通知对象
|
||||
* @returns 保存的通知
|
||||
*/
|
||||
saveNotification(notification: Omit<StoredNotification, 'id' | 'createdAt' | 'read'>): StoredNotification {
|
||||
const notifications = this.getNotifications();
|
||||
const newNotification: StoredNotification = {
|
||||
...notification,
|
||||
id: `notification_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
};
|
||||
|
||||
notifications.unshift(newNotification);
|
||||
this.setNotifications(notifications);
|
||||
|
||||
return newNotification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有通知
|
||||
* @returns 通知列表
|
||||
*/
|
||||
getNotifications(): StoredNotification[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('读取通知存储失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读通知数量
|
||||
* @returns 未读通知数量
|
||||
*/
|
||||
getUnreadCount(): number {
|
||||
const notifications = this.getNotifications();
|
||||
return notifications.filter(n => !n.read).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记通知为已读
|
||||
* @param notificationId 通知ID
|
||||
* @returns 是否标记成功
|
||||
*/
|
||||
markAsRead(notificationId: string): boolean {
|
||||
try {
|
||||
const notifications = this.getNotifications();
|
||||
const updated = notifications.map(n =>
|
||||
n.id === notificationId ? { ...n, read: true } : n
|
||||
);
|
||||
this.setNotifications(updated);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('标记通知已读失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记所有通知为已读
|
||||
* @returns 是否标记成功
|
||||
*/
|
||||
markAllAsRead(): boolean {
|
||||
try {
|
||||
const notifications = this.getNotifications();
|
||||
const updated = notifications.map(n => ({ ...n, read: true }));
|
||||
this.setNotifications(updated);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('标记所有通知已读失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除通知
|
||||
* @param notificationId 通知ID
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
deleteNotification(notificationId: string): boolean {
|
||||
try {
|
||||
const notifications = this.getNotifications();
|
||||
const updated = notifications.filter(n => n.id !== notificationId);
|
||||
this.setNotifications(updated);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除通知失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有通知
|
||||
* @returns 是否清空成功
|
||||
*/
|
||||
clearAllNotifications(): boolean {
|
||||
try {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('清空通知失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存通知列表到本地存储
|
||||
* @param notifications 通知列表
|
||||
*/
|
||||
private setNotifications(notifications: StoredNotification[]): void {
|
||||
try {
|
||||
// 限制存储的通知数量,最多保存100条
|
||||
const limited = notifications.slice(0, 100);
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(limited));
|
||||
} catch (error) {
|
||||
console.error('保存通知存储失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期通知
|
||||
* @param days 保留天数
|
||||
*/
|
||||
cleanupOldNotifications(days: number = 30): void {
|
||||
try {
|
||||
const notifications = this.getNotifications();
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
|
||||
const updated = notifications.filter(n => {
|
||||
const notificationDate = new Date(n.createdAt);
|
||||
return notificationDate >= cutoffDate;
|
||||
});
|
||||
|
||||
this.setNotifications(updated);
|
||||
} catch (error) {
|
||||
console.error('清理过期通知失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationStorage = new NotificationStorage();
|
||||
333
dashboard/src/services/performanceMonitorService.ts
Normal file
333
dashboard/src/services/performanceMonitorService.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* [FE-MON001] 前端性能监控服务
|
||||
* @description 收集和上报 Web Vitals 性能指标
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
import { http } from './http';
|
||||
|
||||
export interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: 'good' | 'needs-improvement' | 'poor';
|
||||
delta: number;
|
||||
id: string;
|
||||
navigationType: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface WebVitalsReport {
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
userId: string;
|
||||
url: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
metrics: PerformanceMetric[];
|
||||
connection?: {
|
||||
effectiveType: string;
|
||||
downlink: number;
|
||||
rtt: number;
|
||||
};
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
totalJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
type MetricRatingThresholds = {
|
||||
good: number;
|
||||
poor: number;
|
||||
};
|
||||
|
||||
const METRIC_THRESHOLDS: Record<string, MetricRatingThresholds> = {
|
||||
LCP: { good: 2500, poor: 4000 },
|
||||
FID: { good: 100, poor: 300 },
|
||||
CLS: { good: 0.1, poor: 0.25 },
|
||||
FCP: { good: 1800, poor: 3000 },
|
||||
TTFB: { good: 800, poor: 1800 },
|
||||
INP: { good: 200, poor: 500 },
|
||||
};
|
||||
|
||||
const getRating = (name: string, value: number): 'good' | 'needs-improvement' | 'poor' => {
|
||||
const thresholds = METRIC_THRESHOLDS[name];
|
||||
if (!thresholds) return 'good';
|
||||
|
||||
if (value <= thresholds.good) return 'good';
|
||||
if (value <= thresholds.poor) return 'needs-improvement';
|
||||
return 'poor';
|
||||
};
|
||||
|
||||
class PerformanceMonitorService {
|
||||
private metrics: Map<string, PerformanceMetric> = new Map();
|
||||
private observers: PerformanceObserver[] = [];
|
||||
private reportQueue: PerformanceMetric[] = [];
|
||||
private isInitialized = false;
|
||||
private reportInterval: NodeJS.Timeout | null = null;
|
||||
private sessionId: string;
|
||||
|
||||
constructor() {
|
||||
this.sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
if (this.isInitialized) return;
|
||||
this.isInitialized = true;
|
||||
|
||||
this.observePerformance();
|
||||
this.collectWebVitals();
|
||||
this.startPeriodicReport();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.flushReports();
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.flushReports();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[PerformanceMonitor] Initialized');
|
||||
}
|
||||
|
||||
private observePerformance(): void {
|
||||
if (typeof PerformanceObserver === 'undefined') return;
|
||||
|
||||
const observeTypes = [
|
||||
'largest-contentful-paint',
|
||||
'first-input',
|
||||
'layout-shift',
|
||||
'paint',
|
||||
'navigation',
|
||||
'longtask',
|
||||
];
|
||||
|
||||
observeTypes.forEach((type) => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
this.processEntry(entry);
|
||||
});
|
||||
});
|
||||
observer.observe({ type, buffered: true });
|
||||
this.observers.push(observer);
|
||||
} catch (e) {
|
||||
console.debug(`[PerformanceMonitor] Observer type ${type} not supported`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private processEntry(entry: PerformanceEntry): void {
|
||||
switch (entry.entryType) {
|
||||
case 'largest-contentful-paint':
|
||||
this.recordMetric('LCP', entry.startTime, entry.duration);
|
||||
break;
|
||||
case 'first-input':
|
||||
this.recordMetric('FID', (entry as PerformanceEventTiming).processingStart - entry.startTime, 0);
|
||||
break;
|
||||
case 'layout-shift':
|
||||
if (!(entry as any).hadRecentInput) {
|
||||
const clsValue = (entry as any).value || 0;
|
||||
this.recordMetric('CLS', clsValue * 1000, 0);
|
||||
}
|
||||
break;
|
||||
case 'paint':
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
this.recordMetric('FCP', entry.startTime, 0);
|
||||
}
|
||||
break;
|
||||
case 'navigation':
|
||||
this.processNavigationTiming(entry as PerformanceNavigationTiming);
|
||||
break;
|
||||
case 'longtask':
|
||||
this.recordMetric('LongTask', entry.duration, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private processNavigationTiming(entry: PerformanceNavigationTiming): void {
|
||||
this.recordMetric('TTFB', entry.responseStart - entry.requestStart, 0);
|
||||
this.recordMetric('DOMLoad', entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart, 0);
|
||||
this.recordMetric('WindowLoad', entry.loadEventEnd - entry.loadEventStart, 0);
|
||||
}
|
||||
|
||||
private collectWebVitals(): void {
|
||||
if (typeof window === 'undefined' || !('performance' in window)) return;
|
||||
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
if (navigation) {
|
||||
this.processNavigationTiming(navigation);
|
||||
}
|
||||
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
paintEntries.forEach((entry) => {
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
this.recordMetric('FCP', entry.startTime, 0);
|
||||
}
|
||||
});
|
||||
|
||||
const memory = (performance as any).memory;
|
||||
if (memory) {
|
||||
this.recordMetric('MemoryUsed', memory.usedJSHeapSize / 1048576, 0);
|
||||
this.recordMetric('MemoryLimit', memory.jsHeapSizeLimit / 1048576, 0);
|
||||
}
|
||||
}
|
||||
|
||||
recordMetric(name: string, value: number, delta: number): void {
|
||||
const metric: PerformanceMetric = {
|
||||
name,
|
||||
value: Math.round(value * 100) / 100,
|
||||
rating: getRating(name, value),
|
||||
delta,
|
||||
id: `${this.sessionId}-${name}`,
|
||||
navigationType: this.getNavigationType(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.metrics.set(name, metric);
|
||||
this.reportQueue.push(metric);
|
||||
}
|
||||
|
||||
private getNavigationType(): string {
|
||||
const entries = performance.getEntriesByType('navigation');
|
||||
if (entries.length > 0) {
|
||||
return (entries[0] as PerformanceNavigationTiming).type;
|
||||
}
|
||||
return 'navigate';
|
||||
}
|
||||
|
||||
private startPeriodicReport(): void {
|
||||
this.reportInterval = setInterval(() => {
|
||||
if (this.reportQueue.length > 0) {
|
||||
this.flushReports();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private async flushReports(): Promise<void> {
|
||||
if (this.reportQueue.length === 0) return;
|
||||
|
||||
const metrics = [...this.reportQueue];
|
||||
this.reportQueue = [];
|
||||
|
||||
const report = this.buildReport(metrics);
|
||||
await this.sendReport(report);
|
||||
}
|
||||
|
||||
private buildReport(metrics: PerformanceMetric[]): WebVitalsReport {
|
||||
const tenantId = localStorage.getItem('tenantId') || 'default';
|
||||
const shopId = localStorage.getItem('shopId') || 'default';
|
||||
const userId = localStorage.getItem('userId') || 'anonymous';
|
||||
|
||||
const report: WebVitalsReport = {
|
||||
tenantId,
|
||||
shopId,
|
||||
userId,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
metrics,
|
||||
};
|
||||
|
||||
const connection = (navigator as any).connection;
|
||||
if (connection) {
|
||||
report.connection = {
|
||||
effectiveType: connection.effectiveType,
|
||||
downlink: connection.downlink,
|
||||
rtt: connection.rtt,
|
||||
};
|
||||
}
|
||||
|
||||
const memory = (performance as any).memory;
|
||||
if (memory) {
|
||||
report.memory = {
|
||||
usedJSHeapSize: memory.usedJSHeapSize,
|
||||
totalJSHeapSize: memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: memory.jsHeapSizeLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
private async sendReport(report: WebVitalsReport): Promise<void> {
|
||||
try {
|
||||
await http.post('/api/telemetry/performance', report);
|
||||
console.log('[PerformanceMonitor] Report sent successfully');
|
||||
} catch (error) {
|
||||
console.error('[PerformanceMonitor] Failed to send report:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getMetrics(): Map<string, PerformanceMetric> {
|
||||
return new Map(this.metrics);
|
||||
}
|
||||
|
||||
getMetric(name: string): PerformanceMetric | undefined {
|
||||
return this.metrics.get(name);
|
||||
}
|
||||
|
||||
getSummary(): Record<string, { value: number; rating: string }> {
|
||||
const summary: Record<string, { value: number; rating: string }> = {};
|
||||
this.metrics.forEach((metric, name) => {
|
||||
summary[name] = {
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
};
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
markMeasure(name: string): void {
|
||||
try {
|
||||
performance.mark(`${name}-start`);
|
||||
} catch (e) {
|
||||
console.debug(`[PerformanceMonitor] Mark failed: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
endMeasure(name: string): number | null {
|
||||
try {
|
||||
performance.mark(`${name}-end`);
|
||||
performance.measure(name, `${name}-start`, `${name}-end`);
|
||||
const entries = performance.getEntriesByName(name, 'measure');
|
||||
const duration = entries[entries.length - 1]?.duration;
|
||||
|
||||
if (duration) {
|
||||
this.recordMetric(`Custom_${name}`, duration, 0);
|
||||
}
|
||||
|
||||
performance.clearMarks(`${name}-start`);
|
||||
performance.clearMarks(`${name}-end`);
|
||||
performance.clearMeasures(name);
|
||||
|
||||
return duration || null;
|
||||
} catch (e) {
|
||||
console.debug(`[PerformanceMonitor] Measure failed: ${name}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.observers.forEach((observer) => observer.disconnect());
|
||||
this.observers = [];
|
||||
|
||||
if (this.reportInterval) {
|
||||
clearInterval(this.reportInterval);
|
||||
this.reportInterval = null;
|
||||
}
|
||||
|
||||
this.flushReports();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitorService();
|
||||
|
||||
export const initPerformanceMonitor = (): void => {
|
||||
performanceMonitor.init();
|
||||
};
|
||||
|
||||
export default performanceMonitor;
|
||||
134
dashboard/src/services/shopDataSource.ts
Normal file
134
dashboard/src/services/shopDataSource.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { http } from './http';
|
||||
import { DataSourceFactory } from './baseDataSource';
|
||||
|
||||
export interface Shop {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
platformAccountId?: string;
|
||||
departmentId?: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'ERROR';
|
||||
config?: Record<string, any>;
|
||||
lastSyncAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShopMember {
|
||||
id: string;
|
||||
shopId: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
role: 'owner' | 'admin' | 'operator' | 'viewer';
|
||||
permissions: string[];
|
||||
assignedBy: string;
|
||||
assignedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShopStats {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
expired: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface ShopMemberCreate {
|
||||
shopId: string;
|
||||
userId: string;
|
||||
role: 'owner' | 'admin' | 'operator' | 'viewer';
|
||||
permissions: string[];
|
||||
assignedBy: string;
|
||||
}
|
||||
|
||||
export interface ShopUpdate {
|
||||
name?: string;
|
||||
platformAccountId?: string;
|
||||
departmentId?: string;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ShopDataSource {
|
||||
getMyShops(): Promise<Shop[]>;
|
||||
getById(id: string): Promise<Shop | null>;
|
||||
getShopMembers(shopId: string): Promise<ShopMember[]>;
|
||||
getUserShops(userId: string): Promise<ShopMember[]>;
|
||||
createShop(data: { name: string; platform: string; platformAccountId?: string; departmentId?: string; config?: Record<string, any> }): Promise<Shop>;
|
||||
updateShop(id: string, data: ShopUpdate): Promise<Shop>;
|
||||
refreshAuth(id: string): Promise<{ success: boolean; message: string }>;
|
||||
deleteShop(id: string): Promise<void>;
|
||||
addMember(data: ShopMemberCreate): Promise<ShopMember>;
|
||||
removeMember(shopId: string, userId: string): Promise<void>;
|
||||
updateMemberRole(shopId: string, userId: string, role: 'owner' | 'admin' | 'operator' | 'viewer', permissions: string[]): Promise<ShopMember>;
|
||||
getStats(): Promise<ShopStats>;
|
||||
}
|
||||
|
||||
export class ShopApiDataSource implements ShopDataSource {
|
||||
async getMyShops(): Promise<Shop[]> {
|
||||
const response = await http.get('/api/shops/my-shops');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Shop | null> {
|
||||
const response = await http.get(`/api/shops/${id}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getShopMembers(shopId: string): Promise<ShopMember[]> {
|
||||
const response = await http.get(`/api/shops/${shopId}/members`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getUserShops(userId: string): Promise<ShopMember[]> {
|
||||
const response = await http.get(`/api/shops/user/${userId}/shops`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async createShop(data: { name: string; platform: string; platformAccountId?: string; departmentId?: string; config?: Record<string, any> }): Promise<Shop> {
|
||||
const response = await http.post('/api/shops', data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async updateShop(id: string, data: ShopUpdate): Promise<Shop> {
|
||||
const response = await http.put(`/api/shops/${id}`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async refreshAuth(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await http.post(`/api/shops/${id}/refresh-auth`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async deleteShop(id: string): Promise<void> {
|
||||
await http.delete(`/api/shops/${id}`);
|
||||
}
|
||||
|
||||
async addMember(data: ShopMemberCreate): Promise<ShopMember> {
|
||||
const response = await http.post('/api/shops/members', data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async removeMember(shopId: string, userId: string): Promise<void> {
|
||||
await http.delete(`/api/shops/members/${shopId}/${userId}`);
|
||||
}
|
||||
|
||||
async updateMemberRole(shopId: string, userId: string, role: 'owner' | 'admin' | 'operator' | 'viewer', permissions: string[]): Promise<ShopMember> {
|
||||
const response = await http.put(`/api/shops/members/${shopId}/${userId}`, { role, permissions });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getStats(): Promise<ShopStats> {
|
||||
const response = await http.get('/api/shops/stats');
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 直接创建实例
|
||||
export const shopDataSource = new ShopApiDataSource();
|
||||
|
||||
// 注册到 DataSourceFactory
|
||||
DataSourceFactory.register('shop', shopDataSource);
|
||||
368
dashboard/src/services/systemStatusDataSource.ts
Normal file
368
dashboard/src/services/systemStatusDataSource.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* [FE-MON004] 系统状态监控数据源
|
||||
* @description 获取系统健康状态、性能指标、服务状态
|
||||
* @version 1.0
|
||||
*/
|
||||
|
||||
import { http } from './http';
|
||||
|
||||
export interface SystemHealthStatus {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
timestamp: string;
|
||||
components: Record<string, ComponentHealth>;
|
||||
metrics: SystemMetrics;
|
||||
}
|
||||
|
||||
export interface ComponentHealth {
|
||||
status: 'up' | 'down' | 'degraded';
|
||||
responseTime: number;
|
||||
lastCheck: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SystemMetrics {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
uptime: number;
|
||||
connections: number;
|
||||
requestRate: number;
|
||||
errorRate: number;
|
||||
avgResponseTime: number;
|
||||
}
|
||||
|
||||
export interface ServiceStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'running' | 'stopped' | 'error' | 'pending';
|
||||
uptime: number;
|
||||
lastHeartbeat: string;
|
||||
version: string;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
port?: number;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export interface AlertInfo {
|
||||
id: string;
|
||||
type: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
source: string;
|
||||
timestamp: string;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export interface PerformanceDataPoint {
|
||||
timestamp: string;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
requestRate: number;
|
||||
responseTime: number;
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface MonitoringDashboardData {
|
||||
health: SystemHealthStatus;
|
||||
services: ServiceStatus[];
|
||||
alerts: AlertInfo[];
|
||||
performanceHistory: PerformanceDataPoint[];
|
||||
activeConnections: number;
|
||||
queueSize: number;
|
||||
cacheHitRate: number;
|
||||
}
|
||||
|
||||
class SystemStatusDataSource {
|
||||
private baseUrl = '/api/system';
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners: Set<(data: MonitoringDashboardData) => void> = new Set();
|
||||
|
||||
async getHealthStatus(): Promise<SystemHealthStatus> {
|
||||
const response = await http.get(`${this.baseUrl}/health`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getServices(): Promise<ServiceStatus[]> {
|
||||
const response = await http.get(`${this.baseUrl}/services`);
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getAlerts(params?: {
|
||||
limit?: number;
|
||||
type?: AlertInfo['type'];
|
||||
acknowledged?: boolean;
|
||||
}): Promise<AlertInfo[]> {
|
||||
const response = await http.get(`${this.baseUrl}/alerts`, { params });
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getPerformanceHistory(params?: {
|
||||
start?: string;
|
||||
end?: string;
|
||||
interval?: string;
|
||||
}): Promise<PerformanceDataPoint[]> {
|
||||
const response = await http.get(`${this.baseUrl}/performance/history`, { params });
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async getDashboardData(): Promise<MonitoringDashboardData> {
|
||||
const [health, services, alerts, performanceHistory] = await Promise.all([
|
||||
this.getHealthStatus(),
|
||||
this.getServices(),
|
||||
this.getAlerts({ limit: 10 }),
|
||||
this.getPerformanceHistory(),
|
||||
]);
|
||||
|
||||
return {
|
||||
health,
|
||||
services,
|
||||
alerts,
|
||||
performanceHistory,
|
||||
activeConnections: (health.metrics as any).connections || 0,
|
||||
queueSize: (health.components.queue as any)?.details?.queueSize || 0,
|
||||
cacheHitRate: (health.components.cache as any)?.details?.hitRate || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async acknowledgeAlert(alertId: string): Promise<void> {
|
||||
await http.post(`${this.baseUrl}/alerts/${alertId}/acknowledge`);
|
||||
}
|
||||
|
||||
async restartService(serviceId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await http.post(`${this.baseUrl}/services/${serviceId}/restart`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
subscribeToUpdates(callback: (data: MonitoringDashboardData) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
|
||||
if (!this.ws && typeof WebSocket !== 'undefined') {
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
if (this.listeners.size === 0) {
|
||||
this.disconnectWebSocket();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private connectWebSocket(): void {
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${this.baseUrl}/ws`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as MonitoringDashboardData;
|
||||
this.listeners.forEach(callback => callback(data));
|
||||
} catch (error) {
|
||||
console.error('[SystemStatus] Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('[SystemStatus] WebSocket error:', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('[SystemStatus] WebSocket closed, reconnecting...');
|
||||
setTimeout(() => this.connectWebSocket(), 5000);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[SystemStatus] Failed to connect WebSocket:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private disconnectWebSocket(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockSystemStatusDataSource {
|
||||
private listeners: Set<(data: MonitoringDashboardData) => void> = new Set();
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
|
||||
async getHealthStatus(): Promise<SystemHealthStatus> {
|
||||
return {
|
||||
status: Math.random() > 0.1 ? 'healthy' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: {
|
||||
database: {
|
||||
status: 'up',
|
||||
responseTime: Math.random() * 50 + 10,
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
redis: {
|
||||
status: 'up',
|
||||
responseTime: Math.random() * 10 + 1,
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
eventBus: {
|
||||
status: 'up',
|
||||
responseTime: Math.random() * 20 + 5,
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
queue: {
|
||||
status: 'up',
|
||||
responseTime: Math.random() * 5 + 1,
|
||||
lastCheck: new Date().toISOString(),
|
||||
details: { queueSize: Math.floor(Math.random() * 100) },
|
||||
},
|
||||
websocket: {
|
||||
status: 'up',
|
||||
responseTime: Math.random() * 10 + 2,
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
api: {
|
||||
status: 'up',
|
||||
responseTime: Math.random() * 30 + 10,
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
cpu: Math.random() * 60 + 20,
|
||||
memory: Math.random() * 50 + 30,
|
||||
disk: Math.random() * 40 + 40,
|
||||
uptime: Date.now() - 86400000 * 7,
|
||||
connections: Math.floor(Math.random() * 100) + 50,
|
||||
requestRate: Math.random() * 100 + 50,
|
||||
errorRate: Math.random() * 2,
|
||||
avgResponseTime: Math.random() * 200 + 50,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getServices(): Promise<ServiceStatus[]> {
|
||||
return [
|
||||
{
|
||||
id: 'api-server',
|
||||
name: 'API Server',
|
||||
status: 'running',
|
||||
uptime: 604800,
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
cpu: Math.random() * 40 + 10,
|
||||
memory: Math.random() * 30 + 20,
|
||||
port: 3001,
|
||||
pid: 12345,
|
||||
},
|
||||
{
|
||||
id: 'worker-1',
|
||||
name: 'Background Worker',
|
||||
status: 'running',
|
||||
uptime: 604800,
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
cpu: Math.random() * 30 + 5,
|
||||
memory: Math.random() * 25 + 15,
|
||||
},
|
||||
{
|
||||
id: 'scheduler',
|
||||
name: 'Task Scheduler',
|
||||
status: 'running',
|
||||
uptime: 604800,
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
cpu: Math.random() * 10 + 2,
|
||||
memory: Math.random() * 10 + 5,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async getAlerts(): Promise<AlertInfo[]> {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
message: 'High memory usage detected on API Server',
|
||||
source: 'api-server',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
acknowledged: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
message: 'Scheduled maintenance completed',
|
||||
source: 'system',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
acknowledged: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async getPerformanceHistory(): Promise<PerformanceDataPoint[]> {
|
||||
const points: PerformanceDataPoint[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 24; i >= 0; i--) {
|
||||
points.push({
|
||||
timestamp: new Date(now - i * 3600000).toISOString(),
|
||||
cpu: Math.random() * 60 + 20,
|
||||
memory: Math.random() * 50 + 30,
|
||||
requestRate: Math.random() * 100 + 50,
|
||||
responseTime: Math.random() * 200 + 50,
|
||||
errorRate: Math.random() * 2,
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
async getDashboardData(): Promise<MonitoringDashboardData> {
|
||||
const [health, services, alerts, performanceHistory] = await Promise.all([
|
||||
this.getHealthStatus(),
|
||||
this.getServices(),
|
||||
this.getAlerts(),
|
||||
this.getPerformanceHistory(),
|
||||
]);
|
||||
|
||||
return {
|
||||
health,
|
||||
services,
|
||||
alerts,
|
||||
performanceHistory,
|
||||
activeConnections: health.metrics.connections,
|
||||
queueSize: (health.components.queue.details?.queueSize as number) || 0,
|
||||
cacheHitRate: 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
async acknowledgeAlert(): Promise<void> {}
|
||||
|
||||
async restartService(): Promise<{ success: boolean; message: string }> {
|
||||
return { success: true, message: 'Service restarted' };
|
||||
}
|
||||
|
||||
subscribeToUpdates(callback: (data: MonitoringDashboardData) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
|
||||
if (!this.interval) {
|
||||
this.interval = setInterval(() => {
|
||||
this.getDashboardData().then(data => {
|
||||
this.listeners.forEach(cb => cb(data));
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
if (this.listeners.size === 0 && this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const useMock = process.env.NODE_ENV === 'development' || process.env.REACT_APP_USE_MOCK === 'true';
|
||||
export const systemStatusDataSource = useMock
|
||||
? new MockSystemStatusDataSource()
|
||||
: new SystemStatusDataSource();
|
||||
|
||||
export default systemStatusDataSource;
|
||||
Reference in New Issue
Block a user