A focused Docker Compose management web application.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: multiple log streams on clientside

Brooke 8509d704 03c28bc4

+50 -14
+2 -2
packages/node/src/api/realtime/logs.rs
··· 51 51 /// Creates a Server-Sent Event from a chunk of log bytes. 52 52 fn create_event(message: ProjectLogChannelMessage) -> Result<SseEvent, Infallible> { 53 53 return match message { 54 - ProjectLogChannelMessage::Close(uuid) => Ok(SseEvent::default().id("close").text(uuid)), 54 + ProjectLogChannelMessage::Close(uuid) => Ok(SseEvent::default().name("close").text(uuid)), 55 55 ProjectLogChannelMessage::Write(uuid, bytes) => { 56 56 let encoded = STANDARD.encode(bytes); 57 - Ok(SseEvent::default().id(uuid).text(encoded)) 57 + Ok(SseEvent::default().name(uuid).text(encoded)) 58 58 } 59 59 }; 60 60 }
+48 -12
packages/panel/src/lib/component/LogTerminal.svelte
··· 2 2 import { parseServerSentEvents, type ServerSentEvent } from "parse-sse"; 3 3 import { WebLinksAddon } from "@xterm/addon-web-links"; 4 4 import type { Attachment } from "svelte/attachments"; 5 + import { Terminal, type ITheme } from "@xterm/xterm"; 6 + import { SvelteMap } from "svelte/reactivity"; 5 7 import { FitAddon } from "@xterm/addon-fit"; 6 - import { Terminal, type ITheme } from "@xterm/xterm"; 7 8 import "@xterm/xterm/css/xterm.css"; 8 9 import { client } from "$lib/api"; 10 + import { onMount } from "svelte"; 9 11 import { Backoff } from "$lib"; 10 12 11 13 let { project }: { project: string } = $props(); 12 14 let loading = $state(true); 15 + 16 + let terminals = $state(new SvelteMap<string, Terminal>()); 13 17 14 18 function getComputedCSSVar(name: string) { 15 19 return getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim(); ··· 33 37 } satisfies ITheme).map(([key, varName]) => [key, getComputedCSSVar(varName)]), 34 38 ); 35 39 36 - const terminal: Attachment<HTMLElement> = (el) => { 37 - const terminal = new Terminal({ theme, disableStdin: true }); 40 + const terminal: (terminal: Terminal) => Attachment<HTMLElement> = (terminal) => (el) => { 38 41 const fitAddon = new FitAddon(); 39 42 40 43 terminal.loadAddon(new WebLinksAddon()); ··· 49 52 const observer = new ResizeObserver(() => fitAddon.fit()); 50 53 observer.observe(el); 51 54 52 - const abort = new AbortController(); 53 - stream(terminal, abort.signal); 54 - 55 55 return () => { 56 - abort.abort(); 57 56 observer.disconnect(); 58 - terminal.dispose(); 59 57 }; 60 58 }; 61 59 62 - async function stream(terminal: Terminal, signal: AbortSignal) { 60 + async function stream(signal: AbortSignal) { 63 61 const backoff = new Backoff(); 64 62 65 63 while (true) { ··· 80 78 if (signal.aborted) return; 81 79 loading = false; 82 80 83 - terminal.write(Uint8Array.from(atob(event.data), (c) => c.charCodeAt(0))); 81 + if (event.type === "close") { 82 + const terminal = terminals.get(event.data); 83 + if (terminal) terminal.dispose(); 84 + terminals.delete(event.data); 85 + } else { 86 + const id = event.type; 87 + if (!terminals.has(id)) terminals.set(id, new Terminal({ theme, disableStdin: true })); 88 + const terminal = terminals.get(event.type)!; 89 + 90 + terminal.write(Uint8Array.from(atob(event.data), (c) => c.charCodeAt(0))); 91 + } 84 92 } 85 - } catch (_) { 93 + } catch (e) { 86 94 if (signal.aborted) return; 87 95 await backoff.wait(); 88 96 } 89 97 } 90 98 } 99 + 100 + onMount(() => { 101 + const abort = new AbortController(); 102 + stream(abort.signal); 103 + 104 + return () => { 105 + terminals.forEach((t) => t.dispose()); 106 + abort.abort(); 107 + }; 108 + }); 91 109 </script> 92 110 93 111 <div class="container"> ··· 95 113 <div class="positioner"><span class="loader"></span></div> 96 114 {/if} 97 115 98 - <div {@attach terminal}></div> 116 + <div class="terminals"> 117 + {#each terminals.entries() as [id, t] (id)} 118 + <div {@attach terminal(t)} data-uuid={id}></div> 119 + {/each} 120 + </div> 99 121 </div> 100 122 101 123 <style lang="scss"> ··· 105 127 background-color: var(--crust); 106 128 border-radius: 10px; 107 129 padding: 10px; 130 + } 131 + 132 + .terminals { 133 + display: grid; 134 + grid-auto-flow: row; 135 + grid-auto-rows: minmax(0, 1fr); 136 + 137 + max-height: 452px; 138 + min-height: 452px; 139 + } 140 + 141 + .terminals > div { 142 + min-width: 0; 143 + width: 100%; 108 144 } 109 145 110 146 .positioner {