Skip to content

Commit

Permalink
feat(client): allow read client's terminal logs
Browse files Browse the repository at this point in the history
  • Loading branch information
tctien342 committed Jan 29, 2025
1 parent 7c4810f commit 3a52355
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 29 deletions.
15 changes: 15 additions & 0 deletions app/[locale]/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,18 @@ body {
.PhotoView-Slider__BannerWrap {
background-color: #000000 !important;
}

.wb-body {
top: 10px !important;
background: transparent !important;
}
.wb-header {
height: 10px !important;
background: rgba(0, 0, 0, 0.1) !important;
}

.dark {
.wb-header {
background: rgba(255, 255, 255, 0.2) !important;
}
}
2 changes: 2 additions & 0 deletions app/[locale]/main/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ChartBarIcon, ListIcon, PlaySquare } from 'lucide-react'
import { forceRecalculatePortal, Portal } from '@/components/Portal'
import { RouteConf } from '@/constants/route'
import { TooltipPopupContainer } from '@/components/TooltipPopup'
import { ClientTerminalWindows } from '@/components/ClientTerminalWindows'

const Layout: IComponent = ({ children }) => {
const ref = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -177,6 +178,7 @@ const Layout: IComponent = ({ children }) => {
<div className='w-full h-full bg-background md:bg-white/10 md:dark:bg-black/10 md:backdrop-blur-sm md:border md:rounded-xl md:p-2'>
<TooltipProvider>{dyn([renderMobileView, renderDesktopView, renderDesktopView], null)}</TooltipProvider>
<TooltipPopupContainer />
<ClientTerminalWindows />
</div>
)
}
Expand Down
11 changes: 11 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
"@trpc/next": "^11.0.0-rc.700",
"@trpc/react-query": "^11.0.0-rc.700",
"@trpc/server": "^11.0.0-rc.700",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.3.6",
"babel-plugin-react-compiler": "^19.0.0-beta-201e55d-20241215",
"citty": "^0.1.6",
Expand Down Expand Up @@ -80,6 +82,7 @@
"react-idle-timer": "^5.7.2",
"react-photo-view": "^1.2.6",
"react-syntax-highlighter": "^15.6.1",
"react-winbox": "^1.5.0",
"react-zoom-pan-pinch": "^3.6.1",
"reflect-metadata": "^0.2.2",
"sass": "^1.83.0",
Expand Down Expand Up @@ -602,6 +605,10 @@

"@unhead/schema": ["@unhead/[email protected]", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-a3TA/OJCRdfbFhcA3Hq24k1ZU1o9szicESrw8DZcGyQFacHnh84mVgnyqSkMnwgCmfN4kvjSiTBlLEHS6+wATw=="],

"@xterm/addon-fit": ["@xterm/[email protected]", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],

"@xterm/xterm": ["@xterm/[email protected]", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],

"@xyflow/react": ["@xyflow/[email protected]", "", { "dependencies": { "@xyflow/system": "0.0.50", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-AFJKVc/fCPtgSOnRst3xdYJwiEcUN9lDY7EO/YiRvFHYCJGgfzg+jpvZjkTOnBLGyrMJre9378pRxAc3fsR06A=="],

"@xyflow/system": ["@xyflow/[email protected]", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-HVUZd4LlY88XAaldFh2nwVxDOcdIBxGpQ5txzwfJPf+CAjj2BfYug1fHs2p4yS7YO8H6A3EFJQovBE8YuHkAdg=="],
Expand Down Expand Up @@ -1474,6 +1481,8 @@

"react-syntax-highlighter": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.27.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg=="],

"react-winbox": ["[email protected]", "", { "dependencies": { "winbox": "=0.2.6" }, "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0" } }, "sha512-8QaLrw+y5M7KO1vxJXcOtgIijPEpAm0qy/cAZhR0ZU/x5uiqCOy7QzIjr8whHLYKQWfl9YotnwDxv9FVL64l2w=="],

"react-zoom-pan-pinch": ["[email protected]", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ=="],

"read-cache": ["[email protected]", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
Expand Down Expand Up @@ -1734,6 +1743,8 @@

"which-typed-array": ["[email protected]", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA=="],

"winbox": ["[email protected]", "", {}, "sha512-P/Tqjcf4bA0Hr1lqR1YliQsisV8xf+t56xgCtqWN8Urw9RLonYSVQGYw+qILp9tgkPs1o7e1dsl6dskaS6GudQ=="],

"word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],

"wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
Expand Down
11 changes: 9 additions & 2 deletions components/ClientInfoMonitoring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { EClientAction, EClientStatus } from '@/entities/enum'
import { cn } from '@/utils/style'
import { trpc } from '@/utils/trpc'
import { TMonitorEvent } from '@saintno/comfyui-sdk'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { MonitoringStat } from './MonitoringStat'

import { ArrowPathIcon, CircleStackIcon, CpuChipIcon, TrashIcon } from '@heroicons/react/24/outline'
Expand All @@ -21,8 +21,9 @@ import { OverflowText } from './OverflowText'
import { SimpleTransitionLayout } from './SimpleTranslation'
import { useToast } from '@/hooks/useToast'
import { dispatchGlobalEvent, EGlobalEvent } from '@/hooks/useGlobalEvent'
import { TriangleAlertIcon } from 'lucide-react'
import { SquareTerminal, TriangleAlertIcon } from 'lucide-react'
import { WorkflowTask } from '@/entities/workflow_task'
import { useClientTerminalWindows } from './ClientTerminalWindows'

export const ClientInfoMonitoring: IComponent<{
client: Client
Expand All @@ -33,6 +34,8 @@ export const ClientInfoMonitoring: IComponent<{
const [clientTasks, setClientTasks] = useState<WorkflowTask[]>()
const [monitoring, setMonitoring] = useState<TMonitorEvent>()

const { open } = useClientTerminalWindows()

trpc.task.lastTasks.useSubscription(
{
clientId: client.id,
Expand Down Expand Up @@ -167,6 +170,10 @@ export const ClientInfoMonitoring: IComponent<{
<ReloadIcon className='mr-2' width={16} height={16} />
<span className='min-w-[100px]'>Force reconnect</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => open(client.id)} className='cursor-pointer'>
<SquareTerminal className='mr-2' width={16} height={16} />
<span className='min-w-[100px]'>Terminal logs</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={status !== EClientStatus.Executing}
Expand Down
228 changes: 228 additions & 0 deletions components/ClientTerminalWindows.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import React, { useRef, useEffect, useCallback } from 'react'
import { create } from 'zustand'
import { Button } from './ui/button'
import { trpc } from '@/utils/trpc'
import { Square, X } from 'lucide-react'
import 'winbox/dist/css/winbox.min.css' // required
import WinBox from 'react-winbox'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import useDarkMode from '@/hooks/useDarkmode'

interface IClientTerminalWindowsState {
openedIds: string[]
open: (id: string) => void
close: (id: string) => void
closeAll: () => void
}

interface IServerLog {
m: string
t: string
}

export const useClientTerminalWindows = create<IClientTerminalWindowsState>((set) => ({
openedIds: [],
open: (id) => {
set((state) => {
if (!state.openedIds.includes(id)) {
return { openedIds: [...state.openedIds, id] }
}
return state
})
},
close: (id) => set((state) => ({ openedIds: state.openedIds.filter((i) => i !== id) })),
closeAll: () => set({ openedIds: [] })
}))

const ClientTerminal: IComponent<{
id: string
}> = ({ id }) => {
const isDark = useDarkMode()
const terminalRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const terminal = useRef<Terminal | null>(null)
const fitAddon = useRef<FitAddon>(new FitAddon())
const buffer = useRef<IServerLog[]>([])
const MAX_LINES = 10000 // Adjust based on your needs

const { data: logs } = trpc.client.getTerminalLogs.useQuery(id)

const handleNewLogs = useCallback((logs: IServerLog[]) => {
if (!terminal.current) return

// Batch updates
const formatted = logs.map(formatLog).join('\r\n') + '\r\n'

// Check current line count
const currentLineCount = terminal.current.buffer.active.length

// Write new logs
terminal.current.write(formatted)
}, [])

trpc.client.watchTerminalLogs.useSubscription(id, {
onData: (data) => {
if (!terminal.current) {
buffer.current.push(data)
} else {
handleNewLogs([data])
}
}
})

useEffect(() => {
if (!terminalRef.current) return

// Initialize terminal
terminal.current = new Terminal({
scrollback: MAX_LINES,
disableStdin: true,
cursorBlink: false,
allowProposedApi: true,
fontSize: 14,
theme: {
background: '#00000000',
foreground: '#d4d4d4'
}
})

terminal.current.loadAddon(fitAddon.current)
terminal.current.open(terminalRef.current)
fitAddon.current.fit()

return () => {
terminal.current?.dispose()
terminal.current = null
}
}, [])

useEffect(() => {
if (!terminal.current) return
if (isDark) {
terminal.current.options.theme = {
background: '#00000000',
foreground: '#d4d4d4'
}
} else {
terminal.current.options.theme = {
background: '#ffffff00',
foreground: '#000000'
}
}
}, [isDark])

useEffect(() => {
const resizeListener = () => fitAddon.current.fit()
// Handle resize event of winbox
window.addEventListener(`${id}-resize`, resizeListener)
return () => {
window.removeEventListener(`${id}-resize`, resizeListener)
}
}, [id])

useEffect(() => {
if (!logs) return
if (terminal.current) {
const formatted = logs.entries.map(formatLog)
terminal.current.write(formatted.join('\r\n') + '\r\n')
}

// Process buffered logs
if (buffer.current.length > 0) {
handleNewLogs(buffer.current)
buffer.current = []
}
}, [handleNewLogs, logs])

const formatLog = (log: IServerLog) => {
const timestamp = `\x1b[38;5;75m[${new Date(log.t).toLocaleString()}]\x1b[0m`
return `${timestamp} ${log.m}`
}

return <div ref={terminalRef} style={{ height: '100%', width: '100%' }} />
}

export const TerminalWinbox: IComponent<{
id: string
onClose: (id: string) => void
}> = ({ id, onClose }) => {
const { data } = trpc.client.get.useQuery(id, {
refetchOnWindowFocus: false
})
const isMaximized = useRef(false)
const winboxRef = useRef<WinBox | null>(null)

const triggerResize = () => {
setTimeout(() => {
window?.dispatchEvent(new Event(`${id}-resize`))
}, 500)
}

const clientName =
data?.name || (data?.host ? new URL(data.host).hostname.split('.')[0] : `Client #${id.slice(0, 8)}`)

return (
<WinBox
key={id}
id={id}
ref={winboxRef}
onClose={() => onClose(id)}
noMin
noFull
onResize={triggerResize}
noMax
noClose
noShadow
x={window.innerWidth / 2 - 300}
y={window.innerHeight / 2 - 128}
width={600}
height={256}
minWidth={600}
minHeight={256}
className='rounded-xl !overflow-hidden !bg-background/90 backdrop-blur !border !shadow relative'
>
<div className='absolute top-2 right-2 z-10 flex gap-1 bg-secondary/50 backdrop-blur rounded-xl p-1 items-center'>
<div className='flex flex-col px-2'>
<code className='text-[10px] font-bold uppercase'>Terminal logs</code>
<code className='uppercase -mt-1'>{clientName}</code>
</div>
<Button
size='icon'
variant='secondary'
onClick={() => {
if (isMaximized.current) {
isMaximized.current = false
winboxRef.current?.restore()
} else {
isMaximized.current = true
winboxRef.current?.maximize()
}
triggerResize()
}}
>
<Square className='w-4 h-4' />
</Button>
<Button size='icon' variant='secondary' onClick={() => onClose(id)}>
<X className='w-4 h-4' />
</Button>
</div>
<div className='absolute w-full h-full overflow-hidden px-1'>
<ClientTerminal id={id} />
</div>
</WinBox>
)
}

export const ClientTerminalWindows = () => {
const { openedIds, close } = useClientTerminalWindows()

return (
<>
{openedIds.map((info) => (
<TerminalWinbox key={info} id={info} onClose={close} />
))}
</>
)
}
Loading

0 comments on commit 3a52355

Please sign in to comment.