UNPKG

polyv-live-cli

Version:

CLI tool for managing PolyV live streaming services.

395 lines 15.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.StreamMetricsPanel = void 0; const blessed_1 = __importDefault(require("blessed")); const blessed_contrib_1 = require("blessed-contrib"); const base_component_1 = require("./base.component"); class StreamMetricsPanel extends base_component_1.BaseComponent { constructor(config, eventBus) { super(config, eventBus); this.channelsData = new Map(); this.currentChannelId = null; this.lastUpdateTime = new Date(); this.alertCooldowns = new Map(); } createWidget() { this.container = blessed_1.default.box({ label: ' Stream Metrics ', border: { type: 'line' }, style: { fg: this.getConfig().chartStyle?.foregroundColor || 'white', bg: this.getConfig().chartStyle?.backgroundColor || 'black', border: { fg: 'cyan' } }, top: this.config.position.y, left: this.config.position.x, width: this.config.position.width, height: this.config.position.height, scrollable: false, alwaysScroll: false, mouse: true, keys: true, vi: true, tags: true }); this.headerBox = blessed_1.default.box({ parent: this.container, top: 0, left: 0, width: '100%', height: 3, content: this.getNoChannelMessage(), style: { fg: 'yellow' }, tags: true }); this.alertBox = blessed_1.default.box({ parent: this.container, top: 'top+3', left: 0, width: '100%', height: 2, content: '', style: { fg: 'red', bold: true }, tags: true }); const chartConfig = { style: { line: this.getConfig().chartStyle?.lineColors?.[0] || 'yellow', text: this.getConfig().chartStyle?.foregroundColor || 'white', baseline: 'white' }, xLabelPadding: 3, xPadding: 5, yLabelPadding: 3, yPadding: 2, legend: { width: 12 }, wholeNumbersOnly: false, numYLabels: 6, numXLabels: 8, showLegend: true, abbreviate: true }; this.lineChart = (0, blessed_contrib_1.line)(chartConfig); this.lineChart.parent = this.container; this.lineChart.top = 'top+5'; this.lineChart.left = 0; this.lineChart.width = '100%'; this.lineChart.height = 'bottom-1'; this.noDataBox = blessed_1.default.box({ parent: this.container, top: 'center', left: 'center', width: 'shrink', height: 'shrink', content: this.getNoDataMessage(), style: { fg: 'gray' }, tags: true, hidden: false }); this.widget = this.container; this.setupKeyBindings(); this.lineChart.hide(); } setupKeyBindings() { this.container.key(['n', 'N'], () => { this.switchToNextChannel(); }); this.container.key(['p', 'P'], () => { this.switchToPreviousChannel(); }); this.container.key(['r', 'R'], () => { this.requestUpdate(); }); this.container.key(['c', 'C'], () => { this.clearAlerts(); }); } render() { if (this.isDestroyed || !this.widget) return; try { this.updateHeader(); this.updateAlertDisplay(); this.updateChart(); this.widget.screen?.render(); } catch (error) { this.handleError(error instanceof Error ? error : new Error('Unknown render error')); } } update(data) { if (this.isDestroyed) return; try { let metricsArray; if (Array.isArray(data)) { metricsArray = data; } else if ('channels' in data && Array.isArray(data.channels)) { metricsArray = data.channels; } else { metricsArray = [data]; } for (const metrics of metricsArray) { this.processChannelMetrics(metrics); } if (!this.currentChannelId && this.channelsData.size > 0) { this.currentChannelId = Array.from(this.channelsData.keys())[0] || null; } this.lastUpdateTime = new Date(); this.updateState({ lastUpdate: this.lastUpdateTime }); if (this.channelsData.size > 0) { this.noDataBox.hide(); this.lineChart.show(); } else { this.noDataBox.show(); this.lineChart.hide(); } this.throttledRender(); } catch (error) { this.handleError(error instanceof Error ? error : new Error('Failed to update stream metrics')); } } processChannelMetrics(metrics) { const channelId = metrics.channelId; let channelData = this.channelsData.get(channelId); if (!channelData) { channelData = { channelId, isActive: metrics.status === 'live', dataPoints: [], alerts: [], lastUpdate: new Date() }; this.channelsData.set(channelId, channelData); } const dataPoint = { timestamp: metrics.lastUpdate || new Date(), bitrate: metrics.bitrate || 0, fps: metrics.fps || 0, viewerCount: metrics.viewerCount || 0, bandwidth: metrics.bandwidth || 0, channelId }; channelData.dataPoints.push(dataPoint); const maxPoints = this.getConfig().maxDataPoints || 100; if (channelData.dataPoints.length > maxPoints) { channelData.dataPoints = channelData.dataPoints.slice(-maxPoints); } channelData.isActive = metrics.status === 'live'; channelData.lastUpdate = new Date(); this.checkAlerts(channelData, dataPoint); } checkAlerts(channelData, dataPoint) { const config = this.getConfig(); const thresholds = config.alertThresholds; const now = new Date(); const cooldownMs = 30000; if (thresholds?.bitrate) { const alertKey = `${channelData.channelId}-bitrate`; const lastAlert = this.alertCooldowns.get(alertKey); if (!lastAlert || (now.getTime() - lastAlert.getTime()) > cooldownMs) { if (dataPoint.bitrate < thresholds.bitrate.min) { const alert = { type: 'bitrate', level: 'warning', message: `Low bitrate: ${dataPoint.bitrate} kbps (min: ${thresholds.bitrate.min} kbps)`, timestamp: now, value: dataPoint.bitrate, threshold: thresholds.bitrate.min }; channelData.alerts.push(alert); this.alertCooldowns.set(alertKey, now); this.emitAlert(channelData.channelId, alert); } else if (dataPoint.bitrate > thresholds.bitrate.max) { const alert = { type: 'bitrate', level: 'error', message: `High bitrate: ${dataPoint.bitrate} kbps (max: ${thresholds.bitrate.max} kbps)`, timestamp: now, value: dataPoint.bitrate, threshold: thresholds.bitrate.max }; channelData.alerts.push(alert); this.alertCooldowns.set(alertKey, now); this.emitAlert(channelData.channelId, alert); } } } if (thresholds?.fps) { const alertKey = `${channelData.channelId}-fps`; const lastAlert = this.alertCooldowns.get(alertKey); if (!lastAlert || (now.getTime() - lastAlert.getTime()) > cooldownMs) { if (dataPoint.fps < thresholds.fps.min) { const alert = { type: 'fps', level: 'warning', message: `Low FPS: ${dataPoint.fps} (min: ${thresholds.fps.min})`, timestamp: now, value: dataPoint.fps, threshold: thresholds.fps.min }; channelData.alerts.push(alert); this.alertCooldowns.set(alertKey, now); this.emitAlert(channelData.channelId, alert); } } } const maxAlerts = 10; if (channelData.alerts.length > maxAlerts) { channelData.alerts = channelData.alerts.slice(-maxAlerts); } } updateHeader() { if (!this.currentChannelId) { this.headerBox.setContent(this.getNoChannelMessage()); return; } const channelData = this.channelsData.get(this.currentChannelId); if (!channelData) { this.headerBox.setContent(this.getNoChannelMessage()); return; } const status = channelData.isActive ? '{green-fg}LIVE{/green-fg}' : '{red-fg}OFFLINE{/red-fg}'; const lastUpdate = channelData.lastUpdate.toLocaleTimeString(); const channelCount = this.channelsData.size; const currentIndex = Array.from(this.channelsData.keys()).indexOf(this.currentChannelId) + 1; const headerText = `Channel: {bold}${this.currentChannelId}{/bold} | Status: ${status} | Updated: {gray-fg}${lastUpdate}{/gray-fg} | {cyan-fg}${currentIndex}/${channelCount}{/cyan-fg}`; this.headerBox.setContent(headerText); } updateAlertDisplay() { if (!this.currentChannelId) { this.alertBox.setContent(''); return; } const channelData = this.channelsData.get(this.currentChannelId); if (!channelData || channelData.alerts.length === 0) { this.alertBox.setContent(''); return; } const latestAlert = channelData.alerts[channelData.alerts.length - 1]; const alertColor = latestAlert?.level === 'error' ? 'red' : 'yellow'; const alertText = `{${alertColor}-fg}⚠ ${latestAlert?.message}{/${alertColor}-fg} | {gray-fg}${latestAlert?.timestamp.toLocaleTimeString()}{/gray-fg}`; this.alertBox.setContent(alertText); } updateChart() { if (!this.currentChannelId) return; const channelData = this.channelsData.get(this.currentChannelId); if (!channelData || channelData.dataPoints.length === 0) return; try { const dataPoints = channelData.dataPoints; const timestamps = dataPoints.map(p => p.timestamp.toLocaleTimeString().slice(0, 5)); const chartData = [ { title: 'Bitrate (kbps)', x: timestamps, y: dataPoints.map(p => p.bitrate), style: { line: this.getConfig().chartStyle?.lineColors?.[0] || 'yellow' } }, { title: 'FPS', x: timestamps, y: dataPoints.map(p => p.fps), style: { line: this.getConfig().chartStyle?.lineColors?.[1] || 'green' } }, { title: 'Viewers', x: timestamps, y: dataPoints.map(p => p.viewerCount), style: { line: this.getConfig().chartStyle?.lineColors?.[2] || 'cyan' } } ]; this.lineChart.setData(chartData); } catch (error) { console.warn('[StreamMetricsPanel] Chart update failed:', error); } } switchToNextChannel() { const channels = Array.from(this.channelsData.keys()); if (channels.length === 0) return; const currentIndex = this.currentChannelId ? channels.indexOf(this.currentChannelId) : -1; const nextIndex = (currentIndex + 1) % channels.length; this.currentChannelId = channels[nextIndex] || null; this.throttledRender(); if (this.currentChannelId) { this.emitChannelSwitch(this.currentChannelId); } } switchToPreviousChannel() { const channels = Array.from(this.channelsData.keys()); if (channels.length === 0) return; const currentIndex = this.currentChannelId ? channels.indexOf(this.currentChannelId) : 0; const prevIndex = currentIndex === 0 ? channels.length - 1 : currentIndex - 1; this.currentChannelId = channels[prevIndex] || null; this.throttledRender(); if (this.currentChannelId) { this.emitChannelSwitch(this.currentChannelId); } } clearAlerts() { if (!this.currentChannelId) return; const channelData = this.channelsData.get(this.currentChannelId); if (channelData) { channelData.alerts = []; this.throttledRender(); } } emitAlert(channelId, alert) { this.emit('component:alert', { componentId: this.state.id, channelId, alert, timestamp: new Date() }); } emitChannelSwitch(channelId) { this.emit('component:channelSwitch', { componentId: this.state.id, channelId, timestamp: new Date() }); } getNoChannelMessage() { return '{center}{gray-fg}No channel selected{/gray-fg}{/center}'; } getNoDataMessage() { return '{center}{gray-fg}No stream data available{/gray-fg}\n{center}{gray-fg}Waiting for stream metrics...{/gray-fg}{/center}'; } getConfig() { return this.config; } getCurrentChannelData() { return this.currentChannelId ? this.channelsData.get(this.currentChannelId) || null : null; } getAllChannelsData() { return new Map(this.channelsData); } setCurrentChannel(channelId) { if (this.channelsData.has(channelId)) { this.currentChannelId = channelId; this.throttledRender(); this.emitChannelSwitch(channelId); } } destroy() { this.stopRefresh(); this.channelsData.clear(); this.alertCooldowns.clear(); super.destroy(); } } exports.StreamMetricsPanel = StreamMetricsPanel; //# sourceMappingURL=stream-metrics.panel.js.map