feat(web): MazeNET 7b — service-level selection + inspector panel
Clicking a service tag selects it (stops node drag), extends Selection
discriminant with {type:'service',id,nodeId}, and renders an inspector
panel showing proto/port/subnet/risk chip + REMOVE SERVICE button
(gated off for observed nodes and degraded topologies). Service-tag
styling now pulls `risk` from DEFAULT_SERVICES metadata instead of
node.status alone.
This commit is contained in:
@@ -30,6 +30,7 @@ interface Props {
|
|||||||
onAutoLayout?: () => void;
|
onAutoLayout?: () => void;
|
||||||
sseConnected?: boolean;
|
sseConnected?: boolean;
|
||||||
lastEventAt?: Date | null;
|
lastEventAt?: Date | null;
|
||||||
|
onSelectService?: (nodeId: string, slug: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtTime = (d: Date) =>
|
const fmtTime = (d: Date) =>
|
||||||
@@ -42,7 +43,7 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
|
{ nets, nodes, edges, deployed, selection, setSelection, pan, dropTargetId, dragging, edgeDraw,
|
||||||
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
|
||||||
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
|
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
|
||||||
onResetView, onAutoLayout, sseConnected, lastEventAt },
|
onResetView, onAutoLayout, sseConnected, lastEventAt, onSelectService },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
|
||||||
@@ -63,8 +64,11 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
}, [nodes, edges]);
|
}, [nodes, edges]);
|
||||||
|
|
||||||
const selNetId = selection?.type === 'net' ? selection.id : null;
|
const selNetId = selection?.type === 'net' ? selection.id : null;
|
||||||
const selNodeId = selection?.type === 'node' ? selection.id : null;
|
const selNodeId = selection?.type === 'node' ? selection.id
|
||||||
|
: selection?.type === 'service' ? selection.nodeId : null;
|
||||||
const selEdgeId = selection?.type === 'edge' ? selection.id : null;
|
const selEdgeId = selection?.type === 'edge' ? selection.id : null;
|
||||||
|
const selServiceNodeId = selection?.type === 'service' ? selection.nodeId : null;
|
||||||
|
const selServiceSlug = selection?.type === 'service' ? selection.id : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -168,7 +172,9 @@ const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
|
|||||||
selected={n.id === selNodeId}
|
selected={n.id === selNodeId}
|
||||||
deployed={deployed}
|
deployed={deployed}
|
||||||
dragging={dragging && n.id === selNodeId}
|
dragging={dragging && n.id === selNodeId}
|
||||||
|
selectedServiceSlug={n.id === selServiceNodeId ? selServiceSlug : null}
|
||||||
onSelect={(id) => setSelection({ type: 'node', id })}
|
onSelect={(id) => setSelection({ type: 'node', id })}
|
||||||
|
onSelectService={onSelectService}
|
||||||
onMouseDown={onNodeMouseDown}
|
onMouseDown={onNodeMouseDown}
|
||||||
onPortMouseDown={onPortMouseDown}
|
onPortMouseDown={onPortMouseDown}
|
||||||
onContextMenu={onNodeContextMenu}
|
onContextMenu={onNodeContextMenu}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus,
|
ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus,
|
||||||
Server, Trash2, X,
|
Server, Trash2, X, Shield,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Net, MazeNode, Edge } from './types';
|
import type { Net, MazeNode, Edge } from './types';
|
||||||
|
import { DEFAULT_SERVICES } from './data';
|
||||||
|
|
||||||
export type Selection =
|
export type Selection =
|
||||||
| { type: 'net'; id: string }
|
| { type: 'net'; id: string }
|
||||||
| { type: 'node'; id: string }
|
| { type: 'node'; id: string }
|
||||||
| { type: 'edge'; id: string }
|
| { type: 'edge'; id: string }
|
||||||
|
| { type: 'service'; id: string; nodeId: string }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,6 +23,7 @@ interface Props {
|
|||||||
onDeleteNet?: (id: string) => void;
|
onDeleteNet?: (id: string) => void;
|
||||||
onDeleteNode?: (id: string) => void;
|
onDeleteNode?: (id: string) => void;
|
||||||
onDeleteEdge?: (id: string) => void;
|
onDeleteEdge?: (id: string) => void;
|
||||||
|
onRemoveService?: (nodeId: string, slug: string) => void;
|
||||||
onAddDecky?: (netId: string) => void;
|
onAddDecky?: (netId: string) => void;
|
||||||
setSelection?: (sel: Selection) => void;
|
setSelection?: (sel: Selection) => void;
|
||||||
pendingChanges?: number;
|
pendingChanges?: number;
|
||||||
@@ -28,12 +31,16 @@ interface Props {
|
|||||||
|
|
||||||
const Inspector: React.FC<Props> = ({
|
const Inspector: React.FC<Props> = ({
|
||||||
selection, nets, nodes, edges, topologyStatus, onClose,
|
selection, nets, nodes, edges, topologyStatus, onClose,
|
||||||
onDeleteNet, onDeleteNode, onDeleteEdge, onAddDecky, setSelection,
|
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, onAddDecky, setSelection,
|
||||||
pendingChanges = 0,
|
pendingChanges = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const net = selection?.type === 'net' ? nets.find((n) => n.id === selection.id) : undefined;
|
const net = selection?.type === 'net' ? nets.find((n) => n.id === selection.id) : undefined;
|
||||||
const node = selection?.type === 'node' ? nodes.find((n) => n.id === selection.id) : undefined;
|
const node = selection?.type === 'node' ? nodes.find((n) => n.id === selection.id) : undefined;
|
||||||
const edge = selection?.type === 'edge' ? edges.find((e) => e.id === selection.id) : undefined;
|
const edge = selection?.type === 'edge' ? edges.find((e) => e.id === selection.id) : undefined;
|
||||||
|
const serviceSel = selection?.type === 'service' ? selection : undefined;
|
||||||
|
const serviceMeta = serviceSel ? DEFAULT_SERVICES.find((s) => s.slug === serviceSel.id) : undefined;
|
||||||
|
const serviceParent = serviceSel ? nodes.find((n) => n.id === serviceSel.nodeId) : undefined;
|
||||||
|
const serviceParentNet = serviceParent ? nets.find((n) => n.id === serviceParent.netId) : undefined;
|
||||||
|
|
||||||
const activeNetIds = useMemo(() => {
|
const activeNetIds = useMemo(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
@@ -224,6 +231,50 @@ const Inspector: React.FC<Props> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{serviceSel && (
|
||||||
|
<>
|
||||||
|
<div className="inspector-head">
|
||||||
|
<Shield
|
||||||
|
size={14}
|
||||||
|
className={serviceMeta?.risk === 'high' ? 'alert-text' : 'violet-accent'}
|
||||||
|
/>
|
||||||
|
<span className="inspector-head-title">
|
||||||
|
{serviceMeta?.name ?? serviceSel.id.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{serviceMeta && (
|
||||||
|
<span className={`chip inspector-head-chip ${
|
||||||
|
serviceMeta.risk === 'high' ? 'alert'
|
||||||
|
: serviceMeta.risk === 'med' ? 'violet'
|
||||||
|
: 'dim-chip'
|
||||||
|
}`}>
|
||||||
|
{serviceMeta.risk.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="kvs">
|
||||||
|
<div className="k">EXPOSED ON</div>
|
||||||
|
<div className="v violet-accent">{serviceParent?.name ?? '—'}</div>
|
||||||
|
<div className="k">PROTOCOL</div>
|
||||||
|
<div className="v">{(serviceMeta?.proto ?? '—').toUpperCase()}</div>
|
||||||
|
<div className="k">PORT</div>
|
||||||
|
<div className="v" style={{ fontWeight: 700 }}>{serviceMeta?.port ?? '—'}</div>
|
||||||
|
<div className="k">SUBNET</div>
|
||||||
|
<div className="v">{serviceParentNet?.label ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
{onRemoveService && serviceParent && serviceParent.kind !== 'observed' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="maze-btn alert small"
|
||||||
|
disabled={topologyStatus === 'degraded'}
|
||||||
|
title={topologyStatus === 'degraded' ? 'topology degraded — mutations blocked' : undefined}
|
||||||
|
onClick={() => onRemoveService(serviceSel.nodeId, serviceSel.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={10} /> REMOVE SERVICE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{pendingChanges > 0 && (
|
{pendingChanges > 0 && (
|
||||||
<div className="inspector-diff-block">
|
<div className="inspector-diff-block">
|
||||||
<div className="type-label inspector-section-label">PENDING DIFF</div>
|
<div className="type-label inspector-section-label">PENDING DIFF</div>
|
||||||
|
|||||||
@@ -276,6 +276,21 @@ const MazeNET: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeServiceFromNode = async (id: string, slug: string) => {
|
||||||
|
const n = nodes.find((x) => x.id === id);
|
||||||
|
if (!n || n.kind !== 'decky' || !n.services.includes(slug)) return;
|
||||||
|
const nextServices = n.services.filter((s) => s !== slug);
|
||||||
|
try {
|
||||||
|
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
|
||||||
|
if (r.kind !== 'applied') return;
|
||||||
|
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
|
||||||
|
? { ...x, services: nextServices } : x));
|
||||||
|
setSelection(null);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'remove service failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addServiceToNode = async (id: string, slug: string) => {
|
const addServiceToNode = async (id: string, slug: string) => {
|
||||||
const n = nodes.find((x) => x.id === id);
|
const n = nodes.find((x) => x.id === id);
|
||||||
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
|
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
|
||||||
@@ -584,6 +599,7 @@ const MazeNET: React.FC = () => {
|
|||||||
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
|
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
|
||||||
sseConnected={streamLive}
|
sseConnected={streamLive}
|
||||||
lastEventAt={lastEventAt}
|
lastEventAt={lastEventAt}
|
||||||
|
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}
|
||||||
/>
|
/>
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
||||||
@@ -608,6 +624,7 @@ const MazeNET: React.FC = () => {
|
|||||||
onDeleteNet={removeNet}
|
onDeleteNet={removeNet}
|
||||||
onDeleteNode={removeNode}
|
onDeleteNode={removeNode}
|
||||||
onDeleteEdge={removeEdge}
|
onDeleteEdge={removeEdge}
|
||||||
|
onRemoveService={removeServiceFromNode}
|
||||||
onAddDecky={(netId) => {
|
onAddDecky={(netId) => {
|
||||||
const net = nets.find((n) => n.id === netId);
|
const net = nets.find((n) => n.id === netId);
|
||||||
if (!net) return;
|
if (!net) return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { MazeNode } from './types';
|
import type { MazeNode } from './types';
|
||||||
|
import { DEFAULT_SERVICES } from './data';
|
||||||
|
|
||||||
const ARCHETYPE_ICONS: Record<string, LucideIcon> = {
|
const ARCHETYPE_ICONS: Record<string, LucideIcon> = {
|
||||||
'linux-server': Server,
|
'linux-server': Server,
|
||||||
@@ -24,13 +25,15 @@ interface Props {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
dragging?: boolean;
|
dragging?: boolean;
|
||||||
deployed?: boolean;
|
deployed?: boolean;
|
||||||
|
selectedServiceSlug?: string | null;
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
|
onSelectService?: (nodeId: string, slug: string) => void;
|
||||||
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deployed, onSelect, onMouseDown, onPortMouseDown, onContextMenu }) => {
|
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deployed, selectedServiceSlug, onSelect, onSelectService, onMouseDown, onPortMouseDown, onContextMenu }) => {
|
||||||
const isDmzGateway = !!(node as { decky_config?: { forwards_l3?: boolean } }).decky_config?.forwards_l3;
|
const isDmzGateway = !!(node as { decky_config?: { forwards_l3?: boolean } }).decky_config?.forwards_l3;
|
||||||
const classes = [
|
const classes = [
|
||||||
'maze-node',
|
'maze-node',
|
||||||
@@ -65,11 +68,25 @@ const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deplo
|
|||||||
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
|
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
|
||||||
{node.services.length > 0 && (
|
{node.services.length > 0 && (
|
||||||
<div className="mn-services">
|
<div className="mn-services">
|
||||||
{node.services.map((s) => (
|
{node.services.map((s) => {
|
||||||
<span key={s} className={`service-tag ${node.status === 'hot' ? 'hot' : ''}`}>
|
const meta = DEFAULT_SERVICES.find((x) => x.slug === s);
|
||||||
{s}
|
const isHigh = meta?.risk === 'high' || node.status === 'hot';
|
||||||
</span>
|
const isSel = selectedServiceSlug === s;
|
||||||
))}
|
return (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className={`service-tag ${isHigh ? 'hot' : ''} ${isSel ? 'service-selected' : ''}`}
|
||||||
|
title={meta ? `${meta.name} · ${meta.proto.toUpperCase()}:${meta.port}` : s}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (!onSelectService) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectService(node.id, s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.kind === 'decky' && <>
|
{node.kind === 'decky' && <>
|
||||||
|
|||||||
Reference in New Issue
Block a user