diff --git a/kernel_ai/services/system_view.py b/kernel_ai/services/system_view.py index e4b1025..51896c3 100644 --- a/kernel_ai/services/system_view.py +++ b/kernel_ai/services/system_view.py @@ -285,6 +285,8 @@ def get_isolation_context(): namespace_keys = ["mnt", "pid", "net", "ipc", "uts", "user"] namespace_labels = {"mnt": "MNT", "pid": "PID", "net": "NET", "ipc": "IPC", "uts": "UTS", "user": "USER"} namespace_counts = {k: {} for k in namespace_keys} + # Per-namespace, per-inode sample process names (each inode = one isolated "world"). + namespace_samples = {k: {} for k in namespace_keys} cgroup_aggregates = {} total_scanned = 0 @@ -305,11 +307,15 @@ def get_isolation_context(): process_name = proc.info.get("name") or "unknown" agg["sample_processes"].append(process_name) + proc_name = proc.info.get("name") or "unknown" for ns_name in namespace_keys: inode = read_namespace_inode(pid, ns_name) if inode: ns_map = namespace_counts[ns_name] ns_map[inode] = ns_map.get(inode, 0) + 1 + samples = namespace_samples[ns_name].setdefault(inode, []) + if len(samples) < 5 and proc_name not in samples: + samples.append(proc_name) except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError): continue @@ -322,6 +328,15 @@ def get_isolation_context(): if entries: dominant_inode, dominant_count = max(entries.items(), key=lambda kv: kv[1]) activity = round((dominant_count / total_scanned), 3) if total_scanned > 0 else 0 + # Top isolated "worlds" for this namespace (one per inode), richest first. + worlds = [ + { + "inode": inode, + "count": count, + "sample": namespace_samples[ns_name].get(inode, []), + } + for inode, count in sorted(entries.items(), key=lambda kv: kv[1], reverse=True)[:6] + ] namespaces.append( { "id": ns_name, @@ -330,6 +345,8 @@ def get_isolation_context(): "dominant_inode": dominant_inode, "dominant_count": dominant_count, "activity": activity, + "isolated": unique_count > 1, + "worlds": worlds, } ) diff --git a/static/css/main.css b/static/css/main.css index 0e16b62..1ea760e 100755 --- a/static/css/main.css +++ b/static/css/main.css @@ -13,6 +13,11 @@ svg { height: 100vh; } +/* Модальный скрим открытого меню процесса (клик по нему закрывает меню) */ +.process-modal-scrim { + cursor: pointer; +} + /* Central circle */ .central-circle { fill: rgba(0, 0, 0, 0.09); @@ -169,6 +174,121 @@ svg { max-width: 300px; } +/* Namespace HUD tooltip (ink-on-paper FUI style) */ +.ns-hud-tooltip { + background: rgba(20, 22, 26, 0.94); + color: #e8eaef; + border: 1px solid rgba(120, 126, 138, 0.5); + border-radius: 3px; + padding: 9px 11px; + min-width: 172px; + max-width: 240px; + box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35); +} +.ns-hud-head { + font-size: 10px; + letter-spacing: 1.6px; + color: #ffffff; + padding-bottom: 5px; + margin-bottom: 6px; + border-bottom: 1px solid rgba(120, 126, 138, 0.35); +} +.ns-hud-desc { + font-size: 9.5px; + line-height: 1.35; + color: #aab0bc; + margin-bottom: 7px; +} +.ns-hud-row { + display: flex; + justify-content: space-between; + font-size: 9.5px; + letter-spacing: 0.4px; + padding: 1px 0; +} +.ns-hud-row span { color: #8b909c; } +.ns-hud-row b { color: #e8eaef; font-weight: 600; } +.ns-hud-bar { + margin-top: 7px; + height: 4px; + border-radius: 2px; + background: rgba(120, 126, 138, 0.25); + overflow: hidden; +} +.ns-hud-bar i { + display: block; + height: 100%; + background: rgba(88, 182, 216, 0.85); +} +.ns-hud-foot { + margin-top: 4px; + font-size: 8px; + letter-spacing: 1.2px; + color: #7f8794; + text-align: right; +} + +/* Marker for namespace cells that contain real isolation (unique_count > 1) */ +.ns-isolated-marker { + animation: nsIsolatedPulse 1.8s ease-in-out infinite; + transform-box: fill-box; + transform-origin: center; +} +@keyframes nsIsolatedPulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 1; } +} + +/* Namespace system-info tree (unfolds from a namespace cell) */ +.ns-tree-conn { stroke: rgba(90, 94, 102, 0.75); stroke-width: 1.3; fill: none; } +.ns-tree-anchor { fill: rgba(60, 63, 70, 0.9); } +.ns-tree-frame { + fill: rgba(20, 22, 26, 0.95); + stroke: rgba(120, 126, 138, 0.5); + stroke-width: 1; +} +.ns-tree-title { + fill: #ffffff; + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + letter-spacing: 1.5px; +} +.ns-tree-link { stroke: rgba(150, 156, 168, 0.5); stroke-width: 1; fill: none; } +.ns-tree-root rect { + fill: rgba(88, 182, 216, 0.9); + stroke: rgba(88, 182, 216, 1); + stroke-width: 1; +} +.ns-tree-root text { + fill: #06131b; + font-family: 'Share Tech Mono', monospace; + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.6px; +} +.ns-tree-branch rect { + fill: rgba(255, 255, 255, 0.1); + stroke: rgba(255, 255, 255, 0.28); + stroke-width: 1; +} +.ns-tree-branch text { + fill: #eef1f5; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 1px; +} +.ns-tree-leaf rect { + fill: rgba(255, 255, 255, 0.045); + stroke: rgba(120, 126, 138, 0.42); + stroke-width: 1; +} +.ns-tree-leaf text { + fill: #c4c9d3; + font-family: 'Share Tech Mono', monospace; + font-size: 8.5px; + letter-spacing: 0.3px; +} + /* Bezier curve styles - matching connection lines */ .bezier-curve { fill: none; diff --git a/static/js/isolation-ui.js b/static/js/isolation-ui.js index 579d633..080a86a 100644 --- a/static/js/isolation-ui.js +++ b/static/js/isolation-ui.js @@ -30,6 +30,7 @@ function drawIsolationConceptLayer(centerX, centerY, width, height) { const mobileLayout = isMobileLayout(); // Skip overlay in Matrix/DNA modes to keep views clean. if (window.kernelContextMenu && ['matrix', 'dna', 'dna-timeline'].includes(window.kernelContextMenu.currentView)) { + collapseNamespaceTree(false); return; } @@ -48,6 +49,38 @@ function drawIsolationConceptLayer(centerX, centerY, width, height) { }); } +// Short human meaning of each Linux namespace, shown in the HUD tooltip. +const NS_META = { + mnt: { name: 'MOUNT', isolates: 'Mount points, filesystem tree' }, + pid: { name: 'PID', isolates: 'Process tree, PID isolation' }, + net: { name: 'NET', isolates: 'Interfaces, stack, ports, routes' }, + ipc: { name: 'IPC', isolates: 'System V IPC, POSIX queues' }, + uts: { name: 'UTS', isolates: 'Hostname and domain name' }, + user: { name: 'USER', isolates: 'UID/GID mapping, privileges' }, + cgroup: { name: 'CGROUP', isolates: 'Cgroup hierarchy root' }, + time: { name: 'TIME', isolates: 'boottime / monotonic clocks' }, +}; + +// Kernel facets each namespace isolates (leaves of the unfolding tree). +const NS_FACETS = { + mnt: ['mount table', 'root filesystem', 'bind & propagation'], + pid: ['process tree', 'PID 1 (init)', '/proc view'], + net: ['interfaces', 'routes & ARP', 'sockets & ports'], + ipc: ['SysV shm / sem', 'POSIX mqueues'], + uts: ['hostname', 'domain name'], + user: ['UID / GID map', 'capabilities'], + cgroup: ['cgroup root'], + time: ['boottime clock', 'monotonic clock'], +}; + +// Where the namespace pointer lives inside the kernel task struct. +const NS_KIND = { + mnt: 'nsproxy', net: 'nsproxy', uts: 'nsproxy', ipc: 'nsproxy', + time: 'nsproxy', cgroup: 'nsproxy', pid: 'pid struct', user: 'cred', +}; + +let expandedNsId = null; + function drawNamespaceShell(centerX, centerY, namespaces) { const shellGroup = svg.selectAll('.tag-icon').empty() ? svg.append('g').attr('class', 'namespace-shell-layer') @@ -62,18 +95,38 @@ function drawNamespaceShell(centerX, centerY, namespaces) { const numSlots = 8; const angleStep = (2 * Math.PI) / numSlots; - const gap = 0.04; - // Keep enlarged scale while restoring "circle slice" geometry. - // Center namespace slices on Icon1 orbit (r=150). + const gap = 0.045; + // Center namespace slices on the Icon1 orbit (r=150). const ringInner = 110; const ringOuter = 190; + // Ink-on-paper palette (page bg is #e6e6e6 → use dark ink for contrast). + const INK = '58, 61, 68'; + const cells = []; + + const restoreFocus = () => { + cells.forEach(c => { + c.segment.attr('opacity', 1); + c.halo.attr('opacity', 0); + c.label.attr('opacity', 1).style('fill', `rgb(${INK})`); + if (c.count) c.count.attr('opacity', 1); + }); + }; + + const setFocus = (idx) => { + cells.forEach((c, j) => { + const focused = j === idx; + c.segment.attr('opacity', focused ? 1 : 0.22); + c.halo.attr('opacity', focused ? 1 : 0); + c.label + .attr('opacity', focused ? 1 : 0.22) + .style('fill', focused ? '#1f2228' : `rgb(${INK})`); + if (c.count) c.count.attr('opacity', focused ? 1 : 0.22); + }); + }; + for (let i = 0; i < numSlots; i++) { const ns = namespaceSlots[i]; - if (!ns) continue; // keep free slots empty - - const activity = Math.max(0, Math.min(1, Number(ns.activity || 0))); - // Center each namespace slice on the corresponding Icon1 angle. const centerAngle = i * angleStep; const startAngle = centerAngle - angleStep / 2 + gap; const endAngle = centerAngle + angleStep / 2 - gap; @@ -81,55 +134,318 @@ function drawNamespaceShell(centerX, centerY, namespaces) { .innerRadius(ringInner) .outerRadius(ringOuter) .startAngle(startAngle) - .endAngle(endAngle); + .endAngle(endAngle) + .cornerRadius(6); + const dPath = arcPath(); + + // Empty slot → muted "unused" placeholder so the ring reads as complete. + if (!ns) { + shellGroup.append('path') + .attr('d', dPath) + .attr('transform', `translate(${centerX}, ${centerY})`) + .attr('fill', 'rgba(90, 92, 98, 0.03)') + .attr('stroke', 'rgba(120, 122, 128, 0.28)') + .attr('stroke-width', 0.8) + .attr('stroke-dasharray', '2 4'); + continue; + } + + const activity = Math.max(0, Math.min(1, Number(ns.activity || 0))); + const meta = NS_META[ns.id] || {}; + const nsName = ns.label || meta.name || String(ns.id || 'NS').toUpperCase(); + // A cell with more than one distinct inode contains real isolation + // (containers / sandboxes) — the most security-relevant signal. + const isolated = !!ns.isolated || Number(ns.unique_count || 0) > 1; + const ACCENT = '88, 182, 216'; + + // Soft focus halo behind the segment (hidden until hover). + const halo = shellGroup.append('path') + .attr('d', dPath) + .attr('transform', `translate(${centerX}, ${centerY})`) + .attr('fill', 'none') + .attr('stroke', `rgba(${INK}, 0.20)`) + .attr('stroke-width', 7) + .attr('opacity', 0) + .style('pointer-events', 'none'); const segment = shellGroup.append('path') - .attr('d', arcPath()) + .attr('d', dPath) .attr('transform', `translate(${centerX}, ${centerY})`) - .attr('fill', `rgba(60, 60, 60, ${0.07 + activity * 0.16})`) - .attr('stroke', `rgba(90, 90, 90, ${0.5 + activity * 0.32})`) - .attr('stroke-width', 1 + activity * 1.4) - .style('cursor', 'help'); + .attr('fill', isolated + ? `rgba(${ACCENT}, ${0.08 + activity * 0.2})` + : `rgba(${INK}, ${0.06 + activity * 0.22})`) + .attr('stroke', isolated + ? `rgba(${ACCENT}, ${0.7 + activity * 0.3})` + : `rgba(${INK}, ${0.42 + activity * 0.4})`) + .attr('stroke-width', isolated ? 1.8 + activity * 1.4 : 1.1 + activity * 1.6) + .style('cursor', 'pointer'); const mid = (startAngle + endAngle) / 2; - const labelR = ringOuter - 12; + const labelR = ringOuter - 13; const lx = centerX + Math.cos(mid - Math.PI / 2) * labelR; const ly = centerY + Math.sin(mid - Math.PI / 2) * labelR; - shellGroup.append('text') + const label = shellGroup.append('text') .attr('x', lx) .attr('y', ly) .attr('text-anchor', 'middle') .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '8px') - .style('letter-spacing', '0.5px') - .style('fill', '#d2d6de') - .text(ns.label || String(ns.id || 'NS').toUpperCase()); + .style('font-size', '8.5px') + .style('letter-spacing', '1.2px') + .style('fill', `rgb(${INK})`) + .style('pointer-events', 'none') + .text(nsName.toUpperCase()); + + // Always-visible live count near the inner edge. + const countR = ringInner + 12; + const cx = centerX + Math.cos(mid - Math.PI / 2) * countR; + const cy = centerY + Math.sin(mid - Math.PI / 2) * countR; + const count = shellGroup.append('text') + .attr('x', cx) + .attr('y', cy) + .attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '9px') + .style('font-weight', '600') + .style('letter-spacing', '0.3px') + .style('fill', isolated ? `rgb(${ACCENT})` : `rgba(${INK}, 0.72)`) + .style('pointer-events', 'none') + .text(String(ns.unique_count || 0)); + + // Pulsing marker flags cells that actually contain isolation. + if (isolated) { + const markR = ringOuter + 6; + shellGroup.append('circle') + .attr('class', 'ns-isolated-marker') + .attr('cx', centerX + Math.cos(mid - Math.PI / 2) * markR) + .attr('cy', centerY + Math.sin(mid - Math.PI / 2) * markR) + .attr('r', 2.6) + .attr('fill', `rgb(${ACCENT})`) + .style('pointer-events', 'none'); + } + + const idx = cells.length; + cells.push({ segment, halo, label, count }); segment .on('mouseenter', (event) => { + setFocus(idx); d3.selectAll('.ns-tooltip').remove(); d3.select('body') .append('div') - .attr('class', 'tooltip ns-tooltip') - .style('opacity', 0.95) - .style('left', `${event.pageX + 10}px`) + .attr('class', 'tooltip ns-tooltip ns-hud-tooltip') + .style('left', `${event.pageX + 14}px`) .style('top', `${event.pageY - 10}px`) .html(` - Namespace ${ns.label || String(ns.id || '').toUpperCase()}
- Unique: ${ns.unique_count || 0}
- Dominant: ${ns.dominant_count || 0} procs
- Inode: ${ns.dominant_inode || 'n/a'} +
NAMESPACE · ${nsName.toUpperCase()}
+
${meta.isolates || 'Resource isolation'}
+
UNIQUE${ns.unique_count || 0}
+
DOMINANT${ns.dominant_count || 0} procs
+
INODE${ns.dominant_inode || 'n/a'}
+
+
ACTIVITY ${Math.round(activity * 100)}%
`); }) .on('mousemove', (event) => { d3.selectAll('.ns-tooltip') - .style('left', `${event.pageX + 10}px`) + .style('left', `${event.pageX + 14}px`) .style('top', `${event.pageY - 10}px`); }) - .on('mouseleave', () => d3.selectAll('.ns-tooltip').remove()); + .on('mouseleave', () => { + restoreFocus(); + d3.selectAll('.ns-tooltip').remove(); + }) + .on('click', (event) => { + event.stopPropagation(); + d3.selectAll('.ns-tooltip').remove(); + if (expandedNsId === ns.id) { + collapseNamespaceTree(); + } else { + restoreFocus(); + expandNamespaceTree(ns, meta, nsName, centerX, centerY, mid); + } + }); } } +function collapseNamespaceTree(animated = true) { + expandedNsId = null; + d3.select('body').on('keydown.nstree', null); + const layer = svg.selectAll('.ns-tree-layer'); + const scrim = svg.selectAll('.ns-tree-scrim'); + if (layer.empty() && scrim.empty()) return; + if (!animated) { layer.remove(); scrim.remove(); return; } + scrim.transition().duration(220).style('opacity', 0).remove(); + layer.transition().duration(200).style('opacity', 0).remove(); +} + +// Mechanically unfold a system-info tree out of the clicked namespace cell. +function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { + collapseNamespaceTree(false); + expandedNsId = ns.id; + + const svgNode = svg.node(); + const W = (svgNode && svgNode.clientWidth) || window.innerWidth; + const H = (svgNode && svgNode.clientHeight) || window.innerHeight; + + // Light scrim calms the busy ring behind the readout. + svg.append('rect') + .attr('class', 'ns-tree-scrim') + .attr('x', 0).attr('y', 0).attr('width', W).attr('height', H) + .attr('fill', 'rgba(230, 231, 233, 0.62)') + .style('opacity', 0) + .style('cursor', 'pointer') + .on('click', () => collapseNamespaceTree()) + .transition().duration(220).style('opacity', 1); + + const layer = svg.append('g').attr('class', 'ns-tree-layer'); + + // Anchor = outer-mid point of the clicked cell (the "square" it grows from). + const a = mid - Math.PI / 2; + const rx = Math.cos(a), ry = Math.sin(a); + const anchorX = cx + rx * 202; + const anchorY = cy + ry * 202; + + // Build the tree: identity + live isolated worlds + kernel facets. + const kind = NS_KIND[ns.id] || 'nsproxy'; + const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + const worlds = Array.isArray(ns.worlds) ? ns.worlds : []; + const worldLeaves = worlds.length + ? worlds.map((w) => ({ + label: clip(`${w.count}p · ${(w.sample && w.sample[0]) || '—'}`, 20), + title: `inode ${w.inode} · ${w.count} procs\n${(w.sample || []).join(', ') || 'n/a'}`, + })) + : [{ label: `${ns.dominant_count || 0} procs` }]; + + const branches = [ + { label: 'IDENTITY', leaves: [ + { label: clip(`inode ${ns.dominant_inode || 'n/a'}`, 20) }, + { label: `/proc/1/ns/${ns.id}` }, + { label: `via ${kind} · ${Math.round((ns.activity || 0) * 100)}%` }, + ] }, + { label: `WORLDS (${ns.unique_count || 0})`, leaves: worldLeaves }, + { label: 'ISOLATES', leaves: (NS_FACETS[ns.id] || ['resource']).map((f) => ({ label: f })) }, + ]; + + // Panel geometry (local content coords). + const rowH = 24; + const headerH = 26; + const padX = 14; + const col0 = padX; // root chip + const rootW = 54; + const col1 = padX + 74; // branch chips + const branchW = 96; + const col2 = padX + 190; // leaf chips + const leafW = 150; + const panelW = col2 + leafW + padX; + const totalLeaves = branches.reduce((s, b) => s + b.leaves.length, 0); + const panelH = headerH + 10 + totalLeaves * rowH + 12; + + // Assign rows: leaves stack, branch = mean of its leaves, root = mean of branches. + let row = 0; + const contentTop = headerH + 10; + const laidBranches = branches.map((br) => { + const leafYs = br.leaves.map(() => contentTop + (row++) * rowH + rowH / 2); + const by = leafYs.reduce((s, v) => s + v, 0) / leafYs.length; + return { ...br, by, leafYs }; + }); + const rootY = laidBranches.reduce((s, b) => s + b.by, 0) / laidBranches.length; + + // Position panel; grow away from screen centre, clamp to viewport. + const dir = anchorX >= cx ? 1 : -1; + let panelX = dir === 1 ? anchorX + 24 : anchorX - 24 - panelW; + let panelY = anchorY - panelH / 2; + const m = 12; + panelX = Math.max(m, Math.min(W - panelW - m, panelX)); + panelY = Math.max(m, Math.min(H - panelH - m, panelY)); + + // Connector from the cell → panel edge ("unfolds from the square"). + const connX = dir === 1 ? panelX : panelX + panelW; + const connY = Math.max(panelY + 12, Math.min(panelY + panelH - 12, anchorY)); + layer.append('circle') + .attr('class', 'ns-tree-anchor') + .attr('cx', anchorX).attr('cy', anchorY).attr('r', 3); + layer.append('line') + .attr('class', 'ns-tree-conn') + .attr('x1', anchorX).attr('y1', anchorY) + .attr('x2', anchorX).attr('y2', anchorY) + .transition().duration(240).ease(d3.easeCubicOut) + .attr('x2', connX).attr('y2', connY); + + const panel = layer.append('g') + .attr('transform', `translate(${panelX}, ${panelY})`) + .on('click', (event) => event.stopPropagation()); + + // Frame unfolds vertically from its centre (mechanical open). + panel.append('rect') + .attr('class', 'ns-tree-frame') + .attr('width', panelW).attr('height', panelH).attr('rx', 4) + .attr('transform', `translate(0, ${panelH / 2}) scale(1, 0.02)`) + .transition().delay(160).duration(220).ease(d3.easeCubicOut) + .attr('transform', 'translate(0,0) scale(1,1)'); + + panel.append('text') + .attr('class', 'ns-tree-title') + .attr('x', padX).attr('y', 17) + .style('opacity', 0) + .text(`NAMESPACE · ${nsName.toUpperCase()}`) + .transition().delay(300).duration(200).style('opacity', 1); + + const linksG = panel.append('g').attr('class', 'ns-tree-links'); + const nodesG = panel.append('g').attr('class', 'ns-tree-nodes'); + + const elbow = (sx, sy, tx, ty) => { + const mx = (sx + tx) / 2; + return `M ${sx},${sy} H ${mx} V ${ty} H ${tx}`; + }; + const addLink = (sx, sy, tx, ty, delay) => { + linksG.append('path') + .attr('class', 'ns-tree-link') + .attr('d', elbow(sx, sy, tx, ty)) + .style('opacity', 0) + .transition().delay(delay).duration(200) + .style('opacity', 1); + }; + const addChip = (fromX, fromY, x, y, w, label, cls, delay, title) => { + const g = nodesG.append('g') + .attr('class', cls) + .attr('transform', `translate(${fromX}, ${fromY}) scale(0.1)`) + .style('opacity', 0); + if (title) { + g.style('cursor', 'help').append('title').text(title); + } + g.append('rect') + .attr('x', 0).attr('y', -9).attr('width', w).attr('height', 18).attr('rx', 3); + g.append('text') + .attr('x', 8).attr('y', 4) + .text(label); + g.transition().delay(delay).duration(260).ease(d3.easeBackOut.overshoot(1.5)) + .attr('transform', `translate(${x}, ${y}) scale(1)`) + .style('opacity', 1); + return g; + }; + + // Root chip. + addChip(col0, rootY, col0, rootY, rootW, nsName.toUpperCase(), 'ns-tree-root', 120); + + // Branches + leaves. + let leafSeq = 0; + laidBranches.forEach((br, bi) => { + const branchDelay = 260 + bi * 90; + addLink(col0 + rootW, rootY, col1, br.by, branchDelay - 40); + addChip(col0 + rootW, rootY, col1, br.by, branchW, br.label, 'ns-tree-branch', branchDelay); + br.leaves.forEach((lf, li) => { + const leafDelay = 430 + leafSeq * 55; + leafSeq += 1; + addLink(col1 + branchW, br.by, col2, br.leafYs[li], leafDelay - 40); + addChip(col1 + branchW, br.by, col2, br.leafYs[li], leafW, lf.label, 'ns-tree-leaf', leafDelay, lf.title); + }); + }); + + d3.select('body').on('keydown.nstree', (e) => { + if (e.key === 'Escape') collapseNamespaceTree(); + }); +} + function drawCgroupConceptCard(width, height, topCgroups) { if (!topCgroups || topCgroups.length === 0) return; const cgroup = topCgroups[0]; diff --git a/static/js/main.js b/static/js/main.js index 13c0a10..49ccb3a 100755 --- a/static/js/main.js +++ b/static/js/main.js @@ -1498,6 +1498,8 @@ function clearPinnedProcessDossier() { const pinnedPid = pinnedProcessDossier.process?.pid; const isHighlighted = window.__highlightedProcess && window.__highlightedProcess.pid === pinnedPid; pinnedProcessDossier = null; + stopProcessModalTopKeeper(); + d3.selectAll('.process-modal-scrim').remove(); d3.selectAll('.process-dossier-layer').remove(); d3.selectAll('.process-interaction-module').remove(); d3.selectAll('.process-node-group').classed('process-pinned', false); @@ -1519,6 +1521,78 @@ function clearPinnedProcessDossier() { } } +// Единый "модальный" скрим под панелями открытого меню процесса. +// Мягкая бумажная вуаль с лёгкой виньеткой к краям гасит плотную основную +// сцену, чтобы панели читались как сфокусированный слой, а не случайные окна. +function ensureProcessModalScrim() { + if (isMobileLayout()) return; + if (!svg.select('.process-modal-scrim').empty()) return; + + let defs = svg.select('defs'); + if (defs.empty()) defs = svg.append('defs'); + if (svg.select('#process-scrim-grad').empty()) { + const grad = defs.append('radialGradient') + .attr('id', 'process-scrim-grad') + .attr('cx', '50%').attr('cy', '46%').attr('r', '65%'); + grad.append('stop').attr('offset', '0%').attr('stop-color', '#e4e4df').attr('stop-opacity', 0.34); + grad.append('stop').attr('offset', '68%').attr('stop-color', '#deded8').attr('stop-opacity', 0.58); + grad.append('stop').attr('offset', '100%').attr('stop-color', '#d3d3cc').attr('stop-opacity', 0.76); + } + + const w = window.innerWidth; + const h = window.innerHeight; + svg.append('rect') + .attr('class', 'process-modal-scrim') + .attr('x', -80).attr('y', -80) + .attr('width', w + 160).attr('height', h + 160) + .attr('fill', 'url(#process-scrim-grad)') + .style('pointer-events', 'all') + .style('opacity', 0) + .transition().duration(220).style('opacity', 1); +} + +// Держим фокус-слой корректным по z-order: любые "живые" элементы (анимация +// syscalls и т.п.), дорисованные в svg ПОСЛЕ вуали, задвигаем ПОД неё. Панели +// меню (scrim/dossier/module) не трогаем — иначе рестартовали бы их анимации. +let processModalTopObserver = null; + +function buryLiveLayersUnderScrim() { + const svgNode = svg.node(); + if (!svgNode) return; + const scrimNode = svgNode.querySelector('.process-modal-scrim'); + if (!scrimNode) return; + const modalClasses = ['process-modal-scrim', 'process-dossier-layer', 'process-interaction-module']; + const toMove = []; + for (let sib = scrimNode.nextSibling; sib; sib = sib.nextSibling) { + if (sib.nodeType !== 1) continue; + const cl = sib.classList; + if (cl && modalClasses.some(c => cl.contains(c))) continue; + toMove.push(sib); + } + toMove.forEach(n => svgNode.insertBefore(n, scrimNode)); +} + +function startProcessModalTopKeeper() { + const svgNode = svg.node(); + if (!svgNode || typeof MutationObserver === 'undefined') return; + buryLiveLayersUnderScrim(); + if (processModalTopObserver) return; + processModalTopObserver = new MutationObserver(() => { + if (!pinnedProcessDossier) return; + processModalTopObserver.disconnect(); + buryLiveLayersUnderScrim(); + processModalTopObserver.observe(svgNode, { childList: true }); + }); + processModalTopObserver.observe(svgNode, { childList: true }); +} + +function stopProcessModalTopKeeper() { + if (processModalTopObserver) { + processModalTopObserver.disconnect(); + processModalTopObserver = null; + } +} + function pinProcessDossier(processData, anchor) { pinnedProcessDossier = { process: processData, @@ -1527,8 +1601,10 @@ function pinProcessDossier(processData, anchor) { ? pinnedProcessDossier.details : {} }; + ensureProcessModalScrim(); renderProcessDossier(); renderProcessInteractionModule(window.innerWidth / 2, window.innerHeight / 2, processData, anchor, pinnedProcessDossier.details); + startProcessModalTopKeeper(); if (window.nginxFilesManager && typeof window.nginxFilesManager.highlightProcessFiles === 'function') { window.nginxFilesManager.highlightProcessFiles(processData.pid); }