104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
Server, Monitor, Shield, Database, Cpu, Globe, Users, HardDrive, Eye,
|
|
type LucideIcon,
|
|
} from '../../icons';
|
|
import type { MazeNode } from './types';
|
|
import { DEFAULT_SERVICES } from './data';
|
|
|
|
const ARCHETYPE_ICONS: Record<string, LucideIcon> = {
|
|
'linux-server': Server,
|
|
'windows-workstation': Monitor,
|
|
'domain-controller': Shield,
|
|
'database-server': Database,
|
|
'iot-device': Cpu,
|
|
'web-application': Globe,
|
|
'deaddeck': HardDrive,
|
|
'attacker-pool': Eye,
|
|
'directory-services': Users,
|
|
};
|
|
|
|
interface Props {
|
|
node: MazeNode;
|
|
absX: number;
|
|
absY: number;
|
|
selected: boolean;
|
|
dragging?: boolean;
|
|
deployed?: boolean;
|
|
selectedServiceSlug?: string | null;
|
|
onSelect?: (id: string) => void;
|
|
onSelectService?: (nodeId: string, slug: string) => void;
|
|
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
|
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
|
|
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
|
|
}
|
|
|
|
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 classes = [
|
|
'maze-node',
|
|
node.kind === 'observed' ? 'observed' : '',
|
|
node.status === 'hot' ? 'hot' : '',
|
|
selected ? 'selected' : '',
|
|
dragging ? 'dragging' : '',
|
|
deployed ? 'deployed' : '',
|
|
deployed && isDmzGateway ? 'dmz-gateway' : '',
|
|
].filter(Boolean).join(' ');
|
|
|
|
const handleDown = (e: React.MouseEvent) => {
|
|
onSelect?.(node.id);
|
|
onMouseDown?.(node.id)(e);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={classes}
|
|
style={{ left: absX, top: absY }}
|
|
onMouseDown={handleDown}
|
|
onContextMenu={onContextMenu?.(node.id)}
|
|
>
|
|
<div className="mn-head">
|
|
<span className={`status-dot ${node.status}`} />
|
|
{(() => {
|
|
const Icon = ARCHETYPE_ICONS[node.archetype] ?? Server;
|
|
return <Icon size={10} className="mn-head-icon" />;
|
|
})()}
|
|
<span className="mn-head-name">{node.name}</span>
|
|
</div>
|
|
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
|
|
{node.services.length > 0 && (
|
|
<div className="mn-services">
|
|
{node.services.map((s) => {
|
|
const meta = DEFAULT_SERVICES.find((x) => x.slug === s);
|
|
const isHigh = meta?.risk === 'high' || node.status === 'hot';
|
|
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>
|
|
)}
|
|
{node.kind === 'decky' && <>
|
|
<span className="mn-port in" />
|
|
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
|
|
</>}
|
|
{node.kind === 'observed' && (
|
|
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default React.memo(NodeCard);
|