From d43fc9e5b9bc34335da7f97b9e4b5cbcb2c64890 Mon Sep 17 00:00:00 2001 From: Thomas Ghysels Date: Fri, 12 Jan 2024 16:54:44 +0100 Subject: [PATCH] Follow logs using websocket --- package.json | 2 + src/components/EditServiceSheet.tsx | 126 +++++++++++++++++++++++----- src/components/ui/sheet.tsx | 2 +- src/lib/date.ts | 2 +- src/lib/docker.ts | 65 +++++++++++++- src/server.dev.ts | 3 + yarn.lock | 56 +++---------- 7 files changed, 190 insertions(+), 66 deletions(-) diff --git a/package.json b/package.json index 4eb4ce5..bf7216d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@hookform/resolvers": "^3.3.1", "react-hook-form": "^7.46.2", "sonner": "^1.3.1", + "ws": "^8.16.0", "zod": "^3.22.2" }, "devDependencies": { @@ -76,6 +77,7 @@ "@types/prompts": "^2.4.5", "@types/react": "^18.2.22", "@types/react-dom": "^18.2.7", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", "@vitejs/plugin-react": "^4.0.4", diff --git a/src/components/EditServiceSheet.tsx b/src/components/EditServiceSheet.tsx index a7980ad..7dc9af9 100644 --- a/src/components/EditServiceSheet.tsx +++ b/src/components/EditServiceSheet.tsx @@ -1,7 +1,7 @@ 'use client' import type { ContainerTaskSpec } from 'dockerode' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { @@ -12,6 +12,7 @@ import { SelectValue, } from '@/components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { humanDateSecond } from '@/lib/date' import { engine, updateService } from '@/lib/docker-client' import { isShortMount, isVolumeName } from '@/lib/docker-util' import { refreshServices } from '@/lib/useRefresh' @@ -47,9 +48,9 @@ export function EditServiceSheet({ open={open} onOpenChange={(open) => { if (!open) - setTimeout(() => { - onClose() - }, 200) + // setTimeout(() => { + onClose() + // }, 200) }} > - Edit service + Edit {editor.Name} Make changes to the service spec.
@@ -372,22 +373,109 @@ function EditServiceForm({ value }: { value: Service }) { function ServiceLogs({ value }: { value: Service }) { const [logs, setLogs] = useState('') + const first = useRef(true) + const ws = useRef() + const id = useRef() + const close = useRef() useEffect(() => { - // axios to browser stream - engine - .get('/services/' + value.ID + '/logs', { - params: { stdout: true }, - }) - .then((res) => { - // handle first character on each line of docker logs - // const logs: string = res as any - // const lines = logs.split('\n') - // let prev = '' - - setLogs((prev) => prev + res) - }) + clearTimeout(close.current) + // Close because of switch + if (id.current && id.current !== value.ID) { + ws.current?.close() + id.current = undefined + ws.current = undefined + } + // Open + if (!id.current) { + id.current = value.ID + ws.current = new WebSocket( + `${window.location.origin.replace('http', 'ws')}/services/${ + value.ID + }/logs` + ) + ws.current.onmessage = (e) => { + setLogs((prev) => prev + e.data) + } + } + return () => { + close.current = setTimeout(() => { + ws.current?.close() + id.current = undefined + ws.current = undefined + }, 200) + } }, [value.ID]) - return
{logs}
+ + const groupedPerTime = [] + let at = 0 + for (const line of logs.split('\n')) { + const next = line.split(' ', 1)[0] + const text = line.slice(next.length + 1) + if (Math.abs(Date.parse(next) - at) > 100) { + at = Date.parse(next) + groupedPerTime.push(at) + } + groupedPerTime.push(text) + } + + const followBottom = useRef(true) + useEffect(() => { + if (first.current) return + if (!bottom.current) return + + const elem = bottom.current + const parent = bottom.current.closest('.overflow-auto') + if (!parent) return + + const listener = () => { + if (!elem) return console.warn('no elem to follow') + const rect = elem.getBoundingClientRect() + const next = rect.top < window.innerHeight + if (followBottom.current === next) return + followBottom.current = next + console.log('followBottom', next) + } + parent.addEventListener('scroll', listener, { passive: true }) + return () => { + parent.removeEventListener('scroll', listener) + } + }, [first.current]) + + const bottom = useRef(null) + useEffect(() => { + if (!followBottom.current) return + bottom.current?.scrollIntoView({ + behavior: first.current ? 'instant' : 'smooth', + }) + + let mounted = true + const t = setTimeout(() => { + if (mounted) first.current = false + }, 500) + return () => { + mounted = false + clearTimeout(t) + } + }, [groupedPerTime.length]) + + return ( + <> +
+ {groupedPerTime.map((line, i) => + typeof line === 'number' ? ( +
+ {new Date(line).toJSON()} - {humanDateSecond(line)} +
+ ) : ( +
+ {line} +
+ ) + )} +
+
+ + ) } function isValidJSON(str: string) { diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 4a78284..57ec36f 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -71,7 +71,7 @@ const SheetContent = React.forwardRef< {...props} > {children} - + Close diff --git a/src/lib/date.ts b/src/lib/date.ts index 1acc71c..bf47cf2 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -11,7 +11,7 @@ export const secondFormatter = new Intl.DateTimeFormat('nl-BE', { minute: 'numeric', second: 'numeric', }) -export function humanDateSecond(date?: string) { +export function humanDateSecond(date?: string | number) { return date ? secondFormatter.format(new Date(date)) : '' } diff --git a/src/lib/docker.ts b/src/lib/docker.ts index d8cd106..884052d 100644 --- a/src/lib/docker.ts +++ b/src/lib/docker.ts @@ -7,7 +7,8 @@ import type { ServiceMode, ServiceSpec as DockerodeServiceSpec, } from 'dockerode' -import type { Request, Response } from 'express' +import type { Express, Request, Response } from 'express' +import { WebSocketServer } from 'ws' import { ServiceLabel } from './types' @@ -41,6 +42,68 @@ function isServiceSpec(req: Request) { ) } +export function setupWebsocket(server: ReturnType) { + // Websockets + const wss = new WebSocketServer({ noServer: true }) // Use an appropriate port + server.on('upgrade', (request, socket, head) => { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request) + }) + }) + wss.on('connection', async (ws, request) => { + try { + const cancelTokenSource = axios.CancelToken.source() + + const res = await engine.get(request.url!, { + params: { stdout: true, timestamps: true, follow: true }, + responseType: 'stream', + cancelToken: cancelTokenSource.token, + }) + const logStream = res.data + + logStream.on('data', (chunk: Buffer) => { + if (ws.readyState > 1) + return console.log( + ' logStream.on.data but ws.readyState', + ws.readyState + ) + ws.send( + chunk + .toString() + .split('\n') + .map((line) => line.slice(8)) + .join('\n') + ) + }) + + logStream.on('end', () => { + ws.close() + }) + + logStream.on('error', (error) => { + if (ws.readyState > 1) return + console.error('Error reading log stream3:', ws.readyState, error) + cancelTokenSource.cancel() + }) + + logStream.on('close', () => { + console.log('logStream.on close close') + ws.close() + }) + + ws.on('close', () => { + console.log('ws.closed by client') + cancelTokenSource.cancel() + }) + } catch (error: any) { + if (ws.readyState > 1) return + + console.error('Error reading log stream4:', ws.readyState, error.message) + } + }) +} + + // engine.interceptors.request.use((config) => { // console.log('🐳', config.url, config.params) // if (config.url?.startsWith('/')) config.url = 'http://localhost' + config.url diff --git a/src/server.dev.ts b/src/server.dev.ts index c80ba56..eb821da 100644 --- a/src/server.dev.ts +++ b/src/server.dev.ts @@ -4,6 +4,7 @@ import { createServer } from 'vite' import { serverRouter } from './api' import { enableDNS } from './api/dns' +import { setupWebsocket } from './lib/docker' import { checkAuth, state } from './lib/state' enableDNS() @@ -42,6 +43,8 @@ global.closeSignal = new Promise((resolve) => server.on('close', () => resolve(1)) ) +setupWebsocket(server) + // Reload on file change if (import.meta.hot) { import.meta.hot.dispose(() => { diff --git a/yarn.lock b/yarn.lock index f0ba66d..d8848c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -637,18 +637,6 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-escape-keydown" "1.0.3" -"@radix-ui/react-dismissable-layer@1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4" - integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g== - dependencies: - "@babel/runtime" "^7.13.10" - "@radix-ui/primitive" "1.0.1" - "@radix-ui/react-compose-refs" "1.0.1" - "@radix-ui/react-primitive" "1.0.3" - "@radix-ui/react-use-callback-ref" "1.0.1" - "@radix-ui/react-use-escape-keydown" "1.0.3" - "@radix-ui/react-dropdown-menu@^2.0.5": version "2.0.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.5.tgz#19bf4de8ffa348b4eb6a86842f14eff93d741170" @@ -818,14 +806,6 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" -"@radix-ui/react-portal@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15" - integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q== - dependencies: - "@babel/runtime" "^7.13.10" - "@radix-ui/react-primitive" "1.0.3" - "@radix-ui/react-presence@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.0.tgz#814fe46df11f9a468808a6010e3f3ca7e0b2e84a" @@ -951,25 +931,6 @@ "@radix-ui/react-roving-focus" "1.0.4" "@radix-ui/react-use-controllable-state" "1.0.1" -"@radix-ui/react-toast@^1.1.5": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.5.tgz#f5788761c0142a5ae9eb97f0051fd3c48106d9e6" - integrity sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw== - dependencies: - "@babel/runtime" "^7.13.10" - "@radix-ui/primitive" "1.0.1" - "@radix-ui/react-collection" "1.0.3" - "@radix-ui/react-compose-refs" "1.0.1" - "@radix-ui/react-context" "1.0.1" - "@radix-ui/react-dismissable-layer" "1.0.5" - "@radix-ui/react-portal" "1.0.4" - "@radix-ui/react-presence" "1.0.1" - "@radix-ui/react-primitive" "1.0.3" - "@radix-ui/react-use-callback-ref" "1.0.1" - "@radix-ui/react-use-controllable-state" "1.0.1" - "@radix-ui/react-use-layout-effect" "1.0.1" - "@radix-ui/react-visually-hidden" "1.0.3" - "@radix-ui/react-use-callback-ref@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz#9e7b8b6b4946fe3cbe8f748c82a2cce54e7b6a90" @@ -1361,6 +1322,13 @@ dependencies: "@types/node" "^18.11.18" +"@types/ws@^8.5.10": + version "8.5.10" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" + integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@^6.7.2": version "6.7.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.7.2.tgz#f18cc75c9cceac8080a9dc2e7d166008c5207b9f" @@ -3316,11 +3284,6 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -next-themes@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45" - integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A== - node-releases@^2.0.13: version "2.0.13" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" @@ -4441,6 +4404,11 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" + integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"