Skip to content

Commit

Permalink
Implement up to 20 layers. Implement URL-load-per-layer for topology …
Browse files Browse the repository at this point in the history
…JSON. Revert CustomTextArea to main. Use NormalModuleReplacementPlugin strategy to monkey patch @grafana/ui for test build. Move LAYER_LIMIT and DEFAULT_LAYER_LIMIT to constants.js. New signals for layer load success/failure. Add a number of tests: autodetect topology, switch between topology load methods, traffic accounting, more than 3 layers.
  • Loading branch information
jkafader-esnet committed Nov 15, 2024
1 parent b90c9a5 commit 9dbbb3b
Show file tree
Hide file tree
Showing 18 changed files with 528 additions and 234 deletions.
5 changes: 3 additions & 2 deletions karma.webpack.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module.exports = function( config ) {
'@emotion/react',
'@emotion/css',
//'@grafana/runtime', // this is commented in favor of the NormalModuleReplacementPlugin entry below.
//'@grafana/ui', // this is commented in favor of the NormalModuleReplacementPlugin entry below.
'@grafana/slate-react',
'react-redux',
'redux',
Expand All @@ -38,7 +39,6 @@ module.exports = function( config ) {
'slate',
'slate-plain-serializer',
'prismjs',
'@grafana/ui',
'jquery',
'moment',
'angular',
Expand Down Expand Up @@ -79,7 +79,8 @@ module.exports = function( config ) {
],
},
plugins: [
new webpack.NormalModuleReplacementPlugin(/@grafana\/runtime/, "../test/react/LocationService.ts")
new webpack.NormalModuleReplacementPlugin(/@grafana\/runtime/, "../test/react/LocationService.ts"),
new webpack.NormalModuleReplacementPlugin(/@grafana\/ui/, "../../test/react/TextArea.tsx"),
],
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
Expand Down
23 changes: 15 additions & 8 deletions src/MapPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { sanitizeTopology } from './components/lib/topologyTools';
import './components/MapCanvas.component.js';
import { PubSub } from './components/lib/pubsub.js';
import { locationService } from '@grafana/runtime';
import { LAYER_LIMIT, setPath } from "./components/lib/utils.js"
import { setPath } from "./components/lib/utils.js"
import { signals } from "./signals.js"
import * as constants from "./constants.js"

export interface MapPanelProps extends PanelProps<MapOptions> {
fieldConfig: any;
Expand Down Expand Up @@ -36,6 +37,7 @@ export class MapPanel extends Component<MapPanelProps> {
subscriptionHandle: any;
variableChangeHandle: any;
_configurationUrl: any;
updateListener: any;

constructor(props: MapPanelProps) {
super(props);
Expand All @@ -55,15 +57,15 @@ export class MapPanel extends Component<MapPanelProps> {
let self = this;
return function (event) {
let setLocation = {};
for (let i = 0; i < LAYER_LIMIT; i++) {
for (let i = 0; i < (self?.props?.options?.layerLimit || constants.DEFAULT_LAYER_LIMIT); i++) {
let layer = self.props.options.layers[i];
const keys = [
'dashboardEdgeSrcVar',
'dashboardEdgeDstVar',
'dashboardNodeVar'
];
keys.forEach((k)=>{
if(layer[k]){
if(layer?.[k]){
setLocation[`var-${layer[k]}`] = null;
}
})
Expand Down Expand Up @@ -119,7 +121,7 @@ export class MapPanel extends Component<MapPanelProps> {
options.layers[1].mapjson,
options.layers[2].mapjson,
];
for(let i=0; i<LAYER_LIMIT; i++){
for(let i=0; i < (this.props?.options?.layerLimit || constants.DEFAULT_LAYER_LIMIT); i++){
if (mapData[i] != null) {
layerUpdates[i] = JSON.stringify(sanitizeTopology(mapData[i]));
}
Expand Down Expand Up @@ -184,7 +186,7 @@ export class MapPanel extends Component<MapPanelProps> {

resolveNodeThresholds(options){
let thresholds: any[] = [];
for (let layer=0; layer < LAYER_LIMIT; layer++) {
for (let layer=0; layer < (this.props?.options?.layerLimit || constants.DEFAULT_LAYER_LIMIT); layer++) {
if (!options.layers[layer]) {
continue;
}
Expand Down Expand Up @@ -247,7 +249,7 @@ export class MapPanel extends Component<MapPanelProps> {
"",
]

for(let layer=0; layer<LAYER_LIMIT; layer++){
for(let layer=0; layer < (this.props?.options?.layerLimit || constants.DEFAULT_LAYER_LIMIT); layer++){

const memoryTopology = JSON.stringify(this.mapCanvas?.current?.topology?.[layer] || null)
const editorTopology = options.layers?.[layer]?.mapjson;
Expand All @@ -256,10 +258,10 @@ export class MapPanel extends Component<MapPanelProps> {
topologyData[layer] = memoryTopology;
} else if (editorTopology !== memoryTopology) {
topologyData[layer] = editorTopology as string;
}
}

// here, we parse the topology data (as strings in topologyData)
// In the case that we have an error, we pass the topologyData along to
// In the case that we have an error, we pass the topologyData along to
// MapCanvas to figure out what went wrong.
// @ts-ignore

Expand Down Expand Up @@ -391,7 +393,12 @@ export class MapPanel extends Component<MapPanelProps> {
this.mapCanvas.current.refresh();
}
if(options.topologySource === "json"){
this.mapCanvas.current.pubsub.clearTopicCallbacks(signals.TOPOLOGY_UPDATED);
this.mapCanvas.current.setTopology(JSON.parse(this.lastTopology));
this.mapCanvas.current.setOptions(options, true);
this.mapCanvas.current.listen(signals.TOPOLOGY_UPDATED,() => {
this.updateTopologyEditor(this.mapCanvas.current['topology']);
});
}
}

Expand Down
131 changes: 16 additions & 115 deletions src/components/CustomTextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';

import { StandardEditorProps, StringFieldConfigSettings } from '@grafana/data';
import { TextArea } from '@grafana/ui';
import { monospacedFontSize } from '../options';

interface CustomTextAreaSettings extends StringFieldConfigSettings {
Expand All @@ -12,13 +13,6 @@ interface Props extends StandardEditorProps<string, CustomTextAreaSettings> {
suffix?: ReactNode;
}

interface ValidationState {
isPristine: boolean;
isTouched: boolean;
isValid: boolean;
errorMessage?: string;
}

function unescape(str) {
return String(str)
.replace(/&amp;/g, '&')
Expand All @@ -27,80 +21,15 @@ function unescape(str) {
.replace(/&quot;/g, '"');
}

function validateMapJsonStr(inStr: string, currentValidationState: ValidationState): ValidationState {
let isValid = true;
let validationFailedMsg: null | string = null;
try {
const parsedObj = JSON.parse(inStr);
if (typeof(parsedObj) !== 'object') {
throw new Error("Bad topology object");
}
if (!Array.isArray(parsedObj.edges) || !Array.isArray(parsedObj.nodes)) {
throw new Error("Missing or bad edges or nodes from topology object");
}
for (const edge of parsedObj.edges) {
const { name, meta, coordinates } = edge;
if (
!name || typeof(name) !== 'string' ||
(!!meta && typeof(meta) !== 'object') ||
meta?.endpoint_identifiers !== 'object' ||
!coordinates || !Array.isArray(coordinates) ||
coordinates.some((coordinate) => {
return !Array.isArray(coordinate)
|| coordinate.length != 2
|| coordinate.some((coord)=>{ return !Number.isFinite(coord)})
})
) {
throw new Error("Bad edge definition");
}
}
for (const node of parsedObj.nodes) {
const { name, meta, coordinate } = node;
if (
!name || typeof(name) !== 'string' ||
(!!meta && typeof(meta) !== 'object') ||
!coordinate || !Array.isArray(coordinate) ||
coordinate.length !== 2 || !Number.isFinite(coordinate[0]) ||
!Number.isFinite(coordinate[1])
) {
throw new Error("Bad node definition");
}
}
} catch (e: any) {
isValid = false;
if (e instanceof Error) {
validationFailedMsg = e.message;
}
}
const newValidationState: any = {
isPristine: isValid ? currentValidationState.isPristine : false,
isTouched: isValid ? currentValidationState.isTouched : false,
isValid: isValid,
errorMessage: null,
};
if (!isValid && validationFailedMsg) {
newValidationState.errorMessage = validationFailedMsg;
}
return newValidationState;
}

export const CustomTextArea: React.FC<Props> = ({ value, onChange, item, suffix }) => {
let textareaRef = useRef<HTMLTextAreaElement>(null);
let [validationState, setValidationState] = useState({
isPristine: true,
isTouched: false,
isValid: false
} as ValidationState);
let [currentEditorValue, setCurrentEditorValue] = useState(value);

const onValueChange = useCallback(
(e: React.SyntheticEvent) => {
let nextValue = value ?? '';
if (e.hasOwnProperty('key')) {
// handling keyboard event
const evt = e as React.KeyboardEvent<HTMLInputElement>;
// if we're not in a <textarea>, the enter key should trigger
// essentially a blur equivalent
if (evt.key === 'Enter' && !item.settings?.useTextarea) {
nextValue = unescape(evt.currentTarget.value.trim());
}
Expand All @@ -112,22 +41,11 @@ export const CustomTextArea: React.FC<Props> = ({ value, onChange, item, suffix
if (nextValue === value) {
return; // no change
}
const newValidationState = validateMapJsonStr(nextValue, {
...validationState,
isPristine: false,
isTouched: true,
});
setValidationState(newValidationState);
setCurrentEditorValue(nextValue);
if (!newValidationState.isValid){
return; // invalid input; don't fire onchange
}
onChange(nextValue === '' ? undefined : nextValue);
},
[value, item.settings?.useTextarea, onChange]
);

// set component initial state
useEffect(() => {
if (!!textareaRef.current) {
// ensure that the js 'value' property stays in sync with the actual DOM value
Expand All @@ -137,40 +55,23 @@ export const CustomTextArea: React.FC<Props> = ({ value, onChange, item, suffix
}
});

// when the value changes externally, update the component's initial state
useEffect(()=>{
setCurrentEditorValue(value);
}, [value])

let attribs = {
style: {
width: "100%",
resize: "none",
}
} as any;
const attribs = {};
if (item.settings?.isMonospaced) {
attribs.style.fontFamily = "monospace";
attribs.style.fontSize = item.settings?.fontSize || monospacedFontSize;
attribs['style'] = {
fontFamily: "monospace",
fontSize: item.settings?.fontSize || monospacedFontSize
};
}

return (
<div>
<textarea
{...attribs}
placeholder={item.settings?.placeholder}
defaultValue={currentEditorValue || ''}
rows={(item.settings?.useTextarea && item.settings.rows) || 5}
onBlur={onValueChange}
onChange={onValueChange}
ref={textareaRef}
/>
{
!validationState.isValid ?
<div className='validation-error' style={{ marginTop: "2px", fontSize:"10px", color: "red" }}>
{validationState.errorMessage}
</div>
: null
}
</div>
<TextArea
{...attribs}
placeholder={item.settings?.placeholder}
defaultValue={value || ''}
rows={(item.settings?.useTextarea && item.settings.rows) || 5}
onBlur={onValueChange}
onKeyDown={onValueChange}
ref={textareaRef}
/>
);
};
22 changes: 12 additions & 10 deletions src/components/EditingInterface.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as pubsub from './lib/pubsub.js';
import * as utils from './lib/utils.js';
const PubSub = pubsub.PubSub;
import { BindableHTMLElement } from './lib/rubbercement.js';
import testIds from '../constants.js';
import * as constants from '../constants.js';
import { signals } from '../signals.js';
import DOMPurify from './lib/purify.es.mjs';

Expand Down Expand Up @@ -242,7 +242,7 @@ class EditingInterface extends BindableHTMLElement {
updateLayerNodes(layer, node, spliceIndex){
const newTopology = [];
var defaultLayer = {"nodes":[], "edges": []};
for(let i=0; i<utils.LAYER_LIMIT; i++){
for(let i=0; i<constants.LAYER_LIMIT; i++){
newTopology.push(JSON.parse(JSON.stringify(this.mapCanvas?._topology?.[i] || defaultLayer)));
}
// coerce to int
Expand Down Expand Up @@ -271,7 +271,7 @@ class EditingInterface extends BindableHTMLElement {

const newTopology = [];
var defaultLayer = {"nodes":[], "edges": []};
for(let i=0; i<utils.LAYER_LIMIT; i++){
for(let i=0; i<constants.LAYER_LIMIT; i++){
newTopology.push(JSON.parse(JSON.stringify(this.mapCanvas?._topology?.[i] || defaultLayer)));
}

Expand Down Expand Up @@ -432,8 +432,10 @@ class EditingInterface extends BindableHTMLElement {

let selectedLayerOptions = "";

for(let i=0; i<utils.LAYER_LIMIT; i++){
selectedLayerOptions += `<option value='${i}' ${ parseInt(this._selectedLayer) === i && "selected" || ""}>Layer ${i+1}</option>`;
for(let i=0; i < (this.mapCanvas?.options?.layerLimit || constants.DEFAULT_LAYER_LIMIT); i++){
selectedLayerOptions += `<option value='${i}' ${ parseInt(this._selectedLayer) === i && "selected" || ""}>
${ this?.mapCanvas?._options?.layers?.[i]?.name || "Layer "+(i+1) }
</option>`;
}

this.shadow.innerHTML = `
Expand Down Expand Up @@ -588,7 +590,7 @@ class EditingInterface extends BindableHTMLElement {
</style>
<div id="dialog" class="dialog">
<!-- add node dialog -->
<div class="dialog-form tight-form-func" id="add_node_dialog" data-testid="${testIds.map}">
<div class="dialog-form tight-form-func" id="add_node_dialog" data-testid="${constants.testIds.map}">
<form id='add_node_form'>
<table>
<tr>
Expand Down Expand Up @@ -703,18 +705,18 @@ class EditingInterface extends BindableHTMLElement {
</div>
</div>
<div class="button-overlay">
<div role='button' aria-label='Edit Edges: ${ this._edgeEditMode ? "On" : "Off" }' class='button edit-mode-only tight-form-func' id='edge_edit_mode' data-testid='${testIds.editEdgeToggleBtn}'>
<div role='button' aria-label='Edit Edges: ${ this._edgeEditMode ? "On" : "Off" }' class='button edit-mode-only tight-form-func' id='edge_edit_mode' data-testid='${constants.testIds.editEdgeToggleBtn}'>
Edit Edges: ${ this._edgeEditMode ? "On" : "Off" }
</div>
<div role='button' aria-label='Edit Nodes: ${ this._nodeEditMode ? "On" : "Off" }' class='button edit-mode-only tight-form-func' id='node_edit_mode' data-testid='${testIds.editNodeToggleBtn}'>
<div role='button' aria-label='Edit Nodes: ${ this._nodeEditMode ? "On" : "Off" }' class='button edit-mode-only tight-form-func' id='node_edit_mode' data-testid='${constants.testIds.editNodeToggleBtn}'>
Edit Nodes: ${ this._nodeEditMode ? "On" : "Off" }
</div>
</div>
<div class="tools-overlay">
<div class='button edit-mode-only tight-form-func' id='add_node' data-testid='${testIds.addNodeBtn}'>
<div class='button edit-mode-only tight-form-func' id='add_node' data-testid='${constants.testIds.addNodeBtn}'>
+&nbsp;Node
</div>
<div class='button edit-mode-only tight-form-func' id='add_edge' data-testid='${testIds.addEdgeBtn}'>
<div class='button edit-mode-only tight-form-func' id='add_edge' data-testid='${constants.testIds.addEdgeBtn}'>
+&nbsp;Edge
</div>
<div class='button edit-mode-only tight-form-func' id='delete_selection' style='${ (this._selectedObject && this._selectedLayer !== null && this._selectedType) ? "display: block" : "display: none" }'>
Expand Down
Loading

0 comments on commit 9dbbb3b

Please sign in to comment.