diff --git a/src/programs/http_client.ts b/src/programs/http_client.ts index c625f6cd..31b7db08 100644 --- a/src/programs/http_client.ts +++ b/src/programs/http_client.ts @@ -87,8 +87,18 @@ export class HttpClient extends ProgramBase { this.resource, ); + srcDevice.showDeviceIcon( + "tcp-handshake", + "🤝", + "TCP handshake in progress", + Layer.App, + ); + // Write request const socket = await this.runner.tcpConnect(this.dstId); + + srcDevice.hideDeviceIcon("tcp-handshake"); + if (!socket) { console.error("HttpClient failed to connect"); showError( @@ -185,7 +195,7 @@ export class HttpServer extends ProgramBase { ); return; } - + srcDevice.showHttpServerIcon(); const listener = await this.runner.tcpListenOn(this.port); if (!listener) { showError(`Port ${this.port} already in use`); @@ -203,6 +213,7 @@ export class HttpServer extends ProgramBase { this.serveClient(socket); } listener.close(); + srcDevice.hideHttpServerIcon(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/styles/alert.css b/src/styles/alert.css index 988761a6..be52bf0b 100644 --- a/src/styles/alert.css +++ b/src/styles/alert.css @@ -16,8 +16,7 @@ border: 1px solid rgba(255, 255, 255, 0.2); /* Subtle border */ max-width: 400px; /* Maximum width */ line-height: 1.5; /* Line spacing */ - font-family: - "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; /* Font family */ + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; opacity: 0; /* Initially transparent */ transition: opacity 0.3s ease-in-out, diff --git a/src/styles/tooltips.css b/src/styles/tooltips.css index f9f40dbe..1990d586 100644 --- a/src/styles/tooltips.css +++ b/src/styles/tooltips.css @@ -17,8 +17,7 @@ max-width: 37.5rem; /* Maximum width in rem (600px / 16) */ white-space: pre-wrap; /* Allow line breaks */ line-height: 1.8; /* Line spacing */ - font-family: - "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; /* Font family */ + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; overflow-y: auto; /* Enable scrolling for long content */ opacity: 0.8; /* Slightly transparent */ transition: opacity 0.2s ease-in-out; /* Smooth transition for opacity */ diff --git a/src/types/network-modules/tcp/tcpState.ts b/src/types/network-modules/tcp/tcpState.ts index bb6cfc04..cacecc76 100644 --- a/src/types/network-modules/tcp/tcpState.ts +++ b/src/types/network-modules/tcp/tcpState.ts @@ -9,6 +9,7 @@ import { AsyncQueue } from "../asyncQueue"; import { SegmentWithIp } from "../tcpModule"; import { GlobalContext } from "../../../context"; import { CONFIG_SWITCH_KEYS } from "../../../config_menu/switches/switch_factory"; +import { Layer } from "../../layer"; enum TcpStateEnum { CLOSED = 0, @@ -714,6 +715,13 @@ export class TcpState { console.debug("[" + this.srcHost.id + "] [TCP] Processing timeout"); retransmitPromise = this.retransmissionQueue.pop(); // Retransmit the segment + this.srcHost.showDeviceIconFor( + "tcp_timeout", + "⏰", + "TCP Timeout", + 2000, + Layer.Transport, + ); this.resendPacket(result.seqNum, result.size); this.congestionControl.notifyTimeout(); continue; diff --git a/src/types/view-devices/vDevice.ts b/src/types/view-devices/vDevice.ts index d0610363..8d6ca797 100644 --- a/src/types/view-devices/vDevice.ts +++ b/src/types/view-devices/vDevice.ts @@ -6,6 +6,7 @@ import { TextStyle, Text, Container, + Ticker, } from "pixi.js"; import { ViewGraph } from "../graphs/viewgraph"; import { @@ -15,7 +16,7 @@ import { urManager, } from "../viewportManager"; import { RightBar } from "../../graphics/right_bar"; -import { Colors, ZIndexLevels } from "../../utils/utils"; +import { Colors, createDeviceIcon, ZIndexLevels } from "../../utils/utils"; import { Position } from "../common"; import { DeviceInfo } from "../../graphics/renderables/device_info"; import { @@ -68,6 +69,8 @@ export abstract class ViewDevice extends Container { private circleGraphic?: Graphics; private idLabel?: Text; private isVisibleFlag = true; // Flag to track visibility + private deviceIcons: Record = {}; + private deviceTooltips: Record = {}; readonly id: DeviceId; readonly viewgraph: ViewGraph; @@ -190,6 +193,9 @@ export abstract class ViewDevice extends Container { updateDevicesAspect() { if (!this.isVisibleFlag) { + for (const iconKey in this.deviceIcons) { + this.hideDeviceIcon(iconKey); + } const edges = this.viewgraph .getConnections(this.id) .filter((e) => e.isVisible()); @@ -400,6 +406,90 @@ export abstract class ViewDevice extends Container { RightBar.getInstance().renderInfo(new DeviceInfo(this)); } + private repositionDeviceIcons() { + const icons = Object.values(this.deviceIcons).filter(Boolean) as Text[]; + if (icons.length === 0) return; + + const spacing = 28; + const baseY = -this.height / 2 - 5; + + const totalWidth = (icons.length - 1) * spacing; + icons.forEach((icon, idx) => { + icon.x = -totalWidth / 2 + idx * spacing; + icon.y = baseY; + }); + } + + showDeviceIconFor( + iconKey: string, + emoji: string, + tooltipText: string | undefined, + durationMs = 2000, + visibleFromLayer?: Layer, + ) { + this.showDeviceIcon(iconKey, emoji, tooltipText, visibleFromLayer); + let progress = 0; + const tick = (ticker: Ticker) => { + progress += ticker.elapsedMS * this.ctx.getCurrentSpeed(); + if (progress >= durationMs) { + Ticker.shared.remove(tick, this); + this.hideDeviceIcon(iconKey); + } + }; + Ticker.shared.add(tick, this); + } + + showDeviceIcon( + iconKey: string, + emoji: string, + tooltipText?: string, + visibleFromLayer?: Layer, + ) { + if ( + visibleFromLayer !== undefined && + this.viewgraph.getLayer() > visibleFromLayer + ) { + return; + } + if (!this.isVisible()) return; + if (this.deviceIcons[iconKey]) return; + const icon = createDeviceIcon(emoji, 0); + this.deviceIcons[iconKey] = icon; + + if (tooltipText) { + icon.on("pointerover", () => { + this.deviceTooltips[iconKey] = showTooltip( + this, + tooltipText, + 0, + icon.y - 30, + this.deviceTooltips[iconKey], + ); + }); + icon.on("pointerout", () => { + hideTooltip(this.deviceTooltips[iconKey]); + }); + } + + this.addChild(icon); + this.repositionDeviceIcons(); + } + + hideDeviceIcon(iconKey: string) { + const icon = this.deviceIcons[iconKey]; + if (icon) { + this.removeChild(icon); + icon.destroy(); + this.deviceIcons[iconKey] = undefined; + this.repositionDeviceIcons(); + } + const tooltip = this.deviceTooltips[iconKey]; + if (tooltip) { + removeTooltip(this, tooltip); + this.deviceTooltips[iconKey] = undefined; + } + } + select() { if (this.isDragCircle) return; this.highlight(); // Calls highlight on select diff --git a/src/types/view-devices/vHost.ts b/src/types/view-devices/vHost.ts index 9a6bdcd7..fcfaba16 100644 --- a/src/types/view-devices/vHost.ts +++ b/src/types/view-devices/vHost.ts @@ -173,6 +173,14 @@ export class ViewHost extends ViewNetworkDevice { this.runningPrograms.clear(); } + showHttpServerIcon() { + this.showDeviceIcon("httpServer", "🌐", "HTTP Server"); + } + + hideHttpServerIcon() { + this.hideDeviceIcon("httpServer"); + } + // TCP private tcpModule = new TcpModule(this); diff --git a/src/types/view-devices/vNetworkDevice.ts b/src/types/view-devices/vNetworkDevice.ts index 892be03c..6ba7bd98 100644 --- a/src/types/view-devices/vNetworkDevice.ts +++ b/src/types/view-devices/vNetworkDevice.ts @@ -210,6 +210,7 @@ export abstract class ViewNetworkDevice extends ViewDevice { // drop packet const frame = new EthernetFrame(mac, sha, packet); dropPacket(this.viewgraph, this.id, frame); + this.showDeviceIconFor("arpDrop", "⛔", "ARP dropped"); return; } // Send an ARP Reply to the requesting device diff --git a/src/types/view-devices/vRouter.ts b/src/types/view-devices/vRouter.ts index 918b533c..fc3a5518 100644 --- a/src/types/view-devices/vRouter.ts +++ b/src/types/view-devices/vRouter.ts @@ -183,6 +183,7 @@ export class ViewRouter extends ViewNetworkDevice { } if (!this.packetQueue.enqueue(datagram)) { console.debug("Packet queue full, dropping packet"); + this.showDeviceIconFor("queueFull", "❗", "Queue full"); this.dropPacket(datagram); return; } @@ -255,7 +256,7 @@ export class ViewRouter extends ViewNetworkDevice { return; } - const result = device.routingTable.all().find((entry) => { + const result = device.routingTable.allActive().find((entry) => { const ip = IpAddress.parse(entry.ip); const mask = IpAddress.parse(entry.mask); return datagram.destinationAddress.isInSubnet(ip, mask); diff --git a/src/types/view-devices/vSwitch.ts b/src/types/view-devices/vSwitch.ts index 9b8c2324..4b51260d 100644 --- a/src/types/view-devices/vSwitch.ts +++ b/src/types/view-devices/vSwitch.ts @@ -78,6 +78,7 @@ export class ViewSwitch extends ViewDevice { } if (device instanceof DataSwitch) { device.updateForwardingTable(mac, iface); + this.showDeviceIconFor("update-ftable", "🔄", "Table Updated"); } }); } @@ -107,6 +108,8 @@ export class ViewSwitch extends ViewDevice { this.updateForwardingTable(frame.source, iface); if (frame.payload instanceof ArpRequest) { const { sha, spa, tha, tpa } = frame.payload; + + this.showDeviceIconFor("broadcast", "📢", "Broadcast"); this.interfaces.forEach((sendingIface, idx) => { const packet = new ArpRequest(sha, spa, tpa, tha); const frame = new EthernetFrame( @@ -137,6 +140,10 @@ export class ViewSwitch extends ViewDevice { { length: getNumberOfInterfaces(this.getType()) }, (_, i) => i, ); + + if (sendingIfaces.length > 1) { + this.showDeviceIconFor("broadcast", "📢", "Broadcast"); + } sendingIfaces.forEach((sendingIface) => this.forwardFrame(frame, sendingIface, iface), ); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f14598c3..abb0d0db 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,11 @@ -import { Application, GraphicsContext, RenderTexture } from "pixi.js"; +import { + Application, + GraphicsContext, + RenderTexture, + Container, + TextStyle, + Text, +} from "pixi.js"; import { Viewport } from "../graphics/viewport"; import { ALERT_MESSAGES } from "./constants/alert_constants"; import { showError } from "../graphics/renderables/alert_manager"; @@ -82,3 +89,50 @@ export function captureAndDownloadViewport( link.click(); }, "image/png"); } + +export function blockPointerEvents(obj: Container) { + obj.on("pointerdown", (e) => e.stopPropagation()); + obj.on("pointerup", (e) => e.stopPropagation()); + obj.on("pointerupoutside", (e) => e.stopPropagation()); + obj.on("pointertap", (e) => e.stopPropagation()); + obj.on("pointermove", (e) => e.stopPropagation()); + obj.on("click", (e) => e.stopPropagation()); +} + +/** + * Creates a PixiJS emoji icon centered above the device. + * @param emoji The emoji or text to display (e.g., "🌐") + * @param yOffset Vertical offset from the center of the device (e.g., -this.height / 2 - 5) + * @param fontSize Font size (optional, default 20) + * @returns A Pixi.Text instance ready to be added as a child + */ +export function createDeviceIcon( + emoji: string, + yOffset: number, + fontSize = 20, +): Text { + const textStyle = new TextStyle({ + fontSize, + }); + + const icon = new Text({ text: emoji, style: textStyle }); + icon.anchor.set(0.5, 1); + icon.x = 1; + icon.y = yOffset; + icon.zIndex = 100; + icon.eventMode = "static"; + icon.interactive = true; + icon.cursor = "pointer"; + + blockPointerEvents(icon); + return icon; +} + +/** + * Changes the emoji/text of a PixiJS Text icon. + * @param icon The Pixi.Text instance. + * @param emoji The emoji or text to display. + */ +export function setIconEmoji(icon: Text, emoji: string) { + icon.text = emoji; +}