feat(streaming): add global activity watchers, client-side buffering, and improved real-time streaming UX
This commit is contained in:
@@ -23,8 +23,12 @@ export class TsviewActivityStream extends DeesElement {
|
||||
@state()
|
||||
private accessor autoScroll: boolean = true;
|
||||
|
||||
@state()
|
||||
private accessor now: number = Date.now();
|
||||
|
||||
private subscription: plugins.smartrx.rxjs.Subscription | null = null;
|
||||
private connectionSubscription: plugins.smartrx.rxjs.Subscription | null = null;
|
||||
private nowInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
@@ -159,6 +163,15 @@ export class TsviewActivityStream extends DeesElement {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.event-time-col {
|
||||
width: 70px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -192,7 +205,6 @@ export class TsviewActivityStream extends DeesElement {
|
||||
.event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -202,11 +214,6 @@ export class TsviewActivityStream extends DeesElement {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.event-details {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
@@ -285,50 +292,66 @@ export class TsviewActivityStream extends DeesElement {
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.nowInterval = setInterval(() => {
|
||||
this.now = Date.now();
|
||||
}, 1000);
|
||||
await this.initializeStreaming();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.nowInterval) {
|
||||
clearInterval(this.nowInterval);
|
||||
this.nowInterval = null;
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private async initializeStreaming() {
|
||||
this.isLoading = true;
|
||||
|
||||
// Subscribe to connection status and trigger re-subscription on reconnect
|
||||
this.connectionSubscription = changeStreamService.connectionStatus$.subscribe(async (status) => {
|
||||
const wasConnected = this.isConnected;
|
||||
this.isConnected = status === 'connected';
|
||||
if (status === 'connected' && !wasConnected) {
|
||||
await this.setupSubscriptions();
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
// setupSubscriptions() is triggered by connectionStatus$ subscriber above
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize activity stream:', error);
|
||||
this.isConnected = false;
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async setupSubscriptions() {
|
||||
// Read buffered events (captured while on other tabs by app-level subscription)
|
||||
const buffered = changeStreamService.getBufferedActivity();
|
||||
if (buffered.length > 0) {
|
||||
this.events = buffered;
|
||||
} else {
|
||||
// Buffer empty (fresh start) — fetch from server
|
||||
const recentEvents = await changeStreamService.getRecentActivity(100);
|
||||
if (recentEvents.length > 0) {
|
||||
this.events = recentEvents;
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
// Set up RxJS listener only once for new live events
|
||||
if (!this.subscription) {
|
||||
this.subscription = changeStreamService.getActivityStream().subscribe((event) => {
|
||||
this.events = [...this.events, event].slice(-500);
|
||||
if (this.autoScroll) {
|
||||
this.scrollToTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
@@ -340,14 +363,15 @@ export class TsviewActivityStream extends DeesElement {
|
||||
this.connectionSubscription.unsubscribe();
|
||||
this.connectionSubscription = null;
|
||||
}
|
||||
changeStreamService.unsubscribeFromActivity();
|
||||
// DO NOT call changeStreamService.unsubscribeFromActivity()
|
||||
// The app-level subscription keeps the server sending events for buffering
|
||||
}
|
||||
|
||||
private scrollToBottom() {
|
||||
private scrollToTop() {
|
||||
requestAnimationFrame(() => {
|
||||
const list = this.shadowRoot?.querySelector('.events-list');
|
||||
if (list) {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
list.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -361,10 +385,11 @@ export class TsviewActivityStream extends DeesElement {
|
||||
}
|
||||
|
||||
private get filteredEvents(): IActivityEvent[] {
|
||||
if (this.filterMode === 'all') {
|
||||
return this.events;
|
||||
let events = this.events;
|
||||
if (this.filterMode !== 'all') {
|
||||
events = events.filter((e) => e.source === this.filterMode);
|
||||
}
|
||||
return this.events.filter((e) => e.source === this.filterMode);
|
||||
return events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
}
|
||||
|
||||
private formatTime(timestamp: string): string {
|
||||
@@ -378,11 +403,13 @@ export class TsviewActivityStream extends DeesElement {
|
||||
|
||||
private formatRelativeTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const diff = this.now - date.getTime();
|
||||
|
||||
if (diff < 60000) {
|
||||
return 'just now';
|
||||
if (diff < 1000) {
|
||||
return 'now';
|
||||
} else if (diff < 60000) {
|
||||
const secs = Math.floor(diff / 1000);
|
||||
return `${secs}s ago`;
|
||||
} else if (diff < 3600000) {
|
||||
const mins = Math.floor(diff / 60000);
|
||||
return `${mins}m ago`;
|
||||
@@ -534,6 +561,9 @@ export class TsviewActivityStream extends DeesElement {
|
||||
: this.filteredEvents.map(
|
||||
(event) => html`
|
||||
<div class="event-item" @click=${() => this.handleEventClick(event)}>
|
||||
<div class="event-time-col" title=${this.formatTime(event.timestamp)}>
|
||||
${this.formatRelativeTime(event.timestamp)}
|
||||
</div>
|
||||
<div class="event-icon ${event.source}">
|
||||
${event.source === 'mongodb' ? this.renderMongoIcon() : this.renderS3Icon()}
|
||||
</div>
|
||||
@@ -543,9 +573,6 @@ export class TsviewActivityStream extends DeesElement {
|
||||
<span class="event-type ${this.getEventType(event)}">${this.getEventType(event)}</span>
|
||||
${this.getEventTitle(event)}
|
||||
</div>
|
||||
<div class="event-time" title=${this.formatTime(event.timestamp)}>
|
||||
${this.formatRelativeTime(event.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-details">
|
||||
<span class="event-path">${this.getEventDetails(event)}</span>
|
||||
|
||||
Reference in New Issue
Block a user