Compare commits
2 Commits
41be660d94
...
d870219136
| Author | SHA1 | Date | |
|---|---|---|---|
| d870219136 | |||
| 52436f8b93 |
@@ -2,6 +2,7 @@
|
|||||||
class NodeDetailsComponent extends Component {
|
class NodeDetailsComponent extends Component {
|
||||||
constructor(container, viewModel, eventBus) {
|
constructor(container, viewModel, eventBus) {
|
||||||
super(container, viewModel, eventBus);
|
super(container, viewModel, eventBus);
|
||||||
|
this.suppressLoadingUI = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupViewModelListeners() {
|
setupViewModelListeners() {
|
||||||
@@ -31,6 +32,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
// Handle loading state update
|
// Handle loading state update
|
||||||
handleLoadingUpdate(isLoading) {
|
handleLoadingUpdate(isLoading) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
if (this.suppressLoadingUI) return;
|
||||||
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,13 @@ class NodeDetailsComponent extends Component {
|
|||||||
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
|
<button class="tab-button ${activeTab === 'endpoints' ? 'active' : ''}" data-tab="endpoints">Endpoints</button>
|
||||||
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
|
<button class="tab-button ${activeTab === 'tasks' ? 'active' : ''}" data-tab="tasks">Tasks</button>
|
||||||
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
|
<button class="tab-button ${activeTab === 'firmware' ? 'active' : ''}" data-tab="firmware">Firmware</button>
|
||||||
|
<button class="tab-refresh-btn" title="Refresh current tab" aria-label="Refresh">
|
||||||
|
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||||
|
<path d="M1 4v6h6" />
|
||||||
|
<path d="M23 20v-6h-6" />
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
|
<div class="tab-content ${activeTab === 'status' ? 'active' : ''}" id="status-tab">
|
||||||
@@ -145,6 +154,7 @@ class NodeDetailsComponent extends Component {
|
|||||||
|
|
||||||
this.setHTML('', html);
|
this.setHTML('', html);
|
||||||
this.setupTabs();
|
this.setupTabs();
|
||||||
|
this.setupTabRefreshButton();
|
||||||
// Restore last active tab from view model if available
|
// Restore last active tab from view model if available
|
||||||
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
|
const restored = this.viewModel && typeof this.viewModel.get === 'function' ? this.viewModel.get('activeTab') : null;
|
||||||
if (restored) {
|
if (restored) {
|
||||||
@@ -153,6 +163,46 @@ class NodeDetailsComponent extends Component {
|
|||||||
this.setupFirmwareUpload();
|
this.setupFirmwareUpload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupTabRefreshButton() {
|
||||||
|
const btn = this.findElement('.tab-refresh-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
this.addEventListener(btn, 'click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg class="refresh-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||||
|
<path d="M1 4v6h6" />
|
||||||
|
<path d="M23 20v-6h-6" />
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeTab = (this.viewModel && typeof this.viewModel.get === 'function') ? (this.viewModel.get('activeTab') || 'status') : 'status';
|
||||||
|
const nodeIp = (this.viewModel && typeof this.viewModel.get === 'function') ? this.viewModel.get('nodeIp') : null;
|
||||||
|
this.suppressLoadingUI = true;
|
||||||
|
|
||||||
|
if (activeTab === 'endpoints' && typeof this.viewModel.loadEndpointsData === 'function') {
|
||||||
|
await this.viewModel.loadEndpointsData();
|
||||||
|
} else if (activeTab === 'tasks' && typeof this.viewModel.loadTasksData === 'function') {
|
||||||
|
await this.viewModel.loadTasksData();
|
||||||
|
} else {
|
||||||
|
// status or firmware: refresh core node details
|
||||||
|
if (nodeIp && typeof this.viewModel.loadNodeDetails === 'function') {
|
||||||
|
await this.viewModel.loadNodeDetails(nodeIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Tab refresh failed:', err);
|
||||||
|
} finally {
|
||||||
|
this.suppressLoadingUI = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderEndpointsTab(endpoints) {
|
renderEndpointsTab(endpoints) {
|
||||||
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
|
if (!endpoints || !Array.isArray(endpoints.endpoints) || endpoints.endpoints.length === 0) {
|
||||||
return `
|
return `
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class TopologyGraphComponent extends Component {
|
|||||||
this.detailsDrawerContent = null;
|
this.detailsDrawerContent = null;
|
||||||
this.detailsDrawerBackdrop = null;
|
this.detailsDrawerBackdrop = null;
|
||||||
this.activeDrawerComponent = null;
|
this.activeDrawerComponent = null;
|
||||||
|
|
||||||
|
// Tooltip for labels on hover
|
||||||
|
this.tooltipEl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine desktop threshold
|
// Determine desktop threshold
|
||||||
@@ -101,6 +104,52 @@ class TopologyGraphComponent extends Component {
|
|||||||
if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
|
if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltip helpers
|
||||||
|
ensureTooltip() {
|
||||||
|
if (this.tooltipEl) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'topology-tooltip';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
this.tooltipEl = el;
|
||||||
|
}
|
||||||
|
|
||||||
|
showTooltip(nodeData, pageX, pageY) {
|
||||||
|
this.ensureTooltip();
|
||||||
|
const labels = (nodeData && nodeData.labels) ? nodeData.labels : ((nodeData && nodeData.resources) ? nodeData.resources : null);
|
||||||
|
if (!labels || Object.keys(labels).length === 0) {
|
||||||
|
this.hideTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chips = Object.entries(labels)
|
||||||
|
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
||||||
|
.join('');
|
||||||
|
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>`;
|
||||||
|
this.positionTooltip(pageX, pageY);
|
||||||
|
this.tooltipEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
positionTooltip(pageX, pageY) {
|
||||||
|
if (!this.tooltipEl) return;
|
||||||
|
const offset = 12;
|
||||||
|
let left = pageX + offset;
|
||||||
|
let top = pageY + offset;
|
||||||
|
const { innerWidth, innerHeight } = window;
|
||||||
|
const rect = this.tooltipEl.getBoundingClientRect();
|
||||||
|
if (left + rect.width > innerWidth - 8) left = pageX - rect.width - offset;
|
||||||
|
if (top + rect.height > innerHeight - 8) top = pageY - rect.height - offset;
|
||||||
|
this.tooltipEl.style.left = `${Math.max(8, left)}px`;
|
||||||
|
this.tooltipEl.style.top = `${Math.max(8, top)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTooltip(pageX, pageY) {
|
||||||
|
if (!this.tooltipEl || !this.tooltipEl.classList.contains('visible')) return;
|
||||||
|
this.positionTooltip(pageX, pageY);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideTooltip() {
|
||||||
|
if (this.tooltipEl) this.tooltipEl.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
updateDimensions(container) {
|
updateDimensions(container) {
|
||||||
// Get the container's actual dimensions
|
// Get the container's actual dimensions
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
@@ -377,15 +426,25 @@ class TopologyGraphComponent extends Component {
|
|||||||
node.append('text')
|
node.append('text')
|
||||||
.text(d => d.ip)
|
.text(d => d.ip)
|
||||||
.attr('x', 15)
|
.attr('x', 15)
|
||||||
.attr('y', 20)
|
.attr('y', 22)
|
||||||
.attr('font-size', '11px')
|
.attr('font-size', '11px')
|
||||||
.attr('fill', 'var(--text-secondary)');
|
.attr('fill', 'var(--text-secondary)');
|
||||||
|
|
||||||
|
// App label (between IP and Status)
|
||||||
|
node.append('text')
|
||||||
|
.text(d => (d.labels && d.labels.app) ? String(d.labels.app) : '')
|
||||||
|
.attr('x', 15)
|
||||||
|
.attr('y', 38)
|
||||||
|
.attr('font-size', '11px')
|
||||||
|
.attr('fill', 'var(--text-secondary)')
|
||||||
|
.attr('font-weight', '500')
|
||||||
|
.attr('display', d => (d.labels && d.labels.app) ? null : 'none');
|
||||||
|
|
||||||
// Status text
|
// Status text
|
||||||
node.append('text')
|
node.append('text')
|
||||||
.text(d => d.status)
|
.text(d => d.status)
|
||||||
.attr('x', 15)
|
.attr('x', 15)
|
||||||
.attr('y', 35)
|
.attr('y', 56)
|
||||||
.attr('font-size', '11px')
|
.attr('font-size', '11px')
|
||||||
.attr('fill', d => this.getNodeColor(d.status))
|
.attr('fill', d => this.getNodeColor(d.status))
|
||||||
.attr('font-weight', '600');
|
.attr('font-weight', '600');
|
||||||
@@ -448,12 +507,18 @@ class TopologyGraphComponent extends Component {
|
|||||||
d3.select(event.currentTarget).select('circle')
|
d3.select(event.currentTarget).select('circle')
|
||||||
.attr('r', d => this.getNodeRadius(d.status) + 4)
|
.attr('r', d => this.getNodeRadius(d.status) + 4)
|
||||||
.attr('stroke-width', 3);
|
.attr('stroke-width', 3);
|
||||||
|
this.showTooltip(d, event.pageX, event.pageY);
|
||||||
});
|
});
|
||||||
|
|
||||||
node.on('mouseout', (event, d) => {
|
node.on('mouseout', (event, d) => {
|
||||||
d3.select(event.currentTarget).select('circle')
|
d3.select(event.currentTarget).select('circle')
|
||||||
.attr('r', d => this.getNodeRadius(d.status))
|
.attr('r', d => this.getNodeRadius(d.status))
|
||||||
.attr('stroke-width', 2);
|
.attr('stroke-width', 2);
|
||||||
|
this.hideTooltip();
|
||||||
|
});
|
||||||
|
|
||||||
|
node.on('mousemove', (event, d) => {
|
||||||
|
this.moveTooltip(event.pageX, event.pageY);
|
||||||
});
|
});
|
||||||
|
|
||||||
link.on('mouseover', (event, d) => {
|
link.on('mouseover', (event, d) => {
|
||||||
@@ -696,7 +761,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
hostname: nodeData.hostname,
|
hostname: nodeData.hostname,
|
||||||
status: this.normalizeStatus(nodeData.status),
|
status: this.normalizeStatus(nodeData.status),
|
||||||
latency: nodeData.latency,
|
latency: nodeData.latency,
|
||||||
labels: nodeData.resources || {}
|
labels: (nodeData.labels && typeof nodeData.labels === 'object') ? nodeData.labels : (nodeData.resources || {})
|
||||||
};
|
};
|
||||||
|
|
||||||
this.memberOverlayComponent.show(memberData);
|
this.memberOverlayComponent.show(memberData);
|
||||||
|
|||||||
@@ -536,6 +536,8 @@ class TopologyViewModel extends ViewModel {
|
|||||||
ip: member.ip,
|
ip: member.ip,
|
||||||
status: member.status || 'UNKNOWN',
|
status: member.status || 'UNKNOWN',
|
||||||
latency: member.latency || 0,
|
latency: member.latency || 0,
|
||||||
|
// Preserve both legacy 'resources' and preferred 'labels'
|
||||||
|
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
||||||
resources: member.resources || {},
|
resources: member.resources || {},
|
||||||
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
||||||
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
||||||
|
|||||||
@@ -2184,6 +2184,29 @@ p {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Refresh button aligned to the right of tabs, blends with tab header */
|
||||||
|
.tabs-header .tab-refresh-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.tabs-header .tab-refresh-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.tabs-header .tab-refresh-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -2925,6 +2948,34 @@ p {
|
|||||||
.details-drawer-backdrop { display: none; }
|
.details-drawer-backdrop { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Topology hover tooltip for labels */
|
||||||
|
.topology-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1001;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
box-shadow: var(--shadow-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.topology-tooltip.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.topology-tooltip .member-labels {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.topology-tooltip .label-chip {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Labels section styling in node details */
|
/* Labels section styling in node details */
|
||||||
.detail-section {
|
.detail-section {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|||||||
Reference in New Issue
Block a user