Skip to content

Commit

Permalink
Follow logs using websocket
Browse files Browse the repository at this point in the history
  • Loading branch information
thgh committed Jan 12, 2024
1 parent fe122d9 commit d43fc9e
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 66 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
126 changes: 107 additions & 19 deletions src/components/EditServiceSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'
Expand Down Expand Up @@ -47,9 +48,9 @@ export function EditServiceSheet({
open={open}
onOpenChange={(open) => {
if (!open)
setTimeout(() => {
onClose()
}, 200)
// setTimeout(() => {
onClose()
// }, 200)
}}
>
<SheetContent
Expand Down Expand Up @@ -128,7 +129,7 @@ function EditServiceForm({ value }: { value: Service }) {
}}
>
<SheetHeader>
<SheetTitle>Edit service</SheetTitle>
<SheetTitle>Edit {editor.Name}</SheetTitle>
<SheetDescription>Make changes to the service spec.</SheetDescription>
</SheetHeader>
<div className="grid gap-4 py-4">
Expand Down Expand Up @@ -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<WebSocket>()
const id = useRef<string>()
const close = useRef<NodeJS.Timeout>()
useEffect(() => {
// axios to browser stream
engine
.get<string>('/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 <pre className="flex-1 min-h-[800px] font-mono text-sm">{logs}</pre>

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<HTMLDivElement>(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 (
<>
<div className="text-xs">
{groupedPerTime.map((line, i) =>
typeof line === 'number' ? (
<div key={i} className=" text-gray-500 mt-2 select-none">
{new Date(line).toJSON()} - {humanDateSecond(line)}
</div>
) : (
<div className="font-mono" key={i}>
{line}
</div>
)
)}
</div>
<div ref={bottom} className="logbottom h-40"></div>
</>
)
}

function isValidJSON(str: string) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="absolute p-6 right-0 top-0 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800">
<SheetPrimitive.Close className="fixed p-6 right-0 top-0 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800">
<X className="h-6 w-6" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) : ''
}

Expand Down
65 changes: 64 additions & 1 deletion src/lib/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -41,6 +42,68 @@ function isServiceSpec(req: Request) {
)
}

export function setupWebsocket(server: ReturnType<Express['listen']>) {
// 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<NodeJS.ReadableStream>(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
Expand Down
3 changes: 3 additions & 0 deletions src/server.dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(() => {
Expand Down
56 changes: 12 additions & 44 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -637,18 +637,6 @@
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"

"@radix-ui/[email protected]":
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"
Expand Down Expand Up @@ -818,14 +806,6 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"

"@radix-ui/[email protected]":
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/[email protected]":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.0.tgz#814fe46df11f9a468808a6010e3f3ca7e0b2e84a"
Expand Down Expand Up @@ -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/[email protected]":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz#9e7b8b6b4946fe3cbe8f748c82a2cce54e7b6a90"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -3316,11 +3284,6 @@ [email protected]:
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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit d43fc9e

Please sign in to comment.