generated from tctien342/ntr-template
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(client): allow read client's terminal logs
- Loading branch information
Showing
9 changed files
with
306 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
|
@@ -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", | ||
|
@@ -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=="], | ||
|
@@ -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=="], | ||
|
@@ -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=="], | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> | ||
))} | ||
</> | ||
) | ||
} |
Oops, something went wrong.