diff --git a/src/webterm/docker_stats.py b/src/webterm/docker_stats.py index ffe2159..be381ce 100644 --- a/src/webterm/docker_stats.py +++ b/src/webterm/docker_stats.py @@ -49,6 +49,9 @@ class DockerStatsCollector: self._task: asyncio.Task | None = None # Track previous CPU values for delta calculation self._prev_cpu: dict[str, tuple[int, int]] = {} + # Service names to poll (can be modified dynamically) + self._service_names: list[str] = [] + self._service_names_lock = asyncio.Lock() @property def available(self) -> bool: @@ -248,7 +251,7 @@ class DockerStatsCollector: self._cpu_history[service_name] = deque(maxlen=STATS_HISTORY_SIZE) self._cpu_history[service_name].append(cpu_percent) - async def _poll_loop(self, service_names: list[str]) -> None: + async def _poll_loop(self) -> None: """Background polling loop.""" # Discover container IDs on first run and periodically refresh service_to_container: dict[str, str] = {} @@ -256,10 +259,14 @@ class DockerStatsCollector: warned_no_containers = False while self._running: + # Get current service names (may change dynamically) + service_names = list(self._service_names) + # Refresh container mapping every 30 iterations (~5 minutes at 10s interval) - if refresh_counter % 30 == 0: + # or immediately if service list changed + if refresh_counter % 30 == 0 or set(service_to_container.keys()) != set(service_names): service_to_container = await self._discover_containers(service_names) - if not service_to_container and not warned_no_containers: + if not service_to_container and service_names and not warned_no_containers: log.warning( "No Docker containers found for CPU stats. " "Ensure Docker socket is mounted (-v /var/run/docker.sock:/var/run/docker.sock)" @@ -290,10 +297,30 @@ class DockerStatsCollector: if self._running: return + self._service_names = list(service_names) self._running = True - self._task = asyncio.create_task(self._poll_loop(service_names)) + self._task = asyncio.create_task(self._poll_loop()) log.info("Started Docker stats collection for %d services", len(service_names)) + def add_service(self, service_name: str) -> None: + """Add a service to the polling list dynamically. + + This is safe to call while the collector is running - the poll loop + will pick up the new service on its next container discovery cycle. + """ + if service_name not in self._service_names: + self._service_names.append(service_name) + log.debug("Added service to stats collector: %s", service_name) + + def remove_service(self, service_name: str) -> None: + """Remove a service from the polling list.""" + if service_name in self._service_names: + self._service_names.remove(service_name) + # Clean up history for removed service + self._cpu_history.pop(service_name, None) + self._prev_cpu.pop(service_name, None) + log.debug("Removed service from stats collector: %s", service_name) + async def stop(self) -> None: """Stop collecting stats.""" self._running = False diff --git a/src/webterm/local_server.py b/src/webterm/local_server.py index e82a04f..1ca6fc5 100644 --- a/src/webterm/local_server.py +++ b/src/webterm/local_server.py @@ -35,7 +35,6 @@ DEFAULT_TERMINAL_SIZE = (132, 45) SCREENSHOT_CACHE_SECONDS = 0.3 SCREENSHOT_MAX_CACHE_SECONDS = 20.0 -CLEAR_AND_REDRAW_SEQ = "\x1b[2J\x1b[H\x1b[3J" # Clear screen and scrollback, move to home WEBTERM_STATIC_PATH = Path(__file__).parent / "static" @@ -361,8 +360,10 @@ class LocalServer: """Callback when a Docker container is added.""" log.info("Container added to dashboard: %s -> %s", name, slug) # Update slug-to-service mapping for sparklines + self._slug_to_service[slug] = name + # Register new service with stats collector so it starts polling if self._docker_stats: - self._slug_to_service[slug] = name + self._docker_stats.add_service(name) log.debug("Added sparkline mapping: %s -> %s", slug, name) # Notify SSE subscribers about dashboard change self._notify_activity("__dashboard__") @@ -370,9 +371,10 @@ class LocalServer: def _on_docker_container_removed(self, slug: str) -> None: """Callback when a Docker container is removed.""" log.info("Container removed from dashboard: %s", slug) - # Remove slug-to-service mapping - if self._docker_stats and slug in self._slug_to_service: - del self._slug_to_service[slug] + # Remove from stats collector and slug mapping + service_name = self._slug_to_service.pop(slug, None) + if self._docker_stats and service_name: + self._docker_stats.remove_service(service_name) # Invalidate any cached screenshots self._screenshot_cache.pop(slug, None) self._screenshot_cache_etag.pop(slug, None) @@ -450,12 +452,6 @@ class LocalServer: self.session_manager.on_session_end(session_id) session_id = None session = None - else: - # Force terminal redraw on reconnect to avoid blank screen - if hasattr(session, "force_redraw"): - await session.force_redraw() - if hasattr(session, "send_bytes"): - await session.send_bytes(CLEAR_AND_REDRAW_SEQ.encode("utf-8")) session_created = session_id is not None diff --git a/src/webterm/static/js/terminal.js b/src/webterm/static/js/terminal.js index e5f4789..6ad0b9b 100644 --- a/src/webterm/static/js/terminal.js +++ b/src/webterm/static/js/terminal.js @@ -10,7 +10,7 @@ Example: For tests, pass a Ghostty instance directly: import { Ghostty, Terminal } from "ghostty-web"; const ghostty = await Ghostty.load(); - const term = new Terminal({ ghostty });`);return s}var W0='ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace',F={tango:{background:"#000000",foreground:"#d3d7cf",cursor:"#d3d7cf",cursorAccent:"#000000",selectionBackground:"#555753",black:"#2e3436",red:"#cc0000",green:"#4e9a06",yellow:"#c4a000",blue:"#3465a4",magenta:"#75507b",cyan:"#06989a",white:"#d3d7cf",brightBlack:"#555753",brightRed:"#ef2929",brightGreen:"#8ae234",brightYellow:"#fce94f",brightBlue:"#729fcf",brightMagenta:"#ad7fa8",brightCyan:"#34e2e2",brightWhite:"#eeeeec"},xterm:{background:"#000000",foreground:"#e5e5e5",cursor:"#e5e5e5",cursorAccent:"#000000",selectionBackground:"#d4d4d4",black:"#000000",red:"#cd0000",green:"#00cd00",yellow:"#cdcd00",blue:"#0000cd",magenta:"#cd00cd",cyan:"#00cdcd",white:"#e5e5e5",brightBlack:"#4d4d4d",brightRed:"#ff0000",brightGreen:"#00ff00",brightYellow:"#ffff00",brightBlue:"#0000ff",brightMagenta:"#ff00ff",brightCyan:"#00ffff",brightWhite:"#ffffff"},monokai:{background:"#2d2a2e",foreground:"#fcfcfa",cursor:"#fcfcfa",cursorAccent:"#2d2a2e",selectionBackground:"#5b595c",black:"#403e41",red:"#ff6188",green:"#a9dc76",yellow:"#ffd866",blue:"#fc9867",magenta:"#ab9df2",cyan:"#78dce8",white:"#fcfcfa",brightBlack:"#727072",brightRed:"#ff6188",brightGreen:"#a9dc76",brightYellow:"#ffd866",brightBlue:"#fc9867",brightMagenta:"#ab9df2",brightCyan:"#78dce8",brightWhite:"#fcfcfa"},ristretto:{background:"#2d2525",foreground:"#fff1f3",cursor:"#fff1f3",cursorAccent:"#2d2525",selectionBackground:"#403838",black:"#2c2525",red:"#fd6883",green:"#adda78",yellow:"#f9cc6c",blue:"#f38d70",magenta:"#a8a9eb",cyan:"#85dacc",white:"#f9f8f5",brightBlack:"#655761",brightRed:"#fd6883",brightGreen:"#adda78",brightYellow:"#f9cc6c",brightBlue:"#f38d70",brightMagenta:"#a8a9eb",brightCyan:"#85dacc",brightWhite:"#f9f8f5"},dark:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",cursorAccent:"#1e1e1e",selectionBackground:"#264f78",black:"#000000",red:"#cd3131",green:"#0dbc79",yellow:"#e5e510",blue:"#2472c8",magenta:"#bc3fbc",cyan:"#11a8cd",white:"#e5e5e5",brightBlack:"#666666",brightRed:"#f14c4c",brightGreen:"#23d18b",brightYellow:"#f5f543",brightBlue:"#3b8eea",brightMagenta:"#d670d6",brightCyan:"#29b8db",brightWhite:"#ffffff"},light:{background:"#ffffff",foreground:"#383a42",cursor:"#526eff",cursorAccent:"#ffffff",selectionBackground:"#add6ff",black:"#000000",red:"#e45649",green:"#50a14f",yellow:"#c18401",blue:"#4078f2",magenta:"#a626a4",cyan:"#0184bc",white:"#a0a1a7",brightBlack:"#5c6370",brightRed:"#e06c75",brightGreen:"#98c379",brightYellow:"#d19a66",brightBlue:"#61afef",brightMagenta:"#c678dd",brightCyan:"#56b6c2",brightWhite:"#ffffff"},dracula:{background:"#282a36",foreground:"#f8f8f2",cursor:"#f8f8f2",cursorAccent:"#282a36",selectionBackground:"#44475a",black:"#21222c",red:"#ff5555",green:"#50fa7b",yellow:"#f1fa8c",blue:"#bd93f9",magenta:"#ff79c6",cyan:"#8be9fd",white:"#f8f8f2",brightBlack:"#6272a4",brightRed:"#ff6e6e",brightGreen:"#69ff94",brightYellow:"#ffffa5",brightBlue:"#d6acff",brightMagenta:"#ff92df",brightCyan:"#a4ffff",brightWhite:"#ffffff"},catppuccin:{background:"#1e1e2e",foreground:"#cdd6f4",cursor:"#f5e0dc",cursorAccent:"#1e1e2e",selectionBackground:"#45475a",black:"#45475a",red:"#f38ba8",green:"#a6e3a1",yellow:"#f9e2af",blue:"#89b4fa",magenta:"#f5c2e7",cyan:"#94e2d5",white:"#bac2de",brightBlack:"#585b70",brightRed:"#f38ba8",brightGreen:"#a6e3a1",brightYellow:"#f9e2af",brightBlue:"#89b4fa",brightMagenta:"#f5c2e7",brightCyan:"#94e2d5",brightWhite:"#a6adc8"},nord:{background:"#2e3440",foreground:"#d8dee9",cursor:"#d8dee9",cursorAccent:"#2e3440",selectionBackground:"#434c5e",black:"#3b4252",red:"#bf616a",green:"#a3be8c",yellow:"#ebcb8b",blue:"#81a1c1",magenta:"#b48ead",cyan:"#88c0d0",white:"#e5e9f0",brightBlack:"#4c566a",brightRed:"#bf616a",brightGreen:"#a3be8c",brightYellow:"#ebcb8b",brightBlue:"#81a1c1",brightMagenta:"#b48ead",brightCyan:"#8fbcbb",brightWhite:"#eceff4"},gruvbox:{background:"#282828",foreground:"#ebdbb2",cursor:"#ebdbb2",cursorAccent:"#282828",selectionBackground:"#504945",black:"#282828",red:"#cc241d",green:"#98971a",yellow:"#d79921",blue:"#458588",magenta:"#b16286",cyan:"#689d6a",white:"#a89984",brightBlack:"#928374",brightRed:"#fb4934",brightGreen:"#b8bb26",brightYellow:"#fabd2f",brightBlue:"#83a598",brightMagenta:"#d3869b",brightCyan:"#8ec07c",brightWhite:"#ebdbb2"},solarized:{background:"#002b36",foreground:"#839496",cursor:"#839496",cursorAccent:"#002b36",selectionBackground:"#073642",black:"#073642",red:"#dc322f",green:"#859900",yellow:"#b58900",blue:"#268bd2",magenta:"#d33682",cyan:"#2aa198",white:"#eee8d5",brightBlack:"#586e75",brightRed:"#cb4b16",brightGreen:"#586e75",brightYellow:"#657b83",brightBlue:"#839496",brightMagenta:"#6c71c4",brightCyan:"#93a1a1",brightWhite:"#fdf6e3"},tokyo:{background:"#1a1b26",foreground:"#a9b1d6",cursor:"#c0caf5",cursorAccent:"#1a1b26",selectionBackground:"#33467c",black:"#15161e",red:"#f7768e",green:"#9ece6a",yellow:"#e0af68",blue:"#7aa2f7",magenta:"#bb9af7",cyan:"#7dcfff",white:"#a9b1d6",brightBlack:"#414868",brightRed:"#f7768e",brightGreen:"#9ece6a",brightYellow:"#e0af68",brightBlue:"#7aa2f7",brightMagenta:"#bb9af7",brightCyan:"#7dcfff",brightWhite:"#c0caf5"}};function z0(V){console.log("[webterm:parseConfig] Parsing config from element");let Z={};if(V.dataset.fontFamily)Z.fontFamily=V.dataset.fontFamily,console.log(`[webterm:parseConfig] fontFamily: "${Z.fontFamily}"`);if(V.dataset.fontSize)Z.fontSize=parseInt(V.dataset.fontSize,10),console.log(`[webterm:parseConfig] fontSize: ${Z.fontSize}`);if(V.dataset.scrollback)Z.scrollback=parseInt(V.dataset.scrollback,10),console.log(`[webterm:parseConfig] scrollback: ${Z.scrollback}`);if(V.dataset.theme){let j=V.dataset.theme.toLowerCase();if(console.log(`[webterm:parseConfig] theme attribute: "${V.dataset.theme}" -> normalized: "${j}"`),console.log(`[webterm:parseConfig] Available themes: ${Object.keys(F).join(", ")}`),console.log(`[webterm:parseConfig] Theme "${j}" in THEMES? ${j in F}`),j in F)Z.theme=F[j],console.log(`[webterm:parseConfig] Using built-in theme "${j}":`,JSON.stringify(Z.theme,null,2));else{console.log("[webterm:parseConfig] Theme not found in THEMES, trying JSON parse...");try{Z.theme=JSON.parse(V.dataset.theme),console.log("[webterm:parseConfig] Parsed custom JSON theme:",Z.theme)}catch($){console.warn(`[webterm:parseConfig] Unknown theme "${V.dataset.theme}", JSON parse failed:`,$)}}}else console.log("[webterm:parseConfig] No theme attribute found on element");return console.log("[webterm:parseConfig] Final config:",Z),Z}function H0(){let V=document.querySelectorAll('script[src*="terminal.js"]');if(V.length>0){let Z=V[0].src;return Z.substring(0,Z.lastIndexOf("/")+1)+"ghostty-vt.wasm"}return"/static/js/ghostty-vt.wasm"}function G0(){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||"ontouchstart"in window&&navigator.maxTouchPoints>0}class m{terminal;fitAddon;socket=null;element;wsUrl;reconnectAttempts=0;maxReconnectAttempts=5;reconnectDelay=1000;messageQueue=[];lastValidSize=null;mobileInput=null;mobileKeybar=null;ctrlActive=!1;shiftActive=!1;fontFamily;fontSize;constructor(V,Z,j,$,P,X){this.element=V,this.wsUrl=Z,this.terminal=j,this.fitAddon=$,this.fontFamily=P,this.fontSize=X}static async create(V,Z,j){console.log("[webterm:create] WebTerminal.create() called"),console.log("[webterm:create] Container:",V),console.log("[webterm:create] wsUrl:",Z),console.log("[webterm:create] Config received:",JSON.stringify(j,null,2));let $=H0();console.log("[webterm:create] WASM path:",$),console.log("[webterm:create] Loading Ghostty WASM...");let P=await v.load($);console.log("[webterm:create] Ghostty loaded:",P);let X=j.theme??F.tango;console.log("[webterm:create] Theme to use (config.theme ?? THEMES.xterm):",JSON.stringify(X,null,2));let R=j.fontFamily?.trim()||W0,L=j.fontSize??16,K={fontFamily:R,fontSize:L,scrollback:j.scrollback??1000,cursorBlink:!0,cursorStyle:"block",theme:X,ghostty:P};console.log("[webterm:create] Full ITerminalOptions:",JSON.stringify(K,null,2)),console.log("[webterm:create] Creating ghostty-web Terminal instance...");let Y=new w(K);console.log("[webterm:create] Terminal created:",Y),console.log("[webterm:create] Terminal.options:",Y.options),console.log("[webterm:create] Creating FitAddon...");let _=new h;console.log("[webterm:create] Loading FitAddon into terminal..."),Y.loadAddon(_),console.log("[webterm:create] Calling terminal.open(container)..."),Y.open(V),console.log("[webterm:create] terminal.open() completed");let z=Y;if(console.log("[webterm:create] Terminal internal keys:",Object.keys(z)),z.renderer){console.log("[webterm:create] Renderer exists:",z.renderer);let W=z.renderer;if(console.log("[webterm:create] Renderer keys:",Object.keys(W)),W.theme)console.log("[webterm:create] Renderer.theme:",W.theme);if(W.palette)console.log("[webterm:create] Renderer.palette:",W.palette)}let u=new m(V,Z,Y,_,R,L);return console.log("[webterm:create] WebTerminal instance created"),u.initialize(),console.log("[webterm:create] WebTerminal initialized"),u}initialize(){console.log("[webterm:init] initialize() called");let V=this.element.querySelector("canvas");if(console.log("[webterm:init] Canvas element:",V),V)console.log("[webterm:init] Canvas dimensions:",{width:V.width,height:V.height,clientWidth:V.clientWidth,clientHeight:V.clientHeight,style:V.style.cssText});if(console.log("[webterm:init] Container dimensions:",{clientWidth:this.element.clientWidth,clientHeight:this.element.clientHeight}),this.waitForFonts().then(()=>{console.log("[webterm:init] Fonts loaded, reapplying font family and fitting...");let Z=this.terminal.renderer;if(Z)Z.setFontFamily(this.fontFamily),Z.remeasureFont(),console.log("[webterm:init] Font family updated via renderer");this.fit(),console.log("[webterm:init] fit() completed");let j=this.element.querySelector("canvas");if(j)console.log("[webterm:init] Canvas after fit:",{width:j.width,height:j.height,clientWidth:j.clientWidth,clientHeight:j.clientHeight})}),this.setupResizeObserver(),window.addEventListener("resize",()=>{this.fit()}),this.terminal.onData((Z)=>{this.send(["stdin",Z])}),this.terminal.onResize((Z)=>{if(this.isValidSize(Z.cols,Z.rows))this.lastValidSize={cols:Z.cols,rows:Z.rows},this.send(["resize",{width:Z.cols,height:Z.rows}])}),this.setupMobileKeyboard(),G0())this.setupMobileKeybar();this.connect()}setupMobileKeyboard(){let V=document.createElement("textarea");V.setAttribute("autocapitalize","off"),V.setAttribute("autocomplete","off"),V.setAttribute("autocorrect","off"),V.setAttribute("spellcheck","false"),V.setAttribute("inputmode","text"),V.setAttribute("enterkeyhint","send"),V.style.cssText=` + const term = new Terminal({ ghostty });`);return s}var W0='ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace',F={tango:{background:"#000000",foreground:"#d3d7cf",cursor:"#d3d7cf",cursorAccent:"#000000",selectionBackground:"#555753",black:"#2e3436",red:"#cc0000",green:"#4e9a06",yellow:"#c4a000",blue:"#3465a4",magenta:"#75507b",cyan:"#06989a",white:"#d3d7cf",brightBlack:"#555753",brightRed:"#ef2929",brightGreen:"#8ae234",brightYellow:"#fce94f",brightBlue:"#729fcf",brightMagenta:"#ad7fa8",brightCyan:"#34e2e2",brightWhite:"#eeeeec"},xterm:{background:"#000000",foreground:"#e5e5e5",cursor:"#e5e5e5",cursorAccent:"#000000",selectionBackground:"#d4d4d4",black:"#000000",red:"#cd0000",green:"#00cd00",yellow:"#cdcd00",blue:"#0000cd",magenta:"#cd00cd",cyan:"#00cdcd",white:"#e5e5e5",brightBlack:"#4d4d4d",brightRed:"#ff0000",brightGreen:"#00ff00",brightYellow:"#ffff00",brightBlue:"#0000ff",brightMagenta:"#ff00ff",brightCyan:"#00ffff",brightWhite:"#ffffff"},monokai:{background:"#2d2a2e",foreground:"#fcfcfa",cursor:"#fcfcfa",cursorAccent:"#2d2a2e",selectionBackground:"#5b595c",black:"#403e41",red:"#ff6188",green:"#a9dc76",yellow:"#ffd866",blue:"#fc9867",magenta:"#ab9df2",cyan:"#78dce8",white:"#fcfcfa",brightBlack:"#727072",brightRed:"#ff6188",brightGreen:"#a9dc76",brightYellow:"#ffd866",brightBlue:"#fc9867",brightMagenta:"#ab9df2",brightCyan:"#78dce8",brightWhite:"#fcfcfa"},ristretto:{background:"#2d2525",foreground:"#fff1f3",cursor:"#fff1f3",cursorAccent:"#2d2525",selectionBackground:"#403838",black:"#2c2525",red:"#fd6883",green:"#adda78",yellow:"#f9cc6c",blue:"#f38d70",magenta:"#a8a9eb",cyan:"#85dacc",white:"#f9f8f5",brightBlack:"#655761",brightRed:"#fd6883",brightGreen:"#adda78",brightYellow:"#f9cc6c",brightBlue:"#f38d70",brightMagenta:"#a8a9eb",brightCyan:"#85dacc",brightWhite:"#f9f8f5"},dark:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",cursorAccent:"#1e1e1e",selectionBackground:"#264f78",black:"#000000",red:"#cd3131",green:"#0dbc79",yellow:"#e5e510",blue:"#2472c8",magenta:"#bc3fbc",cyan:"#11a8cd",white:"#e5e5e5",brightBlack:"#666666",brightRed:"#f14c4c",brightGreen:"#23d18b",brightYellow:"#f5f543",brightBlue:"#3b8eea",brightMagenta:"#d670d6",brightCyan:"#29b8db",brightWhite:"#ffffff"},light:{background:"#ffffff",foreground:"#383a42",cursor:"#526eff",cursorAccent:"#ffffff",selectionBackground:"#add6ff",black:"#000000",red:"#e45649",green:"#50a14f",yellow:"#c18401",blue:"#4078f2",magenta:"#a626a4",cyan:"#0184bc",white:"#a0a1a7",brightBlack:"#5c6370",brightRed:"#e06c75",brightGreen:"#98c379",brightYellow:"#d19a66",brightBlue:"#61afef",brightMagenta:"#c678dd",brightCyan:"#56b6c2",brightWhite:"#ffffff"},dracula:{background:"#282a36",foreground:"#f8f8f2",cursor:"#f8f8f2",cursorAccent:"#282a36",selectionBackground:"#44475a",black:"#21222c",red:"#ff5555",green:"#50fa7b",yellow:"#f1fa8c",blue:"#bd93f9",magenta:"#ff79c6",cyan:"#8be9fd",white:"#f8f8f2",brightBlack:"#6272a4",brightRed:"#ff6e6e",brightGreen:"#69ff94",brightYellow:"#ffffa5",brightBlue:"#d6acff",brightMagenta:"#ff92df",brightCyan:"#a4ffff",brightWhite:"#ffffff"},catppuccin:{background:"#1e1e2e",foreground:"#cdd6f4",cursor:"#f5e0dc",cursorAccent:"#1e1e2e",selectionBackground:"#45475a",black:"#45475a",red:"#f38ba8",green:"#a6e3a1",yellow:"#f9e2af",blue:"#89b4fa",magenta:"#f5c2e7",cyan:"#94e2d5",white:"#bac2de",brightBlack:"#585b70",brightRed:"#f38ba8",brightGreen:"#a6e3a1",brightYellow:"#f9e2af",brightBlue:"#89b4fa",brightMagenta:"#f5c2e7",brightCyan:"#94e2d5",brightWhite:"#a6adc8"},nord:{background:"#2e3440",foreground:"#d8dee9",cursor:"#d8dee9",cursorAccent:"#2e3440",selectionBackground:"#434c5e",black:"#3b4252",red:"#bf616a",green:"#a3be8c",yellow:"#ebcb8b",blue:"#81a1c1",magenta:"#b48ead",cyan:"#88c0d0",white:"#e5e9f0",brightBlack:"#4c566a",brightRed:"#bf616a",brightGreen:"#a3be8c",brightYellow:"#ebcb8b",brightBlue:"#81a1c1",brightMagenta:"#b48ead",brightCyan:"#8fbcbb",brightWhite:"#eceff4"},gruvbox:{background:"#282828",foreground:"#ebdbb2",cursor:"#ebdbb2",cursorAccent:"#282828",selectionBackground:"#504945",black:"#282828",red:"#cc241d",green:"#98971a",yellow:"#d79921",blue:"#458588",magenta:"#b16286",cyan:"#689d6a",white:"#a89984",brightBlack:"#928374",brightRed:"#fb4934",brightGreen:"#b8bb26",brightYellow:"#fabd2f",brightBlue:"#83a598",brightMagenta:"#d3869b",brightCyan:"#8ec07c",brightWhite:"#ebdbb2"},solarized:{background:"#002b36",foreground:"#839496",cursor:"#839496",cursorAccent:"#002b36",selectionBackground:"#073642",black:"#073642",red:"#dc322f",green:"#859900",yellow:"#b58900",blue:"#268bd2",magenta:"#d33682",cyan:"#2aa198",white:"#eee8d5",brightBlack:"#586e75",brightRed:"#cb4b16",brightGreen:"#586e75",brightYellow:"#657b83",brightBlue:"#839496",brightMagenta:"#6c71c4",brightCyan:"#93a1a1",brightWhite:"#fdf6e3"},tokyo:{background:"#1a1b26",foreground:"#a9b1d6",cursor:"#c0caf5",cursorAccent:"#1a1b26",selectionBackground:"#33467c",black:"#15161e",red:"#f7768e",green:"#9ece6a",yellow:"#e0af68",blue:"#7aa2f7",magenta:"#bb9af7",cyan:"#7dcfff",white:"#a9b1d6",brightBlack:"#414868",brightRed:"#f7768e",brightGreen:"#9ece6a",brightYellow:"#e0af68",brightBlue:"#7aa2f7",brightMagenta:"#bb9af7",brightCyan:"#7dcfff",brightWhite:"#c0caf5"}};function z0(V){console.log("[webterm:parseConfig] Parsing config from element");let Z={};if(V.dataset.fontFamily)Z.fontFamily=V.dataset.fontFamily,console.log(`[webterm:parseConfig] fontFamily: "${Z.fontFamily}"`);if(V.dataset.fontSize)Z.fontSize=parseInt(V.dataset.fontSize,10),console.log(`[webterm:parseConfig] fontSize: ${Z.fontSize}`);if(V.dataset.scrollback)Z.scrollback=parseInt(V.dataset.scrollback,10),console.log(`[webterm:parseConfig] scrollback: ${Z.scrollback}`);if(V.dataset.theme){let j=V.dataset.theme.toLowerCase();if(console.log(`[webterm:parseConfig] theme attribute: "${V.dataset.theme}" -> normalized: "${j}"`),console.log(`[webterm:parseConfig] Available themes: ${Object.keys(F).join(", ")}`),console.log(`[webterm:parseConfig] Theme "${j}" in THEMES? ${j in F}`),j in F)Z.theme=F[j],console.log(`[webterm:parseConfig] Using built-in theme "${j}":`,JSON.stringify(Z.theme,null,2));else{console.log("[webterm:parseConfig] Theme not found in THEMES, trying JSON parse...");try{Z.theme=JSON.parse(V.dataset.theme),console.log("[webterm:parseConfig] Parsed custom JSON theme:",Z.theme)}catch($){console.warn(`[webterm:parseConfig] Unknown theme "${V.dataset.theme}", JSON parse failed:`,$)}}}else console.log("[webterm:parseConfig] No theme attribute found on element");return console.log("[webterm:parseConfig] Final config:",Z),Z}function H0(){let V=document.querySelectorAll('script[src*="terminal.js"]');if(V.length>0){let Z=V[0].src;return Z.substring(0,Z.lastIndexOf("/")+1)+"ghostty-vt.wasm"}return"/static/js/ghostty-vt.wasm"}function G0(){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||"ontouchstart"in window&&navigator.maxTouchPoints>0}class m{terminal;fitAddon;socket=null;element;wsUrl;reconnectAttempts=0;maxReconnectAttempts=5;reconnectDelay=1000;messageQueue=[];lastValidSize=null;mobileInput=null;mobileKeybar=null;ctrlActive=!1;shiftActive=!1;fontFamily;fontSize;constructor(V,Z,j,$,P,X){this.element=V,this.wsUrl=Z,this.terminal=j,this.fitAddon=$,this.fontFamily=P,this.fontSize=X}static async create(V,Z,j){console.log("[webterm:create] WebTerminal.create() called"),console.log("[webterm:create] Container:",V),console.log("[webterm:create] wsUrl:",Z),console.log("[webterm:create] Config received:",JSON.stringify(j,null,2));let $=H0();console.log("[webterm:create] WASM path:",$),console.log("[webterm:create] Loading Ghostty WASM...");let P=await v.load($);console.log("[webterm:create] Ghostty loaded:",P);let X=j.theme??F.tango;console.log("[webterm:create] Theme to use (config.theme ?? THEMES.xterm):",JSON.stringify(X,null,2));let R=j.fontFamily?.trim()||W0,L=j.fontSize??16,K={fontFamily:R,fontSize:L,scrollback:j.scrollback??1000,cursorBlink:!0,cursorStyle:"block",theme:X,ghostty:P};console.log("[webterm:create] Full ITerminalOptions:",JSON.stringify(K,null,2)),console.log("[webterm:create] Creating ghostty-web Terminal instance...");let Y=new w(K);console.log("[webterm:create] Terminal created:",Y),console.log("[webterm:create] Terminal.options:",Y.options),console.log("[webterm:create] Creating FitAddon...");let _=new h;console.log("[webterm:create] Loading FitAddon into terminal..."),Y.loadAddon(_),console.log("[webterm:create] Calling terminal.open(container)..."),Y.open(V),console.log("[webterm:create] terminal.open() completed");let z=Y;if(console.log("[webterm:create] Terminal internal keys:",Object.keys(z)),z.renderer){console.log("[webterm:create] Renderer exists:",z.renderer);let W=z.renderer;if(console.log("[webterm:create] Renderer keys:",Object.keys(W)),W.theme)console.log("[webterm:create] Renderer.theme:",W.theme);if(W.palette)console.log("[webterm:create] Renderer.palette:",W.palette)}let u=new m(V,Z,Y,_,R,L);return console.log("[webterm:create] WebTerminal instance created"),u.initialize(),console.log("[webterm:create] WebTerminal initialized"),u}initialize(){console.log("[webterm:init] initialize() called");let V=this.element.querySelector("canvas");if(console.log("[webterm:init] Canvas element:",V),V)console.log("[webterm:init] Canvas dimensions:",{width:V.width,height:V.height,clientWidth:V.clientWidth,clientHeight:V.clientHeight,style:V.style.cssText});if(console.log("[webterm:init] Container dimensions:",{clientWidth:this.element.clientWidth,clientHeight:this.element.clientHeight}),this.waitForFonts().then(()=>{console.log("[webterm:init] Fonts loaded, reapplying font family and fitting..."),this.terminal.options.fontFamily=this.fontFamily;let Z=this.terminal.renderer;if(Z)Z.setFontFamily(this.fontFamily),Z.remeasureFont(),console.log("[webterm:init] Font family updated via renderer");this.fit(),console.log("[webterm:init] fit() completed");let j=this.element.querySelector("canvas");if(j)console.log("[webterm:init] Canvas after fit:",{width:j.width,height:j.height,clientWidth:j.clientWidth,clientHeight:j.clientHeight})}),this.setupResizeObserver(),window.addEventListener("resize",()=>{this.fit()}),this.terminal.onData((Z)=>{this.send(["stdin",Z])}),this.terminal.onResize((Z)=>{if(this.isValidSize(Z.cols,Z.rows))this.lastValidSize={cols:Z.cols,rows:Z.rows},this.send(["resize",{width:Z.cols,height:Z.rows}])}),this.setupMobileKeyboard(),G0())this.setupMobileKeybar();this.connect()}setupMobileKeyboard(){let V=document.createElement("textarea");V.setAttribute("autocapitalize","off"),V.setAttribute("autocomplete","off"),V.setAttribute("autocorrect","off"),V.setAttribute("spellcheck","false"),V.setAttribute("inputmode","text"),V.setAttribute("enterkeyhint","send"),V.style.cssText=` position: absolute; left: 0; top: 0; diff --git a/src/webterm/static/js/terminal.ts b/src/webterm/static/js/terminal.ts index f6183dc..8282ac3 100644 --- a/src/webterm/static/js/terminal.ts +++ b/src/webterm/static/js/terminal.ts @@ -515,7 +515,12 @@ class WebTerminal { // Wait for fonts to load before fitting to ensure correct measurements this.waitForFonts().then(() => { console.log("[webterm:init] Fonts loaded, reapplying font family and fitting..."); - // Use renderer's setFontFamily method to properly update fonts + // IMPORTANT: Font updates require BOTH steps to work correctly: + // 1. Set terminal.options.fontFamily - stores the font stack for future reference + // 2. Call renderer.setFontFamily() + remeasureFont() - applies the font and recalculates metrics + // Without step 1, the font stack is lost and defaults are used on re-render. + // Without step 2, the renderer doesn't know about the new fonts. + this.terminal.options.fontFamily = this.fontFamily; const renderer = (this.terminal as unknown as { renderer?: { setFontFamily: (family: string) => void; remeasureFont: () => void } }).renderer; if (renderer) { renderer.setFontFamily(this.fontFamily); diff --git a/tests/test_docker_stats.py b/tests/test_docker_stats.py index b209232..801049c 100644 --- a/tests/test_docker_stats.py +++ b/tests/test_docker_stats.py @@ -168,6 +168,35 @@ class TestDockerStatsCollector: assert len(collector._cpu_history["test"]) == STATS_HISTORY_SIZE + def test_add_service_dynamic(self): + """Services can be added dynamically after start.""" + collector = DockerStatsCollector("/nonexistent") + collector._service_names = ["svc1"] + + collector.add_service("svc2") + assert "svc2" in collector._service_names + + # Adding same service again is a no-op + collector.add_service("svc2") + assert collector._service_names.count("svc2") == 1 + + def test_remove_service_dynamic(self): + """Services can be removed dynamically.""" + from collections import deque + + collector = DockerStatsCollector("/nonexistent") + collector._service_names = ["svc1", "svc2"] + collector._cpu_history["svc1"] = deque([10.0, 20.0]) + collector._prev_cpu["svc1"] = (100, 200) + + collector.remove_service("svc1") + assert "svc1" not in collector._service_names + assert "svc1" not in collector._cpu_history + assert "svc1" not in collector._prev_cpu + + # Removing non-existent service is safe + collector.remove_service("nonexistent") # Should not raise + class TestLocalServerSparklineEndpoint: """Tests for the CPU sparkline endpoint in LocalServer.""" diff --git a/tests/test_local_server_websocket_integration.py b/tests/test_local_server_websocket_integration.py index d4f752f..d9e1a4a 100644 --- a/tests/test_local_server_websocket_integration.py +++ b/tests/test_local_server_websocket_integration.py @@ -81,19 +81,12 @@ async def test_websocket_creates_session_on_resize(tmp_path): await client.close() assert created["args"] == ("test", 90, 25) - # Reconnect should trigger redraw without creating a new session - called = {"redraw": 0, "stdin": 0} + # Reconnect to an existing session should reuse it and send replay buffer class DummySession: def is_running(self): return True - async def force_redraw(self): - called["redraw"] += 1 - - async def send_bytes(self, data: bytes): - called["stdin"] += 1 - server.session_manager.routes["test"] = "sid" server.session_manager.sessions["sid"] = DummySession() @@ -111,9 +104,6 @@ async def test_websocket_creates_session_on_resize(tmp_path): finally: await client.close() - assert called["redraw"] == 1 - assert called["stdin"] == 1 - @pytest.mark.asyncio async def test_websocket_ping_pong(tmp_path):