import * as plugins from '../plugins.js'; import { changeStreamService, type IActivityEvent, type IMongoChangeEvent, type IS3ChangeEvent } from '../services/index.js'; import { themeStyles } from '../styles/index.js'; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; type TFilterMode = 'all' | 'mongodb' | 's3'; @customElement('tsview-activity-stream') export class TsviewActivityStream extends DeesElement { @state() private accessor events: IActivityEvent[] = []; @state() private accessor filterMode: TFilterMode = 'all'; @state() private accessor isConnected: boolean = false; @state() private accessor isLoading: boolean = true; @state() private accessor autoScroll: boolean = true; private subscription: plugins.smartrx.rxjs.Subscription | null = null; private connectionSubscription: plugins.smartrx.rxjs.Subscription | null = null; public static styles = [ cssManager.defaultStyles, themeStyles, css` :host { display: block; height: 100%; overflow: hidden; } .activity-container { display: flex; flex-direction: column; height: 100%; } .header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid #333; } .header-title { font-size: 18px; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 12px; } .connection-status { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 400; } .status-dot { width: 8px; height: 8px; border-radius: 50%; } .status-dot.connected { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); } .status-dot.disconnected { background: #ef4444; } .status-dot.connecting { background: #f59e0b; animation: pulse 1s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .header-controls { display: flex; align-items: center; gap: 12px; } .filter-tabs { display: flex; gap: 4px; } .filter-tab { padding: 6px 12px; background: transparent; border: 1px solid #444; color: #888; border-radius: 4px; cursor: pointer; font-size: 12px; transition: all 0.2s; } .filter-tab:hover { border-color: #666; color: #aaa; } .filter-tab.active { background: rgba(255, 255, 255, 0.1); border-color: #666; color: #e0e0e0; } .auto-scroll-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #888; cursor: pointer; } .auto-scroll-toggle input { cursor: pointer; } .events-list { flex: 1; overflow-y: auto; padding: 12px; } .event-item { display: flex; gap: 12px; padding: 12px; margin-bottom: 8px; background: rgba(0, 0, 0, 0.2); border-radius: 8px; cursor: pointer; transition: background 0.1s; } .event-item:hover { background: rgba(255, 255, 255, 0.05); } .event-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .event-icon.mongodb { background: rgba(16, 185, 129, 0.2); color: #10b981; } .event-icon.s3 { background: rgba(245, 158, 11, 0.2); color: #f59e0b; } .event-icon svg { width: 18px; height: 18px; } .event-content { flex: 1; min-width: 0; } .event-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; } .event-title { font-size: 13px; font-weight: 500; color: #e0e0e0; } .event-time { font-size: 11px; color: #666; } .event-details { font-size: 12px; color: #888; font-family: monospace; } .event-type { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; text-transform: uppercase; margin-right: 8px; } .event-type.insert, .event-type.add { background: rgba(34, 197, 94, 0.2); color: #4ade80; } .event-type.update, .event-type.modify { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } .event-type.delete { background: rgba(239, 68, 68, 0.2); color: #f87171; } .event-type.replace { background: rgba(168, 85, 247, 0.2); color: #c084fc; } .event-type.drop, .event-type.invalidate { background: rgba(239, 68, 68, 0.3); color: #f87171; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #666; gap: 16px; } .empty-state svg { width: 64px; height: 64px; opacity: 0.5; } .empty-state p { font-size: 14px; } .loading-state { display: flex; align-items: center; justify-content: center; height: 100%; color: #888; } .event-path { color: #aaa; word-break: break-all; } `, ]; async connectedCallback() { super.connectedCallback(); await this.initializeStreaming(); } disconnectedCallback() { super.disconnectedCallback(); this.cleanup(); } private async initializeStreaming() { this.isLoading = true; try { // Connect to WebSocket if not connected await changeStreamService.connect(); // Subscribe to connection status this.connectionSubscription = changeStreamService.connectionStatus$.subscribe((status) => { this.isConnected = status === 'connected'; }); // Subscribe to activity stream await changeStreamService.subscribeToActivity(); // Load recent events const recentEvents = await changeStreamService.getRecentActivity(100); this.events = recentEvents; // Subscribe to new events this.subscription = changeStreamService.getActivityStream().subscribe((event) => { this.events = [...this.events, event].slice(-500); // Keep last 500 events // Auto-scroll if enabled if (this.autoScroll) { this.scrollToBottom(); } }); this.isConnected = true; } catch (error) { console.error('Failed to initialize activity stream:', error); this.isConnected = false; } this.isLoading = false; } private cleanup() { if (this.subscription) { this.subscription.unsubscribe(); this.subscription = null; } if (this.connectionSubscription) { this.connectionSubscription.unsubscribe(); this.connectionSubscription = null; } changeStreamService.unsubscribeFromActivity(); } private scrollToBottom() { requestAnimationFrame(() => { const list = this.shadowRoot?.querySelector('.events-list'); if (list) { list.scrollTop = list.scrollHeight; } }); } private setFilterMode(mode: TFilterMode) { this.filterMode = mode; } private toggleAutoScroll() { this.autoScroll = !this.autoScroll; } private get filteredEvents(): IActivityEvent[] { if (this.filterMode === 'all') { return this.events; } return this.events.filter((e) => e.source === this.filterMode); } private formatTime(timestamp: string): string { const date = new Date(timestamp); return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', }); } private formatRelativeTime(timestamp: string): string { const date = new Date(timestamp); const now = new Date(); const diff = now.getTime() - date.getTime(); if (diff < 60000) { return 'just now'; } else if (diff < 3600000) { const mins = Math.floor(diff / 60000); return `${mins}m ago`; } else if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours}h ago`; } else { return date.toLocaleDateString(); } } private getEventTitle(event: IActivityEvent): string { if (event.source === 'mongodb') { const mongoEvent = event.event as IMongoChangeEvent; return `${mongoEvent.database}.${mongoEvent.collection}`; } else { const s3Event = event.event as IS3ChangeEvent; return s3Event.bucket; } } private getEventDetails(event: IActivityEvent): string { if (event.source === 'mongodb') { const mongoEvent = event.event as IMongoChangeEvent; if (mongoEvent.documentId) { return `Document: ${mongoEvent.documentId}`; } return ''; } else { const s3Event = event.event as IS3ChangeEvent; return s3Event.key; } } private getEventType(event: IActivityEvent): string { return event.event.type; } private handleEventClick(event: IActivityEvent) { // Dispatch navigation event if (event.source === 'mongodb') { const mongoEvent = event.event as IMongoChangeEvent; this.dispatchEvent( new CustomEvent('navigate-to-mongo', { detail: { database: mongoEvent.database, collection: mongoEvent.collection, documentId: mongoEvent.documentId, }, bubbles: true, composed: true, }) ); } else { const s3Event = event.event as IS3ChangeEvent; this.dispatchEvent( new CustomEvent('navigate-to-s3', { detail: { bucket: s3Event.bucket, key: s3Event.key, }, bubbles: true, composed: true, }) ); } } private renderMongoIcon() { return html` `; } private renderS3Icon() { return html` `; } private getConnectionStatusText(): string { if (this.isConnected) { return 'Live'; } return 'Disconnected'; } render() { return html`
No activity yet. Changes will appear here in real-time.