mirror of
https://github.com/grafana/grafana.git
synced 2025-02-15 10:03:33 -06:00
* Geomap: Add network layer * Support text labels for nodes * Add solid styling for edges * Remove symbol option for edge style menu * Add support for edge text labels * Fix linter issues * Simplify multiple data frame handling * Add TODO notes * Add node and edge style categories for options * Remove data frame hardcoding * Hide legend, attempt to hide tooltip by default * Mark network layer as beta * refactor updateEdge * Fix some linter issues * Remove attempt at disabling tooltip for network layer * For edge text add a stroke and increase z index * Restrict field selection based on frame type * refactor * add basic bad data handling (prevent entire panel from breaking) * generate non hard coded graph frames for style editor filtering * code cleanup; remove hardcoded reference to "edges" frame * fix select clearing for Data option * fix styling * fix lookup --------- Co-authored-by: nmarrs <nathanielmarrs@gmail.com> Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
611 lines
18 KiB
TypeScript
611 lines
18 KiB
TypeScript
import {
|
|
DataFrame,
|
|
Field,
|
|
FieldCache,
|
|
FieldColorModeId,
|
|
FieldConfig,
|
|
FieldType,
|
|
MutableDataFrame,
|
|
NodeGraphDataFrameFieldNames,
|
|
} from '@grafana/data';
|
|
|
|
import { EdgeDatum, GraphFrame, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } from './types';
|
|
|
|
type Line = { x1: number; y1: number; x2: number; y2: number };
|
|
|
|
/**
|
|
* Makes line shorter while keeping the middle in he same place.
|
|
*/
|
|
export function shortenLine(line: Line, length: number): Line {
|
|
const vx = line.x2 - line.x1;
|
|
const vy = line.y2 - line.y1;
|
|
const mag = Math.sqrt(vx * vx + vy * vy);
|
|
const ratio = Math.max((mag - length) / mag, 0);
|
|
const vx2 = vx * ratio;
|
|
const vy2 = vy * ratio;
|
|
const xDiff = vx - vx2;
|
|
const yDiff = vy - vy2;
|
|
const newx1 = line.x1 + xDiff / 2;
|
|
const newy1 = line.y1 + yDiff / 2;
|
|
return {
|
|
x1: newx1,
|
|
y1: newy1,
|
|
x2: newx1 + vx2,
|
|
y2: newy1 + vy2,
|
|
};
|
|
}
|
|
|
|
export type NodeFields = {
|
|
id?: Field;
|
|
title?: Field;
|
|
subTitle?: Field;
|
|
mainStat?: Field;
|
|
secondaryStat?: Field;
|
|
arc: Field[];
|
|
details: Field[];
|
|
color?: Field;
|
|
icon?: Field;
|
|
};
|
|
|
|
export function getNodeFields(nodes: DataFrame): NodeFields {
|
|
const normalizedFrames = {
|
|
...nodes,
|
|
fields: nodes.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
|
|
};
|
|
const fieldsCache = new FieldCache(normalizedFrames);
|
|
return {
|
|
id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id.toLowerCase()),
|
|
title: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.title.toLowerCase()),
|
|
subTitle: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.subTitle.toLowerCase()),
|
|
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()),
|
|
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()),
|
|
arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc),
|
|
details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail),
|
|
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
|
|
icon: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.icon),
|
|
};
|
|
}
|
|
|
|
export type EdgeFields = {
|
|
id?: Field;
|
|
source?: Field;
|
|
target?: Field;
|
|
mainStat?: Field;
|
|
secondaryStat?: Field;
|
|
details: Field[];
|
|
};
|
|
|
|
export function getEdgeFields(edges: DataFrame): EdgeFields {
|
|
const normalizedFrames = {
|
|
...edges,
|
|
fields: edges.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
|
|
};
|
|
const fieldsCache = new FieldCache(normalizedFrames);
|
|
return {
|
|
id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id.toLowerCase()),
|
|
source: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source.toLowerCase()),
|
|
target: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.target.toLowerCase()),
|
|
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()),
|
|
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()),
|
|
details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail.toLowerCase()),
|
|
};
|
|
}
|
|
|
|
function findFieldsByPrefix(frame: DataFrame, prefix: string): Field[] {
|
|
return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix)));
|
|
}
|
|
|
|
/**
|
|
* Transform nodes and edges dataframes into array of objects that the layout code can then work with.
|
|
*/
|
|
export function processNodes(
|
|
nodes: DataFrame | undefined,
|
|
edges: DataFrame | undefined
|
|
): {
|
|
nodes: NodeDatum[];
|
|
edges: EdgeDatum[];
|
|
legend?: Array<{
|
|
color: string;
|
|
name: string;
|
|
}>;
|
|
} {
|
|
if (!(edges || nodes)) {
|
|
return { nodes: [], edges: [] };
|
|
}
|
|
|
|
if (nodes) {
|
|
const nodeFields = getNodeFields(nodes);
|
|
if (!nodeFields.id) {
|
|
throw new Error('id field is required for nodes data frame.');
|
|
}
|
|
|
|
// Create the nodes here
|
|
const nodesMap: { [id: string]: NodeDatum } = {};
|
|
for (let i = 0; i < nodeFields.id.values.length; i++) {
|
|
const id = nodeFields.id.values[i];
|
|
nodesMap[id] = makeNodeDatum(id, nodeFields, i);
|
|
}
|
|
|
|
// We may not have edges in case of single node
|
|
let edgeDatums: EdgeDatum[] = edges ? processEdges(edges, getEdgeFields(edges)) : [];
|
|
|
|
for (const e of edgeDatums) {
|
|
// We are adding incoming edges count, so we can later on find out which nodes are the roots
|
|
nodesMap[e.target].incoming++;
|
|
}
|
|
|
|
return {
|
|
nodes: Object.values(nodesMap),
|
|
edges: edgeDatums,
|
|
legend: nodeFields.arc.map((f) => {
|
|
return {
|
|
color: f.config.color?.fixedColor ?? '',
|
|
name: f.config.displayName || f.name,
|
|
};
|
|
}),
|
|
};
|
|
} else {
|
|
// We have only edges here, so we have to construct also nodes out of them
|
|
|
|
// We checked that either node || edges has to be defined and if nodes aren't edges has to be defined
|
|
edges = edges!;
|
|
|
|
const nodesMap: { [id: string]: NodeDatumFromEdge } = {};
|
|
|
|
const edgeFields = getEdgeFields(edges);
|
|
let edgeDatums = processEdges(edges, edgeFields);
|
|
|
|
// Turn edges into reasonable filled in nodes
|
|
for (let i = 0; i < edgeDatums.length; i++) {
|
|
const edge = edgeDatums[i];
|
|
const { source, target } = makeNodeDatumsFromEdge(edgeFields, i);
|
|
|
|
nodesMap[target.id] = nodesMap[target.id] || target;
|
|
nodesMap[source.id] = nodesMap[source.id] || source;
|
|
|
|
// Check the stats fields. They can be also strings which we cannot really aggregate so only aggregate in case
|
|
// they are numbers. Here we just sum all incoming edges to get the final value for node.
|
|
if (computableField(edgeFields.mainStat)) {
|
|
nodesMap[target.id].mainStatNumeric =
|
|
(nodesMap[target.id].mainStatNumeric ?? 0) + edgeFields.mainStat!.values[i];
|
|
}
|
|
|
|
if (computableField(edgeFields.secondaryStat)) {
|
|
nodesMap[target.id].secondaryStatNumeric =
|
|
(nodesMap[target.id].secondaryStatNumeric ?? 0) + edgeFields.secondaryStat!.values[i];
|
|
}
|
|
|
|
// We are adding incoming edges count, so we can later on find out which nodes are the roots
|
|
nodesMap[edge.target].incoming++;
|
|
}
|
|
|
|
// It is expected for stats to be Field, so we have to create them.
|
|
const nodes = normalizeStatsForNodes(nodesMap, edgeFields);
|
|
|
|
return {
|
|
nodes,
|
|
edges: edgeDatums,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turn data frame data into EdgeDatum that node graph understands
|
|
* @param edges
|
|
* @param edgeFields
|
|
*/
|
|
function processEdges(edges: DataFrame, edgeFields: EdgeFields): EdgeDatum[] {
|
|
if (!edgeFields.id) {
|
|
throw new Error('id field is required for edges data frame.');
|
|
}
|
|
|
|
return edgeFields.id.values.map((id, index) => {
|
|
const target = edgeFields.target?.values[index];
|
|
const source = edgeFields.source?.values[index];
|
|
|
|
return {
|
|
id,
|
|
dataFrameRowIndex: index,
|
|
source,
|
|
target,
|
|
mainStat: edgeFields.mainStat ? statToString(edgeFields.mainStat.config, edgeFields.mainStat.values[index]) : '',
|
|
secondaryStat: edgeFields.secondaryStat
|
|
? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values[index])
|
|
: '',
|
|
};
|
|
});
|
|
}
|
|
|
|
function computableField(field?: Field) {
|
|
return field && field.type === FieldType.number;
|
|
}
|
|
|
|
/**
|
|
* Instead of just simple numbers node graph requires to have Field in NodeDatum (probably for some formatting info in
|
|
* config). So we create them here and fill with correct data.
|
|
* @param nodesMap
|
|
* @param edgeFields
|
|
*/
|
|
function normalizeStatsForNodes(nodesMap: { [id: string]: NodeDatumFromEdge }, edgeFields: EdgeFields): NodeDatum[] {
|
|
const secondaryStatValues: any[] = [];
|
|
const mainStatValues: any[] = [];
|
|
const secondaryStatField = computableField(edgeFields.secondaryStat)
|
|
? {
|
|
...edgeFields.secondaryStat!,
|
|
values: secondaryStatValues,
|
|
}
|
|
: undefined;
|
|
|
|
const mainStatField = computableField(edgeFields.mainStat)
|
|
? {
|
|
...edgeFields.mainStat!,
|
|
values: mainStatValues,
|
|
}
|
|
: undefined;
|
|
|
|
return Object.values(nodesMap).map((node, index) => {
|
|
if (mainStatField || secondaryStatField) {
|
|
const newNode = {
|
|
...node,
|
|
};
|
|
|
|
if (mainStatField) {
|
|
newNode.mainStat = mainStatField;
|
|
mainStatValues.push(node.mainStatNumeric);
|
|
newNode.dataFrameRowIndex = index;
|
|
}
|
|
|
|
if (secondaryStatField) {
|
|
newNode.secondaryStat = secondaryStatField;
|
|
secondaryStatValues.push(node.secondaryStatNumeric);
|
|
newNode.dataFrameRowIndex = index;
|
|
}
|
|
return newNode;
|
|
}
|
|
return node;
|
|
});
|
|
}
|
|
|
|
function makeNodeDatumsFromEdge(edgeFields: EdgeFields, index: number) {
|
|
const targetId = edgeFields.target?.values[index];
|
|
const sourceId = edgeFields.source?.values[index];
|
|
return {
|
|
target: makeSimpleNodeDatum(targetId, index),
|
|
source: makeSimpleNodeDatum(sourceId, index),
|
|
};
|
|
}
|
|
|
|
function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge {
|
|
return {
|
|
id: name,
|
|
title: name,
|
|
subTitle: '',
|
|
dataFrameRowIndex: index,
|
|
incoming: 0,
|
|
arcSections: [],
|
|
};
|
|
}
|
|
|
|
function makeNodeDatum(id: string, nodeFields: NodeFields, index: number): NodeDatum {
|
|
return {
|
|
id: id,
|
|
title: nodeFields.title?.values[index] || '',
|
|
subTitle: nodeFields.subTitle?.values[index] || '',
|
|
dataFrameRowIndex: index,
|
|
incoming: 0,
|
|
mainStat: nodeFields.mainStat,
|
|
secondaryStat: nodeFields.secondaryStat,
|
|
arcSections: nodeFields.arc,
|
|
color: nodeFields.color,
|
|
icon: nodeFields.icon?.values[index] || '',
|
|
};
|
|
}
|
|
|
|
export function statToString(config: FieldConfig, value: number | string): string {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
} else {
|
|
const decimals = config.decimals || 2;
|
|
if (Number.isFinite(value)) {
|
|
return value.toFixed(decimals) + (config.unit ? ' ' + config.unit : '');
|
|
} else {
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Utilities mainly for testing
|
|
*/
|
|
|
|
export function makeNodesDataFrame(count: number) {
|
|
const frame = nodesFrame();
|
|
for (let i = 0; i < count; i++) {
|
|
frame.add(makeNode(i));
|
|
}
|
|
|
|
return frame;
|
|
}
|
|
|
|
function makeNode(index: number) {
|
|
return {
|
|
id: index.toString(),
|
|
title: `service:${index}`,
|
|
subtitle: 'service',
|
|
arc__success: 0.5,
|
|
arc__errors: 0.5,
|
|
mainstat: 0.1,
|
|
secondarystat: 2,
|
|
color: 0.5,
|
|
icon: 'database',
|
|
};
|
|
}
|
|
|
|
function nodesFrame() {
|
|
const fields: any = {
|
|
[NodeGraphDataFrameFieldNames.id]: {
|
|
values: [],
|
|
type: FieldType.string,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.title]: {
|
|
values: [],
|
|
type: FieldType.string,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.subTitle]: {
|
|
values: [],
|
|
type: FieldType.string,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.mainStat]: {
|
|
values: [],
|
|
type: FieldType.number,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.secondaryStat]: {
|
|
values: [],
|
|
type: FieldType.number,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.arc + 'success']: {
|
|
values: [],
|
|
type: FieldType.number,
|
|
config: { color: { fixedColor: 'green' } },
|
|
},
|
|
[NodeGraphDataFrameFieldNames.arc + 'errors']: {
|
|
values: [],
|
|
type: FieldType.number,
|
|
config: { color: { fixedColor: 'red' } },
|
|
},
|
|
[NodeGraphDataFrameFieldNames.color]: {
|
|
values: [],
|
|
type: FieldType.number,
|
|
config: { color: { mode: 'continuous-GrYlRd' } },
|
|
},
|
|
[NodeGraphDataFrameFieldNames.icon]: {
|
|
values: [],
|
|
type: FieldType.string,
|
|
},
|
|
};
|
|
|
|
return new MutableDataFrame({
|
|
name: 'nodes',
|
|
fields: Object.keys(fields).map((key) => ({
|
|
...fields[key],
|
|
name: key,
|
|
})),
|
|
});
|
|
}
|
|
|
|
export function makeEdgesDataFrame(
|
|
edges: Array<Partial<{ source: string; target: string; mainstat: number; secondarystat: number }>>
|
|
) {
|
|
const frame = edgesFrame();
|
|
for (const edge of edges) {
|
|
frame.add({
|
|
id: edge.source + '--' + edge.target,
|
|
...edge,
|
|
});
|
|
}
|
|
|
|
return frame;
|
|
}
|
|
|
|
function edgesFrame() {
|
|
const fields: any = {
|
|
[NodeGraphDataFrameFieldNames.id]: {
|
|
values: [],
|
|
type: FieldType.string,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.source]: {
|
|
values: [],
|
|
type: FieldType.string,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.target]: {
|
|
values: [],
|
|
type: FieldType.string,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.mainStat]: {
|
|
values: [],
|
|
type: FieldType.number,
|
|
},
|
|
[NodeGraphDataFrameFieldNames.secondaryStat]: {
|
|
values: [],
|
|
type: FieldType.number,
|
|
},
|
|
};
|
|
|
|
return new MutableDataFrame({
|
|
name: 'edges',
|
|
fields: Object.keys(fields).map((key) => ({
|
|
...fields[key],
|
|
name: key,
|
|
})),
|
|
});
|
|
}
|
|
|
|
export interface Bounds {
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
left: number;
|
|
center: {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get bounds of the graph meaning the extent of the nodes in all directions.
|
|
*/
|
|
export function graphBounds(nodes: NodeDatum[]): Bounds {
|
|
if (nodes.length === 0) {
|
|
return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } };
|
|
}
|
|
|
|
const bounds = nodes.reduce(
|
|
(acc, node) => {
|
|
if (node.x! > acc.right) {
|
|
acc.right = node.x!;
|
|
}
|
|
if (node.x! < acc.left) {
|
|
acc.left = node.x!;
|
|
}
|
|
if (node.y! > acc.bottom) {
|
|
acc.bottom = node.y!;
|
|
}
|
|
if (node.y! < acc.top) {
|
|
acc.top = node.y!;
|
|
}
|
|
return acc;
|
|
},
|
|
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity }
|
|
);
|
|
|
|
const y = bounds.top + (bounds.bottom - bounds.top) / 2;
|
|
const x = bounds.left + (bounds.right - bounds.left) / 2;
|
|
|
|
return {
|
|
...bounds,
|
|
center: {
|
|
x,
|
|
y,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function getNodeGraphDataFrames(frames: DataFrame[], options?: NodeGraphOptions) {
|
|
// TODO: this not in sync with how other types of responses are handled. Other types have a query response
|
|
// processing pipeline which ends up populating redux state with proper data. As we move towards more dataFrame
|
|
// oriented API it seems like a better direction to move such processing into to visualisations and do minimal
|
|
// and lazy processing here. Needs bigger refactor so keeping nodeGraph and Traces as they are for now.
|
|
let nodeGraphFrames = frames.filter((frame) => {
|
|
if (frame.meta?.preferredVisualisationType === 'nodeGraph') {
|
|
return true;
|
|
}
|
|
|
|
if (frame.name === 'nodes' || frame.name === 'edges' || frame.refId === 'nodes' || frame.refId === 'edges') {
|
|
return true;
|
|
}
|
|
|
|
const fieldsCache = new FieldCache(frame);
|
|
if (fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
// If panel options are provided, interpolate their values in to the data frames
|
|
if (options) {
|
|
nodeGraphFrames = applyOptionsToFrames(nodeGraphFrames, options);
|
|
}
|
|
return nodeGraphFrames;
|
|
}
|
|
|
|
export const applyOptionsToFrames = (frames: DataFrame[], options: NodeGraphOptions): DataFrame[] => {
|
|
return frames.map((frame) => {
|
|
const fieldsCache = new FieldCache(frame);
|
|
|
|
// Edges frame has source which can be used to identify nodes vs edges frames
|
|
if (fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source.toLowerCase())) {
|
|
if (options?.edges?.mainStatUnit) {
|
|
const field = frame.fields.find((field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.mainStat);
|
|
if (field) {
|
|
field.config = { ...field.config, unit: options.edges.mainStatUnit };
|
|
}
|
|
}
|
|
if (options?.edges?.secondaryStatUnit) {
|
|
const field = frame.fields.find(
|
|
(field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.secondaryStat
|
|
);
|
|
if (field) {
|
|
field.config = { ...field.config, unit: options.edges.secondaryStatUnit };
|
|
}
|
|
}
|
|
} else {
|
|
if (options?.nodes?.mainStatUnit) {
|
|
const field = frame.fields.find((field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.mainStat);
|
|
if (field) {
|
|
field.config = { ...field.config, unit: options.nodes.mainStatUnit };
|
|
}
|
|
}
|
|
if (options?.nodes?.secondaryStatUnit) {
|
|
const field = frame.fields.find(
|
|
(field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.secondaryStat
|
|
);
|
|
if (field) {
|
|
field.config = { ...field.config, unit: options.nodes.secondaryStatUnit };
|
|
}
|
|
}
|
|
if (options?.nodes?.arcs?.length) {
|
|
for (const arc of options.nodes.arcs) {
|
|
const field = frame.fields.find((field) => field.name.toLowerCase() === arc.field);
|
|
if (field && arc.color) {
|
|
field.config = { ...field.config, color: { fixedColor: arc.color, mode: FieldColorModeId.Fixed } };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return frame;
|
|
});
|
|
};
|
|
|
|
// Returns an array of node ids which are connected to a given edge
|
|
export const findConnectedNodesForEdge = (nodes: NodeDatum[], edges: EdgeDatum[], edgeId: string): string[] => {
|
|
const edge = edges.find((edge) => edge.id === edgeId);
|
|
if (edge) {
|
|
return [
|
|
...new Set(nodes.filter((node) => edge.source === node.id || edge.target === node.id).map((node) => node.id)),
|
|
];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
// Returns an array of node ids which are connected to a given node
|
|
export const findConnectedNodesForNode = (nodes: NodeDatum[], edges: EdgeDatum[], nodeId: string): string[] => {
|
|
const node = nodes.find((node) => node.id === nodeId);
|
|
if (node) {
|
|
const linkedEdges = edges.filter((edge) => edge.source === node.id || edge.target === node.id);
|
|
return [
|
|
...new Set(
|
|
linkedEdges.flatMap((edge) =>
|
|
nodes.filter((n) => edge.source === n.id || edge.target === n.id).map((n) => n.id)
|
|
)
|
|
),
|
|
];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
export const getGraphFrame = (frames: DataFrame[]) => {
|
|
return frames.reduce<GraphFrame>(
|
|
(acc, frame) => {
|
|
const sourceField = frame.fields.filter((f) => f.name === 'source');
|
|
if (sourceField.length) {
|
|
acc.edges.push(frame);
|
|
} else {
|
|
acc.nodes.push(frame);
|
|
}
|
|
return acc;
|
|
},
|
|
{ edges: [], nodes: [] }
|
|
);
|
|
};
|