Chore: Move StreamingDataFrame to @grafana/data (#72014)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,622 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { DataFrame, Field, FieldDTO, FieldType, Labels, parseLabels, QueryResultMeta } from '..';
|
||||
import { join } from '../transformations/transformers/joinDataFrames';
|
||||
import { renderLegendFormat } from '../utils/legend';
|
||||
|
||||
import {
|
||||
DataFrameJSON,
|
||||
decodeFieldValueEntities,
|
||||
FieldSchema,
|
||||
guessFieldTypeFromValue,
|
||||
toFilteredDataFrameDTO,
|
||||
} from '.';
|
||||
|
||||
/**
|
||||
* Indicate if the frame is appened or replace
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export enum StreamingFrameAction {
|
||||
Append = 'append',
|
||||
Replace = 'replace',
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* */
|
||||
export interface StreamingFrameOptions {
|
||||
maxLength: number; // 1000
|
||||
maxDelta: number; // how long to keep things
|
||||
action: StreamingFrameAction; // default will append
|
||||
|
||||
/** optionally format field names based on labels */
|
||||
displayNameFormat?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream packet info is attached to StreamingDataFrames and indicate how many
|
||||
* rows were added to the end of the frame. The number of discarded rows can be
|
||||
* calculated from previous state
|
||||
*/
|
||||
export interface StreamPacketInfo {
|
||||
number: number;
|
||||
action: StreamingFrameAction;
|
||||
length: number;
|
||||
schemaChanged: boolean;
|
||||
}
|
||||
|
||||
const PROM_STYLE_METRIC_LABEL = '__name__';
|
||||
|
||||
enum PushMode {
|
||||
wide,
|
||||
labels,
|
||||
// long
|
||||
}
|
||||
|
||||
export type SerializedStreamingDataFrame = {
|
||||
name?: string;
|
||||
fields: FieldDTO[];
|
||||
refId?: string;
|
||||
meta: QueryResultMeta;
|
||||
schemaFields: FieldSchema[];
|
||||
timeFieldIndex: number;
|
||||
pushMode: PushMode;
|
||||
length: number;
|
||||
packetInfo: StreamPacketInfo;
|
||||
options: StreamingFrameOptions;
|
||||
labels: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unlike a circular buffer, this will append and periodically slice the front
|
||||
*/
|
||||
export class StreamingDataFrame implements DataFrame {
|
||||
name?: string;
|
||||
refId?: string;
|
||||
meta: QueryResultMeta = {};
|
||||
|
||||
fields: Field[] = [];
|
||||
length = 0;
|
||||
|
||||
private schemaFields: FieldSchema[] = [];
|
||||
private timeFieldIndex = -1;
|
||||
private pushMode = PushMode.wide;
|
||||
|
||||
// current labels
|
||||
private labels: Set<string> = new Set();
|
||||
readonly packetInfo: StreamPacketInfo = {
|
||||
schemaChanged: true,
|
||||
number: 0,
|
||||
action: StreamingFrameAction.Replace,
|
||||
length: 0,
|
||||
};
|
||||
|
||||
private constructor(public options: StreamingFrameOptions) {
|
||||
// Get Length to show up if you use spread
|
||||
Object.defineProperty(this, 'length', {
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
// Get fields to show up if you use spread
|
||||
Object.defineProperty(this, 'fields', {
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
|
||||
serialize = (
|
||||
fieldPredicate?: (f: Field) => boolean,
|
||||
optionsOverride?: Partial<StreamingFrameOptions>,
|
||||
trimValues?: {
|
||||
maxLength?: number;
|
||||
}
|
||||
): SerializedStreamingDataFrame => {
|
||||
const options = optionsOverride ? Object.assign({}, { ...this.options, ...optionsOverride }) : this.options;
|
||||
const dataFrameDTO = toFilteredDataFrameDTO(this, fieldPredicate);
|
||||
|
||||
const numberOfItemsToRemove = getNumberOfItemsToRemove(
|
||||
dataFrameDTO.fields.map((f) => f.values) as unknown[][],
|
||||
typeof trimValues?.maxLength === 'number' ? Math.min(trimValues.maxLength, options.maxLength) : options.maxLength,
|
||||
this.timeFieldIndex,
|
||||
options.maxDelta
|
||||
);
|
||||
|
||||
dataFrameDTO.fields = dataFrameDTO.fields.map((f) => ({
|
||||
...f,
|
||||
values: (f.values as unknown[]).slice(numberOfItemsToRemove),
|
||||
}));
|
||||
|
||||
const length = dataFrameDTO.fields[0]?.values?.length ?? 0;
|
||||
|
||||
return {
|
||||
...dataFrameDTO,
|
||||
// TODO: Labels and schema are not filtered by field
|
||||
labels: this.labels,
|
||||
schemaFields: this.schemaFields,
|
||||
|
||||
name: this.name,
|
||||
refId: this.refId,
|
||||
meta: this.meta,
|
||||
length,
|
||||
timeFieldIndex: this.timeFieldIndex,
|
||||
pushMode: this.pushMode,
|
||||
packetInfo: this.packetInfo,
|
||||
options,
|
||||
};
|
||||
};
|
||||
|
||||
private initFromSerialized = (serialized: Omit<SerializedStreamingDataFrame, 'options'>) => {
|
||||
this.name = serialized.name;
|
||||
this.refId = serialized.refId;
|
||||
this.meta = serialized.meta;
|
||||
this.length = serialized.length;
|
||||
this.labels = serialized.labels;
|
||||
this.schemaFields = serialized.schemaFields;
|
||||
this.timeFieldIndex = serialized.timeFieldIndex;
|
||||
this.pushMode = serialized.pushMode;
|
||||
this.packetInfo.length = serialized.packetInfo.length;
|
||||
this.packetInfo.number = serialized.packetInfo.number;
|
||||
this.packetInfo.action = StreamingFrameAction.Replace;
|
||||
this.packetInfo.schemaChanged = true;
|
||||
this.fields = serialized.fields.map((f) => ({
|
||||
...f,
|
||||
type: f.type ?? FieldType.other,
|
||||
config: f.config ?? {},
|
||||
values: f.values ?? [],
|
||||
}));
|
||||
|
||||
assureValuesAreWithinLengthLimit(
|
||||
this.fields.map((f) => f.values),
|
||||
this.options.maxLength,
|
||||
this.timeFieldIndex,
|
||||
this.options.maxDelta
|
||||
);
|
||||
};
|
||||
|
||||
static deserialize = (serialized: SerializedStreamingDataFrame) => {
|
||||
const frame = new StreamingDataFrame(serialized.options);
|
||||
frame.initFromSerialized(serialized);
|
||||
return frame;
|
||||
};
|
||||
|
||||
static empty = (opts?: Partial<StreamingFrameOptions>): StreamingDataFrame =>
|
||||
new StreamingDataFrame(getStreamingFrameOptions(opts));
|
||||
|
||||
static fromDataFrameJSON = (frame: DataFrameJSON, opts?: Partial<StreamingFrameOptions>): StreamingDataFrame => {
|
||||
const streamingDataFrame = new StreamingDataFrame(getStreamingFrameOptions(opts));
|
||||
streamingDataFrame.push(frame);
|
||||
return streamingDataFrame;
|
||||
};
|
||||
|
||||
private get alwaysReplace() {
|
||||
return this.options.action === StreamingFrameAction.Replace;
|
||||
}
|
||||
|
||||
needsResizing = ({ maxLength, maxDelta }: StreamingFrameOptions) => {
|
||||
const needsMoreLength = maxLength && this.options.maxLength < maxLength;
|
||||
const needsBiggerDelta = maxDelta && this.options.maxDelta < maxDelta;
|
||||
const needsToOverrideDefaultInfinityDelta = maxDelta && this.options.maxDelta === Infinity;
|
||||
return Boolean(needsMoreLength || needsBiggerDelta || needsToOverrideDefaultInfinityDelta);
|
||||
};
|
||||
|
||||
resize = ({ maxLength, maxDelta }: Partial<StreamingFrameOptions>) => {
|
||||
if (maxDelta) {
|
||||
if (this.options.maxDelta === Infinity) {
|
||||
this.options.maxDelta = maxDelta;
|
||||
} else {
|
||||
this.options.maxDelta = Math.max(maxDelta, this.options.maxDelta);
|
||||
}
|
||||
}
|
||||
this.options.maxLength = Math.max(this.options.maxLength, maxLength ?? 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* apply the new message to the existing data. This will replace the existing schema
|
||||
* if a new schema is included in the message, or append data matching the current schema
|
||||
*/
|
||||
push(msg: DataFrameJSON): StreamPacketInfo {
|
||||
const { schema, data } = msg;
|
||||
|
||||
this.packetInfo.number++;
|
||||
this.packetInfo.length = 0;
|
||||
this.packetInfo.schemaChanged = false;
|
||||
|
||||
if (schema) {
|
||||
this.pushMode = PushMode.wide;
|
||||
this.timeFieldIndex = schema.fields.findIndex((f) => f.type === FieldType.time);
|
||||
const firstField = schema.fields[0];
|
||||
if (
|
||||
this.timeFieldIndex === 1 &&
|
||||
firstField.type === FieldType.string &&
|
||||
(firstField.name === 'labels' || firstField.name === 'Labels')
|
||||
) {
|
||||
this.pushMode = PushMode.labels;
|
||||
this.timeFieldIndex = 0; // after labels are removed!
|
||||
}
|
||||
|
||||
const niceSchemaFields = this.pushMode === PushMode.labels ? schema.fields.slice(1) : schema.fields;
|
||||
|
||||
this.refId = schema.refId;
|
||||
if (schema.meta) {
|
||||
this.meta = { ...schema.meta };
|
||||
}
|
||||
|
||||
const { displayNameFormat } = this.options;
|
||||
if (hasSameStructure(this.schemaFields, niceSchemaFields)) {
|
||||
const len = niceSchemaFields.length;
|
||||
this.fields.forEach((f, idx) => {
|
||||
const sf = niceSchemaFields[idx % len];
|
||||
f.config = sf.config ?? {};
|
||||
f.labels = sf.labels;
|
||||
});
|
||||
if (displayNameFormat) {
|
||||
this.fields.forEach((f) => {
|
||||
const labels = { [PROM_STYLE_METRIC_LABEL]: f.name, ...f.labels };
|
||||
f.config.displayNameFromDS = renderLegendFormat(displayNameFormat, labels);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.packetInfo.schemaChanged = true;
|
||||
const isWide = this.pushMode === PushMode.wide;
|
||||
this.fields = niceSchemaFields.map((f) => {
|
||||
const config = f.config ?? {};
|
||||
if (displayNameFormat) {
|
||||
const labels = { [PROM_STYLE_METRIC_LABEL]: f.name, ...f.labels };
|
||||
config.displayNameFromDS = renderLegendFormat(displayNameFormat, labels);
|
||||
}
|
||||
return {
|
||||
config,
|
||||
name: f.name,
|
||||
labels: f.labels,
|
||||
type: f.type ?? FieldType.other,
|
||||
// transfer old values by type & name, unless we relied on labels to match fields
|
||||
values: isWide
|
||||
? this.fields.find((of) => of.name === f.name && f.type === of.type)?.values ??
|
||||
Array(this.length).fill(undefined)
|
||||
: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
this.schemaFields = niceSchemaFields;
|
||||
}
|
||||
|
||||
if (data && data.values.length && data.values[0].length) {
|
||||
let { values, entities } = data;
|
||||
|
||||
if (entities) {
|
||||
entities.forEach((ents, i) => {
|
||||
if (ents) {
|
||||
decodeFieldValueEntities(ents, values[i]);
|
||||
// TODO: append replacements to field
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pushMode === PushMode.labels) {
|
||||
// augment and transform data to match current schema for standard circPush() path
|
||||
const labeledTables = transpose(values);
|
||||
|
||||
// make sure fields are initalized for each label
|
||||
for (const label of labeledTables.keys()) {
|
||||
if (!this.labels.has(label)) {
|
||||
this.packetInfo.schemaChanged = true;
|
||||
this.addLabel(label);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: cache higher up
|
||||
let dummyTable = Array(this.schemaFields.length).fill([]);
|
||||
|
||||
let tables: AlignedData[] = [];
|
||||
this.labels.forEach((label) => {
|
||||
tables.push(labeledTables.get(label) ?? dummyTable);
|
||||
});
|
||||
|
||||
values = join(tables);
|
||||
}
|
||||
|
||||
if (values.length !== this.fields.length) {
|
||||
if (this.fields.length) {
|
||||
throw new Error(
|
||||
`push message mismatch. Expected: ${this.fields.length}, received: ${values.length} (labels=${
|
||||
this.pushMode === PushMode.labels
|
||||
})`
|
||||
);
|
||||
}
|
||||
|
||||
this.fields = values.map((vals, idx) => {
|
||||
let name = `Field ${idx}`;
|
||||
let type = guessFieldTypeFromValue(vals[0]);
|
||||
const isTime = idx === 0 && type === FieldType.number && (vals as number[])[0] > 1600016688632;
|
||||
if (isTime) {
|
||||
type = FieldType.time;
|
||||
name = 'Time';
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
config: {},
|
||||
values: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let appended = values;
|
||||
this.packetInfo.length = values[0].length;
|
||||
|
||||
if (this.alwaysReplace || !this.length) {
|
||||
this.packetInfo.action = StreamingFrameAction.Replace;
|
||||
} else {
|
||||
this.packetInfo.action = StreamingFrameAction.Append;
|
||||
|
||||
// mutates appended
|
||||
appended = this.fields.map((f) => f.values);
|
||||
circPush(appended, values, this.options.maxLength, this.timeFieldIndex, this.options.maxDelta);
|
||||
}
|
||||
|
||||
appended.forEach((v, i) => {
|
||||
const field = this.fields[i];
|
||||
const { state } = field;
|
||||
field.values = v;
|
||||
if (state) {
|
||||
state.calcs = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Update the frame length
|
||||
this.length = appended[0].length;
|
||||
}
|
||||
|
||||
return {
|
||||
...this.packetInfo,
|
||||
};
|
||||
}
|
||||
|
||||
pushNewValues = (values: unknown[][]) => {
|
||||
if (!values?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.packetInfo.action = this.options.action;
|
||||
this.packetInfo.number++;
|
||||
this.packetInfo.length = values[0].length;
|
||||
this.packetInfo.schemaChanged = false;
|
||||
|
||||
if (this.options.action === StreamingFrameAction.Append) {
|
||||
circPush(
|
||||
this.fields.map((f) => f.values),
|
||||
values,
|
||||
this.options.maxLength,
|
||||
this.timeFieldIndex,
|
||||
this.options.maxDelta
|
||||
);
|
||||
} else {
|
||||
values.forEach((v, i) => {
|
||||
if (this.fields[i]) {
|
||||
this.fields[i].values = v;
|
||||
}
|
||||
});
|
||||
|
||||
assureValuesAreWithinLengthLimit(
|
||||
this.fields.map((f) => f.values),
|
||||
this.options.maxLength,
|
||||
this.timeFieldIndex,
|
||||
this.options.maxDelta
|
||||
);
|
||||
}
|
||||
const newLength = this.fields?.[0]?.values.length;
|
||||
if (newLength !== undefined) {
|
||||
this.length = newLength;
|
||||
}
|
||||
};
|
||||
|
||||
resetStateCalculations = () => {
|
||||
this.fields.forEach((f) => {
|
||||
f.state = {
|
||||
...(f.state ?? {}),
|
||||
calcs: undefined,
|
||||
range: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
getMatchingFieldIndexes = (fieldPredicate: (f: Field) => boolean): number[] =>
|
||||
this.fields
|
||||
.map((f, index) => (fieldPredicate(f) ? index : undefined))
|
||||
.filter((val) => val !== undefined) as number[];
|
||||
|
||||
getValuesFromLastPacket = (): unknown[][] =>
|
||||
this.fields.map((f) => {
|
||||
const values = f.values;
|
||||
return values.slice(Math.max(values.length - this.packetInfo.length));
|
||||
});
|
||||
|
||||
hasAtLeastOnePacket = () => Boolean(this.packetInfo.length);
|
||||
|
||||
// adds a set of fields for a new label
|
||||
private addLabel(label: string) {
|
||||
const { displayNameFormat } = this.options;
|
||||
const labelCount = this.labels.size;
|
||||
|
||||
// parse labels
|
||||
const parsedLabels = parseLabelsFromField(label);
|
||||
|
||||
if (labelCount === 0) {
|
||||
// mutate existing fields and add labels
|
||||
this.fields.forEach((f, i) => {
|
||||
if (i > 0) {
|
||||
f.labels = parsedLabels;
|
||||
if (displayNameFormat) {
|
||||
const labels = { [PROM_STYLE_METRIC_LABEL]: f.name, ...parsedLabels };
|
||||
f.config.displayNameFromDS = renderLegendFormat(displayNameFormat, labels);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for (let i = 1; i < this.schemaFields.length; i++) {
|
||||
let proto = this.schemaFields[i] as Field;
|
||||
const config = proto.config ?? {};
|
||||
if (displayNameFormat) {
|
||||
const labels = { [PROM_STYLE_METRIC_LABEL]: proto.name, ...parsedLabels };
|
||||
config.displayNameFromDS = renderLegendFormat(displayNameFormat, labels);
|
||||
}
|
||||
this.fields.push({
|
||||
...proto,
|
||||
config,
|
||||
labels: parsedLabels,
|
||||
values: Array(this.length).fill(undefined),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.labels.add(label);
|
||||
}
|
||||
|
||||
getOptions = (): Readonly<StreamingFrameOptions> => this.options;
|
||||
}
|
||||
|
||||
export function getStreamingFrameOptions(opts?: Partial<StreamingFrameOptions>): StreamingFrameOptions {
|
||||
return {
|
||||
maxLength: opts?.maxLength ?? 1000,
|
||||
maxDelta: opts?.maxDelta ?? Infinity,
|
||||
action: opts?.action ?? StreamingFrameAction.Append,
|
||||
displayNameFormat: opts?.displayNameFormat,
|
||||
};
|
||||
}
|
||||
|
||||
// converts vertical insertion records with table keys in [0] and column values in [1...N]
|
||||
// to join()-able tables with column arrays
|
||||
export function transpose(vrecs: any[][]) {
|
||||
let tableKeys = new Set(vrecs[0]);
|
||||
let tables = new Map();
|
||||
|
||||
tableKeys.forEach((key) => {
|
||||
let cols = Array(vrecs.length - 1)
|
||||
.fill(null)
|
||||
.map(() => []);
|
||||
|
||||
tables.set(key, cols);
|
||||
});
|
||||
|
||||
for (let r = 0; r < vrecs[0].length; r++) {
|
||||
let table = tables.get(vrecs[0][r]);
|
||||
for (let c = 1; c < vrecs.length; c++) {
|
||||
table[c - 1].push(vrecs[c][r]);
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
// binary search for index of closest value
|
||||
export function closestIdx(num: number, arr: number[], lo?: number, hi?: number) {
|
||||
let mid;
|
||||
lo = lo || 0;
|
||||
hi = hi || arr.length - 1;
|
||||
let bitwise = hi <= 2147483647;
|
||||
|
||||
while (hi - lo > 1) {
|
||||
mid = bitwise ? (lo + hi) >> 1 : Math.floor((lo + hi) / 2);
|
||||
|
||||
if (arr[mid] < num) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
|
||||
if (num - arr[lo] <= arr[hi] - num) {
|
||||
return lo;
|
||||
}
|
||||
|
||||
return hi;
|
||||
}
|
||||
|
||||
export function parseLabelsFromField(str: string): Labels {
|
||||
if (!str.length) {
|
||||
return {};
|
||||
}
|
||||
if (str.charAt(0) === '{') {
|
||||
return parseLabels(str);
|
||||
}
|
||||
const parsedLabels: Labels = {};
|
||||
str.split(',').forEach((kv) => {
|
||||
const [key, val] = kv.trim().split('=');
|
||||
parsedLabels[key] = val;
|
||||
});
|
||||
return parsedLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal // not exported in yet
|
||||
*/
|
||||
export function getLastStreamingDataFramePacket(frame: DataFrame) {
|
||||
const pi = (frame as StreamingDataFrame).packetInfo;
|
||||
return pi?.action ? pi : undefined;
|
||||
}
|
||||
|
||||
// mutable circular push
|
||||
function circPush(data: unknown[][], newData: unknown[][], maxLength = Infinity, deltaIdx = 0, maxDelta = Infinity) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
for (let k = 0; k < newData[i].length; k++) {
|
||||
data[i].push(newData[i][k]);
|
||||
}
|
||||
}
|
||||
|
||||
return assureValuesAreWithinLengthLimit(data, maxLength, deltaIdx, maxDelta);
|
||||
}
|
||||
|
||||
function assureValuesAreWithinLengthLimit(data: unknown[][], maxLength = Infinity, deltaIdx = 0, maxDelta = Infinity) {
|
||||
const count = getNumberOfItemsToRemove(data, maxLength, deltaIdx, maxDelta);
|
||||
|
||||
if (count) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i].splice(0, count);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function getNumberOfItemsToRemove(data: unknown[][], maxLength = Infinity, deltaIdx = 0, maxDelta = Infinity) {
|
||||
if (!data[0]?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nlen = data[0].length;
|
||||
|
||||
let sliceIdx = 0;
|
||||
|
||||
if (nlen > maxLength) {
|
||||
sliceIdx = nlen - maxLength;
|
||||
}
|
||||
|
||||
if (maxDelta !== Infinity && deltaIdx >= 0) {
|
||||
const deltaLookup = data[deltaIdx] as number[];
|
||||
|
||||
const low = deltaLookup[sliceIdx];
|
||||
const high = deltaLookup[nlen - 1];
|
||||
|
||||
if (high - low > maxDelta) {
|
||||
sliceIdx = closestIdx(high - maxDelta, deltaLookup, sliceIdx);
|
||||
}
|
||||
}
|
||||
|
||||
return sliceIdx;
|
||||
}
|
||||
|
||||
function hasSameStructure(a: FieldSchema[], b: FieldSchema[]): boolean {
|
||||
if (a?.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const fA = a[i];
|
||||
const fB = b[i];
|
||||
if (fA.name !== fB.name || fA.type !== fB.type) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from './ArrayDataFrame';
|
||||
export * from './DataFrameJSON';
|
||||
export * from './frameComparisons';
|
||||
export { anySeriesWithTimeField, isTimeSeriesFrame, isTimeSeriesFrames } from './utils';
|
||||
export { StreamingDataFrame, StreamingFrameAction, type StreamingFrameOptions } from './StreamingDataFrame';
|
||||
|
||||
Reference in New Issue
Block a user