mirror of
https://github.com/netdata/netdata.git
synced 2025-04-23 04:50:22 +00:00
parent
5d960b419e
commit
9cbf6b7ae9
3 changed files with 379 additions and 8 deletions
collectors
health/schema.d
371
collectors/network-viewer.plugin/viewer.html
Normal file
371
collectors/network-viewer.plugin/viewer.html
Normal file
|
@ -0,0 +1,371 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Network Viewer</title>
|
||||||
|
<style>
|
||||||
|
/* Styles to make the canvas full width and height */
|
||||||
|
body, html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#d3-canvas {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400&display=swap" rel="stylesheet">
|
||||||
|
<!-- Include D3.js -->
|
||||||
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
// The transformData function
|
||||||
|
function transformData(dataPayload, desiredColumnNames, columns) {
|
||||||
|
console.log(dataPayload);
|
||||||
|
|
||||||
|
const transformedData = [];
|
||||||
|
|
||||||
|
// Create a map to store data per application
|
||||||
|
const appMap = new Map();
|
||||||
|
|
||||||
|
dataPayload.data.forEach(row => {
|
||||||
|
const rowData = {};
|
||||||
|
desiredColumnNames.forEach(columnName => {
|
||||||
|
const columnIndex = columns[columnName].index;
|
||||||
|
rowData[columnName] = row[columnIndex];
|
||||||
|
});
|
||||||
|
|
||||||
|
const appName = rowData['Process'];
|
||||||
|
if (!appMap.has(appName)) {
|
||||||
|
appMap.set(appName, {
|
||||||
|
listenCount: 0,
|
||||||
|
inboundCount: 0,
|
||||||
|
outboundCount: 0,
|
||||||
|
localCount: 0,
|
||||||
|
privateCount: 0,
|
||||||
|
publicCount: 0,
|
||||||
|
totalCount: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appData = appMap.get(appName);
|
||||||
|
appData.totalCount++;
|
||||||
|
|
||||||
|
if (rowData['Direction'] === 'listen') {
|
||||||
|
appData.listenCount++;
|
||||||
|
}
|
||||||
|
else if (rowData['Direction'] === 'local') {
|
||||||
|
appData.localCount++;
|
||||||
|
}
|
||||||
|
else if (rowData['Direction'] === 'inbound') {
|
||||||
|
appData.inboundCount++;
|
||||||
|
}
|
||||||
|
else if (rowData['Direction'] === 'outbound') {
|
||||||
|
appData.outboundCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowData['RemoteAddressSpace'] === 'public') {
|
||||||
|
appData.publicCount++;
|
||||||
|
}
|
||||||
|
else if (rowData['RemoteAddressSpace'] === 'private') {
|
||||||
|
appData.privateCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert the map to an array format
|
||||||
|
for (let [appName, appData] of appMap) {
|
||||||
|
transformedData.push({
|
||||||
|
name: appName,
|
||||||
|
...appData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(transformedData);
|
||||||
|
|
||||||
|
return transformedData;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="d3-canvas"></div> <!-- Div for D3 rendering -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to draw the circles and labels for each application with border forces
|
||||||
|
function drawApplications(data) {
|
||||||
|
console.log(data);
|
||||||
|
console.log(data.length)
|
||||||
|
const maxTotalCount = d3.max(data, d => d.totalCount);
|
||||||
|
const maxXCount = d3.max(data, d => Math.abs(Math.max(d.publicCount, d.privateCount)));
|
||||||
|
const maxStrength = 0.005;
|
||||||
|
const borderPadding = 40;
|
||||||
|
console.log("maxTotalCount", maxTotalCount);
|
||||||
|
console.log("maxXCount", maxXCount);
|
||||||
|
|
||||||
|
const max = {
|
||||||
|
totalCount: d3.max(data, d => d.totalCount),
|
||||||
|
localCount: d3.max(data, d => d.localCount),
|
||||||
|
listenCount: d3.max(data, d => d.listenCount),
|
||||||
|
privateCount: d3.max(data, d => d.privateCount),
|
||||||
|
publicCount: d3.max(data, d => d.publicCount),
|
||||||
|
inboundCount: d3.max(data, d => d.inboundCount),
|
||||||
|
outboundCount: d3.max(data, d => d.outboundCount),
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
const cw = w / 2;
|
||||||
|
const ch = h / 2;
|
||||||
|
console.log(w, h, cw, ch);
|
||||||
|
console.log(w, h, cw, ch);
|
||||||
|
|
||||||
|
const minSize = 13;
|
||||||
|
const maxSize = Math.max(5, Math.min(w, h) / data.length) + minSize; // Avoid division by zero or too large sizes
|
||||||
|
|
||||||
|
const publicColor = "#bbaa00";
|
||||||
|
const privateColor = "#5555ff";
|
||||||
|
const serverColor = "#009900";
|
||||||
|
const clientColor = "#990000";
|
||||||
|
|
||||||
|
function hexToHalfOpacityRGBA(hex) {
|
||||||
|
// Ensure the hex color is valid
|
||||||
|
if (hex.length !== 7 || hex[0] !== '#') {
|
||||||
|
throw new Error('Invalid hex color format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the red, green, and blue components
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
|
||||||
|
// Return the color in RGBA format with half opacity
|
||||||
|
return `rgba(${r}, ${g}, ${b}, 0.5)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieColors = d3.scaleOrdinal()
|
||||||
|
.domain(["publicCount", "privateCount", "listenInboundCount", "outboundCount", "others"])
|
||||||
|
.range([publicColor, privateColor, serverColor, clientColor, "#666666"]); // Example colors
|
||||||
|
|
||||||
|
const pie = d3.pie().value(d => d.value);
|
||||||
|
const arc = d3.arc();
|
||||||
|
|
||||||
|
function getPieData(d) {
|
||||||
|
const others = d.totalCount - (d.publicCount + d.privateCount + d.listenCount + d.inboundCount + d.outboundCount);
|
||||||
|
return [
|
||||||
|
{value: d.publicCount},
|
||||||
|
{value: d.privateCount},
|
||||||
|
{value: d.listenCount + d.inboundCount},
|
||||||
|
{value: d.outboundCount},
|
||||||
|
{value: others > 0 ? others : 0}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceStrengthScale = d3.scaleLinear()
|
||||||
|
.domain([0, maxTotalCount])
|
||||||
|
.range([0, maxStrength]);
|
||||||
|
|
||||||
|
const circleSize = d3.scaleLog()
|
||||||
|
.domain([1, maxTotalCount]) // Assuming maxTotalCount is the maximum value in your data
|
||||||
|
.range([minSize, maxSize])
|
||||||
|
.clamp(true); // Clamps the output so that it stays within the range
|
||||||
|
|
||||||
|
const logScaleRight = d3.scaleLog().domain([1, max.publicCount + 1]).range([0, cw - borderPadding]);
|
||||||
|
const logScaleLeft = d3.scaleLog().domain([1, max.privateCount + 1]).range([0, cw - borderPadding]);
|
||||||
|
const logScaleTop = d3.scaleLog().domain([1, max.outboundCount + 1]).range([0, ch - borderPadding]);
|
||||||
|
const logScaleBottom = d3.scaleLog().domain([1, (max.listenCount + max.inboundCount) / 2 + 1]).range([0, ch - borderPadding]);
|
||||||
|
|
||||||
|
data.forEach((d, i) => {
|
||||||
|
const forces = {
|
||||||
|
total: d.totalCount / max.totalCount,
|
||||||
|
local: d.localCount / max.localCount,
|
||||||
|
listen: d.listenCount / max.listenCount,
|
||||||
|
private: d.privateCount / max.privateCount,
|
||||||
|
public: d.publicCount / max.publicCount,
|
||||||
|
inbound: d.inboundCount / max.inboundCount,
|
||||||
|
outbound: d.outboundCount / max.outboundCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = {
|
||||||
|
right: logScaleRight(d.publicCount + 1),
|
||||||
|
left: logScaleLeft(d.privateCount + 1),
|
||||||
|
top: logScaleTop(d.outboundCount + 1),
|
||||||
|
bottom: logScaleBottom((d.listenCount + d.inboundCount) / 2 + 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
d.targetX = cw + pos.right - pos.left;
|
||||||
|
d.targetY = ch + pos.bottom - pos.top;
|
||||||
|
|
||||||
|
if(d.name === 'deluged')
|
||||||
|
console.log("object", d, "forces", forces, "pos", pos);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
const svg = d3.select('#d3-canvas').append('svg')
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', '100%');
|
||||||
|
|
||||||
|
// Top area - Clients
|
||||||
|
svg.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', 0)
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', borderPadding / 2) // Adjust height as needed
|
||||||
|
.style('fill', hexToHalfOpacityRGBA(clientColor));
|
||||||
|
|
||||||
|
svg.append('text')
|
||||||
|
.text('Clients')
|
||||||
|
.attr('x', '50%')
|
||||||
|
.attr('y', borderPadding / 2 - 4) // Adjust y position based on the rectangle's height
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.style('font-family', 'IBM Plex Sans')
|
||||||
|
.style('font-size', '14px')
|
||||||
|
.style('font-weight', 'bold'); // Make the font bold
|
||||||
|
|
||||||
|
// Bottom area - Servers
|
||||||
|
svg.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', h - borderPadding / 2)
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', borderPadding / 2) // Adjust height as needed
|
||||||
|
.style('fill', hexToHalfOpacityRGBA(serverColor));
|
||||||
|
|
||||||
|
svg.append('text')
|
||||||
|
.text('Servers')
|
||||||
|
.attr('x', '50%')
|
||||||
|
.attr('y', h - borderPadding / 2 + 16) // Adjust y position based on the rectangle's height
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.style('font-family', 'IBM Plex Sans')
|
||||||
|
.style('font-size', '14px')
|
||||||
|
.style('font-weight', 'bold'); // Make the font bold
|
||||||
|
|
||||||
|
svg.append('rect')
|
||||||
|
.attr('x', w - borderPadding / 2) // Position it close to the right edge
|
||||||
|
.attr('y', 0)
|
||||||
|
.attr('width', borderPadding / 2) // Width of the border area
|
||||||
|
.attr('height', '100%')
|
||||||
|
.style('fill', hexToHalfOpacityRGBA(publicColor));
|
||||||
|
|
||||||
|
svg.append('text')
|
||||||
|
.text('Public')
|
||||||
|
.attr('x', w - (borderPadding / 2)) // Position close to the right edge
|
||||||
|
.attr('y', ch - 10) // Vertically centered
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle') // Center alignment of the text
|
||||||
|
.attr('transform', `rotate(90, ${w - (borderPadding / 2)}, ${ch})`) // Rotate around the text's center
|
||||||
|
.style('font-family', 'IBM Plex Sans')
|
||||||
|
.style('font-size', '14px')
|
||||||
|
.style('font-weight', 'bold'); // Make the font bold
|
||||||
|
|
||||||
|
svg.append('rect')
|
||||||
|
.attr('x', 0) // Positioned at the left edge
|
||||||
|
.attr('y', 0)
|
||||||
|
.attr('width', borderPadding / 2) // Width of the border area
|
||||||
|
.attr('height', '100%')
|
||||||
|
.style('fill', hexToHalfOpacityRGBA(privateColor));
|
||||||
|
|
||||||
|
svg.append('text')
|
||||||
|
.text('Private')
|
||||||
|
.attr('x', borderPadding / 2) // Position close to the left edge
|
||||||
|
.attr('y', ch) // Vertically centered
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle') // Center alignment of the text
|
||||||
|
.attr('transform', `rotate(-90, ${borderPadding / 2 - 10}, ${ch})`) // Rotate around the text's center
|
||||||
|
.style('font-family', 'IBM Plex Sans')
|
||||||
|
.style('font-size', '14px')
|
||||||
|
.style('font-weight', 'bold'); // Make the font bold
|
||||||
|
|
||||||
|
function boundaryForce(alpha) {
|
||||||
|
return function(d) {
|
||||||
|
const nodeRadius = circleSize(d.totalCount) / 2;
|
||||||
|
d.x = Math.max(borderPadding + nodeRadius, Math.min(w - borderPadding - nodeRadius, d.x));
|
||||||
|
d.y = Math.max(borderPadding + nodeRadius, Math.min(h - borderPadding - nodeRadius, d.y));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulation = d3.forceSimulation(data)
|
||||||
|
.force('center', d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2).strength(1)) // Scale center force strength
|
||||||
|
.force("x", d3.forceX(d => d.targetX).strength(0.3))
|
||||||
|
.force("y", d3.forceY(d => d.targetY).strength(0.3))
|
||||||
|
.force("charge", d3.forceManyBody().strength(-0.5))
|
||||||
|
.force("collide", d3.forceCollide(d => circleSize(d.totalCount) * 1.1 + 15).strength(1))
|
||||||
|
.force("boundary", boundaryForce(0.5))
|
||||||
|
.on('tick', ticked);
|
||||||
|
|
||||||
|
const app = svg.selectAll('.app')
|
||||||
|
.data(data)
|
||||||
|
.enter().append('g')
|
||||||
|
.attr('class', 'app')
|
||||||
|
.call(d3.drag()
|
||||||
|
.on('start', dragstarted)
|
||||||
|
.on('drag', dragged)
|
||||||
|
.on('end', dragended));
|
||||||
|
|
||||||
|
app.each(function(d) {
|
||||||
|
const group = d3.select(this);
|
||||||
|
const pieData = pie(getPieData(d));
|
||||||
|
const radius = circleSize(d.totalCount);
|
||||||
|
|
||||||
|
group.selectAll('path')
|
||||||
|
.data(pieData)
|
||||||
|
.enter().append('path')
|
||||||
|
.attr('d', arc.innerRadius(0).outerRadius(radius))
|
||||||
|
.attr('fill', (d, i) => pieColors(i));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.append('text')
|
||||||
|
.text(d => d.name)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('y', d => circleSize(d.totalCount) + 10)
|
||||||
|
.style('font-family', 'IBM Plex Sans') // Set the font family
|
||||||
|
.style('font-size', '12px') // Set the font size
|
||||||
|
.style('font-weight', 'bold'); // Make the font bold
|
||||||
|
|
||||||
|
// Initialize app positions at the center
|
||||||
|
app.attr('transform', `translate(${window.innerWidth / 2}, ${window.innerHeight / 2})`);
|
||||||
|
|
||||||
|
function ticked() {
|
||||||
|
app.attr('transform', d => `translate(${d.x}, ${d.y})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragstarted(event, d) {
|
||||||
|
if (!event.active) simulation.alphaTarget(1).restart();
|
||||||
|
d.fx = d.x;
|
||||||
|
d.fy = d.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragged(event, d) {
|
||||||
|
d.fx = event.x;
|
||||||
|
d.fy = event.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragended(event, d) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
d.fx = null;
|
||||||
|
d.fy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify your fetchData function to call drawApplications after data transformation
|
||||||
|
function fetchData() {
|
||||||
|
fetch('http://localhost:19999/api/v1/function?function=network-viewer')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Your existing code
|
||||||
|
const desiredColumns = ["Direction", "Protocol", "Namespace", "Process", "CommandLine", "LocalIP", "LocalPort", "RemoteIP", "RemotePort", "LocalAddressSpace", "RemoteAddressSpace"];
|
||||||
|
const transformed = transformData(data, desiredColumns, data.columns);
|
||||||
|
|
||||||
|
// Now draw the applications with border forces
|
||||||
|
drawApplications(transformed);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching data:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data on start
|
||||||
|
window.onload = fetchData;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -170,7 +170,7 @@ typedef struct local_socket {
|
||||||
static inline void local_sockets_log(LS_STATE *ls, const char *format, ...) __attribute__ ((format(__printf__, 2, 3)));
|
static inline void local_sockets_log(LS_STATE *ls, const char *format, ...) __attribute__ ((format(__printf__, 2, 3)));
|
||||||
static inline void local_sockets_log(LS_STATE *ls, const char *format, ...) {
|
static inline void local_sockets_log(LS_STATE *ls, const char *format, ...) {
|
||||||
if(++ls->stats.errors_encountered == ls->config.max_errors) {
|
if(++ls->stats.errors_encountered == ls->config.max_errors) {
|
||||||
nd_log(NDLS_COLLECTORS, NDLP_ERR, "LOCAL-LISTENERS: max number of logs reached. Not logging anymore");
|
nd_log(NDLS_COLLECTORS, NDLP_ERR, "LOCAL-SOCKETS: max number of logs reached. Not logging anymore");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,7 +183,7 @@ static inline void local_sockets_log(LS_STATE *ls, const char *format, ...) {
|
||||||
vsnprintf(buf, sizeof(buf), format, args);
|
vsnprintf(buf, sizeof(buf), format, args);
|
||||||
va_end(args);
|
va_end(args);
|
||||||
|
|
||||||
nd_log(NDLS_COLLECTORS, NDLP_ERR, "LOCAL-LISTENERS: %s", buf);
|
nd_log(NDLS_COLLECTORS, NDLP_ERR, "LOCAL-SOCKETS: %s", buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------------------------------------------------
|
// --------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
|
@ -452,9 +452,9 @@
|
||||||
"ui:classNames": "dyncfg-grid-col-span-5-2"
|
"ui:classNames": "dyncfg-grid-col-span-5-2"
|
||||||
},
|
},
|
||||||
"value": {
|
"value": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-6",
|
"ui:classNames": "dyncfg-grid dyncfg-grid-col-6 dyncfg-grid-col-span-1-6",
|
||||||
"database_lookup": {
|
"database_lookup": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-6",
|
"ui:classNames": "dyncfg-grid dyncfg-grid-col-6 dyncfg-grid-col-span-1-6",
|
||||||
"after": {
|
"after": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-1"
|
"ui:classNames": "dyncfg-grid-col-span-1-1"
|
||||||
},
|
},
|
||||||
|
@ -479,7 +479,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"conditions": {
|
"conditions": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-6",
|
"ui:classNames": "dyncfg-grid dyncfg-grid-col-6 dyncfg-grid-col-span-1-6",
|
||||||
"warning_condition": {
|
"warning_condition": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-2"
|
"ui:classNames": "dyncfg-grid-col-span-1-2"
|
||||||
},
|
},
|
||||||
|
@ -494,7 +494,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-6",
|
"ui:classNames": "dyncfg-grid dyncfg-grid-col-6 dyncfg-grid-col-span-1-6",
|
||||||
"execute": {
|
"execute": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-3"
|
"ui:classNames": "dyncfg-grid-col-span-1-3"
|
||||||
},
|
},
|
||||||
|
@ -507,7 +507,7 @@
|
||||||
"delay": {
|
"delay": {
|
||||||
"ui:Collapsible": true,
|
"ui:Collapsible": true,
|
||||||
"ui:InitiallyExpanded": false,
|
"ui:InitiallyExpanded": false,
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-6",
|
"ui:classNames": "dyncfg-grid dyncfg-grid-col-6 dyncfg-grid-col-span-1-6",
|
||||||
"up": {
|
"up": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-2"
|
"ui:classNames": "dyncfg-grid-col-span-1-2"
|
||||||
},
|
},
|
||||||
|
@ -524,7 +524,7 @@
|
||||||
"repeat": {
|
"repeat": {
|
||||||
"ui:Collapsible": true,
|
"ui:Collapsible": true,
|
||||||
"ui:InitiallyExpanded": false,
|
"ui:InitiallyExpanded": false,
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-6",
|
"ui:classNames": "dyncfg-grid dyncfg-grid-col-6 dyncfg-grid-col-span-1-6",
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"ui:classNames": "dyncfg-grid-col-span-1-2"
|
"ui:classNames": "dyncfg-grid-col-span-1-2"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Reference in a new issue