polyv-live-cli
Version:
CLI tool for managing PolyV live streaming services.
395 lines • 15.2 kB
JavaScript
"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