From 0cb260f85441591d909f333b9c28124a96fe0937 Mon Sep 17 00:00:00 2001 From: jialin Date: Thu, 16 Apr 2026 18:30:47 +0800 Subject: [PATCH] feat: logs history --- src/components/logs-viewer/styles/index.less | 127 +++++++ .../logs-viewer/virtual-log-list.tsx | 352 ++++++++++++++++++ src/locales/en-US/models.ts | 4 +- src/locales/ja-JP/models.ts | 4 +- src/locales/ru-RU/models.ts | 4 +- src/locales/tr-TR/models.ts | 4 +- src/locales/zh-CN/models.ts | 4 +- src/pages/llmodels/apis/index.ts | 5 + .../llmodels/components/view-logs-modal.tsx | 114 +++++- src/pages/llmodels/config/types.ts | 5 + .../use-query-instance-restart-count.ts | 42 +++ src/utils/index.ts | 15 + 12 files changed, 655 insertions(+), 25 deletions(-) create mode 100644 src/components/logs-viewer/styles/index.less create mode 100644 src/components/logs-viewer/virtual-log-list.tsx create mode 100644 src/pages/llmodels/services/use-query-instance-restart-count.ts diff --git a/src/components/logs-viewer/styles/index.less b/src/components/logs-viewer/styles/index.less new file mode 100644 index 00000000..f5036b6b --- /dev/null +++ b/src/components/logs-viewer/styles/index.less @@ -0,0 +1,127 @@ +.logs-viewer-wrap-w2 { + position: relative; + + .pg { + position: absolute; + top: 16px; + right: 20px; + width: 40px; + height: 130px; + + .pg-inner { + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: -18px; + // width: 80px; + height: 185px; + + &:hover { + .pagination { + display: flex; + } + } + } + + .pagination { + display: none; + } + + &:hover { + .pagination { + display: flex; + } + } + + &.at-top { + .pagination { + display: flex; + } + } + } + } + + .loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + padding-top: 100px; + display: flex; + justify-content: center; + background-color: var(--color-fill-spin-bg); + } + + .copy { + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + + button { + color: rgba(255, 255, 255, 70%); + background-color: rgba(71, 71, 71, 100%); + + &:hover { + color: rgba(255, 255, 255, 90%) !important; + background-color: rgba(71, 71, 71, 100%) !important; + } + } + } + + .wrap { + padding: 5px 0 2px 10px; + background-color: var(--color-logs-bg); + border-radius: var(--ant-border-radius); + font-family: + monospace, Menlo, Courier, 'Courier New', Consolas, Monaco, + 'Liberation Mono' !important; + + .content { + word-wrap: break-word; + height: 100%; + padding-right: 2px; + + &.line-break { + word-wrap: break-word; + } + + .text { + min-height: 22px; + } + + color: var(--color-logs-text); + font-size: var(--font-size-small); + line-height: 22px; + white-space: pre-wrap; + background-color: var(--color-logs-bg); + } + } + + .xterm { + .xterm-viewport { + overflow-y: auto !important; + + &::-webkit-scrollbar { + width: var(--scrollbar-size); + height: var(--scrollbar-size); + } + + &::-webkit-scrollbar-thumb { + background-color: var(--color-scrollbar-thumb); + border-radius: 4px; + } + + &::-webkit-scrollbar-track { + background-color: var(--color-scrollbar-track); + border-radius: 4px; + } + } + } +} diff --git a/src/components/logs-viewer/virtual-log-list.tsx b/src/components/logs-viewer/virtual-log-list.tsx new file mode 100644 index 00000000..923c293c --- /dev/null +++ b/src/components/logs-viewer/virtual-log-list.tsx @@ -0,0 +1,352 @@ +import useSetChunkFetch from '@/hooks/use-chunk-fetch'; +import { useMemoizedFn } from 'ahooks'; +import { Spin } from 'antd'; +import classNames from 'classnames'; +import _ from 'lodash'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; +import LogsList from './logs-list'; +import LogsPagination from './logs-pagination'; +import './styles/index.less'; +import useLogsPagination from './use-logs-pagination'; + +interface LogsViewerProps { + height?: number; + content?: string; + url: string; + params?: Record; + ref?: any; + tail?: number; + enableScorllLoad?: boolean; + diffHeight?: number; + isDownloading?: boolean; +} + +const LogsViewer: React.FC = forwardRef((props, ref) => { + const { + diffHeight, + url, + tail: defaultTail, + enableScorllLoad = true, + isDownloading, + params + } = props; + const { pageSize, page, setPage, setTotalPage, totalPage } = + useLogsPagination(); + const { setChunkFetch } = useSetChunkFetch(); + const chunkRequedtRef = useRef(null); + const [logs, setLogs] = useState([]); + const logParseWorker = useRef(null); + const tail = useRef(defaultTail); + const [loading, setLoading] = useState(false); + const [isAtTop, setIsAtTop] = useState(false); + const [scrollPos, setScrollPos] = useState([]); + const logListRef = useRef(null); + const loadMoreDone = useRef(false); + const pageRef = useRef(page); + const totalPageRef = useRef(totalPage); + const isLoadingMoreRef = useRef(false); + const [currentData, setCurrentPageData] = useState([]); + const scrollPosRef = useRef({ + pos: 'bottom', + page: 1 + }); + const lineCountRef = useRef(0); + const clearScreen = useRef(false); + + useImperativeHandle(ref, () => ({ + abort() { + chunkRequedtRef.current?.current?.abort?.(); + logParseWorker.current?.terminate?.(); + } + })); + + const removeBracketsFromLine = (row: string) => { + return row.startsWith('(…)') ? row.slice(3) : row; + }; + + const setCurrentData = (lines: string[]) => { + const dataList = lines.map((line, index) => { + return { + content: line, + uid: `${pageRef.current}-${index}` + }; + }); + + setCurrentPageData(dataList); + }; + + const debounceLoading = _.debounce(() => { + setLoading(false); + isLoadingMoreRef.current = false; + if (logListRef.current?.scroller) { + logListRef.current.scroller.style['pointer-events'] = 'auto'; + } + }, 1000); + + const getCurrent = useCallback(() => { + if (pageRef.current < 1) { + pageRef.current = 1; + } + const start = (pageRef.current - 1) * pageSize; + const end = pageRef.current * pageSize; + const currentLogs = logs.slice(start, end); + setPage(pageRef.current); + setCurrentData(currentLogs); + }, [logs, pageSize]); + + const getPrePage = useCallback(() => { + pageRef.current = pageRef.current - 1; + + getCurrent(); + + setScrollPos(['bottom', pageRef.current]); + scrollPosRef.current = { + pos: 'bottom', + page: pageRef.current + }; + }, [getCurrent]); + + const getNextPage = useCallback(() => { + pageRef.current = pageRef.current + 1; + + getCurrent(); + + setScrollPos(['top', pageRef.current]); + scrollPosRef.current = { + pos: 'top', + page: pageRef.current + }; + }, [getCurrent]); + + const handleonBackend = useCallback(() => { + pageRef.current = totalPageRef.current; + getCurrent(); + + console.log('pageRef.current', pageRef.current); + setScrollPos(['bottom', pageRef.current]); + scrollPosRef.current = { + pos: 'bottom', + page: pageRef.current + }; + }, [getCurrent]); + + const handleonToFirst = useCallback(() => { + pageRef.current = 1; + getCurrent(); + setScrollPos(['top', pageRef.current]); + scrollPosRef.current = { + pos: 'top', + page: pageRef.current + }; + }, [getCurrent]); + + const updateContent = (data: string) => { + if (isLoadingMoreRef.current) { + setLoading(true); + if (logListRef.current?.scroller) { + logListRef.current.scroller.style['pointer-events'] = 'none'; + } + } + logParseWorker.current.postMessage({ + inputStr: data, + page: pageRef.current, + reset: clearScreen.current, + isDownloading: isDownloading + }); + clearScreen.current = false; + }; + + const createChunkConnection = async () => { + chunkRequedtRef.current?.current?.abort?.(); + logParseWorker.current?.postMessage({ + inputStr: '', + page: pageRef.current, + reset: true, + isDownloading: isDownloading + }); + chunkRequedtRef.current = setChunkFetch({ + url, + params: { + tail: tail.current, + ...props.params + }, + watch: params?.watch ?? true, + contentType: 'text', + handler: updateContent + }); + }; + + const handleOnScroll = useMemoizedFn( + async (data: { isTop: boolean; isBottom: boolean }) => { + const { isTop, isBottom } = data; + setIsAtTop(isTop); + if (isBottom) { + scrollPosRef.current = { + pos: 'bottom', + page: page + }; + } else if (isTop) { + scrollPosRef.current = { + pos: 'top', + page: page + }; + } else { + scrollPosRef.current = { + pos: 'middle', + page: page + }; + } + if ( + loading || + (logs.length > 0 && + lineCountRef.current < pageSize - 1 && + !loadMoreDone.current) || + !enableScorllLoad + ) { + return; + } + + if (isTop && !loadMoreDone.current) { + tail.current = undefined; + createChunkConnection(); + loadMoreDone.current = true; + isLoadingMoreRef.current = true; + clearScreen.current = true; + } else if (isTop && page <= totalPage && page > 1) { + // getPrePage(); + } else if (isBottom && page < totalPage) { + // getNextPage(); + } + } + ); + + const debouncedScroll = useCallback( + _.throttle(() => { + if (scrollPos[0] === 'top' && scrollPosRef.current.pos === 'top') { + logListRef.current?.scrollToTop(); + } + if (scrollPosRef.current.pos === 'bottom') { + logListRef.current?.scrollToBottom(); + } + }, 150), + [scrollPos] + ); + + useEffect(() => { + createChunkConnection(); + return () => { + chunkRequedtRef.current?.current?.abort?.(); + }; + }, [url, isDownloading, props.params]); + + useEffect(() => { + debouncedScroll(); + }, [scrollPos]); + + useEffect(() => { + logParseWorker.current?.terminate?.(); + + logParseWorker.current = new Worker( + // @ts-ignore + new URL('./parse-worker.ts', import.meta.url), + { + type: 'module' + } + ); + + logParseWorker.current.onmessage = (event: any) => { + const { result, lines } = event.data; + lineCountRef.current = lines; + + if (pageRef.current < 1) { + pageRef.current = 1; + } + + const oldTotalPage = totalPageRef.current; + + totalPageRef.current = Math.ceil(result.length / pageSize); + + if (isLoadingMoreRef.current) { + pageRef.current = totalPageRef.current; + } else if ( + pageRef.current === oldTotalPage && + scrollPosRef.current.pos === 'bottom' + ) { + scrollPosRef.current = { + pos: 'bottom', + page: pageRef.current + }; + pageRef.current = totalPageRef.current; + setScrollPos(['bottom', pageRef.current]); + } + + const start = (pageRef.current - 1) * pageSize; + const end = pageRef.current * pageSize; + const currentLogs = result.slice(start, end); + + setLogs(result); + setTotalPage(totalPageRef.current); + setPage(pageRef.current); + setCurrentData(currentLogs); + debounceLoading(); + }; + + return () => { + if (logParseWorker.current) { + logParseWorker.current.terminate(); + } + }; + }, []); + + return ( +
+
+
+ +
+ {loading && ( + + )} + {totalPage > 1 && ( +
+
+ +
+
+ )} +
+
+ ); +}); + +export default LogsViewer; diff --git a/src/locales/en-US/models.ts b/src/locales/en-US/models.ts index 2cffff15..11a18927 100644 --- a/src/locales/en-US/models.ts +++ b/src/locales/en-US/models.ts @@ -282,5 +282,7 @@ export default { 'models.table.instance.benchmark': 'Run Benchmark', 'models.table.modelView': 'Model List', 'models.table.instanceView': 'Instance List', - 'models.table.category': 'Category' + 'models.table.category': 'Category', + 'models.instance.firstStart': 'First Deployment', + 'models.instance.startHistory': 'Startup History' }; diff --git a/src/locales/ja-JP/models.ts b/src/locales/ja-JP/models.ts index d5b58f74..b498bfd3 100644 --- a/src/locales/ja-JP/models.ts +++ b/src/locales/ja-JP/models.ts @@ -282,7 +282,9 @@ export default { 'models.table.instance.benchmark': 'Run Benchmark', 'models.table.modelView': 'Model List', 'models.table.instanceView': 'Instance List', - 'models.table.category': 'Category' + 'models.table.category': 'Category', + 'models.instance.firstStart': 'First Deployment', + 'models.instance.startHistory': 'Startup History' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/ru-RU/models.ts b/src/locales/ru-RU/models.ts index bef59099..fd160c0f 100644 --- a/src/locales/ru-RU/models.ts +++ b/src/locales/ru-RU/models.ts @@ -286,7 +286,9 @@ export default { 'models.table.instance.benchmark': 'Run Benchmark', 'models.table.modelView': 'Model List', 'models.table.instanceView': 'Instance List', - 'models.table.category': 'Category' + 'models.table.category': 'Category', + 'models.instance.firstStart': 'First Deployment', + 'models.instance.startHistory': 'Startup History' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/tr-TR/models.ts b/src/locales/tr-TR/models.ts index 52e16765..28ac477f 100644 --- a/src/locales/tr-TR/models.ts +++ b/src/locales/tr-TR/models.ts @@ -282,7 +282,9 @@ export default { 'models.table.instance.benchmark': 'Kıyaslama Çalıştır', 'models.table.modelView': 'Model List', 'models.table.instanceView': 'Instance List', - 'models.table.category': 'Category' + 'models.table.category': 'Category', + 'models.instance.firstStart': 'First Deployment', + 'models.instance.startHistory': 'Startup History' }; // ========== To-Do: Translate Keys (Remove After Translation) ========== diff --git a/src/locales/zh-CN/models.ts b/src/locales/zh-CN/models.ts index 9979ee41..a3ee6273 100644 --- a/src/locales/zh-CN/models.ts +++ b/src/locales/zh-CN/models.ts @@ -266,5 +266,7 @@ export default { 'models.table.instance.benchmark': '运行基准测试', 'models.table.modelView': '模型列表', 'models.table.instanceView': '实例列表', - 'models.table.category': '类别' + 'models.table.category': '类别', + 'models.instance.firstStart': '首次部署', + 'models.instance.startHistory': '启动记录' }; diff --git a/src/pages/llmodels/apis/index.ts b/src/pages/llmodels/apis/index.ts index 532e495c..5231ed22 100644 --- a/src/pages/llmodels/apis/index.ts +++ b/src/pages/llmodels/apis/index.ts @@ -152,6 +152,11 @@ export async function queryModelInstanceLogs(id: number) { method: 'GET' }); } +export async function queryModelInstanceRestartCount(id: number) { + return request(`${MODEL_INSTANCE_API}/${id}/log-options`, { + method: 'GET' + }); +} // ===================== Model Instances end ===================== diff --git a/src/pages/llmodels/components/view-logs-modal.tsx b/src/pages/llmodels/components/view-logs-modal.tsx index 1a5c6ed4..d8149324 100644 --- a/src/pages/llmodels/components/view-logs-modal.tsx +++ b/src/pages/llmodels/components/view-logs-modal.tsx @@ -1,15 +1,18 @@ import useSetChunkRequest from '@/hooks/use-chunk-request'; -import { LogsViewer } from '@gpustack/core-ui'; +import { CloseOutlined } from '@ant-design/icons'; +import { BaseSelect, LogsViewer } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; -import { Modal } from 'antd'; +import { Button, Modal } from 'antd'; import React, { useCallback, useEffect, useState } from 'react'; import { MODELS_API } from '../apis'; + import { InstanceRealtimeLogStatus, InstanceStatusMap } from '../config'; +import useQueryModelInstanceRestartCount from '../services/use-query-instance-restart-count'; type ViewModalProps = { open: boolean; url: string; - id?: number | string; + id?: number; modelId?: number | string; tail?: number; status?: string; @@ -24,7 +27,12 @@ const ViewLogsModal: React.FC = (props) => { const [isDownloading, setIsDownloading] = useState( status === InstanceStatusMap.Downloading ); + const [params, setParams] = useState({ + follow: true + }); const logsViewerRef = React.useRef(null); + const { countOptions, fetchData, cancelRequest } = + useQueryModelInstanceRestartCount(); const requestRef = React.useRef(null); const contentRef = React.useRef(null); @@ -68,6 +76,82 @@ const ViewLogsModal: React.FC = (props) => { }; }, [open]); + const cancelOnClose = () => { + logsViewerRef.current?.abort(); + requestRef.current?.current?.cancel?.(); + cancelRequest(); + }; + + const handleOnChange = (value: number, option: any) => { + if (!option) { + setParams({ + follow: true + }); + } else { + setParams({ + follow: true, + watch: false, + restart_count: option.value, + worker_id: option.worker_id + }); + } + }; + + const renderTitle = () => { + return ( + + + {intl.formatMessage({ id: 'common.button.viewlog' })} + + + + {intl.formatMessage({ id: 'models.instance.startHistory' })} ( + {countOptions.length}) + + } + options={countOptions} + labelRender={(option) => { + return ( + + {option?.label} + + ); + }} + style={{ + width: 400, + marginRight: 16 + }} + > + + + + ); + }; + useEffect(() => { if (open && props.id) { requestRef.current?.current?.cancel?.(); @@ -75,32 +159,24 @@ const ViewLogsModal: React.FC = (props) => { url: `${MODELS_API}/${props.modelId}/instances`, handler: updateHandler }); + fetchData(props.id); } else { - logsViewerRef.current?.abort(); - requestRef.current?.current?.cancel?.(); + cancelOnClose(); } return () => { - logsViewerRef.current?.abort(); - requestRef.current?.current?.cancel?.(); + cancelOnClose(); }; }, [props.id, open]); return ( - - {' '} - {intl.formatMessage({ id: 'common.button.viewlog' })} - - - } + title={renderTitle()} open={open} centered={true} onCancel={handleCancel} destroyOnHidden={true} - closeIcon={true} + closeIcon={false} mask={{ closable: false }} @@ -116,14 +192,12 @@ const ViewLogsModal: React.FC = (props) => {
diff --git a/src/pages/llmodels/config/types.ts b/src/pages/llmodels/config/types.ts index a7430117..a4c68534 100644 --- a/src/pages/llmodels/config/types.ts +++ b/src/pages/llmodels/config/types.ts @@ -375,3 +375,8 @@ export interface DraftModelItem { name: string; algorithm: string; } + +export interface InstanceRestartCount { + restart_counts: number[]; + workers: { id: number; name: string }[]; +} diff --git a/src/pages/llmodels/services/use-query-instance-restart-count.ts b/src/pages/llmodels/services/use-query-instance-restart-count.ts new file mode 100644 index 00000000..ef48d6db --- /dev/null +++ b/src/pages/llmodels/services/use-query-instance-restart-count.ts @@ -0,0 +1,42 @@ +import { useQueryData } from '@/hooks/use-query-data-list'; +import { formatOrdinal } from '@/utils'; +import { useIntl } from '@umijs/max'; +import { useState } from 'react'; +import { queryModelInstanceRestartCount } from '../apis'; +import { InstanceRestartCount } from '../config/types'; + +export default function useQueryModelInstanceRestartCount() { + const { detailData, loading, cancelRequest, fetchData } = + useQueryData({ + fetchDetail: queryModelInstanceRestartCount, + key: 'modelInstanceRestartCount' + }); + const intl = useIntl(); + + const [dataList, setDataList] = useState< + { value: number; label: number | string; worker_id: number }[] + >([]); + + const fetchRestartCount = async (instance_id: number) => { + const res = await fetchData(instance_id); + const workerId = res?.workers?.[0]?.id || 0; + const dataList = + res?.restart_counts?.map((count, index) => { + return { + value: count, + label: + count === 0 + ? intl.formatMessage({ id: 'models.instance.firstStart' }) + : formatOrdinal(count), + worker_id: workerId + }; + }) || []; + setDataList(dataList); + }; + return { + countOptions: dataList, + loading, + cancelRequest, + fetchData: fetchRestartCount + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 80fab049..9689c313 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -344,3 +344,18 @@ export const parseParamsString = (paramsString: string): string[] => { return result; }; + +// ordinal.ts +const enOrdinalRules = new Intl.PluralRules('en', { type: 'ordinal' }); + +const suffixMap: Record = { + one: 'st', + two: 'nd', + few: 'rd', + other: 'th' +}; + +export function formatOrdinal(n: number): string { + const rule = enOrdinalRules.select(n); + return `${n}${suffixMap[rule]}`; +}