mirror of
https://github.com/postmannen/ctrl.git
synced 2025-03-31 01:24:31 +00:00
WebUI:
- Added network graph - Updated doc - Specify key to use in settings - Other minor changes
This commit is contained in:
parent
5d99554c3b
commit
8fd43b2305
9 changed files with 904 additions and 25 deletions
|
@ -27,6 +27,10 @@
|
|||
- [ACL](./core_acl.md)
|
||||
- [audit log](./core_audit_log.md)
|
||||
|
||||
# WebUI
|
||||
|
||||
- [WebUI Overview](./webui_overview.md)
|
||||
|
||||
# Examples standard messages
|
||||
|
||||
- [Http Get](./example_standard_reqhttpget.md)
|
||||
|
|
39
doc/src/webui_overview.md
Normal file
39
doc/src/webui_overview.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
# WebUI Overview
|
||||
|
||||
The WebUI is a web application that allows you to interact with CTRL via NATS.
|
||||
|
||||
NB: The WebUI is in early development.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### Send Commands
|
||||
|
||||
Send commands to one or many nodes. The command will be sendt, executed, and the result will be displayed in the WebUI.
|
||||
|
||||
### Network Graph
|
||||
|
||||
Visualize the network of nodes.
|
||||
|
||||
The graph shows the nodes that are connected to the central node, and the time since the last **hello** message was received from the node.
|
||||
|
||||
When hovering over a node, the node details will be shown in a tooltip. The defined file templates for the node will also be shown in the tooltip, and the user can select one of the templates to use for the command to execute on the node.
|
||||
|
||||

|
||||
|
||||
### File Templates
|
||||
|
||||
File templates containing a script can be used to execute commands. The file templates should be stored in the **files** directory on the **central** node. The WebUI will then ask the central node for available templates, and show the file templates in a dropdown menu for the user to select from.
|
||||
|
||||

|
||||
|
||||
### Settings
|
||||
|
||||
Set setting like the Node name of the UI, NATS server URL, and NKEY to use for authentication to the NATS server.
|
||||
|
||||

|
||||
|
||||
### Flame Graph
|
||||
|
||||
In development.
|
BIN
doc/webui-file-templates.png
Normal file
BIN
doc/webui-file-templates.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 224 KiB |
BIN
doc/webui-general.png
Normal file
BIN
doc/webui-general.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 436 KiB |
BIN
doc/webui-network-graph-infobox.png
Normal file
BIN
doc/webui-network-graph-infobox.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 408 KiB |
BIN
doc/webui-settings.png
Normal file
BIN
doc/webui-settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 146 KiB |
182
webui/index.html
182
webui/index.html
|
@ -41,6 +41,97 @@
|
|||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.extras-toggle {
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.extras-toggle:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.extras-content {
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.extras-content.collapsed {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.form-divider {
|
||||
margin: 20px 0;
|
||||
border-top: 2px solid #ddd;
|
||||
}
|
||||
|
||||
#networkGraphForm h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#networkGraphForm {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
#graphContainer,
|
||||
#graphContainer2 {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
border-top: 1px solid #444;
|
||||
margin-top: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.menu-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 15px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.menu-section-header:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.menu-section-content {
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.menu-section-content.collapsed {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.menu-section-content a {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.collapsed + .arrow {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -55,7 +146,19 @@
|
|||
<nav class="side-menu">
|
||||
<a href="index.html" class="active">Command</a>
|
||||
<a href="#" id="fileTemplatesLink">File Templates</a>
|
||||
<a href="#" id="graphLink">Graph</a>
|
||||
<a href="settings.html">Settings</a>
|
||||
|
||||
<!-- Add the foldable Development section -->
|
||||
<div class="menu-section">
|
||||
<div class="menu-section-header">
|
||||
<span>Development</span>
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="menu-section-content collapsed">
|
||||
<a href="#" id="flameGraphLink">Flame Graph</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="split-container">
|
||||
|
@ -71,19 +174,6 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="jetstreamToNode">Jetstream To Node:</label>
|
||||
<input type="text" id="jetstreamToNode" placeholder="btdev1" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="useDetectedShell">Use Detected Shell:</label>
|
||||
<select id="useDetectedShell">
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="method">Method:</label>
|
||||
<input type="text" id="method" value="cliCommand" />
|
||||
|
@ -105,19 +195,38 @@
|
|||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="methodTimeout">Method Timeout (seconds):</label>
|
||||
<input type="number" id="methodTimeout" value="3" />
|
||||
<div class="extras-toggle">
|
||||
<span>Advanced Options</span>
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="extras-content collapsed">
|
||||
<div class="form-group">
|
||||
<label for="jetstreamToNode">Jetstream To Node:</label>
|
||||
<input type="text" id="jetstreamToNode" placeholder="btdev1" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="replyMethod">Reply Method:</label>
|
||||
<input type="text" id="replyMethod" value="webUI" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="useDetectedShell">Use Detected Shell:</label>
|
||||
<select id="useDetectedShell">
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ackTimeout">ACK Timeout:</label>
|
||||
<input type="number" id="ackTimeout" value="0" />
|
||||
<div class="form-group">
|
||||
<label for="methodTimeout">Method Timeout (seconds):</label>
|
||||
<input type="number" id="methodTimeout" value="3" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="replyMethod">Reply Method:</label>
|
||||
<input type="text" id="replyMethod" value="webUI" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ackTimeout">ACK Timeout:</label>
|
||||
<input type="number" id="ackTimeout" value="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
|
@ -126,6 +235,14 @@
|
|||
<button type="button" id="generateBtn">Generate Command</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Add new horizontal divider and form -->
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<form id="networkGraphForm">
|
||||
<h3>Network Graph</h3>
|
||||
<div id="graphContainer2"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<textarea
|
||||
|
@ -143,11 +260,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="flameGraphPopup" class="popup">
|
||||
<div class="popup-content">
|
||||
<!-- Content will be dynamically added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/nats@2.29.1/index.min.js"
|
||||
type="module"
|
||||
></script>
|
||||
<script type="module" src="script.js"></script>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<script>
|
||||
// Function to close menu
|
||||
function closeMenu() {
|
||||
|
@ -223,6 +347,18 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add this to your existing script section
|
||||
document
|
||||
.querySelector(".menu-section-header")
|
||||
.addEventListener("click", function () {
|
||||
const content = this.nextElementSibling;
|
||||
content.classList.toggle("collapsed");
|
||||
const arrow = this.querySelector(".arrow");
|
||||
arrow.style.transform = content.classList.contains("collapsed")
|
||||
? "rotate(-90deg)"
|
||||
: "rotate(0)";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
598
webui/script.js
598
webui/script.js
|
@ -5,6 +5,8 @@
|
|||
// import { createUser, fromPublic, fromSeed } from "https://esm.run/@nats-io/nkeys";
|
||||
|
||||
import { wsconnect, nkeyAuthenticator } from "https://esm.run/@nats-io/nats-core";
|
||||
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
|
||||
import { flamegraph } from 'https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/+esm';
|
||||
|
||||
const commandForm = document.getElementById('commandForm');
|
||||
const generateBtn = document.getElementById('generateBtn');
|
||||
|
@ -13,10 +15,17 @@ const writeFileBtn = document.getElementById('writeFileBtn');
|
|||
const outputArea = document.getElementById('outputArea');
|
||||
const fileTemplatesLink = document.getElementById('fileTemplatesLink');
|
||||
const fileTemplatesPopup = document.getElementById('fileTemplatesPopup');
|
||||
const graphLink = document.getElementById('graphLink');
|
||||
const graphPopup = document.getElementById('graphPopup');
|
||||
const flameGraphLink = document.getElementById('flameGraphLink');
|
||||
const flameGraphPopup = document.getElementById('flameGraphPopup');
|
||||
|
||||
let nc;
|
||||
let filesList = new Map();
|
||||
let settings2;
|
||||
let helloNodesList = new Map();
|
||||
let nodeList = [];
|
||||
let nodeInfoMap = new Map(); // Store node information
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const outputArea = document.getElementById('outputArea');
|
||||
|
@ -118,6 +127,53 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
localStorage.setItem('filelist', JSON.stringify(filelist));
|
||||
}
|
||||
|
||||
if (ctrlMsgMethodInstructions[0] === "helloNodesList") {
|
||||
nodeList = ctrlMsgData.split("\n")
|
||||
.filter(name => name.trim()); // Remove empty entries
|
||||
console.log("DEBUG: nodeList:", nodeList);
|
||||
|
||||
// -------------------------------
|
||||
// For each file in the list, we send a message to the central node
|
||||
for (const file of nodeList) {
|
||||
if (!file.trim()) continue; // Skip empty lines
|
||||
|
||||
console.log("DEBUG: Requesting content for file:", file);
|
||||
// Send a command to get the content of each file
|
||||
const js2 = {
|
||||
"toNodes": ["central"],
|
||||
"useDetectedShell": false,
|
||||
"method": "cliCommand",
|
||||
"methodArgs": ["/bin/bash","-c","cat data/hello-messages/"+file+"/hello.log"],
|
||||
"methodTimeout": 3,
|
||||
"fromNode": settings2.defaultNode,
|
||||
"replyMethod": "webUI",
|
||||
"replyMethodInstructions": ["helloNodeContent",file],
|
||||
"ACKTimeout": 0
|
||||
};
|
||||
nc.publish("central.cliCommand", JSON.stringify(js2));
|
||||
}
|
||||
// -------------------------------
|
||||
|
||||
// Always initialize graph
|
||||
initGraph();
|
||||
}
|
||||
|
||||
if (ctrlMsgMethodInstructions[0] === "helloNodeContent") {
|
||||
const node = ctrlMsgMethodInstructions[1];
|
||||
const nodeContent = ctrlMsgData;
|
||||
console.log("----HELLO NODE CONTENT------: helloNodeContent", node, nodeContent);
|
||||
|
||||
try {
|
||||
// Store the raw content instead of parsing as JSON
|
||||
nodeInfoMap.set(node, { content: nodeContent });
|
||||
|
||||
// Always update graph
|
||||
initGraph();
|
||||
} catch (error) {
|
||||
console.error("Error handling node content:", error);
|
||||
}
|
||||
}
|
||||
|
||||
outputArea.value += `Received message on ${msg.subject}:\n${ctrlMsgData}\n`;
|
||||
|
||||
// Scroll to bottom
|
||||
|
@ -141,6 +197,20 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
}
|
||||
});
|
||||
|
||||
// After successful NATS connection, request initial node list
|
||||
const js2 = {
|
||||
"toNodes": ["central"],
|
||||
"useDetectedShell": false,
|
||||
"method": "cliCommand",
|
||||
"methodArgs": ["/bin/bash","-c","ls -1 data/hello-messages/"],
|
||||
"methodTimeout": 3,
|
||||
"fromNode": settings2.defaultNode,
|
||||
"replyMethod": "webUI",
|
||||
"replyMethodInstructions": ["helloNodesList"],
|
||||
"ACKTimeout": 0
|
||||
};
|
||||
nc.publish("central.cliCommand", JSON.stringify(js2));
|
||||
|
||||
} catch (err) {
|
||||
outputArea.value += `Failed to connect to NATS server: ${err.message}\n`;
|
||||
}
|
||||
|
@ -173,6 +243,35 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
}, true);
|
||||
|
||||
initSplitter();
|
||||
|
||||
// Add extras section toggle functionality
|
||||
const extrasToggle = document.querySelector('.extras-toggle');
|
||||
if (extrasToggle) {
|
||||
extrasToggle.addEventListener('click', function() {
|
||||
const extrasContent = document.querySelector('.extras-content');
|
||||
if (extrasContent) {
|
||||
extrasContent.classList.toggle('collapsed');
|
||||
// Update arrow direction
|
||||
const arrow = this.querySelector('.arrow');
|
||||
if (arrow) {
|
||||
arrow.textContent = extrasContent.classList.contains('collapsed') ? '▼' : '▲';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add auto-resize to the third method argument textarea
|
||||
const methodArg3 = document.querySelector('.method-arg:last-child');
|
||||
if (methodArg3) {
|
||||
// Set initial height to one line
|
||||
methodArg3.style.height = '2.4em'; // Approximately one line of text
|
||||
methodArg3.style.resize = 'none'; // Disable manual resize
|
||||
|
||||
// Add input event listener for auto-resize
|
||||
methodArg3.addEventListener('input', function() {
|
||||
autoResizeTextarea(this);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function generateCommandData() {
|
||||
|
@ -469,3 +568,502 @@ style.textContent = `
|
|||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Add this after your other event listeners
|
||||
|
||||
// Add the graph initialization function
|
||||
function initGraph() {
|
||||
// Update single container
|
||||
const containerId = "#graphContainer2";
|
||||
// Clear any existing graph
|
||||
d3.select(containerId).selectAll("*").remove();
|
||||
|
||||
// Only proceed if we have nodes
|
||||
if (!nodeList || nodeList.length === 0) {
|
||||
console.log("No nodes to display");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the SVG container
|
||||
const container = document.querySelector(containerId);
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const svg = d3.select(containerId)
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
|
||||
// Create nodes data from nodeList
|
||||
const nodes = nodeList.map(nodeId => ({
|
||||
id: nodeId,
|
||||
group: nodeId === "central" ? 1 : 2,
|
||||
status: "active",
|
||||
connections: nodeId === "central" ? nodeList.length - 1 : 1,
|
||||
uptime: "calculating...",
|
||||
role: nodeId === "central" ? "central node" : "edge node"
|
||||
}));
|
||||
|
||||
// Create links - connect all non-central nodes to central
|
||||
const links = nodes
|
||||
.filter(node => node.id !== "central")
|
||||
.map(node => ({
|
||||
source: "central",
|
||||
target: node.id,
|
||||
value: 1
|
||||
}));
|
||||
|
||||
// Create the force simulation
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force("link", d3.forceLink(links).id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-300))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2));
|
||||
|
||||
// Add the links
|
||||
const link = svg.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.join("line")
|
||||
.attr("class", "link");
|
||||
|
||||
// Update the tooltip initialization
|
||||
const tooltip = d3.select(containerId)
|
||||
.append("div")
|
||||
.attr("class", "tooltip")
|
||||
.style("opacity", 0)
|
||||
.style("position", "absolute")
|
||||
.style("background-color", "white")
|
||||
.style("padding", "5px")
|
||||
.style("border", "1px solid #ddd")
|
||||
.style("border-radius", "4px")
|
||||
.style("pointer-events", "auto")
|
||||
.style("z-index", "1000");
|
||||
|
||||
// Update node selection to include hover behavior
|
||||
const node = svg.append("g")
|
||||
.selectAll("g")
|
||||
.data(nodes)
|
||||
.join("g")
|
||||
.attr("class", d => `node ${d.id}`)
|
||||
.call(d3.drag()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended))
|
||||
.on("mouseover", function(event, d) {
|
||||
// Clear any existing tooltips first
|
||||
d3.select(containerId + " .tooltip")
|
||||
.style("opacity", 0)
|
||||
.html(""); // Clear content
|
||||
|
||||
const tooltip = d3.select(containerId + " .tooltip");
|
||||
|
||||
// Always ensure pointer events are enabled
|
||||
tooltip.style("pointer-events", "auto")
|
||||
.style("display", "block"); // Ensure tooltip is displayed
|
||||
|
||||
// Show the tooltip with full opacity
|
||||
tooltip.transition()
|
||||
.duration(200)
|
||||
.style("opacity", 1);
|
||||
|
||||
// Get node info from the map
|
||||
const nodeInfo = nodeInfoMap.get(d.id) || {};
|
||||
|
||||
// Extract timestamp from the raw content
|
||||
let timestamp = 'N/A';
|
||||
if (nodeInfo.content) {
|
||||
timestamp = nodeInfo.content.trim().split(',')[0];
|
||||
}
|
||||
|
||||
// Get filelist from localStorage for creating command buttons
|
||||
const filelist = JSON.parse(localStorage.getItem('filelist') || '{}');
|
||||
const fileButtons = Object.keys(filelist)
|
||||
.map(fileName => `
|
||||
<button class="tooltip-cmd-btn"
|
||||
data-filename="${fileName}"
|
||||
data-nodeid="${d.id}"
|
||||
style="margin: 2px;
|
||||
padding: 6px 12px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;">
|
||||
${fileName}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
// Create tooltip content with node info and command buttons
|
||||
const tooltipContent = `
|
||||
<div style="min-width: 200px;">
|
||||
<table style="border-collapse: collapse; width: 100%; margin-bottom: 10px;">
|
||||
<tr>
|
||||
<td style="padding: 5px;"><strong>Node: ${d.id || 'Unknown'}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px;"><strong>Last Hello: ${timestamp}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="border-top: 1px solid #ccc; padding-top: 8px;">
|
||||
<div style="margin-bottom: 5px;"><strong>Available Commands:</strong></div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
|
||||
${fileButtons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
tooltip
|
||||
.html(tooltipContent)
|
||||
.style("background-color", "white")
|
||||
.style("border", "1px solid black")
|
||||
.style("padding", "10px")
|
||||
.style("border-radius", "5px")
|
||||
.style("font-size", "12px")
|
||||
.style("box-shadow", "2px 2px 6px rgba(0, 0, 0, 0.3)");
|
||||
|
||||
// Position tooltip above the node
|
||||
const tooltipNode = tooltip.node();
|
||||
const tooltipHeight = tooltipNode.getBoundingClientRect().height;
|
||||
const nodeRadius = d.id === "central" ? 15 : 10;
|
||||
const yOffset = tooltipHeight + nodeRadius + 5; // 5px extra padding
|
||||
|
||||
// Get SVG container's position
|
||||
const svgRect = svg.node().getBoundingClientRect();
|
||||
|
||||
tooltip
|
||||
.style("position", "absolute")
|
||||
.style("left", `${svgRect.left + d.x}px`)
|
||||
.style("top", `${svgRect.top + d.y - yOffset}px`)
|
||||
.style("transform", "translateX(-50%)") // Center horizontally
|
||||
.style("z-index", "1000");
|
||||
|
||||
// Add click handlers for the command buttons
|
||||
tooltip.selectAll('.tooltip-cmd-btn').on('click', function(e) {
|
||||
e.preventDefault(); // Prevent default button behavior
|
||||
e.stopPropagation(); // Stop event bubbling
|
||||
|
||||
const fileName = this.getAttribute('data-filename');
|
||||
const nodeId = this.getAttribute('data-nodeid');
|
||||
const filelist = JSON.parse(localStorage.getItem('filelist') || '{}');
|
||||
const fileContent = filelist[fileName]?.content;
|
||||
|
||||
if (fileContent) {
|
||||
const commandData = {
|
||||
toNodes: [nodeId],
|
||||
useDetectedShell: false,
|
||||
method: "cliCommand",
|
||||
methodArgs: ["/bin/bash", "-c", fileContent],
|
||||
methodTimeout: 3,
|
||||
fromNode: settings2.defaultNode,
|
||||
replyMethod: "webUI",
|
||||
ACKTimeout: 0
|
||||
};
|
||||
|
||||
if (nc) {
|
||||
nc.publish(`${nodeId}.cliCommand`, JSON.stringify(commandData));
|
||||
outputArea.value += `\nSent command from template ${fileName} to node ${nodeId}\n`;
|
||||
outputArea.scrollTop = outputArea.scrollHeight;
|
||||
} else {
|
||||
console.error("NATS connection not available");
|
||||
outputArea.value += `\nError: NATS connection not available\n`;
|
||||
}
|
||||
} else {
|
||||
console.error(`File content not found for ${fileName}`);
|
||||
outputArea.value += `\nError: File content not found for ${fileName}\n`;
|
||||
}
|
||||
});
|
||||
})
|
||||
.on("mouseout", function(event, d) {
|
||||
const tooltip = d3.select(containerId + " .tooltip");
|
||||
const toElement = event.relatedTarget;
|
||||
|
||||
// Don't hide if moving to the tooltip
|
||||
if (tooltip.node() && tooltip.node().contains(toElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start a timer to hide
|
||||
setTimeout(() => {
|
||||
// Only hide if not hovering over tooltip or node
|
||||
if (!document.querySelector(containerId + " .tooltip:hover") &&
|
||||
!document.querySelector(containerId + " .node:hover")) {
|
||||
tooltip.transition()
|
||||
.duration(300)
|
||||
.style("opacity", 0)
|
||||
.on("end", function() {
|
||||
// After fade out, set display to none
|
||||
tooltip.style("display", "none");
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Update the tooltip mouseleave handler
|
||||
tooltip.on("mouseleave", function(event) {
|
||||
const tooltip = d3.select(this);
|
||||
|
||||
setTimeout(() => {
|
||||
// Only hide if not hovering over node
|
||||
if (!document.querySelector(containerId + " .node:hover")) {
|
||||
tooltip.transition()
|
||||
.duration(300)
|
||||
.style("opacity", 0)
|
||||
.on("end", function() {
|
||||
// After fade out, set display to none
|
||||
tooltip.style("display", "none");
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Add circles to nodes
|
||||
node.append("circle")
|
||||
.attr("r", d => d.id === "central" ? 15 : 10);
|
||||
|
||||
// Add labels to nodes
|
||||
node.append("text")
|
||||
.text(d => d.id)
|
||||
.attr("x", 15)
|
||||
.attr("y", 5);
|
||||
|
||||
// Update positions on each tick
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
|
||||
node
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// Drag functions
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).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;
|
||||
}
|
||||
}
|
||||
|
||||
// Add these styles to the existing style section
|
||||
const extraStyles = document.createElement('style');
|
||||
extraStyles.textContent = `
|
||||
.extras-toggle {
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.extras-toggle:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.extras-content {
|
||||
max-height: 1000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.extras-content.collapsed {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
margin-left: 10px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(extraStyles);
|
||||
|
||||
// Add this function after your existing functions
|
||||
function autoResizeTextarea(textarea) {
|
||||
// Reset height to 1 line (using line-height as reference)
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Set new height based on scrollHeight
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
// Add this event listener with the other DOMContentLoaded event listeners
|
||||
flameGraphLink.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const popupContent = document.querySelector('#flameGraphPopup .popup-content');
|
||||
|
||||
popupContent.innerHTML = `
|
||||
<span class="close-popup">×</span>
|
||||
<h2>Flame Graph Demo</h2>
|
||||
<div id="flameGraphContainer"></div>
|
||||
`;
|
||||
|
||||
// Create demo data
|
||||
const data = {
|
||||
name: "root",
|
||||
value: 380, // Sum of all child process values
|
||||
children: [
|
||||
{
|
||||
name: "Process A",
|
||||
value: 180, // Sum of all Task A values
|
||||
children: [
|
||||
{ name: "Task A1", value: 20 },
|
||||
{ name: "Task A2", value: 20 },
|
||||
{ name: "Task A3", value: 20 },
|
||||
{ name: "Task A4", value: 20 },
|
||||
{ name: "Task A5", value: 20 },
|
||||
{ name: "Task A6", value: 20 },
|
||||
{ name: "Task A7", value: 20 },
|
||||
{ name: "Task A8", value: 20 },
|
||||
{ name: "Task A9", value: 20 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Process B",
|
||||
value: 200, // Sum of B1 (180) + B2 (20)
|
||||
children: [
|
||||
{
|
||||
name: "Task B1",
|
||||
value: 180, // Sum of subtasks
|
||||
children: [
|
||||
{ name: "Subtask B1.1", value: 90 },
|
||||
{ name: "Subtask B1.2", value: 90 }
|
||||
]
|
||||
},
|
||||
{ name: "Task B2", value: 20 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Update chart dimensions
|
||||
const chart = flamegraph()
|
||||
.width(900)
|
||||
.height(350) // Increased height but keeping it less than container height
|
||||
.cellHeight(25)
|
||||
.transitionDuration(750)
|
||||
.minFrameSize(5)
|
||||
.tooltip(true);
|
||||
|
||||
// Render flame graph
|
||||
d3.select("#flameGraphContainer")
|
||||
.datum(data)
|
||||
.call(chart);
|
||||
|
||||
// Re-attach close button handler
|
||||
document.querySelector('#flameGraphPopup .close-popup').addEventListener('click', function() {
|
||||
flameGraphPopup.style.display = "none";
|
||||
});
|
||||
|
||||
flameGraphPopup.style.display = "block";
|
||||
closeMenu();
|
||||
});
|
||||
|
||||
// Add keyboard shortcut for flame graph popup
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
flameGraphLink.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Add these styles after the other styles in the file
|
||||
const flameGraphStyles = document.createElement('style');
|
||||
flameGraphStyles.textContent = `
|
||||
#flameGraphContainer {
|
||||
width: 100%;
|
||||
height: 400px; // Increased from 200px to match chart height plus margins
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
#flameGraphPopup .popup-content {
|
||||
width: 90%;
|
||||
max-width: 1000px;
|
||||
margin: 5% auto;
|
||||
padding: 30px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; // Added to contain the graph
|
||||
}
|
||||
|
||||
.d3-flame-graph rect {
|
||||
stroke: #EEE;
|
||||
fill-opacity: .8;
|
||||
}
|
||||
|
||||
.d3-flame-graph rect:hover {
|
||||
stroke: #474747;
|
||||
stroke-width: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.d3-flame-graph-label {
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-family: Verdana;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
line-height: 1.5;
|
||||
padding: 0 0 0;
|
||||
font-weight: 400;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.d3-flame-graph .fade {
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
.d3-flame-graph .title {
|
||||
font-size: 20px;
|
||||
font-family: Verdana;
|
||||
}
|
||||
|
||||
#flameGraphPopup {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
#flameGraphLink {
|
||||
display: inline-block;
|
||||
margin: 10px;
|
||||
padding: 8px 15px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
#flameGraphLink:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(flameGraphStyles);
|
106
webui/styles.css
106
webui/styles.css
|
@ -21,10 +21,12 @@ body {
|
|||
.left-panel {
|
||||
min-width: 200px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
overflow-y: hidden;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
width: 50%; /* Set initial width */
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
|
@ -54,6 +56,7 @@ form {
|
|||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
@ -317,3 +320,102 @@ button:hover {
|
|||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Add these styles to your existing CSS */
|
||||
.graph-content {
|
||||
width: 90%;
|
||||
height: 80vh;
|
||||
margin: 5vh auto;
|
||||
}
|
||||
|
||||
#networkGraphForm {
|
||||
margin-top: 20px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#networkGraphForm h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#graphContainer2 {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Ensure the SVG fills the container */
|
||||
#graphContainer2 svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.node circle {
|
||||
fill: #69b3a2;
|
||||
stroke: #fff;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.node.central circle {
|
||||
fill: #ff7675;
|
||||
}
|
||||
|
||||
.node text {
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.link {
|
||||
stroke: #999;
|
||||
stroke-opacity: 0.6;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/* Update the tooltip styles */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
padding: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: auto; /* Changed from none to auto to allow hover */
|
||||
z-index: 1000;
|
||||
transform: translateX(-50%); /* Center horizontally */
|
||||
transition: opacity 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tooltip:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tooltip table {
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tooltip td {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tooltip td:first-child {
|
||||
color: #69b3a2;
|
||||
font-weight: bold;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
textarea.method-arg {
|
||||
min-height: 2.4em;
|
||||
height: 2.4em;
|
||||
overflow-y: hidden;
|
||||
line-height: 1.4;
|
||||
padding: 0.5em;
|
||||
resize: none;
|
||||
transition: height 0.1s ease-out;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue